From da43ec94b571c813e6b3fddb39c0819e03aa1b61 Mon Sep 17 00:00:00 2001 From: lpm0073 Date: Sun, 5 Jul 2026 08:29:04 -0600 Subject: [PATCH] chore: add Canvas jupyter notebook supplement --- netflix_review_concepts.ipynb | 1368 +++++++++++++++++++++++++++++++++ 1 file changed, 1368 insertions(+) create mode 100644 netflix_review_concepts.ipynb diff --git a/netflix_review_concepts.ipynb b/netflix_review_concepts.ipynb new file mode 100644 index 0000000..f63348e --- /dev/null +++ b/netflix_review_concepts.ipynb @@ -0,0 +1,1368 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "aa29b491", + "metadata": {}, + "source": [ + "\n", + "# From Means to Neural Networks: A Companion Notebook\n", + "\n", + "This notebook is the hands-on companion to the \"From Means to Neural Networks\" module.\n", + "It contains **every code sample from the module**, in order, with extra explanation aimed\n", + "at programmers who are new to the Python data science stack. If you already know how to\n", + "`pip install` a package and write a `for` loop, you can skim the boxed explanations and\n", + "go straight to the code.\n", + "\n", + "**How to use this notebook:** run the cells from top to bottom, in order. Later sections\n", + "reuse variables (`x`, `y`, `rng`, etc.) defined in earlier ones, exactly the way the\n", + "original module's examples build on each other. If you jump around and get a\n", + "`NameError`, scroll up and re-run the cell that defines the missing variable.\n", + "\n", + "---\n", + "\n", + "## Setting up your environment\n", + "\n", + "Before any of this code will run, you need Python itself installed (any recent Python 3.9+\n", + "works fine) and a handful of third-party packages. \"Third-party\" just means these packages\n", + "aren't part of the Python standard library — they're published on the **Python Package\n", + "Index (PyPI)**, the central repository of installable Python packages, and you install them\n", + "with **pip**, the package manager that ships with Python.\n", + "\n", + "Open a terminal and run:\n", + "\n", + "```bash\n", + "pip install numpy scipy scikit-learn matplotlib pandas jupyter\n", + "```\n", + "\n", + "A quick breakdown of what each package does (you'll meet all of them properly as you\n", + "work through this notebook):\n", + "\n", + "- **numpy** — fast arrays and linear algebra. The foundation everything else is built on.\n", + "- **scipy** — additional scientific/statistical routines built on top of numpy.\n", + "- **scikit-learn** (imported as `sklearn`) — ready-made machine learning models and tools.\n", + "- **matplotlib** — plotting and charts.\n", + "- **pandas** — labeled, tabular data (think \"spreadsheet in Python\").\n", + "- **jupyter** — the notebook environment itself (the thing you're reading this in).\n", + "\n", + "If you're running this inside an existing Jupyter notebook and a package is missing, you\n", + "can also install it from directly inside a cell by prefixing the command with `!`, e.g.\n", + "`!pip install scipy`. That runs the command in your shell rather than as Python code.\n", + "\n", + "One more note on style: throughout this notebook you'll see `import numpy as np`. The\n", + "`as np` part is just a nickname — it lets you type `np.mean(...)` instead of the longer\n", + "`numpy.mean(...)`. These abbreviations (`np` for numpy, `pd` for pandas, `plt` for\n", + "`matplotlib.pyplot`) are so universal in the Python data science world that using anything\n", + "else would confuse other people reading your code.\n" + ] + }, + { + "cell_type": "markdown", + "id": "97e17cd4", + "metadata": {}, + "source": [ + "\n", + "## Section 1 — Describing Data\n", + "\n", + "### Mean and variance\n", + "\n", + "Our first import: `numpy`, almost always nicknamed `np`. Numpy's headline feature is the\n", + "**array** — think of it as a Python list that's specialized for fast, whole-collection math.\n", + "`np.array([...])` turns an ordinary Python list into a numpy array; from then on, functions\n", + "like `np.mean()` and `np.var()` work on the entire array at once, with no manual loop.\n", + "\n", + "- `np.mean(x)` — the average of every value in `x`.\n", + "- `np.var(x)` — the population variance (see the note right after this cell about the\n", + " `ddof` argument — it matters).\n", + "- `np.sqrt(...)` — square root; here used to turn variance into standard deviation.\n", + "\n", + "`f\"...\"` strings (called f-strings) let you drop a variable straight into text, e.g.\n", + "`f\"mean = {mean_x:.2f}\"` prints `mean_x` rounded to 2 decimal places.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9b5c1b02", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "x = np.array([12, 15, 14, 10, 18, 22, 9, 17])\n", + "\n", + "mean_x = np.mean(x)\n", + "var_x = np.var(x) # population variance (divides by n)\n", + "\n", + "print(f\"mean = {mean_x:.2f}\")\n", + "print(f\"variance = {var_x:.2f}\")\n", + "print(f\"standard deviation = {np.sqrt(var_x):.2f}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "25b343d7", + "metadata": {}, + "source": [ + "\n", + "**A gotcha worth knowing right away:** `np.var()` divides by `n` by default (the\n", + "\"population\" variance). Many stats textbooks, and pandas, divide by `n - 1` instead (the\n", + "\"sample\" variance, a small correction for the fact that you estimated the mean from the\n", + "same data you're measuring spread in). Pass `ddof=1` to `np.var()` if you want that\n", + "correction: `np.var(x, ddof=1)`. It won't change any conclusions in this module, but it\n", + "will produce a slightly different number than pandas' `.var()` if you don't match them up —\n", + "worth remembering so it doesn't cost you time during a lab.\n", + "\n", + "### Covariance\n", + "\n", + "`np.cov()` computes how two variables move together. It returns a full 2x2 matrix, not a\n", + "single number — indexing it with `[0, 1]` pulls out the one number we actually want.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b6d1e548", + "metadata": {}, + "outputs": [], + "source": [ + "x = np.array([1, 2, 3, 4, 5, 6])\n", + "y = np.array([2, 4, 5, 4, 5, 8])\n", + "\n", + "cov_xy = np.cov(x, y, ddof=0)[0, 1]\n", + "print(f\"covariance(x, y) = {cov_xy:.2f}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "0425e29e", + "metadata": {}, + "source": [ + "\n", + "`np.cov(x, y, ddof=0)` returns:\n", + "\n", + "```\n", + "[[ var(x) cov(x, y) ]\n", + " [ cov(x, y) var(y) ]]\n", + "```\n", + "\n", + "The diagonal holds each variable's own variance; the off-diagonal (equal on both sides)\n", + "holds the covariance between them. `ddof=0` tells numpy to use the population version\n", + "(divide by `n`), matching `np.var()`'s default from the previous cell.\n", + "\n", + "### Correlation\n", + "\n", + "`np.corrcoef()` works the same way — it also returns a matrix, and `[0, 1]` pulls out the\n", + "correlation between `x` and `y`, a number always between -1 and 1.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c2ae32aa", + "metadata": {}, + "outputs": [], + "source": [ + "corr_xy = np.corrcoef(x, y)[0, 1]\n", + "print(f\"correlation(x, y) = {corr_xy:.3f}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "0176b03e", + "metadata": {}, + "source": [ + "\n", + "### Looking at your data, not just summary numbers\n", + "\n", + "`pandas` (nicknamed `pd`) is the standard library for tabular data in Python — think of its\n", + "core object, the `DataFrame`, as a spreadsheet living inside your code. This is our first\n", + "use of it. `pd.DataFrame({...})` builds a table from a dictionary where each key becomes a\n", + "column name and each value is that column's data. `.describe()` then prints count, mean,\n", + "standard deviation, min, max, and quartiles for every numeric column in one call — a fast\n", + "first look at any new data set.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "056be789", + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "\n", + "df = pd.DataFrame({\"x\": x, \"y\": y})\n", + "print(df.describe())\n" + ] + }, + { + "cell_type": "markdown", + "id": "d6142438", + "metadata": {}, + "source": [ + "\n", + "### Standardizing data (z-scores)\n", + "\n", + "Subtract the mean, divide by the standard deviation. The result always has mean 0 and\n", + "standard deviation 1, which makes it possible to compare variables that were originally on\n", + "completely different scales (dollars vs. years, for instance).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a28bca11", + "metadata": {}, + "outputs": [], + "source": [ + "z = (x - np.mean(x)) / np.std(x)\n", + "print(f\"standardized mean = {np.mean(z):.4f}, standardized std = {np.std(z):.4f}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "f86c2c01", + "metadata": {}, + "source": [ + "\n", + "### Outliers and robustness\n", + "\n", + "`np.append(x, 500)` returns a new array with `500` tacked on the end (numpy arrays are a\n", + "fixed size, so \"appending\" always makes a new array rather than growing the old one in\n", + "place — a subtle but important difference from Python lists).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1290e3dc", + "metadata": {}, + "outputs": [], + "source": [ + "x_with_outlier = np.append(x, 500) # one extreme value added\n", + "print(f\"mean without outlier: {np.mean(x):.2f}, with outlier: {np.mean(x_with_outlier):.2f}\")\n", + "print(f\"std without outlier: {np.std(x):.2f}, with outlier: {np.std(x_with_outlier):.2f}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "a474c7bd", + "metadata": {}, + "source": [ + "\n", + "### Describing several variables at once\n", + "\n", + "One last new tool here: `np.random.default_rng(seed)` creates a **random number\n", + "generator** object, conventionally called `rng`. Passing a fixed `seed` (any integer) means\n", + "every call to `rng.uniform(...)` or `rng.normal(...)` produces the *same* \"random\" numbers\n", + "every time you run the notebook — this is what makes an example reproducible. The module\n", + "introduces this properly in Section 2; we create `rng` a little early here so this cell\n", + "runs on its own.\n", + "\n", + "- `rng.uniform(low, high, size)` draws `size` numbers uniformly between `low` and `high`.\n", + "- `rng.normal(mean, std, size)` draws `size` numbers from a normal (bell-curve)\n", + " distribution.\n", + "- `df.corr()` computes the correlation between every pair of columns in a DataFrame at\n", + " once — a correlation matrix.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3587eb92", + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "\n", + "rng = np.random.default_rng(7) # created here so this example is self-contained\n", + "\n", + "df = pd.DataFrame({\n", + " \"hours_studied\": rng.uniform(0, 10, 50),\n", + " \"sleep_hours\": rng.uniform(4, 9, 50),\n", + "})\n", + "df[\"exam_score\"] = 5 * df[\"hours_studied\"] + 2 * df[\"sleep_hours\"] + rng.normal(0, 5, 50)\n", + "\n", + "print(df.corr())\n" + ] + }, + { + "cell_type": "markdown", + "id": "3138770d", + "metadata": {}, + "source": [ + "\n", + "## Section 2 — Fitting Lines\n", + "\n", + "We regenerate `rng` here with a fixed seed (`42`) so this section's examples are\n", + "reproducible independent of Section 1's random numbers above.\n", + "\n", + "- `rng.uniform(0, 10, n)` — `n` random x-values between 0 and 10.\n", + "- `rng.normal(0, 4.0, n)` — `n` random noise values, added to a \"true\" linear relationship\n", + " so the data looks like real, noisy measurements rather than a perfect line.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7cf809c6", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "rng = np.random.default_rng(42)\n", + "n = 60\n", + "x = rng.uniform(0, 10, n)\n", + "y = 2.5 * x + 3.0 + rng.normal(0, 4.0, n) # true slope 2.5, true intercept 3.0\n", + "\n", + "beta1_hat = np.cov(x, y, ddof=0)[0, 1] / np.var(x)\n", + "beta0_hat = np.mean(y) - beta1_hat * np.mean(x)\n", + "\n", + "print(f\"fitted slope (beta1) = {beta1_hat:.3f}\")\n", + "print(f\"fitted intercept (beta0) = {beta0_hat:.3f}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "d57ee248", + "metadata": {}, + "source": [ + "\n", + "### Checking the by-hand formula against a library\n", + "\n", + "`scipy` is a scientific computing library built on top of numpy. We only need one function\n", + "from it here: `scipy.stats.linregress`, which fits a line for you and returns an object\n", + "with `.slope` and `.intercept` attributes (plus more, like the correlation coefficient).\n", + "If scipy isn't installed yet: `pip install scipy`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4e32d191", + "metadata": {}, + "outputs": [], + "source": [ + "from scipy import stats\n", + "\n", + "result = stats.linregress(x, y)\n", + "print(f\"scipy slope = {result.slope:.3f}, intercept = {result.intercept:.3f}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "b82f1b44", + "metadata": {}, + "source": [ + "\n", + "### The matrix-form solution\n", + "\n", + "`np.column_stack([...])` glues 1-D arrays together as side-by-side columns of a single 2-D\n", + "array — here, a column of all `1`s next to the `x` column, forming the \"design matrix\" `X`.\n", + "`X.T` is the transpose (rows and columns swapped), `@` is matrix multiplication (numpy's\n", + "dedicated operator for it, distinct from `*` which multiplies element-by-element), and\n", + "`np.linalg.inv(...)` computes a matrix inverse. Together, `np.linalg.inv(X.T @ X) @ X.T @ y`\n", + "is the \"normal equations\" formula solved directly.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0880a93a", + "metadata": {}, + "outputs": [], + "source": [ + "X = np.column_stack([np.ones(n), x]) # design matrix: intercept column + x column\n", + "beta_matrix = np.linalg.inv(X.T @ X) @ X.T @ y\n", + "print(f\"matrix-form solution: {beta_matrix}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "3cf43c34", + "metadata": {}, + "source": [ + "\n", + "### Multiple regression: just add another column\n", + "\n", + "Predicting `y` from two inputs (`x` and `x2`) instead of one requires no new machinery —\n", + "just one more column in `X`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7db816ad", + "metadata": {}, + "outputs": [], + "source": [ + "x2 = 0.5 * x + rng.normal(0, 2, n)\n", + "y_multi = 2.5 * x - 1.2 * x2 + 3.0 + rng.normal(0, 4.0, n)\n", + "\n", + "X_multi = np.column_stack([np.ones(n), x, x2])\n", + "beta_multi = np.linalg.inv(X_multi.T @ X_multi) @ X_multi.T @ y_multi\n", + "print(f\"multiple regression coefficients: {beta_multi}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "88d2f25d", + "metadata": {}, + "source": [ + "\n", + "### Doing the same thing with scikit-learn\n", + "\n", + "`scikit-learn` (imported as `sklearn`) is the standard machine-learning library in Python.\n", + "Its models all share one interface: create the model object, call `.fit(X, y)` on your\n", + "data, then read off results (here, `.coef_` and `.intercept_`). Note that scikit-learn adds\n", + "its own intercept column automatically, so we pass it only the `x` and `x2` columns\n", + "(`X_multi[:, 1:]` — every row, every column *except* the first one).\n", + "If sklearn isn't installed: `pip install scikit-learn`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "febd1a33", + "metadata": {}, + "outputs": [], + "source": [ + "from sklearn.linear_model import LinearRegression\n", + "\n", + "model = LinearRegression().fit(X_multi[:, 1:], y_multi) # sklearn adds its own intercept\n", + "print(f\"sklearn coefficients: {model.coef_}, intercept: {model.intercept_}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "db8f40db", + "metadata": {}, + "source": [ + "\n", + "### Curves are still \"linear\" models\n", + "\n", + "Adding a column for `x**2` lets the same machinery fit a parabola. \"Linear\" refers to being\n", + "linear in the *parameters*, not to the shape of the resulting curve.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8a8ab508", + "metadata": {}, + "outputs": [], + "source": [ + "X_quadratic = np.column_stack([np.ones(n), x, x**2])\n", + "beta_quadratic = np.linalg.inv(X_quadratic.T @ X_quadratic) @ X_quadratic.T @ y\n", + "print(f\"quadratic fit coefficients: {beta_quadratic}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "c55f3be0", + "metadata": {}, + "source": [ + "\n", + "### A small worked example, entirely by hand\n", + "\n", + "Five data points, so you can see every number involved without a wall of output.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d9463783", + "metadata": {}, + "outputs": [], + "source": [ + "x_small = np.array([1, 2, 3, 4, 5])\n", + "y_small = np.array([2.1, 3.9, 6.2, 7.8, 10.1])\n", + "\n", + "beta1 = np.cov(x_small, y_small, ddof=0)[0, 1] / np.var(x_small)\n", + "beta0 = np.mean(y_small) - beta1 * np.mean(x_small)\n", + "print(f\"fitted line: y = {beta0:.2f} + {beta1:.2f} * x\")\n", + "\n", + "for xi, yi in zip(x_small, y_small):\n", + " prediction = beta0 + beta1 * xi\n", + " print(f\"x={xi}: actual y={yi:.1f}, predicted y={prediction:.2f}, residual={yi - prediction:.2f}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "8c0bf2b1", + "metadata": {}, + "source": [ + "\n", + "## Section 3 — Measuring Error\n", + "\n", + "We rebuild the same fitted line from Section 2 (same seed, same data) so this section\n", + "stands on its own, then compute its residuals — the leftover error at each point,\n", + "`actual - predicted`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "09be8c0f", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "rng = np.random.default_rng(42)\n", + "n = 60\n", + "x = rng.uniform(0, 10, n)\n", + "y = 2.5 * x + 3.0 + rng.normal(0, 4.0, n)\n", + "\n", + "beta1_hat = np.cov(x, y, ddof=0)[0, 1] / np.var(x)\n", + "beta0_hat = np.mean(y) - beta1_hat * np.mean(x)\n", + "y_hat = beta0_hat + beta1_hat * x\n", + "\n", + "residuals = y - y_hat\n", + "print(f\"mean residual = {np.mean(residuals):.4f}\") # should be ~0 for OLS, always\n", + "print(f\"residual std dev = {np.std(residuals):.3f}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "78f093fc", + "metadata": {}, + "source": [ + "\n", + "### Mean squared error (MSE) and R-squared\n", + "\n", + "`sklearn.metrics` is scikit-learn's collection of ready-made scoring functions — no need to\n", + "hand-write the averaging/squaring yourself.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c6e63e5a", + "metadata": {}, + "outputs": [], + "source": [ + "from sklearn.metrics import mean_squared_error, r2_score\n", + "\n", + "mse = mean_squared_error(y, y_hat)\n", + "print(f\"MSE = {mse:.3f}\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ac6ac3e1", + "metadata": {}, + "outputs": [], + "source": [ + "r2 = r2_score(y, y_hat)\n", + "print(f\"R^2 = {r2:.3f}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "2876641f", + "metadata": {}, + "source": [ + "\n", + "### Mean absolute error (MAE)\n", + "\n", + "Same idea as MSE, but averaging the absolute value of each residual instead of its square —\n", + "so one huge outlier error doesn't dominate the score the way it does under MSE.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4760056e", + "metadata": {}, + "outputs": [], + "source": [ + "from sklearn.metrics import mean_absolute_error\n", + "\n", + "mae = mean_absolute_error(y, y_hat)\n", + "print(f\"MAE = {mae:.3f} (compare to MSE = {mse:.3f} and RMSE = {np.sqrt(mse):.3f})\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "02a6b8a4", + "metadata": {}, + "source": [ + "\n", + "### Comparing two candidate models side by side\n", + "\n", + "`x.reshape(-1, 1)` turns a 1-D array into a 2-D array with one column — scikit-learn\n", + "models always expect their input `X` to be 2-D (rows = samples, columns = features), even\n", + "when there's only one feature. `-1` tells numpy \"figure out this dimension automatically.\"\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5e7a890c", + "metadata": {}, + "outputs": [], + "source": [ + "from sklearn.linear_model import LinearRegression\n", + "from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score\n", + "\n", + "X_linear = x.reshape(-1, 1)\n", + "X_quad = np.column_stack([x, x**2])\n", + "\n", + "for name, X_features in [(\"linear\", X_linear), (\"quadratic\", X_quad)]:\n", + " m = LinearRegression().fit(X_features, y)\n", + " preds = m.predict(X_features)\n", + " print(\n", + " f\"{name:10s} MSE={mean_squared_error(y, preds):7.2f} \"\n", + " f\"MAE={mean_absolute_error(y, preds):6.2f} \"\n", + " f\"R2={r2_score(y, preds):.3f}\"\n", + " )\n" + ] + }, + { + "cell_type": "markdown", + "id": "ef167168", + "metadata": {}, + "source": [ + "\n", + "## Section 4 — Optimizing Parameters\n", + "\n", + "### Train/test splits, and a first look at overfitting\n", + "\n", + "A batch of new imports, all from scikit-learn:\n", + "\n", + "- `train_test_split` — randomly splits your data into a training portion and a held-out\n", + " test portion (here, 70% / 30%, via `test_size=0.3`). `random_state=0` is scikit-learn's\n", + " version of a random seed — fix it and the split is reproducible.\n", + "- `PolynomialFeatures(degree)` — automatically builds the `x`, `x**2`, `x**3`, ... columns\n", + " for you, so you don't have to type them out by hand.\n", + "- `make_pipeline(...)` — chains preprocessing steps and a model together into one object,\n", + " so `.fit()` and `.predict()` run the whole chain in one call.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2f0423ac", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "from sklearn.model_selection import train_test_split\n", + "from sklearn.linear_model import LinearRegression\n", + "from sklearn.preprocessing import PolynomialFeatures\n", + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.metrics import mean_squared_error\n", + "\n", + "rng = np.random.default_rng(1)\n", + "n = 40\n", + "x = np.linspace(-3, 3, n)\n", + "y = 0.5 * x**3 - 2 * x**2 + x + 5 + rng.normal(0, 6, n)\n", + "\n", + "X_train, X_test, y_train, y_test = train_test_split(\n", + " x.reshape(-1, 1), y, test_size=0.3, random_state=0\n", + ")\n", + "\n", + "for degree in [1, 3, 11]:\n", + " model = make_pipeline(PolynomialFeatures(degree), LinearRegression())\n", + " model.fit(X_train, y_train)\n", + " train_mse = mean_squared_error(y_train, model.predict(X_train))\n", + " test_mse = mean_squared_error(y_test, model.predict(X_test))\n", + " print(f\"degree {degree:2d}: train MSE = {train_mse:7.2f} test MSE = {test_mse:7.2f}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "1b735316", + "metadata": {}, + "source": [ + "\n", + "`np.linspace(start, stop, n)` — unlike `rng.uniform`, this produces `n` *evenly spaced*\n", + "values from `start` to `stop` (not random), which is handy for plotting smooth curves.\n", + "\n", + "### Gradient descent, written from scratch\n", + "\n", + "No new imports here — everything is built from numpy operations you've already used. The\n", + "function below implements the mechanics of gradient descent directly: start from a guess\n", + "(`w = np.zeros(...)`, all zeros), repeatedly compute the gradient of the squared-error\n", + "loss, and step a little in the opposite direction.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "94b0a2de", + "metadata": {}, + "outputs": [], + "source": [ + "def gradient_descent_linreg(X, y, lr=0.1, n_iter=200):\n", + " n_samples, n_features = X.shape\n", + " w = np.zeros(n_features)\n", + " loss_history = []\n", + " for _ in range(n_iter):\n", + " y_pred = X @ w\n", + " grad = -(2 / n_samples) * X.T @ (y - y_pred)\n", + " w = w - lr * grad\n", + " loss_history.append(np.mean((y - y_pred) ** 2))\n", + " return w, loss_history\n", + "\n", + "x_data = rng.uniform(0, 10, 60)\n", + "y_data = 2.5 * x_data + 3.0 + rng.normal(0, 4.0, 60)\n", + "\n", + "x_std = (x_data - x_data.mean()) / x_data.std() # standardizing helps convergence\n", + "X = np.column_stack([np.ones(60), x_std])\n", + "\n", + "w, loss_history = gradient_descent_linreg(X, y_data, lr=0.3, n_iter=200)\n", + "print(f\"final weights: {w}\")\n", + "print(f\"loss after 1 iteration: {loss_history[0]:.2f}, after 200: {loss_history[-1]:.2f}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "2114b14e", + "metadata": {}, + "source": [ + "\n", + "### Cross-validation\n", + "\n", + "`cross_val_score` automates the \"split into k folds, train/test k times, average the\n", + "scores\" process. `scoring=\"neg_mean_squared_error\"` is *negative* MSE because\n", + "scikit-learn's convention is that higher scores are always better — we negate it back\n", + "(`-scores`) to get an ordinary, positive MSE for printing.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "841cf66d", + "metadata": {}, + "outputs": [], + "source": [ + "from sklearn.model_selection import cross_val_score\n", + "from sklearn.linear_model import LinearRegression\n", + "\n", + "scores = cross_val_score(\n", + " LinearRegression(), x.reshape(-1, 1), y, cv=5, scoring=\"neg_mean_squared_error\"\n", + ")\n", + "print(f\"per-fold MSE: {-scores}\")\n", + "print(f\"average MSE across folds: {-scores.mean():.3f}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "d952fe48", + "metadata": {}, + "source": [ + "\n", + "## Section 5 — Extending From Lines to Many-Dimensional Models\n", + "\n", + "### Visualizing a loss surface as a grid of numbers\n", + "\n", + "No new imports — just numpy again, used here to build a grid of loss values over a range\n", + "of possible `(b0, b1)` pairs. `[[... for b0 in b0_grid] for b1 in b1_grid]` is a **nested\n", + "list comprehension**: for every `b1`, compute the loss at every `b0`, producing a 2-D grid\n", + "that could be plotted as a surface (with matplotlib's 3D plotting, not shown here to keep\n", + "this cell fast).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f205f452", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "rng = np.random.default_rng(42)\n", + "n = 60\n", + "x = rng.uniform(0, 10, n)\n", + "y = 2.5 * x + 3.0 + rng.normal(0, 4.0, n)\n", + "\n", + "def loss_surface(b0, b1, x, y):\n", + " return np.mean((y - (b0 + b1 * x)) ** 2)\n", + "\n", + "b0_grid = np.linspace(-15, 20, 60)\n", + "b1_grid = np.linspace(-2, 6, 60)\n", + "loss_values = np.array([[loss_surface(b0, b1, x, y) for b0 in b0_grid] for b1 in b1_grid])\n", + "\n", + "print(f\"loss surface shape: {loss_values.shape}\")\n", + "print(f\"minimum loss on this grid: {loss_values.min():.2f}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "a8805de6", + "metadata": {}, + "source": [ + "\n", + "### A non-convex surface: Himmelblau's function\n", + "\n", + "Plain Python functions this time (no sklearn or scipy needed) — `himmelblau` is the\n", + "landscape, `himmelblau_grad` is its gradient (worked out by hand with calculus), and\n", + "`gradient_descent` is a small, generic version of the optimizer from Section 4.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7acbecfd", + "metadata": {}, + "outputs": [], + "source": [ + "def himmelblau(x, y):\n", + " return (x**2 + y - 11) ** 2 + (x + y**2 - 7) ** 2\n", + "\n", + "def himmelblau_grad(p):\n", + " x, y = p\n", + " dfdx = 4 * x * (x**2 + y - 11) + 2 * (x + y**2 - 7)\n", + " dfdy = 2 * (x**2 + y - 11) + 4 * y * (x + y**2 - 7)\n", + " return np.array([dfdx, dfdy])\n", + "\n", + "def gradient_descent(grad_fn, start, lr=0.01, n_iter=300):\n", + " p = np.array(start, dtype=float)\n", + " for _ in range(n_iter):\n", + " p = p - lr * grad_fn(p)\n", + " return p\n", + "\n", + "for start in [(-4, 4), (4, 4), (-4, -4), (4, -2)]:\n", + " end_point = gradient_descent(himmelblau_grad, start)\n", + " print(f\"started at {start}, converged near {np.round(end_point, 2)}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "7d154b93", + "metadata": {}, + "source": [ + "\n", + "### A toy experiment: local minima get rarer as dimensions grow\n", + "\n", + "`np.linalg.eigvalsh(...)` computes the eigenvalues of a symmetric matrix — used here as a\n", + "stand-in for checking whether a random point curves \"upward\" in every direction at once\n", + "(all eigenvalues positive means a true local minimum).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "abc802ef", + "metadata": {}, + "outputs": [], + "source": [ + "rng = np.random.default_rng(0)\n", + "\n", + "def fraction_true_local_minima(dimension, n_trials=300):\n", + " count = 0\n", + " for _ in range(n_trials):\n", + " A = rng.normal(size=(dimension, dimension))\n", + " hessian_stand_in = (A + A.T) / 2 # a random symmetric matrix, standing in for a Hessian\n", + " eigenvalues = np.linalg.eigvalsh(hessian_stand_in)\n", + " if np.all(eigenvalues > 0): # positive in every direction => true local min\n", + " count += 1\n", + " return count / n_trials\n", + "\n", + "for d in [2, 4, 8, 12]:\n", + " frac = fraction_true_local_minima(d)\n", + " print(f\"dimension {d:2d}: fraction of critical points that are true local minima = {frac:.3f}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "cc78a6b0", + "metadata": {}, + "source": [ + "\n", + "### Feature scaling with `StandardScaler`\n", + "\n", + "`StandardScaler` does exactly the z-score standardizing from Section 1 (`(x - mean) /\n", + "std`), but as a reusable scikit-learn object with a `.fit_transform()` method — handy once\n", + "you have many columns to standardize at once instead of one.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6bd5372d", + "metadata": {}, + "outputs": [], + "source": [ + "from sklearn.preprocessing import StandardScaler\n", + "\n", + "X_raw = np.column_stack([x_data, x_data * 1000]) # two features on very different scales\n", + "scaler = StandardScaler()\n", + "X_scaled = scaler.fit_transform(X_raw)\n", + "print(f\"raw feature ranges: {X_raw.min(axis=0)} to {X_raw.max(axis=0)}\")\n", + "print(f\"scaled feature means: {X_scaled.mean(axis=0).round(3)}, stds: {X_scaled.std(axis=0).round(3)}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "3d2e0391", + "metadata": {}, + "source": [ + "\n", + "## Section 6 — From Machine Learning to Artificial Intelligence\n", + "\n", + "### A tiny neural network, built from scratch with numpy\n", + "\n", + "No new libraries — a neural network's forward pass is just matrix multiplication\n", + "(`@`) and a simple nonlinear function (`relu`, which just zeroes out negative numbers)\n", + "applied in between two layers of weights.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f191b4ac", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "def relu(z):\n", + " return np.maximum(0, z)\n", + "\n", + "def simple_forward_pass(x, W1, b1, W2, b2):\n", + " '''\n", + " x: input vector\n", + " W1, b1: weights and bias of the first (hidden) layer\n", + " W2, b2: weights and bias of the second (output) layer\n", + " '''\n", + " hidden = relu(W1 @ x + b1) # first layer: weighted sum, then nonlinearity\n", + " output = W2 @ hidden + b2 # second layer: another weighted sum\n", + " return output\n", + "\n", + "rng = np.random.default_rng(0)\n", + "x_input = rng.normal(size=3) # 3 input features\n", + "W1 = rng.normal(size=(4, 3)) * 0.5 # hidden layer: 4 hidden units\n", + "b1 = np.zeros(4)\n", + "W2 = rng.normal(size=(1, 4)) * 0.5 # output layer: 1 output value\n", + "b2 = np.zeros(1)\n", + "\n", + "prediction = simple_forward_pass(x_input, W1, b1, W2, b2)\n", + "print(f\"network output: {prediction}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "a58294ce", + "metadata": {}, + "source": [ + "\n", + "### Letting scikit-learn train a real (small) neural network\n", + "\n", + "`MLPRegressor` (\"Multi-Layer Perceptron\") is scikit-learn's built-in neural network model.\n", + "It follows the exact same `.fit()` / `.predict()` interface as `LinearRegression` — that\n", + "consistency is deliberate on scikit-learn's part.\n", + "\n", + "- `hidden_layer_sizes=(16, 16)` — two hidden layers, 16 neurons each.\n", + "- `activation=\"relu\"` — the nonlinearity applied between layers.\n", + "- `max_iter=2000` — the maximum number of training iterations before giving up.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "523d4a52", + "metadata": {}, + "outputs": [], + "source": [ + "from sklearn.neural_network import MLPRegressor\n", + "from sklearn.metrics import mean_squared_error\n", + "\n", + "x_data = np.linspace(-3, 3, 100).reshape(-1, 1)\n", + "y_data = np.sin(x_data).ravel() + rng.normal(0, 0.1, 100)\n", + "\n", + "net = MLPRegressor(\n", + " hidden_layer_sizes=(16, 16), # two hidden layers, 16 units each\n", + " activation=\"relu\",\n", + " max_iter=2000,\n", + " random_state=0,\n", + ")\n", + "net.fit(x_data, y_data)\n", + "predictions = net.predict(x_data)\n", + "\n", + "print(f\"training MSE: {mean_squared_error(y_data, predictions):.4f}\")\n", + "print(f\"number of weight matrices: {len(net.coefs_)}\")\n", + "print(f\"total trainable parameters: {sum(w.size for w in net.coefs_) + sum(b.size for b in net.intercepts_)}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "da22e958", + "metadata": {}, + "source": [ + "\n", + "(`.ravel()` flattens a 2-D array with one column back down to a plain 1-D array — needed\n", + "here because `x_data` is 2-D for scikit-learn's benefit, but `np.sin(x_data)` inherits that\n", + "same 2-D shape and we want a flat array of targets.)\n" + ] + }, + { + "cell_type": "markdown", + "id": "6c1eac4a", + "metadata": {}, + "source": [ + "\n", + "## Section 7 — Using Python to Perform All of It\n", + "\n", + "This section is a tour of the toolchain itself. If you haven't installed everything yet,\n", + "one command covers this whole module:\n", + "\n", + "```bash\n", + "pip install numpy scipy scikit-learn matplotlib pandas\n", + "```\n", + "\n", + "### NumPy: arrays and linear algebra, end to end\n", + "\n", + "The complete normal-equations calculation from Section 2, in one place.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "db721357", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "# The normal equations, end to end, using only NumPy\n", + "rng = np.random.default_rng(0)\n", + "x = rng.uniform(0, 10, 100)\n", + "y = 2.5 * x + 3.0 + rng.normal(0, 4.0, 100)\n", + "\n", + "X = np.column_stack([np.ones_like(x), x])\n", + "beta = np.linalg.inv(X.T @ X) @ X.T @ y\n", + "print(f\"beta = {beta}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "cb94eeb6", + "metadata": {}, + "source": [ + "\n", + "`np.ones_like(x)` creates an array of `1`s with the same shape as `x` — a small convenience\n", + "over writing `np.ones(len(x))` by hand.\n", + "\n", + "### SciPy: an independent check\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f4589338", + "metadata": {}, + "outputs": [], + "source": [ + "from scipy import stats\n", + "\n", + "result = stats.linregress(x, y)\n", + "print(f\"slope = {result.slope:.3f}, intercept = {result.intercept:.3f}, r-squared = {result.rvalue**2:.3f}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "6d47d3c1", + "metadata": {}, + "source": [ + "\n", + "### scikit-learn: fit, then predict\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b592cb3f", + "metadata": {}, + "outputs": [], + "source": [ + "from sklearn.linear_model import LinearRegression\n", + "from sklearn.model_selection import train_test_split\n", + "from sklearn.metrics import mean_squared_error, r2_score\n", + "\n", + "X_train, X_test, y_train, y_test = train_test_split(\n", + " x.reshape(-1, 1), y, test_size=0.3, random_state=0\n", + ")\n", + "\n", + "model = LinearRegression()\n", + "model.fit(X_train, y_train)\n", + "predictions = model.predict(X_test)\n", + "\n", + "print(f\"test MSE = {mean_squared_error(y_test, predictions):.3f}\")\n", + "print(f\"test R^2 = {r2_score(y_test, predictions):.3f}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "8f4741da", + "metadata": {}, + "source": [ + "\n", + "### Matplotlib: seeing what the numbers mean\n", + "\n", + "`import matplotlib.pyplot as plt` is the standard nickname — `plt` — for matplotlib's\n", + "main plotting interface. `plt.scatter` draws points, `plt.plot` draws a connected line,\n", + "and `plt.show()` displays the finished figure (in a Jupyter notebook, the figure usually\n", + "appears automatically even without `.show()`, but it's good practice to include it).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5a0b0a89", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "plt.scatter(x, y, alpha=0.7)\n", + "plt.plot(sorted(x), model.predict(np.sort(x).reshape(-1, 1)), color=\"crimson\")\n", + "plt.xlabel(\"x\")\n", + "plt.ylabel(\"y\")\n", + "plt.title(\"Data with fitted line\")\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "id": "c4ef4e41", + "metadata": {}, + "source": [ + "\n", + "### pandas: tabular data with labels\n", + "\n", + "We reuse `x_data` / `y_data` from Section 6's neural network example here, to show moving\n", + "data between a DataFrame and the plain numpy arrays scikit-learn expects.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "29c4369e", + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "\n", + "df = pd.DataFrame({\"x\": x_data.ravel(), \"y\": y_data})\n", + "X_from_df = df[[\"x\"]].to_numpy() # extract as a NumPy array for sklearn\n", + "y_from_df = df[\"y\"].to_numpy()\n", + "\n", + "model = LinearRegression().fit(X_from_df, y_from_df)\n", + "print(f\"fitted from a DataFrame: slope = {model.coef_[0]:.3f}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "5fdb4f3a", + "metadata": {}, + "source": [ + "\n", + "### Reproducibility\n", + "\n", + "The whole notebook relies on this pattern: fix a seed, and \"random\" numbers become\n", + "exactly repeatable.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a252d8c1", + "metadata": {}, + "outputs": [], + "source": [ + "rng_a = np.random.default_rng(7)\n", + "rng_b = np.random.default_rng(7)\n", + "print(np.array_equal(rng_a.normal(size=5), rng_b.normal(size=5))) # True: identical seeds, identical draws\n" + ] + }, + { + "cell_type": "markdown", + "id": "2ff646c8", + "metadata": {}, + "source": [ + "\n", + "### Quick reference: concept → library\n", + "\n", + "| Concept | Section | Library / function |\n", + "|---|---|---|\n", + "| Mean, variance, covariance, correlation | 1 | `numpy` |\n", + "| Correlation matrix across many variables | 1 | `pandas.DataFrame.corr()` |\n", + "| Normal equations (by hand) | 2 | `numpy.linalg.inv` |\n", + "| Least-squares fit (verified) | 2 | `scipy.stats.linregress` |\n", + "| Multiple / polynomial regression | 2 | `sklearn.linear_model.LinearRegression`, `PolynomialFeatures` |\n", + "| MSE, MAE, R² | 3 | `sklearn.metrics` |\n", + "| Train/test split, cross-validation | 4 | `sklearn.model_selection` |\n", + "| Gradient descent (from scratch) | 4 | `numpy` |\n", + "| Loss surfaces, non-convex optimization | 5 | `numpy` + `matplotlib` (3D plotting) |\n", + "| Feature scaling | 5 | `sklearn.preprocessing.StandardScaler` |\n", + "| All plots and figures | 1–5 | `matplotlib` |\n" + ] + }, + { + "cell_type": "markdown", + "id": "aa19311a", + "metadata": {}, + "source": [ + "\n", + "## Section 8 — From Fitting Lines to Recognizing a Cat in a Photo\n", + "\n", + "### Step 1 — a photo is just a large table of numbers\n", + "\n", + "`sklearn.datasets.load_digits()` ships a small, built-in data set of handwritten digit\n", + "images directly inside scikit-learn — no download and no internet connection required,\n", + "which is exactly why we use it here instead of real cat photographs. Each image is an 8x8\n", + "grid of numbers (pixel brightness values), stored as a numpy array.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "731031b5", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "from sklearn.datasets import load_digits\n", + "\n", + "digits = load_digits()\n", + "image = digits.images[0] # an 8x8 grayscale image, as a NumPy array\n", + "print(image.shape) # (8, 8)\n", + "print(image) # each entry is a pixel brightness value\n" + ] + }, + { + "cell_type": "markdown", + "id": "1a50b943", + "metadata": {}, + "source": [ + "\n", + "### Step 2 — treat every pixel as a predictor, and classification as fitting\n", + "\n", + "`image.reshape(len(images), -1)` **flattens** each 8x8 grid into a single row of 64\n", + "numbers — same idea as flattening a table, just applied to every image in the data set at\n", + "once. `digits.target` holds the true digit (0-9) for each image — the label we're trying\n", + "to predict.\n", + "\n", + "`LogisticRegression` is scikit-learn's model for classification (predicting a category\n", + "instead of a number) — same `.fit()` / `.predict()` interface you've already used several\n", + "times. `accuracy_score` reports the fraction of test images the model classified\n", + "correctly.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d90b0bc2", + "metadata": {}, + "outputs": [], + "source": [ + "from sklearn.model_selection import train_test_split\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.metrics import accuracy_score\n", + "\n", + "X = digits.images.reshape(len(digits.images), -1) # flatten each 8x8 image to 64 numbers\n", + "y = digits.target # the true digit, 0-9, for each image\n", + "\n", + "X_train, X_test, y_train, y_test = train_test_split(\n", + " X, y, test_size=0.3, random_state=0\n", + ")\n", + "\n", + "clf = LogisticRegression(max_iter=2000)\n", + "clf.fit(X_train, y_train)\n", + "predictions = clf.predict(X_test)\n", + "\n", + "print(f\"test accuracy: {accuracy_score(y_test, predictions):.3f}\")\n", + "print(f\"number of weights being fit: {clf.coef_.size}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "6b85a4aa", + "metadata": {}, + "source": [ + "\n", + "### Step 4 — a hand-designed convolutional kernel\n", + "\n", + "No new libraries here — this is a from-scratch, plain-numpy implementation of a single\n", + "2-D convolution, to make the mechanics concrete before you meet a real deep-learning\n", + "library's (much faster, GPU-accelerated) version of the same operation.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b68500f9", + "metadata": {}, + "outputs": [], + "source": [ + "def apply_kernel(image, kernel):\n", + " '''A minimal, from-scratch 2D convolution -- no padding, unit stride.'''\n", + " kh, kw = kernel.shape\n", + " ih, iw = image.shape\n", + " out_h, out_w = ih - kh + 1, iw - kw + 1\n", + " output = np.zeros((out_h, out_w))\n", + " for i in range(out_h):\n", + " for j in range(out_w):\n", + " patch = image[i:i + kh, j:j + kw]\n", + " output[i, j] = np.sum(patch * kernel) # the same weighted-sum idea, applied locally\n", + " return output\n", + "\n", + "vertical_edge_kernel = np.array([\n", + " [-1, 0, 1],\n", + " [-1, 0, 1],\n", + " [-1, 0, 1],\n", + "])\n", + "\n", + "sample_image = digits.images[0]\n", + "edges = apply_kernel(sample_image, vertical_edge_kernel)\n", + "print(f\"original shape: {sample_image.shape}, after kernel: {edges.shape}\")\n", + "print(np.round(edges, 1))\n" + ] + }, + { + "cell_type": "markdown", + "id": "1df3a1aa", + "metadata": {}, + "source": [ + "\n", + "---\n", + "\n", + "## You made it through the whole stack\n", + "\n", + "Every cell in this notebook builds on the ones before it: `numpy` arrays hold the\n", + "numbers, `scipy` and `sklearn` fit and score models on them, `pandas` organizes them into\n", + "tables, and `matplotlib` lets you look at the result. That combination — and the habit of\n", + "fitting a model, measuring its error, and checking it on data it hasn't seen — is the same\n", + "loop underneath everything from a two-parameter regression to a full neural network.\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv (3.13.9.final.0)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}