{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "597d3720",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h1 class=\"cal cal-h1\">Lecture 10: Optimization and Gradient Descent (CS 189, Fall 2026)</h1>\n",
    "\n",
    "This notebook accompanies Lecture 10 and Chapter 7 of Bishop and Bishop, *Deep Learning: Foundations and Concepts*. It covers:\n",
    "\n",
    "1. simple one-dimensional optimization problems and convexity,\n",
    "2. the error surface of two small models (logistic regression and a non-linear sine regression model),\n",
    "3. the gradient of the error function,\n",
    "4. batch gradient descent and loss curves, and\n",
    "5. the Hessian, the quadratic approximation of the error function, and the eigenvectors of the Hessian at a stationary point.\n",
    "\n",
    "The convergence analysis of gradient descent, momentum, and Adam are covered in the Lecture 11 notebook.\n",
    "\n",
    "Sections used in lecture are marked in their headings. **(Lecture Demo n)** is run during the lecture from the matching \"Demo\" slide, and **(Lecture Slido)** produces the plots for a Slido question. Other sections produce plots that appear on the slides marked \"Plots in demo notebook.\"\n",
    "\n",
    "| Notebook section | Lecture 10 slide |\n",
    "|---|---|\n",
    "| Optimization Basics | Examples Optimization Problems (plots) |\n",
    "| (Lecture Demo 1) The Error Surface | Demo: Understanding the Error Surface; Points on the Error Surface (plots) |\n",
    "| (Lecture Slido) Match the Model with the Error Function | Slido: Match the model with the loss |\n",
    "| (Lecture Demo 2) The Gradient of the Error Function | Demo: The Gradient of the Error Function |\n",
    "| (Lecture Demo 3) Batch Gradient Descent | Demo: Batch Gradient Descent; Loss (Error) Curves |\n",
    "| (Lecture Demo 4) Gradient Descent on a Parabola | Demo: Gradient Descent on a Parabola; the Slido question \"Assuming a simple quadratic equation (e.g., x^2) is gradient descent guaranteed to converge?\"; the result is the slide \"What are the conditions for convergence?\" |\n",
    "| (Lecture Demo 5) Second-Order Structure of the Error Function | Hessian Exercise (plots) |\n",
    "| (Lecture Demo 5) The Quadratic Approximation | Demo: Taylor Expansions and the Hessian; Eigenvectors of the Hessian (plots) |\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c2410788",
   "metadata": {},
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "import pandas as pd\n",
    "import plotly.express as px\n",
    "import plotly.graph_objects as go\n",
    "from plotly import figure_factory as ff\n",
    "from plotly.subplots import make_subplots\n",
    "\n",
    "colors = px.colors.qualitative.Plotly\n",
    "px.defaults.width = 800"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b2eafc04",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h2 class=\"cal cal-h2\">Plotting Code</h2>\n",
    "\n",
    "The functions below produce the visualizations used throughout the notebook. They are not part of the course material, but they must be run before the rest of the notebook.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "642949d6",
   "metadata": {},
   "outputs": [],
   "source": [
    "def make_plot_grid(figs, rows, cols):\n",
    "    \"\"\"Arrange several Plotly figures in a grid of subplots.\n",
    "\n",
    "    The traces and the 2D axis titles of each figure are copied into the grid.\n",
    "    Layout elements such as shapes (e.g., add_vline) and annotations are not\n",
    "    copied and must be added to the returned figure directly.\n",
    "    \"\"\"\n",
    "    def get_trace_type(fig):\n",
    "        for trace in fig.data:\n",
    "            if trace.type == 'surface':\n",
    "                return 'surface'\n",
    "            if trace.type in ('scatter3d', 'mesh3d', 'cone'):\n",
    "                return 'scene'\n",
    "        return 'xy'\n",
    "    types = [get_trace_type(fig) for fig in figs]\n",
    "    specs = [[{'type': ty} for ty in types[i:i + cols]]\n",
    "             for i in range(0, len(figs), cols)]\n",
    "    fig_grid = make_subplots(rows=rows, cols=cols, specs=specs,\n",
    "                             subplot_titles=[fig.layout.title.text or '' for fig in figs])\n",
    "    for i, (fig, ty) in enumerate(zip(figs, types)):\n",
    "        row, col = i // cols + 1, i % cols + 1\n",
    "        fig_grid.add_traces(fig.data, rows=row, cols=col)\n",
    "        if ty == 'xy':\n",
    "            fig_grid.update_xaxes(title_text=fig.layout.xaxis.title.text, row=row, col=col)\n",
    "            fig_grid.update_yaxes(title_text=fig.layout.yaxis.title.text, row=row, col=col)\n",
    "    return fig_grid"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e66af47b",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Fixed colors for the true class and fixed symbols for the predicted class.\n",
    "DIAGNOSIS_COLORS = {'benign': 'blue', 'malignant': 'red'}\n",
    "PREDICTION_SYMBOLS = {'Predicted 1 (benign)': 'cross', 'Predicted 0 (malignant)': 'circle-open'}\n",
    "\n",
    "\n",
    "def plot_lr_predictions(w, cancer_df):\n",
    "    \"\"\"Plot the data, the decision boundary, and the predicted probability surface\n",
    "    of the logistic regression model p(t=1|x) = sigmoid(w^T x).\n",
    "\n",
    "    In this dataset t=1 denotes a benign tumor and t=0 a malignant tumor. Color shows the\n",
    "    true class (benign blue, malignant red). The symbol shows the predicted class:\n",
    "    + for a prediction of 1 (benign) and o for a prediction of 0 (malignant).\n",
    "    \"\"\"\n",
    "    df = cancer_df.copy()\n",
    "    p = logistic_model(w, df[['mean radius', 'mean texture']].values)\n",
    "    df['prediction'] = np.where(p > 0.5, 'Predicted 1 (benign)', 'Predicted 0 (malignant)')\n",
    "    fig = px.scatter(df, x='mean radius', y='mean texture',\n",
    "                     symbol='prediction', color='diagnosis',\n",
    "                     symbol_map=PREDICTION_SYMBOLS, color_discrete_map=DIAGNOSIS_COLORS,\n",
    "                     category_orders={'diagnosis': list(DIAGNOSIS_COLORS),\n",
    "                                      'prediction': list(PREDICTION_SYMBOLS)})\n",
    "    for (i, t) in enumerate(fig.data):\n",
    "        t.legendgroup = str(i)\n",
    "    # Decision boundary: w_1 x_1 + w_2 x_2 = 0\n",
    "    xs = np.linspace(df['mean radius'].min(), df['mean radius'].max(), 100)\n",
    "    decision_boundary = -(w[0] * xs) / w[1]\n",
    "    fig.add_scatter(x=xs, y=decision_boundary, mode='lines',\n",
    "                    name='Decision Boundary', legendgroup='Decision Boundary',\n",
    "                    line=dict(color='black', width=2, dash='dash'))\n",
    "    # Probability surface p(t=1|x)\n",
    "    ys = np.linspace(df['mean texture'].min(), df['mean texture'].max(), 100)\n",
    "    X, Y = np.meshgrid(xs, ys)\n",
    "    Z = logistic_model(w, np.c_[X.ravel(), Y.ravel()]).reshape(X.shape)\n",
    "    # Light red where the model predicts malignant (p near 0), white at the decision\n",
    "    # boundary (p = 0.5), and light blue where it predicts benign (p near 1).\n",
    "    fig.add_contour(x=xs, y=ys, z=Z, zmin=0, zmax=1,\n",
    "                    colorscale=[[0, '#fddbc7'], [0.5, '#f7f7f7'], [1, '#d1e5f0']],\n",
    "                    contours=dict(start=0.1, end=0.9, size=0.1), line=dict(width=0.5, color='gray'),\n",
    "                    name='p(t=1|x)',\n",
    "                    colorbar=dict(title='p(t=1|x)', x=1.05, y=0.3, len=0.75))\n",
    "    fig.update_layout(title=f'w=({w[0]:.2f}, {w[1]:.2f})',\n",
    "                      xaxis_range=[xs.min(), xs.max()], yaxis_range=[ys.min(), ys.max()],\n",
    "                      xaxis_title='Mean Radius (standardized)',\n",
    "                      yaxis_title='Mean Texture (standardized)',\n",
    "                      width=800, height=600)\n",
    "    return fig"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b7bcf080",
   "metadata": {},
   "outputs": [],
   "source": [
    "def plot_loss(w1, w2, error, ncontours=50, names=('w1', 'w2')):\n",
    "    \"\"\"Plot the error surface (left) and its contours (right) on a grid of weights.\n",
    "\n",
    "    Args:\n",
    "        w1, w2: Meshgrid arrays of the two weights.\n",
    "        error: Array of error values with the same shape as w1 and w2.\n",
    "        ncontours: Number of contour levels.\n",
    "        names: Axis labels for the two weights.\n",
    "    \"\"\"\n",
    "    surf_fig = go.Figure()\n",
    "    surf_fig.add_surface(z=error, x=w1, y=w2,\n",
    "                         colorscale='Viridis_r', opacity=0.7, showscale=False,\n",
    "                         contours=dict(z=dict(show=True, highlightcolor=\"white\",\n",
    "                                              start=error.min(), end=error.max(),\n",
    "                                              size=(error.max() - error.min()) / ncontours)))\n",
    "    surf_fig.update_layout(title=\"Error Surface\")\n",
    "    contour_fig = go.Figure()\n",
    "    contour_fig.add_contour(x=w1.flatten(), y=w2.flatten(), z=error.flatten(),\n",
    "                            colorscale='Viridis_r', opacity=0.7,\n",
    "                            contours=dict(start=error.min(), end=error.max(),\n",
    "                                          size=(error.max() - error.min()) / ncontours),\n",
    "                            colorbar=dict(x=1.05, y=0.35, len=0.75))\n",
    "    contour_fig.update_layout(title=\"Error Contours\")\n",
    "    fig = make_plot_grid([surf_fig, contour_fig], 1, 2).update_layout(height=800)\n",
    "    fig.update_layout(scene=dict(xaxis_title=names[0], yaxis_title=names[1],\n",
    "                                 zaxis_title='Error', aspectmode='cube'))\n",
    "    fig.update_layout(xaxis_range=[w1.min(), w1.max()], yaxis_range=[w2.min(), w2.max()],\n",
    "                      xaxis_title=names[0], yaxis_title=names[1])\n",
    "    # Use the same scale for both weight axes on the contour plot. Otherwise one axis is\n",
    "    # stretched and the gradient no longer appears perpendicular to the contours.\n",
    "    # (The 3D scene takes no 2D axis, so the contour plot uses xaxis/yaxis.)\n",
    "    fig.update_yaxes(scaleanchor='x', scaleratio=1, constrain='domain', row=1, col=2)\n",
    "    fig.update_xaxes(constrain='domain', row=1, col=2)\n",
    "    return fig"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "6c86258f",
   "metadata": {},
   "outputs": [],
   "source": [
    "def plot_gradient(w1, w2, error, dw1, dw2, scale=1.0, names=('w1', 'w2')):\n",
    "    \"\"\"Plot the error surface and contours together with the gradient field.\n",
    "\n",
    "    The cones (left) and arrows (right) show the gradient vector at each grid\n",
    "    point. The gradient points in the direction of steepest ascent, so gradient\n",
    "    descent moves in the opposite direction.\n",
    "    \"\"\"\n",
    "    fig = plot_loss(w1, w2, error, names=names)\n",
    "    fig.add_trace(\n",
    "        go.Cone(\n",
    "            x=w1.flatten(), y=w2.flatten(), z=np.zeros_like(error).flatten(),  # ground plane\n",
    "            u=dw1.flatten(), v=dw2.flatten(), w=np.zeros_like(error).flatten(),  # no vertical component\n",
    "            sizeref=2, anchor=\"tail\", showscale=False\n",
    "        ), 1, 1)\n",
    "    quiver = ff.create_quiver(\n",
    "        x=w1.flatten(), y=w2.flatten(), u=dw1.flatten(), v=dw2.flatten(),\n",
    "        line_width=2, line_color=\"white\",\n",
    "        scale=scale, arrow_scale=.2, showlegend=False)\n",
    "    fig.add_traces(quiver.data, rows=1, cols=2)\n",
    "    return fig"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7053e9ad",
   "metadata": {},
   "outputs": [],
   "source": [
    "def add_solution_path(fig, errors, ws):\n",
    "    \"\"\"Overlay an optimization path on a figure created by plot_loss or plot_gradient.\"\"\"\n",
    "    s = np.linspace(0, 1, len(ws))\n",
    "    fig.add_scatter3d(x=ws[:, 0], y=ws[:, 1], z=errors, marker_color=s, marker_size=5,\n",
    "                      mode='lines+markers', line=dict(color='black', width=2), opacity=0.5,\n",
    "                      name='Optimization Path', legendgroup='Optimization Path',\n",
    "                      row=1, col=1)\n",
    "    fig.add_scatter(x=ws[:, 0], y=ws[:, 1], marker_color=s,\n",
    "                    mode='lines+markers', line=dict(color='black', width=2), opacity=0.5,\n",
    "                    name='Optimization Path', legendgroup='Optimization Path',\n",
    "                    showlegend=False,\n",
    "                    row=1, col=2)\n",
    "    fig.add_scatter3d(x=[ws[-1, 0]], y=[ws[-1, 1]], z=[errors[-1]],\n",
    "                      mode='markers', marker=dict(color='red', size=10),\n",
    "                      name='Final Solution', legendgroup='Final Solution',\n",
    "                      row=1, col=1)\n",
    "    fig.add_scatter(x=[ws[-1, 0]], y=[ws[-1, 1]],\n",
    "                    mode='markers', marker=dict(color='red', size=20),\n",
    "                    name='Final Solution', legendgroup='Final Solution',\n",
    "                    showlegend=False,\n",
    "                    row=1, col=2)\n",
    "    return fig"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "47a09cc7",
   "metadata": {},
   "outputs": [],
   "source": [
    "def make_loss_curve(path, error_func):\n",
    "    \"\"\"Plot the error as a function of the iteration number for a path of weights.\"\"\"\n",
    "    errors = [error_func(w) for w in path]\n",
    "    fig = px.line(x=np.arange(len(errors)), y=errors,\n",
    "                  labels={'x': 'Iteration (gradient steps)', 'y': 'Error'})\n",
    "    fig.update_traces(line_width=4)\n",
    "    fig.update_layout(margin=dict(l=20, r=20, t=20, b=20))\n",
    "    return fig"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d9197bb1",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h2 class=\"cal cal-h2\">Optimization Basics</h2>\n",
    "\n",
    "Most learning problems can be written as the optimization problem\n",
    "$$\n",
    "w^\\star = \\arg\\min_{w \\in \\Theta} E(w),\n",
    "$$\n",
    "where $E(w)$ is the error (objective) function and $\\Theta$ is the constraint set. We begin with three one-dimensional examples. These are the plots on the slide \"Examples Optimization Problems.\"\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3c170028",
   "metadata": {},
   "source": [
    "**Problem 1.** Unconstrained minimization of a quadratic:\n",
    "$$\n",
    "\\arg \\min_{w \\in \\mathbb{R}} \\; w^2 - 3w + 4.\n",
    "$$\n",
    "Setting the derivative $2w - 3$ to zero gives the unique minimizer $w^\\star = 3/2$, with minimum value $7/4$.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "bbc08cf5",
   "metadata": {},
   "outputs": [],
   "source": [
    "w = np.linspace(-2, 7, 100)\n",
    "f = lambda w: w**2 - 3 * w + 4\n",
    "fig = px.line(x=w, y=f(w), labels={'x': 'w', 'y': 'f(w)'})\n",
    "fig.update_traces(line_width=5)\n",
    "fig"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "37e9c188",
   "metadata": {},
   "source": [
    "**Problem 2.** The same objective restricted to the integers:\n",
    "$$\n",
    "\\arg \\min_{w \\in \\mathbb{Z}} \\; w^2 - 3w + 4.\n",
    "$$\n",
    "The minimum value $2$ is attained at both $w = 1$ and $w = 2$, so the minimizer is not unique. Because the domain is discrete, the condition $f'(w) = 0$ can no longer be used to locate the solution.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3b435cfe",
   "metadata": {},
   "outputs": [],
   "source": [
    "w = np.arange(-2, 7)\n",
    "f = lambda w: w**2 - 3 * w + 4\n",
    "fig = px.scatter(x=w, y=f(w), labels={'x': 'w', 'y': 'f(w)'})\n",
    "fig.update_traces(marker_size=7)\n",
    "fig"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8d8006f2",
   "metadata": {},
   "source": [
    "**Problem 3.** Unconstrained minimization of a quartic:\n",
    "$$\n",
    "\\arg \\min_{w \\in \\mathbb{R}} \\; w^4 - 5 w^2 + w + 4.\n",
    "$$\n",
    "The derivative $4w^3 - 10w + 1$ has three real roots, so the function has three stationary points: a local minimum at $w \\approx 1.53$ ($f \\approx -0.69$), a local maximum at $w \\approx 0.10$, and the global minimum at $w \\approx -1.63$ ($f \\approx -3.86$). A method that only uses local information, such as gradient descent, may converge to either minimum depending on where it starts.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "11421535",
   "metadata": {},
   "outputs": [],
   "source": [
    "w = np.linspace(-3, 3, 100)\n",
    "f = lambda w: w**4 - 5 * w**2 + w + 4\n",
    "fig = px.line(x=w, y=f(w), labels={'x': 'w', 'y': 'f(w)'})\n",
    "fig.update_traces(line_width=5)\n",
    "fig.update_layout(margin=dict(l=20, r=20, t=20, b=20))\n",
    "fig"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "68da2007",
   "metadata": {},
   "outputs": [],
   "source": [
    "# The stationary points are the real roots of f'(w) = 4w^3 - 10w + 1.\n",
    "stationary = np.sort(np.roots([4, 0, -10, 1]).real)\n",
    "for w_s in stationary:\n",
    "    print(f\"w = {w_s:6.3f},  f(w) = {f(w_s):6.3f},  f''(w) = {12 * w_s**2 - 10:6.2f}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2730b582",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h3 class=\"cal cal-h3\">Convexity</h3>\n",
    "\n",
    "A set $\\Theta$ is **convex** if for any $w_1, w_2 \\in \\Theta$ and any $0 \\le t \\le 1$, the point $t w_1 + (1-t) w_2$ also lies in $\\Theta$. That is, the set contains the line segment between any two of its points.\n",
    "\n",
    "A function $f$ defined on a convex set $\\Theta$ is **convex** if for any $w_1, w_2 \\in \\Theta$ and any $0 \\le t \\le 1$,\n",
    "$$\n",
    "f\\big(t w_1 + (1-t) w_2\\big) \\le t f(w_1) + (1-t) f(w_2).\n",
    "$$\n",
    "Geometrically, the secant line between any two points on the graph of $f$ lies on or above the graph. For a convex function every local minimum is a global minimum, and when $f$ is twice differentiable, convexity is equivalent to the Hessian being positive semi-definite everywhere.\n",
    "\n",
    "The plots below show a secant line for the convex function of Problem 1 and for the non-convex function of Problem 3. In the second case the secant line lies below the graph near $w = 0$.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "74cb3fa7",
   "metadata": {},
   "outputs": [],
   "source": [
    "def plot_secant(w, f, x1, x2):\n",
    "    \"\"\"Plot a function together with the secant line between x1 and x2.\"\"\"\n",
    "    fig = px.line(x=w, y=f(w), labels={'x': 'w', 'y': 'f(w)'})\n",
    "    fig.add_scatter(x=[x1, x2], y=[f(x1), f(x2)],\n",
    "                    mode='markers+lines', name='Secant Line',\n",
    "                    marker=dict(size=20, color='green'),\n",
    "                    line=dict(color='green', dash=\"dash\"))\n",
    "    fig.update_traces(line_width=5)\n",
    "    fig.update_layout(showlegend=False)\n",
    "    return fig"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "64bc6831",
   "metadata": {},
   "outputs": [],
   "source": [
    "w = np.linspace(-2, 7, 100)\n",
    "f = lambda w: w**2 - 3 * w + 4\n",
    "fig_convex = plot_secant(w, f, -1, 5)\n",
    "fig_convex.update_layout(title=\"Convex Function\")\n",
    "\n",
    "w = np.linspace(-3, 3, 100)\n",
    "f = lambda w: w**4 - 5 * w**2 + w + 4\n",
    "fig_nonconvex = plot_secant(w, f, -1, 1)\n",
    "fig_nonconvex.update_layout(title=\"Non-convex Function\")\n",
    "\n",
    "fig = make_plot_grid([fig_convex, fig_nonconvex], 1, 2)\n",
    "fig.update_layout(showlegend=False)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "897131bd",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h2 class=\"cal cal-h2\">(Lecture Demo 1) The Error Surface</h2>\n",
    "\n",
    "Each setting of the parameters $w$ corresponds to a point on the error surface $E(w)$ sitting over weight space (Bishop, Section 7.1). When there are only two parameters we can evaluate $E(w)$ on a grid and plot the surface directly. We do this for two models: logistic regression, whose error function is convex, and a non-linear regression model, whose error function is not.\n",
    "\n",
    "*Lecture:* run from the slide \"Demo: Understanding the Error Surface.\" The surface plots also appear on \"Points on the Error Surface.\"\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a2221077",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h3 class=\"cal cal-h3\">Logistic Regression on the Breast Cancer Data</h3>\n",
    "\n",
    "We use two features of the breast cancer dataset in `sklearn`, `\"mean radius\"` and `\"mean texture\"`, each standardized to zero mean and unit variance. The target is $t = 1$ for a benign tumor and $t = 0$ for a malignant tumor.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "0480004a",
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn import datasets\n",
    "from sklearn.preprocessing import StandardScaler\n",
    "\n",
    "cancer_dict = datasets.load_breast_cancer(as_frame=True)\n",
    "cancer_df = pd.DataFrame(cancer_dict.data, columns=cancer_dict.feature_names)\n",
    "cancer_df = cancer_df[['mean radius', 'mean texture']].copy()\n",
    "# Target t=1 denotes a benign tumor and t=0 a malignant tumor.\n",
    "cancer_df['target'] = cancer_dict.target.values\n",
    "cancer_df['diagnosis'] = cancer_dict.target_names[cancer_df['target']]\n",
    "# Standardize each feature to zero mean and unit variance.\n",
    "scaler = StandardScaler()\n",
    "cancer_df[['mean radius', 'mean texture']] = scaler.fit_transform(\n",
    "    cancer_df[['mean radius', 'mean texture']])\n",
    "print(\"The dataset:\", cancer_df.shape)\n",
    "\n",
    "display(cancer_df.head())\n",
    "px.scatter(cancer_df, x='mean radius', y='mean texture', color='diagnosis', opacity=0.7,\n",
    "           color_discrete_map=DIAGNOSIS_COLORS)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "575d6340",
   "metadata": {},
   "source": [
    "The two classes are approximately separated by a straight line, so a linear decision boundary is a reasonable model.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d6e056aa",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h4 class=\"cal cal-h4\">The Logistic Regression Model</h4>\n",
    "\n",
    "The logistic regression model is\n",
    "$$\n",
    "p(t=1 \\mid x) = \\sigma(w^\\top x + b) = \\frac{1}{1 + e^{-(w^\\top x + b)}}.\n",
    "$$\n",
    "So that the error surface can be plotted in two dimensions, we drop the bias $b$ and keep the two weights $w_1$ and $w_2$:\n",
    "$$\n",
    "p(t=1 \\mid x) = \\sigma(w_1 x_1 + w_2 x_2).\n",
    "$$\n",
    "Because the features are standardized, the data are centered at the origin, and a decision boundary that passes through the origin is a reasonable approximation.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "eca1cdd7",
   "metadata": {},
   "outputs": [],
   "source": [
    "def sigmoid(z):\n",
    "    \"\"\"Logistic sigmoid function.\"\"\"\n",
    "    return 1 / (1 + np.exp(-z))\n",
    "\n",
    "def logistic_model(w, x):\n",
    "    \"\"\"Logistic regression model p(t=1|x) = sigmoid(w^T x) (no bias term).\"\"\"\n",
    "    return sigmoid(x @ w)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "75d3442b",
   "metadata": {},
   "source": [
    "The following plots show the decision boundary and the predicted probability for two parameter settings.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "26669e57",
   "metadata": {},
   "outputs": [],
   "source": [
    "guesses = np.array([[-1., -1.],\n",
    "                    [-2., -4.]])\n",
    "figs = [plot_lr_predictions(w, cancer_df) for w in guesses]\n",
    "figs[1].update_traces(showlegend=False)\n",
    "fig = make_plot_grid(figs, 1, 2)\n",
    "fig.update_layout(height=600,\n",
    "                  xaxis_range=figs[0].layout.xaxis.range, yaxis_range=figs[0].layout.yaxis.range,\n",
    "                  xaxis2_range=figs[1].layout.xaxis.range, yaxis2_range=figs[1].layout.yaxis.range)\n",
    "fig"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a31a1044",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h4 class=\"cal cal-h4\">The Cross-Entropy Error Surface</h4>\n",
    "\n",
    "Let $y_n = \\sigma(w^\\top x_n)$ denote the predicted probability that $t_n = 1$. The average negative log-likelihood (cross-entropy) error function is\n",
    "$$\n",
    "E(w) = -\\frac{1}{N} \\sum_{n=1}^N \\Big( t_n \\ln y_n + (1 - t_n) \\ln (1 - y_n) \\Big).\n",
    "$$\n",
    "Writing $z_n = w^\\top x_n$ and using $\\ln \\sigma(z) = z - \\ln(1 + e^{z})$ and $\\ln(1 - \\sigma(z)) = -\\ln(1 + e^{z})$, this simplifies to\n",
    "$$\n",
    "E(w) = \\frac{1}{N} \\sum_{n=1}^N \\Big( \\ln\\big(1 + e^{z_n}\\big) - t_n z_n \\Big).\n",
    "$$\n",
    "The function $\\ln(1 + e^{z})$ is evaluated with `np.logaddexp(0, z)`, which avoids overflow for large $z$.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "181399bc",
   "metadata": {},
   "outputs": [],
   "source": [
    "def neg_log_likelihood(w, x=None, t=None):\n",
    "    \"\"\"Average negative log-likelihood (cross-entropy) of the logistic model.\n",
    "\n",
    "    Uses the identity -[t ln sigmoid(z) + (1-t) ln(1-sigmoid(z))] = ln(1+e^z) - t z,\n",
    "    evaluated with the numerically stable np.logaddexp(0, z) = ln(1+e^z).\n",
    "    Defaults to the full training set.\n",
    "    \"\"\"\n",
    "    x = cancer['x'] if x is None else x\n",
    "    t = cancer['t'] if t is None else t\n",
    "    z = x @ w\n",
    "    return np.mean(np.logaddexp(0, z) - t * z)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7d7133e7",
   "metadata": {},
   "source": [
    "We store the data, the grid of weights, and the quantities derived from them in a dictionary.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1125218b",
   "metadata": {},
   "outputs": [],
   "source": [
    "cancer = dict()\n",
    "cancer['x'] = cancer_df[['mean radius', 'mean texture']].values\n",
    "cancer['t'] = cancer_df['target'].values.astype(float)\n",
    "cancer['npts'] = 30\n",
    "cancer['w1'], cancer['w2'] = np.meshgrid(\n",
    "    np.linspace(-10, 1, cancer['npts']),\n",
    "    np.linspace(-5, 1.3, cancer['npts']))\n",
    "cancer['ws'] = np.stack([cancer['w1'].flatten(), cancer['w2'].flatten()]).T"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c578aed5",
   "metadata": {},
   "outputs": [],
   "source": [
    "cancer['error'] = np.array([neg_log_likelihood(w) for w in cancer['ws']])\n",
    "cancer['error'] = cancer['error'].reshape(cancer['w1'].shape)\n",
    "fig = plot_loss(cancer['w1'], cancer['w2'], cancer['error'])\n",
    "for i, g in enumerate(guesses):\n",
    "    fig.add_scatter3d(x=[g[0]], y=[g[1]], z=[neg_log_likelihood(g)],\n",
    "                      mode='markers', marker=dict(size=10, color=colors[i + 2]),\n",
    "                      name=f'w = {g}', legendgroup=str(i), row=1, col=1)\n",
    "    fig.add_scatter(x=[g[0]], y=[g[1]], mode='markers',\n",
    "                    marker=dict(size=10, color=colors[i + 2]),\n",
    "                    name=f'w = {g}', legendgroup=str(i), showlegend=False,\n",
    "                    row=1, col=2)\n",
    "fig"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c1c21ef2",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h4 class=\"cal cal-h4\">Grid Search</h4>\n",
    "\n",
    "With the error evaluated on the grid, the simplest estimate of the minimizer is the grid point with the lowest error. This approach requires $K^D$ error evaluations for a grid with $K$ points per dimension in $D$ dimensions, so it is only practical for very small $D$.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "2083fc78",
   "metadata": {},
   "outputs": [],
   "source": [
    "best_ind = np.argmin(cancer['error'])\n",
    "cancer['grid_best'] = cancer['ws'][best_ind]\n",
    "\n",
    "fig = plot_loss(cancer['w1'], cancer['w2'], cancer['error'])\n",
    "for i, g in enumerate(guesses):\n",
    "    fig.add_scatter3d(x=[g[0]], y=[g[1]], z=[neg_log_likelihood(g)],\n",
    "                      mode='markers', marker=dict(size=10, color=colors[i + 2]),\n",
    "                      name=f'w = {g}', legendgroup=str(i), row=1, col=1)\n",
    "    fig.add_scatter(x=[g[0]], y=[g[1]], mode='markers',\n",
    "                    marker=dict(size=10, color=colors[i + 2]),\n",
    "                    name=f'w = {g}', legendgroup=str(i), showlegend=False,\n",
    "                    row=1, col=2)\n",
    "label = f\"Grid best [{cancer['grid_best'][0]:0.2f}, {cancer['grid_best'][1]:0.2f}]\"\n",
    "fig.add_scatter3d(x=[cancer['grid_best'][0]], y=[cancer['grid_best'][1]], z=[cancer['error'].min()],\n",
    "                  mode='markers', marker=dict(size=10, color='red'),\n",
    "                  name=label, legendgroup=\"Best\", row=1, col=1)\n",
    "fig.add_scatter(x=[cancer['grid_best'][0]], y=[cancer['grid_best'][1]],\n",
    "                mode='markers', marker=dict(size=10, color='red'),\n",
    "                name=label, legendgroup=\"Best\", showlegend=False, row=1, col=2)\n",
    "fig"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3b96aa04",
   "metadata": {},
   "outputs": [],
   "source": [
    "plot_lr_predictions(cancer['grid_best'], cancer_df)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "59606652",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h3 class=\"cal cal-h3\">Squared Error for a Non-Linear Model</h3>\n",
    "\n",
    "We now construct a less well-behaved error surface using a non-linear regression model with a squared error function. The data are generated from a noisy sine wave.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5c3fcf42",
   "metadata": {},
   "outputs": [],
   "source": [
    "sine = dict()\n",
    "np.random.seed(42)\n",
    "sine['n'] = 200\n",
    "sine['x'] = np.sort(np.random.rand(sine['n']) * 2.5 * np.pi)  # sorted for plotting\n",
    "sine['y'] = np.sin(1.1 + 2.5 * sine['x']) + 0.5 * np.random.randn(sine['n'])\n",
    "sine_df = pd.DataFrame({'x': sine['x'], 'y': sine['y']})"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "30360eec",
   "metadata": {},
   "source": [
    "Because we generated the data, the true regression function is known:\n",
    "$$\n",
    "h(x) = \\sin(1.1 + 2.5 x),\n",
    "$$\n",
    "and the targets are $y_n = h(x_n) + \\epsilon_n$ with $\\epsilon_n \\sim \\mathcal{N}(0, 0.5^2)$.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "dc5aa614",
   "metadata": {},
   "outputs": [],
   "source": [
    "fig = px.scatter(sine_df, x='x', y='y')\n",
    "fig.update_traces(marker_color='black')\n",
    "data_trace = fig.data[0]\n",
    "fig"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e5979373",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h4 class=\"cal cal-h4\">The Sine Regression Model</h4>\n",
    "\n",
    "We fit a model of the form\n",
    "$$\n",
    "y(x, w) = \\sin(w_0 + w_1 x),\n",
    "$$\n",
    "with parameters $w = (w_0, w_1)$. The model is non-linear in both parameters: it is periodic in $w_0$ and oscillates faster as $w_1$ grows. As a result the error function is not convex.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "22f94d8a",
   "metadata": {},
   "outputs": [],
   "source": [
    "def sine_model(w, x):\n",
    "    \"\"\"Non-linear regression model y(x, w) = sin(w_0 + w_1 x).\"\"\"\n",
    "    return np.sin(w[0] + x * w[1])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3376d84c",
   "metadata": {},
   "source": [
    "The following three parameter settings are evaluated on 100 test points and plotted against the data.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7007d1be",
   "metadata": {},
   "outputs": [],
   "source": [
    "sine['guesses'] = np.array([[0, 2], [2, 3], [0, 3.5]])\n",
    "sine['xhat'] = np.linspace(sine_df['x'].min(), sine_df['x'].max(), 100)\n",
    "sine['pred_df'] = pd.DataFrame({'x': sine['xhat']})\n",
    "for w in sine['guesses']:\n",
    "    sine['pred_df'][f'yhat(w={w})'] = sine_model(w, sine['xhat'])\n",
    "sine['pred_df'].head()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b9df67a6",
   "metadata": {},
   "outputs": [],
   "source": [
    "fig = go.Figure()\n",
    "for i, w in enumerate(sine['pred_df'].columns[1:]):\n",
    "    fig.add_trace(go.Scatter(x=sine['pred_df']['x'], y=sine['pred_df'][w],\n",
    "                             mode='lines', name=w,\n",
    "                             line=dict(width=4, color=colors[i + 2])))\n",
    "fig.add_trace(data_trace)\n",
    "fig.update_layout(margin=dict(l=20, r=20, t=20, b=20),\n",
    "                  xaxis_title='x', yaxis_title='y')\n",
    "fig"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6bf88085",
   "metadata": {},
   "source": [
    "None of these parameter settings fits the data well. To compare parameter settings quantitatively we need an error function.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3ac45b24",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h4 class=\"cal cal-h4\">The Mean Squared Error Surface</h4>\n",
    "\n",
    "For a data set $\\mathcal{D} = \\{(x_n, y_n)\\}_{n=1}^N$ we use the mean squared error\n",
    "$$\n",
    "E(w) = \\frac{1}{N} \\sum_{n=1}^N \\big(y_n - y(x_n, w)\\big)^2\n",
    "= \\frac{1}{N} \\sum_{n=1}^N \\big(y_n - \\sin(w_0 + w_1 x_n)\\big)^2.\n",
    "$$\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "41c549e2",
   "metadata": {},
   "outputs": [],
   "source": [
    "def sine_MSE(w, x=None, y=None):\n",
    "    \"\"\"Mean squared error of the sine model. Defaults to the full training set.\"\"\"\n",
    "    x = sine['x'] if x is None else x\n",
    "    y = sine['y'] if y is None else y\n",
    "    return np.mean((y - sine_model(w, x)) ** 2)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6bcd1e8c",
   "metadata": {},
   "source": [
    "We evaluate $E(w)$ on a grid over $(w_0, w_1)$ and plot the resulting surface. Unlike the logistic regression error, this surface has several basins separated by ridges.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "0f90a1a4",
   "metadata": {},
   "outputs": [],
   "source": [
    "sine['npts'] = 30\n",
    "sine['w0'], sine['w1'] = np.meshgrid(\n",
    "    np.linspace(-1.5, 3, sine['npts']), np.linspace(1, 4, sine['npts']))\n",
    "# Stack the grid into an array of weight vectors (w_0, w_1).\n",
    "sine['ws'] = np.stack([sine['w0'].flatten(), sine['w1'].flatten()]).T\n",
    "sine['error'] = np.array([sine_MSE(w) for w in sine['ws']]).reshape(sine['w0'].shape)\n",
    "\n",
    "names = ('w0', 'w1')\n",
    "fig = plot_loss(sine['w0'], sine['w1'], sine['error'], names=names)\n",
    "for i, w in enumerate(sine['guesses']):\n",
    "    fig.add_trace(go.Scatter3d(x=[w[0]], y=[w[1]], z=[sine_MSE(w)],\n",
    "                               mode='markers', marker=dict(size=5, color=colors[i + 2]),\n",
    "                               name=f'w=({w[0]}, {w[1]})', legendgroup=str(i)),\n",
    "                  row=1, col=1)\n",
    "    fig.add_trace(go.Scatter(x=[w[0]], y=[w[1]],\n",
    "                             mode='markers', marker=dict(size=20, color=colors[i + 2]),\n",
    "                             name=f'w=({w[0]}, {w[1]})', legendgroup=str(i), showlegend=False),\n",
    "                  row=1, col=2)\n",
    "fig"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3e3dff71",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h4 class=\"cal cal-h4\">Grid Search</h4>\n",
    "\n",
    "As before, we take the grid point with the lowest error.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3c72bd7a",
   "metadata": {},
   "outputs": [],
   "source": [
    "ind = np.argmin(sine['error'])\n",
    "sine['grid_best'] = sine['ws'][ind, :]\n",
    "sine['grid_best_error'] = sine['error'].flatten()[ind]\n",
    "print(f\"Best weights: {sine['grid_best']}, with error: {sine['grid_best_error']:.4f}\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f5805bde",
   "metadata": {},
   "outputs": [],
   "source": [
    "fig = plot_loss(sine['w0'], sine['w1'], sine['error'], names=names)\n",
    "for i, w in enumerate(sine['guesses']):\n",
    "    fig.add_trace(go.Scatter3d(x=[w[0]], y=[w[1]], z=[sine_MSE(w)],\n",
    "                               mode='markers', marker=dict(size=5, color=colors[i + 2]),\n",
    "                               name=f'w=({w[0]}, {w[1]})', legendgroup=str(i)),\n",
    "                  row=1, col=1)\n",
    "    fig.add_trace(go.Scatter(x=[w[0]], y=[w[1]],\n",
    "                             mode='markers', marker=dict(size=20, color=colors[i + 2]),\n",
    "                             name=f'w=({w[0]}, {w[1]})', legendgroup=str(i), showlegend=False),\n",
    "                  row=1, col=2)\n",
    "label = f\"Grid best [{sine['grid_best'][0]:0.2f}, {sine['grid_best'][1]:0.2f}]\"\n",
    "fig.add_scatter3d(x=[sine['grid_best'][0]], y=[sine['grid_best'][1]], z=[sine['grid_best_error']],\n",
    "                  mode='markers', marker=dict(size=10, color='red'),\n",
    "                  name=label, legendgroup=\"Best\", row=1, col=1)\n",
    "fig.add_scatter(x=[sine['grid_best'][0]], y=[sine['grid_best'][1]],\n",
    "                mode='markers', marker=dict(size=20, color='red'),\n",
    "                name=label, legendgroup=\"Best\", showlegend=False, row=1, col=2)\n",
    "fig"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "636f95bb",
   "metadata": {},
   "source": [
    "The model at the best grid point is a much better fit to the data than the three initial guesses.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5e6ff7d8",
   "metadata": {},
   "outputs": [],
   "source": [
    "fig = go.Figure()\n",
    "for i, w in enumerate(sine['pred_df'].columns[1:]):\n",
    "    fig.add_trace(go.Scatter(x=sine['pred_df']['x'], y=sine['pred_df'][w], mode='lines', name=w,\n",
    "                             line=dict(width=4, color=colors[i + 2])))\n",
    "fig.add_trace(data_trace)\n",
    "fig.add_scatter(x=sine['xhat'], y=sine_model(sine['grid_best'], sine['xhat']),\n",
    "                mode='lines',\n",
    "                name=f\"Grid best w=({sine['grid_best'][0]:.2f}, {sine['grid_best'][1]:.2f})\",\n",
    "                line=dict(width=8, color='red'))\n",
    "fig.update_layout(margin=dict(l=20, r=20, t=20, b=20),\n",
    "                  xaxis_title='x', yaxis_title='y')\n",
    "fig"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3c8322d4",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h3 class=\"cal cal-h3\">(Lecture Slido) Match the Model with the Error Function</h3>\n",
    "\n",
    "The following three cells produce the plots used in the Slido question that asks students to match each model with its error function. Each model is fit to one feature of the breast cancer data.\n",
    "\n",
    "1. Logistic regression with a single weight, $p(t=1 \\mid x) = \\sigma(w x)$, using the standardized mean radius as $x$. The error is a function of one parameter.\n",
    "2. Least squares regression through the origin, $y = w x$, predicting the standardized mean texture from the standardized mean radius. The error is a quadratic function of one parameter.\n",
    "3. Logistic regression with a bias, $p(t=1 \\mid x) = \\sigma(w_1 + w_2 x)$, with the feature shifted by $+3$. Because the shifted feature is not centered, the bias and the slope trade off against each other: many pairs $(w_1, w_2)$ with a similar decision boundary $x = -w_1 / w_2$ fit the data about equally well, so the error surface has a long, narrow valley.\n",
    "\n",
    "A small amount of vertical jitter is added to the binary targets so that overlapping points are visible.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "984e675c",
   "metadata": {},
   "outputs": [],
   "source": [
    "rng = np.random.default_rng(0)\n",
    "x1 = cancer['x'][:, 0]\n",
    "fig1 = px.scatter(x=x1, y=cancer['t'] + 0.02 * rng.normal(size=cancer['t'].shape),\n",
    "                  color=cancer_df['diagnosis'], color_discrete_map=DIAGNOSIS_COLORS,\n",
    "                  title=\"Cancer Data (Jittered)\",\n",
    "                  labels={'x': 'Mean Radius (standardized)', 'y': 't', 'color': 'diagnosis'})\n",
    "\n",
    "ws = np.linspace(-20, 3, 100)\n",
    "nll = np.array([neg_log_likelihood(np.array([w]), x1[:, None]) for w in ws])\n",
    "\n",
    "fig2 = px.line(x=ws, y=nll, labels={'x': 'w', 'y': 'Negative Log-Likelihood'})\n",
    "ind = np.argmin(nll)\n",
    "best_w = ws[ind]\n",
    "fig2.add_scatter(x=[best_w], y=[nll[ind]], mode='markers',\n",
    "                 marker=dict(color='red', size=10), name=\"Best w\")\n",
    "xtest = np.linspace(x1.min(), x1.max(), 100)\n",
    "fig1.add_scatter(x=xtest, y=sigmoid(best_w * xtest),\n",
    "                 mode='lines', line=dict(color='black', width=4),\n",
    "                 name=f'Logistic Model (w={best_w:.2f})')\n",
    "fig = make_plot_grid([fig1, fig2], 1, 2)\n",
    "fig.add_vline(x=best_w, line=dict(color='red', dash='dash'),\n",
    "              annotation_text=f\"Best w={best_w:.2f}\",\n",
    "              annotation_position=\"top right\", row=1, col=2)\n",
    "fig.update_layout(height=600)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1d67ebcd",
   "metadata": {},
   "outputs": [],
   "source": [
    "x1, x2 = cancer['x'][:, 0], cancer['x'][:, 1]\n",
    "fig1 = px.scatter(x=x1, y=x2, title=\"Cancer Data\",\n",
    "                  labels={'x': 'Mean Radius (standardized)', 'y': 'Mean Texture (standardized)'})\n",
    "ws = np.linspace(-5, 5, 100)\n",
    "sqloss = np.array([np.mean((x2 - w * x1) ** 2) for w in ws])\n",
    "fig2 = px.line(x=ws, y=sqloss, labels={'x': 'w', 'y': 'Squared Loss'})\n",
    "ind = np.argmin(sqloss)\n",
    "best_w = ws[ind]\n",
    "xtest = np.linspace(x1.min(), x1.max(), 100)\n",
    "fig1.add_scatter(x=xtest, y=best_w * xtest,\n",
    "                 mode='lines', line=dict(color='black', width=4),\n",
    "                 name=f'Linear Model (w={best_w:.2f})')\n",
    "fig2.add_scatter(x=[best_w], y=[sqloss[ind]], mode='markers',\n",
    "                 marker=dict(color='red', size=10), name=\"Best w\")\n",
    "fig = make_plot_grid([fig1, fig2], 1, 2)\n",
    "fig.add_vline(x=best_w, line=dict(color='red', dash='dash'),\n",
    "              annotation_text=f\"Best w={best_w:.2f}\",\n",
    "              annotation_position=\"top right\", row=1, col=2)\n",
    "fig.update_layout(height=600)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d1879c9c",
   "metadata": {},
   "outputs": [],
   "source": [
    "offset = 3\n",
    "x1 = cancer['x'][:, 0] + offset\n",
    "fig1 = px.scatter(x=x1, y=cancer['t'] + 0.02 * rng.normal(size=cancer['t'].shape),\n",
    "                  color=cancer_df['diagnosis'], color_discrete_map=DIAGNOSIS_COLORS,\n",
    "                  title=\"Cancer Data (Jittered, Shifted)\",\n",
    "                  labels={'x': 'Mean Radius (standardized + 3)', 'y': 't', 'color': 'diagnosis'})\n",
    "w1, w2 = np.meshgrid(np.linspace(5, 15, 40), np.linspace(-8, 0, 40))\n",
    "ws = np.stack([w1.flatten(), w2.flatten()]).T\n",
    "# Features [1, x] so that w[0] is the bias and w[1] is the slope.\n",
    "x_aug = np.stack([np.ones_like(x1), x1], axis=1)\n",
    "nll = np.array([neg_log_likelihood(w, x_aug) for w in ws]).reshape(w1.shape)\n",
    "\n",
    "fig2 = plot_loss(w1, w2, nll, names=('w1 (bias)', 'w2 (slope)'))\n",
    "ind = np.argmin(nll)\n",
    "best_w = ws[ind, :]\n",
    "fig2.add_scatter3d(x=[best_w[0]], y=[best_w[1]], z=[nll.flatten()[ind]],\n",
    "                   mode='markers', marker=dict(size=10, color='red'),\n",
    "                   name=f'Best w=({best_w[0]:.2f}, {best_w[1]:.2f})', legendgroup='Best',\n",
    "                   row=1, col=1)\n",
    "fig2.add_scatter(x=[best_w[0]], y=[best_w[1]],\n",
    "                 mode='markers', marker=dict(size=20, color='red'),\n",
    "                 name=f'Best w=({best_w[0]:.2f}, {best_w[1]:.2f})', legendgroup='Best',\n",
    "                 showlegend=False, row=1, col=2)\n",
    "fig2.show()\n",
    "xtest = np.linspace(x1.min(), x1.max(), 100)\n",
    "fig1.add_scatter(x=xtest, y=sigmoid(best_w[1] * xtest + best_w[0]),\n",
    "                 mode='lines', line=dict(color='black', width=4),\n",
    "                 name=f'Logistic Model (w=({best_w[0]:.2f}, {best_w[1]:.2f}))')\n",
    "fig1.update_layout(height=600)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "af230cd0",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h2 class=\"cal cal-h2\">(Lecture Demo 2) The Gradient of the Error Function</h2>\n",
    "\n",
    "The gradient $\\nabla E(w)$ is the vector of partial derivatives $\\partial E / \\partial w_j$. It points in the direction of the greatest rate of increase of $E$, and for a small step $\\delta w$ the change in the error is $\\delta E \\simeq \\delta w^\\top \\nabla E(w)$ (Bishop, Eq. 7.1). In the plots below the gradient is drawn as a vector field in weight space: cones under the surface on the left, and arrows on the contour plot on the right. The gradient lives in the $D$-dimensional weight space, not on the error surface itself.\n",
    "\n",
    "*Lecture:* run from the slide \"Demo: The Gradient of the Error Function,\" after the Slido question on the dimension of the gradient.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "46189357",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h3 class=\"cal cal-h3\">Gradient of the Cross-Entropy Error</h3>\n",
    "\n",
    "For the logistic regression model without a bias, $y_n = \\sigma(w^\\top x_n)$, and the average negative log-likelihood is\n",
    "$$\n",
    "E(w) = -\\frac{1}{N} \\sum_{n=1}^N \\Big( t_n \\ln \\sigma(w^\\top x_n) + (1 - t_n) \\ln\\big(1 - \\sigma(w^\\top x_n)\\big) \\Big).\n",
    "$$\n",
    "The derivative of the logistic sigmoid satisfies\n",
    "$$\n",
    "\\frac{d\\sigma}{dz} = \\sigma(z)\\big(1 - \\sigma(z)\\big).\n",
    "$$\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2e82a229",
   "metadata": {},
   "source": [
    "Applying the chain rule, the $j^\\text{th}$ component of the gradient is\n",
    "\\begin{align*}\n",
    "\\frac{\\partial E}{\\partial w_j}\n",
    "&= -\\frac{1}{N} \\sum_{n=1}^N \\left( \\frac{t_n}{\\sigma(w^\\top x_n)} \\frac{\\partial \\sigma(w^\\top x_n)}{\\partial w_j} - \\frac{1 - t_n}{1 - \\sigma(w^\\top x_n)} \\frac{\\partial \\sigma(w^\\top x_n)}{\\partial w_j} \\right) \\\\\n",
    "&= -\\frac{1}{N} \\sum_{n=1}^N \\Big( t_n \\big(1 - \\sigma(w^\\top x_n)\\big) - (1 - t_n)\\, \\sigma(w^\\top x_n) \\Big) x_{nj} \\\\\n",
    "&= \\frac{1}{N} \\sum_{n=1}^N \\big(\\sigma(w^\\top x_n) - t_n\\big) x_{nj}.\n",
    "\\end{align*}\n",
    "The gradient is the average of the prediction errors $y_n - t_n$ weighted by the inputs, the same form as for linear regression with a squared error (Bishop, Section 5.4.3).\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c950e70e",
   "metadata": {},
   "outputs": [],
   "source": [
    "def grad_NLL(w, x=None, t=None):\n",
    "    \"\"\"Gradient of the average negative log-likelihood of the logistic model.\n",
    "\n",
    "    Accepts the full data set (default), a mini-batch, or a single data point.\n",
    "    \"\"\"\n",
    "    x = cancer['x'] if x is None else np.atleast_2d(x)\n",
    "    t = cancer['t'] if t is None else np.atleast_1d(t)\n",
    "    y = logistic_model(w, x)\n",
    "    return np.mean((y - t).reshape(-1, 1) * x, 0)\n",
    "\n",
    "\n",
    "grad_NLL(np.array([-1., 2.]))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6d0cff4f",
   "metadata": {},
   "source": [
    "We evaluate the gradient at every grid point and plot the resulting vector field. The arrows are long where the surface is steep and short near the minimum.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "91a34785",
   "metadata": {},
   "outputs": [],
   "source": [
    "(cancer['dw1'], cancer['dw2']) = np.array([grad_NLL(w) for w in cancer['ws']]).T\n",
    "cancer['dw1'] = cancer['dw1'].reshape(cancer['w1'].shape)\n",
    "cancer['dw2'] = cancer['dw2'].reshape(cancer['w1'].shape)\n",
    "fig = plot_gradient(cancer['w1'], cancer['w2'], cancer['error'],\n",
    "                    cancer['dw1'], cancer['dw2'], scale=2)\n",
    "fig"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1faa6e41",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h3 class=\"cal cal-h3\">Gradient of the Squared Error for the Sine Model</h3>\n",
    "\n",
    "For the sine model $y(x, w) = \\sin(w_0 + w_1 x)$ with the mean squared error\n",
    "$$\n",
    "E(w) = \\frac{1}{N} \\sum_{n=1}^N \\big(y_n - \\sin(w_0 + w_1 x_n)\\big)^2,\n",
    "$$\n",
    "the gradient is\n",
    "$$\n",
    "\\nabla E(w) =\n",
    "\\begin{bmatrix}\n",
    "\\partial E / \\partial w_0 \\\\\n",
    "\\partial E / \\partial w_1\n",
    "\\end{bmatrix}.\n",
    "$$\n",
    "Writing $u_n = w_0 + w_1 x_n$ and applying the chain rule gives\n",
    "\\begin{align*}\n",
    "\\frac{\\partial E}{\\partial w_0}\n",
    "&= -\\frac{2}{N} \\sum_{n=1}^N \\big(y_n - \\sin u_n\\big) \\cos u_n, \\\\\n",
    "\\frac{\\partial E}{\\partial w_1}\n",
    "&= -\\frac{2}{N} \\sum_{n=1}^N \\big(y_n - \\sin u_n\\big) \\cos u_n \\; x_n.\n",
    "\\end{align*}\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ae9a45be",
   "metadata": {},
   "outputs": [],
   "source": [
    "def grad_sine_MSE(w, x=None, y=None):\n",
    "    \"\"\"Gradient of the mean squared error of the sine model.\n",
    "\n",
    "    Accepts the full data set (default), a mini-batch, or a single data point.\n",
    "    \"\"\"\n",
    "    x = sine['x'] if x is None else np.atleast_1d(x)\n",
    "    y = sine['y'] if y is None else np.atleast_1d(y)\n",
    "    u = w[0] + w[1] * x\n",
    "    r = y - np.sin(u)\n",
    "    grad_w0 = -2 * np.mean(r * np.cos(u))\n",
    "    grad_w1 = -2 * np.mean(r * np.cos(u) * x)\n",
    "    return np.array([grad_w0, grad_w1])\n",
    "\n",
    "\n",
    "grad_sine_MSE(np.array([0., 2.]))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f460643b",
   "metadata": {},
   "outputs": [],
   "source": [
    "(sine['dw0'], sine['dw1']) = np.array([grad_sine_MSE(w) for w in sine['ws']]).T\n",
    "sine['dw0'] = sine['dw0'].reshape(sine['w1'].shape)\n",
    "sine['dw1'] = sine['dw1'].reshape(sine['w1'].shape)\n",
    "fig = plot_gradient(sine['w0'], sine['w1'], sine['error'],\n",
    "                    sine['dw0'], sine['dw1'], scale=0.1, names=names)\n",
    "fig"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "fc8eaedc",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h2 class=\"cal cal-h2\">(Lecture Demo 3) Batch Gradient Descent</h2>\n",
    "\n",
    "Gradient descent starts from an initial weight vector $w^{(0)}$ and repeatedly takes a small step in the direction of the negative gradient (Bishop, Eq. 7.16):\n",
    "$$\n",
    "w^{(\\tau)} = w^{(\\tau-1)} - \\eta \\nabla E\\big(w^{(\\tau-1)}\\big),\n",
    "$$\n",
    "where $\\eta > 0$ is the learning rate. Because $E$ is defined over the whole training set, each step requires a pass through all $N$ data points, and the method is therefore called **batch** gradient descent. The implementation below uses a fixed learning rate and stops when the update becomes smaller than a threshold $\\epsilon$ or after a maximum number of iterations.\n",
    "\n",
    "*Lecture:* run from the slide \"Demo: Batch Gradient Descent.\" The loss curves below go with the slide \"Loss (Error) Curves.\"\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "0a84eeee",
   "metadata": {},
   "outputs": [],
   "source": [
    "def gradient_descent(w_0, gradient, learning_rate=1, nepochs=10, epsilon=1e-6):\n",
    "    \"\"\"Batch gradient descent with a fixed learning rate.\n",
    "\n",
    "    Args:\n",
    "        w_0: Initial weight vector.\n",
    "        gradient: Function that returns the gradient of the error at w.\n",
    "        learning_rate: Learning rate (step size) eta.\n",
    "        nepochs: Maximum number of iterations.\n",
    "        epsilon: Stop when the norm of the update falls below this threshold.\n",
    "    Returns:\n",
    "        path: Array containing the weight vector at each iteration.\n",
    "    \"\"\"\n",
    "    w_old = np.asarray(w_0, dtype=float)\n",
    "    path = [w_old]\n",
    "    for _ in range(nepochs):\n",
    "        w = w_old - learning_rate * gradient(w_old)\n",
    "        path.append(w)\n",
    "        if np.linalg.norm(w - w_old) < epsilon:\n",
    "            break\n",
    "        w_old = w\n",
    "    return np.array(path)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "157e404c",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h3 class=\"cal cal-h3\">Gradient Descent for Logistic Regression</h3>\n",
    "\n",
    "Starting from $w^{(0)} = (0, 0)$ with $\\eta = 1$, the iterates move downhill, perpendicular to the contours, and then along the floor of the valley towards the minimum. Progress along the valley is slow because the curvature in that direction is small, so after 100 iterations the final point is still short of the minimum $w^\\star \\approx (-3.96, -0.89)$ found below.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "6e91695e",
   "metadata": {},
   "outputs": [],
   "source": [
    "w0 = np.array([0., 0.])\n",
    "path = gradient_descent(w0, grad_NLL, learning_rate=1, nepochs=100)\n",
    "errors = [neg_log_likelihood(w) for w in path]\n",
    "fig = plot_gradient(cancer['w1'], cancer['w2'], cancer['error'],\n",
    "                    cancer['dw1'], cancer['dw2'], scale=2)\n",
    "add_solution_path(fig, errors, path)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "5994f108",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h3 class=\"cal cal-h3\">Gradient Descent for the Sine Model</h3>\n",
    "\n",
    "For the sine model we use a smaller learning rate, $\\eta = 0.01$. The error surface is much more strongly curved in the $w_1$ direction than in the $w_0$ direction, because $\\partial u_n / \\partial w_1 = x_n$ and the inputs range up to $2.5\\pi$.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cc817f57",
   "metadata": {},
   "outputs": [],
   "source": [
    "w0 = np.array([2., 2.])\n",
    "path = gradient_descent(w0, grad_sine_MSE, learning_rate=.01, nepochs=20)\n",
    "errors = [sine_MSE(w) for w in path]\n",
    "fig = plot_gradient(sine['w0'], sine['w1'], sine['error'],\n",
    "                    sine['dw0'], sine['dw1'], scale=.1, names=names)\n",
    "add_solution_path(fig, errors, path)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6f1f730d",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h3 class=\"cal cal-h3\">Loss Curves</h3>\n",
    "\n",
    "In problems with more than two or three parameters the error surface cannot be visualized. Instead, we monitor the **loss curve**, which plots the error against the iteration number. A curve that decreases steadily indicates a suitable learning rate. A curve that oscillates or increases indicates that the learning rate is too large, and a curve that decreases very slowly suggests that it is too small.\n",
    "\n",
    "The first example uses the sine model with $\\eta = 0.05$.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "df40baf0",
   "metadata": {},
   "outputs": [],
   "source": [
    "w0 = np.array([2.5, 1.9])\n",
    "path = gradient_descent(w0, grad_sine_MSE, learning_rate=.05, nepochs=50)\n",
    "\n",
    "fig_loss = make_loss_curve(path, sine_MSE)\n",
    "fig_loss.show()\n",
    "\n",
    "errors = [sine_MSE(w) for w in path]\n",
    "fig = plot_gradient(sine['w0'], sine['w1'], sine['error'],\n",
    "                    sine['dw0'], sine['dw1'], scale=.1, names=names)\n",
    "add_solution_path(fig, errors, path)\n",
    "fig.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "369c4774",
   "metadata": {},
   "source": [
    "The second example returns to logistic regression, starting far from the minimum at $w^{(0)} = (-10, -5)$ with the much larger learning rate $\\eta = 10$. The iteration is stable because the curvature of this error surface is small: near the minimum the largest eigenvalue of the Hessian is approximately $0.08$, so any $\\eta < 2/\\lambda_{\\max} \\approx 25$ is locally stable (this condition is derived in Lecture 11). The appropriate scale of $\\eta$ is therefore problem dependent, and $0 < \\eta < 1$ is only a common starting point.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "82e89ad5",
   "metadata": {},
   "outputs": [],
   "source": [
    "w0 = np.array([-10., -5.])\n",
    "path = gradient_descent(w0, grad_NLL, learning_rate=10, nepochs=100)\n",
    "\n",
    "fig_loss = make_loss_curve(path, neg_log_likelihood)\n",
    "fig_loss.show()\n",
    "\n",
    "errors = [neg_log_likelihood(w) for w in path]\n",
    "fig = plot_gradient(cancer['w1'], cancer['w2'], cancer['error'],\n",
    "                    cancer['dw1'], cancer['dw2'], scale=2)\n",
    "add_solution_path(fig, errors, path)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2f67ee2c",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h2 class=\"cal cal-h2\">(Lecture Demo 4) Gradient Descent on a Parabola</h2>\n",
    "\n",
    "Consider $f(x) = x^2 + 1$, with $f'(x) = 2x$. A gradient descent step is\n",
    "$$\n",
    "x^{(\\tau)} = x^{(\\tau-1)} - 2\\eta\\, x^{(\\tau-1)} = (1 - 2\\eta)\\, x^{(\\tau-1)},\n",
    "$$\n",
    "so $x^{(\\tau)} = (1 - 2\\eta)^\\tau x^{(0)}$. The iterates converge to the minimum at $x = 0$ if and only if $|1 - 2\\eta| < 1$, that is $0 < \\eta < 1$. With $\\eta = 1.1$ the factor is $-1.2$, and the iterates alternate in sign and grow in magnitude. Gradient descent is therefore not guaranteed to converge, even on a simple quadratic.\n",
    "\n",
    "The axes below are fixed, so the plot can be compared across learning rates. For $\\eta$ slightly below 1 the iterates alternate in sign and shrink, at $\\eta = 1$ they alternate between $\\pm 5$ (the starting value $x^{(0)} = 5$), and for $\\eta$ slightly above 1 they alternate and grow.\n",
    "\n",
    "*Lecture:* run from the slide \"Demo: Gradient Descent on a Parabola,\" around the Slido question \"Assuming a simple quadratic equation (e.g., x^2) is gradient descent guaranteed to converge?\" The plot with the default settings is the figure on the slide \"What are the conditions for convergence?\", which leads into the quadratic approximation below: near a minimum every smooth error function looks like this parabola, with the curvature given by the Hessian.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "917c0711",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Try values slightly below and above 1, e.g., 0.9, 0.95, 1.0, 1.05, 1.1.\n",
    "learning_rate = 1.05\n",
    "x0, nepochs = 5., 10\n",
    "\n",
    "def grad_f(x):\n",
    "    return 2 * x\n",
    "\n",
    "path = gradient_descent(x0, grad_f, learning_rate=learning_rate, nepochs=nepochs)\n",
    "\n",
    "x = np.linspace(-13, 13, 200)\n",
    "fig = px.line(x=x, y=x**2 + 1, labels={'x': 'x', 'y': 'f(x)'})\n",
    "fig.update_traces(line_width=5)\n",
    "fig.add_scatter(x=path, y=path**2 + 1, mode='markers+lines',\n",
    "                marker=dict(size=10, color='red'),\n",
    "                line=dict(color='black', width=2, dash=\"dash\"),\n",
    "                name='Gradient Descent Path')\n",
    "# Fixed axes so that the plot does not rescale when the learning rate changes.\n",
    "# Points that leave this range (large learning rates) are not shown.\n",
    "fig.update_layout(title=f\"η = {learning_rate},  x(τ) = (1 − 2η)^τ x(0) with 1 − 2η = {1 - 2 * learning_rate:.2f}\",\n",
    "                  xaxis_range=[-13, 13], yaxis_range=[-5, 175], height=500)\n",
    "fig"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "151de0e6",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h2 class=\"cal cal-h2\">(Lecture Demo 5) Second-Order Structure of the Error Function</h2>\n",
    "\n",
    "The Hessian $H = \\nabla\\nabla E(w)$ is the $D \\times D$ matrix of second derivatives,\n",
    "$$\n",
    "H_{jk} = \\frac{\\partial^2 E}{\\partial w_j \\, \\partial w_k}.\n",
    "$$\n",
    "It describes the local curvature of the error surface. Because mixed partial derivatives commute for smooth functions, $H$ is symmetric.\n",
    "\n",
    "*Lecture:* the exercise below has the plots for the slide \"Hessian Exercise.\" Demo 5 continues in \"The Quadratic Approximation.\"\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6005fcf7",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h3 class=\"cal cal-h3\">Exercise: A Simple Quadratic</h3>\n",
    "\n",
    "For $E(w) = (w_0 - 1)^2 + (w_1 - 2)^2 + 1$ the gradient is $\\nabla E(w) = \\big(2(w_0 - 1),\\, 2(w_1 - 2)\\big)^\\top$ and the Hessian is the constant matrix $H = 2I$. Both eigenvalues equal 2, so the stationary point $w^\\star = (1, 2)$ is a minimum and the contours are circles.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "61e66668",
   "metadata": {},
   "outputs": [],
   "source": [
    "import sympy as sp\n",
    "\n",
    "w0_s, w1_s = sp.symbols('w0 w1')\n",
    "E_ex = (w0_s - 1)**2 + (w1_s - 2)**2 + 1\n",
    "display(sp.Matrix([sp.diff(E_ex, v) for v in (w0_s, w1_s)]))\n",
    "display(sp.hessian(E_ex, (w0_s, w1_s)))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ba9424f6",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Error surface and gradient field of the exercise function (plots for the \"Hessian Exercise\" slide).\n",
    "ex_w0, ex_w1 = np.meshgrid(np.linspace(-1, 3, 13), np.linspace(0, 4, 13))\n",
    "ex_E = (ex_w0 - 1)**2 + (ex_w1 - 2)**2 + 1\n",
    "plot_gradient(ex_w0, ex_w1, ex_E, 2 * (ex_w0 - 1), 2 * (ex_w1 - 2),\n",
    "              scale=0.08, names=('w0', 'w1'))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d2c3b70e",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h3 class=\"cal cal-h3\">The Hessian of the Logistic Regression Error</h3>\n",
    "\n",
    "Starting from the gradient derived above,\n",
    "$$\n",
    "\\frac{\\partial E}{\\partial w_j} = \\frac{1}{N} \\sum_{n=1}^N \\big(\\sigma(w^\\top x_n) - t_n\\big) x_{nj},\n",
    "$$\n",
    "we differentiate with respect to $w_k$ and use $d\\sigma/dz = \\sigma(z)(1 - \\sigma(z))$:\n",
    "\\begin{align*}\n",
    "\\frac{\\partial^2 E}{\\partial w_k \\, \\partial w_j}\n",
    "&= \\frac{1}{N} \\sum_{n=1}^N x_{nj} \\frac{\\partial}{\\partial w_k} \\sigma(w^\\top x_n) \\\\\n",
    "&= \\frac{1}{N} \\sum_{n=1}^N \\sigma(w^\\top x_n)\\big(1 - \\sigma(w^\\top x_n)\\big) x_{nj} x_{nk}.\n",
    "\\end{align*}\n",
    "In matrix form, $H = \\frac{1}{N} X^\\top R X$ where $R$ is diagonal with $R_{nn} = y_n (1 - y_n)$. Since $R_{nn} > 0$, the Hessian is positive semi-definite for every $w$, which shows that the logistic regression error function is convex.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "2a17c048",
   "metadata": {},
   "outputs": [],
   "source": [
    "def hessian_NLL(w):\n",
    "    \"\"\"Hessian of the average negative log-likelihood of the logistic model.\"\"\"\n",
    "    x = cancer['x']\n",
    "    p = logistic_model(w, x)\n",
    "    return (x * (p * (1 - p))[:, None]).T @ x / len(p)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "15557542",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h3 class=\"cal cal-h3\">The Hessian of the Sine Regression Error</h3>\n",
    "\n",
    "With $u_n = w_0 + w_1 x_n$, the gradient of the mean squared error is\n",
    "$$\n",
    "\\frac{\\partial E}{\\partial w_0} = -\\frac{2}{N} \\sum_{n=1}^N (y_n - \\sin u_n) \\cos u_n,\n",
    "\\qquad\n",
    "\\frac{\\partial E}{\\partial w_1} = -\\frac{2}{N} \\sum_{n=1}^N (y_n - \\sin u_n) \\cos u_n \\, x_n.\n",
    "$$\n",
    "Differentiating again with the product rule, and defining $a_n = \\cos^2 u_n + (y_n - \\sin u_n) \\sin u_n$, gives\n",
    "$$\n",
    "\\frac{\\partial^2 E}{\\partial w_0^2} = \\frac{2}{N} \\sum_{n=1}^N a_n,\n",
    "\\qquad\n",
    "\\frac{\\partial^2 E}{\\partial w_0 \\, \\partial w_1} = \\frac{2}{N} \\sum_{n=1}^N a_n x_n,\n",
    "\\qquad\n",
    "\\frac{\\partial^2 E}{\\partial w_1^2} = \\frac{2}{N} \\sum_{n=1}^N a_n x_n^2.\n",
    "$$\n",
    "The term $(y_n - \\sin u_n)\\sin u_n$ can be negative, so $a_n$ can be negative and the Hessian need not be positive semi-definite. The check below finds points on the grid where the Hessian has a negative eigenvalue, so the error function is not convex.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "a7d3ac93",
   "metadata": {},
   "outputs": [],
   "source": [
    "def hessian_sine_MSE(w):\n",
    "    \"\"\"Hessian of the mean squared error of the sine model.\"\"\"\n",
    "    x = sine['x']\n",
    "    y = sine['y']\n",
    "    u = w[0] + w[1] * x\n",
    "    a = np.cos(u) ** 2 + (y - np.sin(u)) * np.sin(u)\n",
    "    h00 = 2 * np.mean(a)\n",
    "    h01 = 2 * np.mean(a * x)\n",
    "    h11 = 2 * np.mean(a * x ** 2)\n",
    "    return np.array([[h00, h01], [h01, h11]])"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "2f7bbc98",
   "metadata": {},
   "outputs": [],
   "source": [
    "# The smallest Hessian eigenvalue over the grid is negative, so E is not convex.\n",
    "min_eig = min(np.linalg.eigvalsh(hessian_sine_MSE(w))[0] for w in sine['ws'])\n",
    "print(f\"Smallest Hessian eigenvalue on the grid: {min_eig:.2f}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cf59df53",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h3 class=\"cal cal-h3\">Checking the Derivation with Symbolic Differentiation</h3>\n",
    "\n",
    "The `sympy` library can compute the same derivatives symbolically. We define the error as a symbolic sum over the data, compute its Hessian, and convert the result into a numerical function. On Colab, `sympy` may need to be upgraded first with `!pip install --upgrade sympy`.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7b2269f5",
   "metadata": {},
   "outputs": [],
   "source": [
    "import sympy as sp\n",
    "\n",
    "w0_s, w1_s, x_s, y_s = sp.symbols('w0 w1 x y')\n",
    "# Squared error for a single data point and its gradient and Hessian\n",
    "E_n = (y_s - sp.sin(w0_s + w1_s * x_s))**2\n",
    "display(sp.Matrix([sp.diff(E_n, v) for v in (w0_s, w1_s)]))\n",
    "display(sp.hessian(E_n, (w0_s, w1_s)))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "4a8b3b39",
   "metadata": {},
   "outputs": [],
   "source": [
    "n_s = sp.symbols('n', integer=True, positive=True)\n",
    "i_s = sp.Idx('i', n_s)\n",
    "x_i, y_i = sp.IndexedBase('x'), sp.IndexedBase('y')\n",
    "\n",
    "# Mean squared error over the data set as a symbolic sum\n",
    "E_sym = sp.Sum((y_i[i_s] - sp.sin(w0_s + w1_s * x_i[i_s]))**2, (i_s, 0, n_s - 1)) / n_s\n",
    "H_sym = sp.hessian(E_sym, (w0_s, w1_s))\n",
    "H_fun = sp.lambdify((w0_s, w1_s, x_i, y_i, n_s), H_sym, modules='numpy')\n",
    "\n",
    "def hessian_sine_MSE_sympy(w):\n",
    "    return np.array(H_fun(w[0], w[1], sine['x'], sine['y'], len(sine['x'])), dtype=float)\n",
    "\n",
    "w_test = np.array([1.1, 2.5])\n",
    "print(hessian_sine_MSE(w_test))\n",
    "print(hessian_sine_MSE_sympy(w_test))\n",
    "assert np.allclose(hessian_sine_MSE(w_test), hessian_sine_MSE_sympy(w_test))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "797ef348",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h2 class=\"cal cal-h2\">(Lecture Demo 5) The Quadratic Approximation</h2>\n",
    "\n",
    "The second-order Taylor expansion of $E(w)$ around a point $\\widehat{w}$ is (Bishop, Eq. 7.3)\n",
    "$$\n",
    "E(w) \\simeq E(\\widehat{w}) + (w - \\widehat{w})^\\top b + \\frac{1}{2} (w - \\widehat{w})^\\top H (w - \\widehat{w}),\n",
    "$$\n",
    "where $b = \\nabla E(\\widehat{w})$ and $H = \\nabla\\nabla E(\\widehat{w})$. At a stationary point $w^\\star$ the linear term vanishes. Let $H u_i = \\lambda_i u_i$ with orthonormal eigenvectors $u_i$, and define $\\alpha_i = u_i^\\top (w - w^\\star)$. Then (Bishop, Eq. 7.11)\n",
    "$$\n",
    "E(w) \\simeq E(w^\\star) + \\frac{1}{2} \\sum_i \\lambda_i \\alpha_i^2.\n",
    "$$\n",
    "The contours of constant error are ellipses whose axes are aligned with the eigenvectors $u_i$, with lengths proportional to $\\lambda_i^{-1/2}$. If all eigenvalues are positive, $w^\\star$ is a local minimum.\n",
    "\n",
    "*Lecture:* run from the slide \"Demo: Taylor Expansions and the Hessian.\" The eigenvector plot for the sine model is the figure on the slide \"Eigenvectors of the Hessian.\"\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ff772797",
   "metadata": {},
   "outputs": [],
   "source": [
    "def taylor_loss(w, w_hat, E_hat, g_hat, H_hat):\n",
    "    \"\"\"Second-order Taylor approximation of the error around w_hat.\"\"\"\n",
    "    delta = w - w_hat\n",
    "    return E_hat + delta @ g_hat + 0.5 * delta @ H_hat @ delta"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "2ba2c767",
   "metadata": {},
   "outputs": [],
   "source": [
    "def plot_eigenvectors(w1, w2, taylor, w_star, lam, U, names=('w1', 'w2'), scaling=0.5):\n",
    "    \"\"\"Contours of a quadratic approximation with the Hessian eigenvectors drawn\n",
    "    from w_star with lengths proportional to 1/sqrt(lambda_i).\"\"\"\n",
    "    fig = go.Figure()\n",
    "    fig.add_contour(z=taylor.flatten(), x=w1.flatten(), y=w2.flatten(),\n",
    "                    colorscale='viridis_r', opacity=0.5,\n",
    "                    contours=dict(start=taylor.min(), end=taylor.max(),\n",
    "                                  size=(taylor.max() - taylor.min()) / 50),\n",
    "                    colorbar=dict(x=1.05, y=0.3, len=0.75))\n",
    "    lengths = scaling / np.sqrt(np.abs(lam))\n",
    "    cx, cy = w_star\n",
    "    for i, (lam_i, u_i, s_i) in enumerate(zip(lam, U.T, lengths), start=1):\n",
    "        color = colors[i]\n",
    "        fig.add_scatter(x=[cx, cx + s_i * u_i[0]], y=[cy, cy + s_i * u_i[1]],\n",
    "                        mode='lines+markers', line=dict(width=2, color=color),\n",
    "                        name=f'u{i} (λ={lam_i:.3g})', legendgroup=f'u{i}')\n",
    "        fig.add_scatter(x=[cx, cx - s_i * u_i[0]], y=[cy, cy - s_i * u_i[1]],\n",
    "                        mode='lines', line=dict(width=2, dash='dot', color=color),\n",
    "                        legendgroup=f'u{i}', showlegend=False)\n",
    "    fig.update_layout(title=\"Quadratic Approximation of the Error\",\n",
    "                      xaxis_title=names[0], yaxis_title=names[1],\n",
    "                      height=600, width=1200)\n",
    "    # Equal axis scales, so that orthogonal eigenvectors look orthogonal.\n",
    "    fig.update_xaxes(range=[w1.min(), w1.max()], constrain='domain')\n",
    "    fig.update_yaxes(range=[w2.min(), w2.max()], constrain='domain',\n",
    "                     scaleanchor='x', scaleratio=1)\n",
    "    return fig"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "339a6e3e",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h3 class=\"cal cal-h3\">Logistic Regression</h3>\n",
    "\n",
    "We expand around the minimum $w^\\star$. The best grid point is only an approximation to the minimum, so we first refine it with gradient descent until the gradient is close to zero.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d51e79df",
   "metadata": {},
   "outputs": [],
   "source": [
    "cancer['w_star'] = gradient_descent(cancer['grid_best'], grad_NLL,\n",
    "                                    learning_rate=10, nepochs=1000, epsilon=1e-10)[-1]\n",
    "cancer['E_star'] = neg_log_likelihood(cancer['w_star'])\n",
    "cancer['g_star'] = grad_NLL(cancer['w_star'])\n",
    "cancer['H_star'] = hessian_NLL(cancer['w_star'])\n",
    "cancer['lam'], cancer['U'] = np.linalg.eigh(cancer['H_star'])\n",
    "print(\"w_star:\", cancer['w_star'])\n",
    "print(\"E(w_star):\", cancer['E_star'])\n",
    "print(\"gradient at w_star:\", cancer['g_star'])\n",
    "print(\"Hessian at w_star:\\n\", cancer['H_star'])\n",
    "print(\"Eigenvalues:\", cancer['lam'])\n",
    "print(\"Eigenvectors (columns):\\n\", cancer['U'])\n",
    "print(\"Condition number:\", cancer['lam'][-1] / cancer['lam'][0])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c8e4d08f",
   "metadata": {},
   "source": [
    "Both eigenvalues are positive, so $w^\\star$ is a minimum, as expected for a convex error function. The surface below overlays the quadratic approximation (orange) on the true error surface. The approximation is accurate near $w^\\star$ and degrades farther away.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "251249d4",
   "metadata": {},
   "outputs": [],
   "source": [
    "fig = plot_gradient(cancer['w1'], cancer['w2'], cancer['error'], cancer['dw1'], cancer['dw2'], scale=2)\n",
    "cancer['taylor_loss'] = np.array([\n",
    "    taylor_loss(w, cancer['w_star'], cancer['E_star'], cancer['g_star'], cancer['H_star'])\n",
    "    for w in cancer['ws']]).reshape(cancer['w1'].shape)\n",
    "tl = cancer['taylor_loss']\n",
    "fig.add_surface(z=tl, x=cancer['w1'], y=cancer['w2'],\n",
    "                colorscale='plasma_r', opacity=0.5, showscale=False,\n",
    "                contours=dict(z=dict(show=True, highlightcolor=\"white\",\n",
    "                                     start=tl.min(), end=tl.max(),\n",
    "                                     size=(tl.max() - tl.min()) / 50)),\n",
    "                row=1, col=1)\n",
    "fig.update_layout(scene=dict(zaxis=dict(range=[0, 3])))\n",
    "fig"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "69ff242c",
   "metadata": {},
   "source": [
    "The eigenvector with the smaller eigenvalue points along the long axis of the elliptical contours, the direction of lowest curvature.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "a80df351",
   "metadata": {},
   "outputs": [],
   "source": [
    "plot_eigenvectors(cancer['w1'], cancer['w2'], cancer['taylor_loss'],\n",
    "                  cancer['w_star'], cancer['lam'], cancer['U'])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ae942d26",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h3 class=\"cal cal-h3\">Sine Regression</h3>\n",
    "\n",
    "We repeat the analysis for the sine model, refining the best grid point to the nearby local minimum.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7fe1679a",
   "metadata": {},
   "outputs": [],
   "source": [
    "sine['w_star'] = gradient_descent(sine['grid_best'], grad_sine_MSE,\n",
    "                                  learning_rate=0.05, nepochs=5000, epsilon=1e-10)[-1]\n",
    "sine['E_star'] = sine_MSE(sine['w_star'])\n",
    "sine['g_star'] = grad_sine_MSE(sine['w_star'])\n",
    "sine['H_star'] = hessian_sine_MSE(sine['w_star'])\n",
    "sine['lam'], sine['U'] = np.linalg.eigh(sine['H_star'])\n",
    "print(\"w_star:\", sine['w_star'])\n",
    "print(\"E(w_star):\", sine['E_star'])\n",
    "print(\"gradient at w_star:\", sine['g_star'])\n",
    "print(\"Hessian at w_star:\\n\", sine['H_star'])\n",
    "print(\"Eigenvalues:\", sine['lam'])\n",
    "print(\"Eigenvectors (columns):\\n\", sine['U'])\n",
    "print(\"Condition number:\", sine['lam'][-1] / sine['lam'][0])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b647ba91",
   "metadata": {},
   "source": [
    "Both eigenvalues are positive, so $w^\\star$ is a local minimum. The error function is not convex, however: the Hessian is indefinite at other points of weight space (see the check above), and the quadratic approximation describes the surface only within the basin around $w^\\star$. The two eigenvalues differ by a factor of about 80, so the contours near the minimum are long, narrow ellipses.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "0bf41c7a",
   "metadata": {},
   "outputs": [],
   "source": [
    "fig = plot_gradient(sine['w0'], sine['w1'], sine['error'], sine['dw0'], sine['dw1'],\n",
    "                    scale=0.1, names=names)\n",
    "sine['taylor_loss'] = np.array([\n",
    "    taylor_loss(w, sine['w_star'], sine['E_star'], sine['g_star'], sine['H_star'])\n",
    "    for w in sine['ws']]).reshape(sine['w1'].shape)\n",
    "tl = sine['taylor_loss']\n",
    "fig.add_surface(z=tl, x=sine['w0'], y=sine['w1'],\n",
    "                colorscale='plasma_r', opacity=0.5, showscale=False,\n",
    "                contours=dict(z=dict(show=True, highlightcolor=\"white\",\n",
    "                                     start=tl.min(), end=tl.max(),\n",
    "                                     size=(tl.max() - tl.min()) / 50)),\n",
    "                row=1, col=1)\n",
    "fig.update_layout(scene=dict(zaxis=dict(range=[0, 3])))\n",
    "fig"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "24ed5648",
   "metadata": {},
   "outputs": [],
   "source": [
    "plot_eigenvectors(sine['w0'], sine['w1'], sine['taylor_loss'],\n",
    "                  sine['w_star'], sine['lam'], sine['U'], names=names)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e15addf9",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h3 class=\"cal cal-h3\">Classifying Stationary Points</h3>\n",
    "\n",
    "At a stationary point the quadratic approximation is $E(w^\\star) + \\frac{1}{2}\\sum_i \\lambda_i \\alpha_i^2$, so the signs of the eigenvalues determine the type of the point: all positive gives a minimum, all negative a maximum, and mixed signs a saddle point. Moving along $u_i$ increases the error if $\\lambda_i > 0$ and decreases it if $\\lambda_i < 0$. The figure for the slides (`slides/slides.ipynb`, \"Hessian directions at stationary points\") draws the eigenvectors for each case. The helper below classifies a stationary point from its Hessian.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "0412ccd3",
   "metadata": {},
   "outputs": [],
   "source": [
    "def classify_stationary_point(H, tol=1e-10):\n",
    "    lam = np.linalg.eigvalsh(H)\n",
    "    if np.all(lam > tol):\n",
    "        return 'minimum', lam\n",
    "    if np.all(lam < -tol):\n",
    "        return 'maximum', lam\n",
    "    if np.any(lam > tol) and np.any(lam < -tol):\n",
    "        return 'saddle point', lam\n",
    "    return 'degenerate (some eigenvalues are zero)', lam\n",
    "\n",
    "for name, H_ex in [('minimum', np.diag([0.25, 1.0])), ('maximum', np.diag([-0.25, -1.0])),\n",
    "                   ('saddle', np.diag([0.25, -1.0])), ('sine model at w*', sine['H_star'])]:\n",
    "    print(f\"{name:>18}: {classify_stationary_point(H_ex)}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d8e6b0bb",
   "metadata": {},
   "source": [
    "<link rel=\"stylesheet\" href=\"berkeley.css\">\n",
    "\n",
    "<h2 class=\"cal cal-h2\">Next Lecture</h2>\n",
    "\n",
    "The Lecture 11 notebook continues from this point. It uses the quadratic approximation developed above to analyze when gradient descent converges and how fast, and then introduces momentum, learning rate schedules, Adam, and stochastic gradient descent.\n"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "py_3_11",
   "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.11.13"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
