{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "af5c49b5",
   "metadata": {},
   "source": [
    "[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/MeiraDavidson/math_module2/blob/main/notebooks/ch8_convexity.ipynb)\n\n",
    "# Chapter 8 — Companion Notebook\n",
    "## Maxima, Minima, and the Shape of a Function\n",
    "*where a curve peaks, where it bottoms out, and why some bowls have no false floors*\n",
    "\n",
    "Three live pictures of the chapter's payoff: classify a critical point with *both* derivative tests while the curve is painted by its curvature, watch chords sit **above** and tangents sit **below** a convex graph, and drop gradient-descent balls into a convex bowl (all reach the one floor) versus a two-valley landscape (they split) — the keystone made visible: *a convex bowl has no false floors.*\n",
    "\n",
    "> **How to use this notebook.** Run every cell from the top (Shift+Enter). Each widget below is live — drag the sliders and watch the picture respond. Requires `ipywidgets` and `matplotlib`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "308d9fe3",
   "metadata": {},
   "outputs": [],
   "source": [
    "%matplotlib inline\n",
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "from matplotlib import animation\n",
    "from ipywidgets import (interact, interactive, fixed, Dropdown, Checkbox,\n",
    "                        Button, Output, VBox, HBox,\n",
    "                        FloatSlider, IntSlider, FloatLogSlider)\n",
    "from IPython.display import HTML, display, Markdown\n",
    "\n",
    "# Book 2 palette — matches the printed figures in figures/png/\n",
    "BLUE=\"#1f4e79\"; RED=\"#c0392b\"; GREEN=\"#2e8b57\"; ORANGE=\"#e08e0b\"\n",
    "PURPLE=\"#6c3483\"; TEAL=\"#1b9e9e\"; GRAY=\"#7f8c8d\"; DARK=\"#2c3e50\"\n",
    "SHADE=\"#9fc5e8\"; SHADE2=\"#f4b6ad\"; SHADEG=\"#a9dfbf\"; LIGHT=\"#d6dbdf\"\n",
    "\n",
    "plt.rcParams.update({\n",
    "    \"figure.figsize\": (7.2, 4.5), \"figure.dpi\": 96,\n",
    "    \"font.family\": \"serif\", \"mathtext.fontset\": \"cm\", \"font.size\": 12,\n",
    "    \"axes.spines.top\": False, \"axes.spines.right\": False,\n",
    "    \"axes.grid\": True, \"grid.color\": LIGHT, \"grid.linewidth\": 0.7,\n",
    "    \"lines.linewidth\": 2.2, \"lines.solid_capstyle\": \"round\",\n",
    "})\n",
    "\n",
    "def axes(ax=None, title=None):\n",
    "    '''A clean axes with a light grid; returns the axes.'''\n",
    "    if ax is None:\n",
    "        _, ax = plt.subplots()\n",
    "    if title:\n",
    "        ax.set_title(title, color=DARK)\n",
    "    return ax\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "eb9bcd55",
   "metadata": {},
   "source": [
    "## 1. Classify the critical point\n",
    "\n",
    "A **critical point** is where $f'(c)=0$ — a *candidate* for a max or a min, never a verdict on its own. Two tests deliver the verdict:\n",
    "\n",
    "- **First-derivative test:** watch the sign of $f'$ change across $c$. From $+$ to $-$ is a **maximum**; from $-$ to $+$ is a **minimum**; no change is **neither** (like $x^3$ at $0$).\n",
    "- **Second-derivative test:** read $f''(c)$. Positive $\\Rightarrow$ **minimum** (the curve cups upward), negative $\\Rightarrow$ **maximum** (it caps downward), zero $\\Rightarrow$ **inconclusive**.\n",
    "\n",
    "Pick a function. The curve is painted **green** where it is *concave up* ($f''>0$, a cup) and **red** where it is *concave down* ($f''<0$, a cap); hollow dots mark the **inflection points** where the two meet. Each critical point is flagged with what *both* tests conclude — and you can confirm they agree. *(Figures 8.2, 8.3.)*"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "01a1ef33",
   "metadata": {},
   "outputs": [],
   "source": [
    "# (f, f', f'') for each menu entry, with a plotting range.\n",
    "_W1 = {\n",
    "    'f(x) = x^3 - 3x': dict(\n",
    "        f=lambda x: x**3 - 3*x, fp=lambda x: 3*x**2 - 3,\n",
    "        fpp=lambda x: 6*x, lo=-2.4, hi=2.4),\n",
    "    'f(x) = x^4 - 2x^2': dict(\n",
    "        f=lambda x: x**4 - 2*x**2, fp=lambda x: 4*x**3 - 4*x,\n",
    "        fpp=lambda x: 12*x**2 - 4, lo=-1.8, hi=1.8),\n",
    "    'f(x) = x^3 (saddle slope)': dict(\n",
    "        f=lambda x: x**3, fp=lambda x: 3*x**2,\n",
    "        fpp=lambda x: 6*x, lo=-1.6, hi=1.6),\n",
    "    'f(x) = sin(x)': dict(\n",
    "        f=lambda x: np.sin(x), fp=lambda x: np.cos(x),\n",
    "        fpp=lambda x: -np.sin(x), lo=-2*np.pi, hi=2*np.pi),\n",
    "}\n",
    "\n",
    "def _sign_changes(g, xs):\n",
    "    '''Indices i where g(xs) changes sign between i and i+1.'''\n",
    "    s = np.sign(g(xs))\n",
    "    s[s == 0] = 1                 # treat exact zeros as a side\n",
    "    return np.where(np.diff(s) != 0)[0]\n",
    "\n",
    "def _classify(curve='f(x) = x^3 - 3x'):\n",
    "    d = _W1[curve]\n",
    "    f, fp, fpp, lo, hi = d['f'], d['fp'], d['fpp'], d['lo'], d['hi']\n",
    "    xs = np.linspace(lo, hi, 1600)\n",
    "    ys = f(xs)\n",
    "\n",
    "    fig, ax = plt.subplots()\n",
    "    # Paint the curve by the sign of f'' (concavity).\n",
    "    up = fpp(xs) > 0\n",
    "    bnd = [0] + list(np.where(np.diff(up.astype(int)) != 0)[0] + 1)\n",
    "    bnd += [len(xs)]\n",
    "    lab_up = lab_dn = True\n",
    "    for i in range(len(bnd) - 1):\n",
    "        s, e = bnd[i], bnd[i + 1]\n",
    "        if e - s < 2:\n",
    "            continue\n",
    "        cu = up[s]\n",
    "        col = GREEN if cu else RED\n",
    "        lab = None\n",
    "        if cu and lab_up:\n",
    "            lab, lab_up = \"concave up  (f'' > 0)\", False\n",
    "        elif (not cu) and lab_dn:\n",
    "            lab, lab_dn = \"concave down  (f'' < 0)\", False\n",
    "        ax.plot(xs[s:e+1], ys[s:e+1], color=col, lw=3.0, label=lab,\n",
    "                zorder=2)\n",
    "    # Inflection points: where f'' changes sign.\n",
    "    inf = _sign_changes(fpp, xs)\n",
    "    if inf.size:\n",
    "        xi = 0.5 * (xs[inf] + xs[inf + 1])\n",
    "        ax.scatter(xi, f(xi), s=70, facecolors='white',\n",
    "                   edgecolors=DARK, linewidths=1.8, zorder=6,\n",
    "                   label='inflection  (f\\u2032\\u2032 = 0)')\n",
    "    # Critical points: where f' changes sign OR just touches zero.\n",
    "    crit_i = _sign_changes(fp, xs)\n",
    "    xc = list(0.5 * (xs[crit_i] + xs[crit_i + 1]))\n",
    "    # Also catch f' = 0 with no sign change (e.g. x^3 at 0): local min of |f'|.\n",
    "    near = np.where(np.abs(fp(xs)) < 1e-2)[0]\n",
    "    for k in near:\n",
    "        x0 = xs[k]\n",
    "        if all(abs(x0 - c) > 0.15 for c in xc):\n",
    "            xc.append(x0)\n",
    "    xc = sorted(xc)\n",
    "\n",
    "    verdicts = []\n",
    "    h = 1e-3\n",
    "    for c in xc:\n",
    "        ax.scatter([c], [f(c)], s=95, color=DARK, zorder=7)\n",
    "        # First-derivative test: sign of f' just left / right.\n",
    "        sl, sr = fp(c - 5*h), fp(c + 5*h)\n",
    "        if sl > 0 and sr < 0:\n",
    "            fdt = 'max'\n",
    "        elif sl < 0 and sr > 0:\n",
    "            fdt = 'min'\n",
    "        else:\n",
    "            fdt = 'neither'\n",
    "        # Second-derivative test: sign of f''(c).\n",
    "        v = fpp(c)\n",
    "        if v > 1e-6:\n",
    "            sdt = 'min'\n",
    "        elif v < -1e-6:\n",
    "            sdt = 'max'\n",
    "        else:\n",
    "            sdt = 'inconclusive (f\\u2032\\u2032 = 0)'\n",
    "        verdicts.append((c, fdt, sdt))\n",
    "        ax.axvline(c, color=GRAY, lw=0.8, ls=':')\n",
    "    ax.axhline(0, color=GRAY, lw=0.8, ls=':')\n",
    "    ax.set_xlim(lo, hi)\n",
    "    ax.set_xlabel('$x$'); ax.set_ylabel('$y$')\n",
    "    ax.set_title('Curvature painted on; critical points classified',\n",
    "                 color=DARK)\n",
    "    ax.legend(loc='best', framealpha=0.9, fontsize=9)\n",
    "    plt.show()\n",
    "\n",
    "    # Print both verdicts so the reader sees them agree.\n",
    "    if not verdicts:\n",
    "        print('No critical points (f\\u2032 \\u2260 0) in this window.')\n",
    "    else:\n",
    "        print(f'Critical points of  {curve}:')\n",
    "        for c, fdt, sdt in verdicts:\n",
    "            agree = '\\u2713 agree' if fdt == sdt else (\n",
    "                '(2nd test inconclusive \\u2014 trust the 1st)')\n",
    "            print(f\"  x \\u2248 {c:+.3f}:  1st-deriv test \\u2192 {fdt:<8}\"\n",
    "                  f\"  2nd-deriv test \\u2192 {sdt:<24} {agree}\")\n",
    "\n",
    "interact(_classify,\n",
    "         curve=Dropdown(options=list(_W1), description='function'));\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "fdfeb83f",
   "metadata": {},
   "source": [
    "## 2. Convexity checker: chords above, tangents below\n",
    "\n",
    "Two of the **four faces of convexity** are pictures you can test by eye:\n",
    "\n",
    "- **Chord above graph** — the straight line between any two points on the graph lies *on or above* the curve.\n",
    "- **Tangent below graph** — the graph lies *on or above* every one of its tangent lines.\n",
    "\n",
    "A **convex** function ($f''\\ge 0$ everywhere) passes both, always. A non-convex one fails somewhere. Pick a function and slide the **seed** to draw fresh random chords (**orange**) and tangents (**red**). Each gets a $\\checkmark$ if it lands on the correct side and a $\\times$ if it doesn't — so the convex curves come up all checks, and the wavy one gives itself away. *(Faces 1 and 2.)*"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5556a8df",
   "metadata": {},
   "outputs": [],
   "source": [
    "_W2 = {\n",
    "    'f(x) = x^2  (convex)':  dict(\n",
    "        f=lambda x: x**2, fp=lambda x: 2*x,\n",
    "        lo=-3.0, hi=3.0, convex=True),\n",
    "    'f(x) = e^x  (convex)':  dict(\n",
    "        f=lambda x: np.exp(x), fp=lambda x: np.exp(x),\n",
    "        lo=-2.2, hi=1.8, convex=True),\n",
    "    'f(x) = x^4  (convex)':  dict(\n",
    "        f=lambda x: x**4, fp=lambda x: 4*x**3,\n",
    "        lo=-1.7, hi=1.7, convex=True),\n",
    "    'f(x) = x^3 - 3x  (NOT convex)': dict(\n",
    "        f=lambda x: x**3 - 3*x, fp=lambda x: 3*x**2 - 3,\n",
    "        lo=-2.4, hi=2.4, convex=False),\n",
    "    'f(x) = sin(x)  (NOT convex)': dict(\n",
    "        f=lambda x: np.sin(x), fp=lambda x: np.cos(x),\n",
    "        lo=-np.pi, hi=np.pi, convex=False),\n",
    "}\n",
    "\n",
    "def _convexity(curve='f(x) = x^2  (convex)', seed=0, n_lines=3):\n",
    "    d = _W2[curve]\n",
    "    f, fp, lo, hi = d['f'], d['fp'], d['lo'], d['hi']\n",
    "    rng = np.random.default_rng(seed)\n",
    "    xs = np.linspace(lo, hi, 800)\n",
    "    ys = f(xs)\n",
    "\n",
    "    fig, ax = plt.subplots()\n",
    "    ax.plot(xs, ys, color=BLUE, lw=2.6, label='$f(x)$', zorder=3)\n",
    "\n",
    "    chord_ok = tang_ok = 0\n",
    "    lab_c = lab_t = True\n",
    "    span = hi - lo\n",
    "    for _ in range(n_lines):\n",
    "        # --- a random chord: should lie ABOVE a convex graph ---\n",
    "        a, b = np.sort(rng.uniform(lo, hi, 2))\n",
    "        if b - a < 0.3 * span:        # keep chords visible\n",
    "            b = min(hi, a + 0.45 * span)\n",
    "        t = np.linspace(0, 1, 60)\n",
    "        cx = a + t * (b - a)\n",
    "        chord = f(a) + t * (f(b) - f(a))      # the straight line\n",
    "        above = np.all(chord >= f(cx) - 1e-9)  # chord on/above curve\n",
    "        chord_ok += int(above)\n",
    "        lab = ('chord (should be above)' if lab_c else None)\n",
    "        lab_c = False\n",
    "        ax.plot(cx, chord, color=ORANGE, lw=1.8, zorder=4, label=lab)\n",
    "        mark = '$\\\\checkmark$' if above else '$\\\\times$'\n",
    "        mc = GREEN if above else RED\n",
    "        ax.annotate(mark, xy=(cx[30], chord[30]), color=mc,\n",
    "                    fontsize=15, ha='center', va='bottom', zorder=8)\n",
    "\n",
    "        # --- a random tangent: should lie BELOW a convex graph ---\n",
    "        x0 = rng.uniform(lo + 0.1*span, hi - 0.1*span)\n",
    "        tx = np.linspace(lo, hi, 60)\n",
    "        tang = f(x0) + fp(x0) * (tx - x0)      # the tangent line\n",
    "        below = np.all(tang <= f(tx) + 1e-9)   # tangent on/below curve\n",
    "        tang_ok += int(below)\n",
    "        lab = ('tangent (should be below)' if lab_t else None)\n",
    "        lab_t = False\n",
    "        ax.plot(tx, tang, color=RED, lw=1.6, ls='--', zorder=2,\n",
    "                label=lab)\n",
    "        mark = '$\\\\checkmark$' if below else '$\\\\times$'\n",
    "        mc = GREEN if below else RED\n",
    "        ax.scatter([x0], [f(x0)], s=30, color=RED, zorder=6)\n",
    "        ax.annotate(mark, xy=(tx[5], tang[5]), color=mc,\n",
    "                    fontsize=15, ha='center', va='top', zorder=8)\n",
    "\n",
    "    pad = 0.12 * (np.nanmax(ys) - np.nanmin(ys) + 1e-9)\n",
    "    ax.set_ylim(np.nanmin(ys) - 3*pad, np.nanmax(ys) + 3*pad)\n",
    "    ax.set_xlim(lo, hi)\n",
    "    ax.set_xlabel('$x$'); ax.set_ylabel('$y$')\n",
    "    verdict = 'convex \\u2014 all on the right side' if d['convex'] \\\n",
    "        else 'not convex \\u2014 some land wrong'\n",
    "    ax.set_title(verdict, color=(GREEN if d['convex'] else RED))\n",
    "    ax.legend(loc='best', framealpha=0.9, fontsize=9)\n",
    "    plt.show()\n",
    "\n",
    "    print(f'{curve}')\n",
    "    print(f'  chords above curve : {chord_ok}/{n_lines}')\n",
    "    print(f'  tangents below curve: {tang_ok}/{n_lines}')\n",
    "    if d['convex']:\n",
    "        print('  \\u2713 convex: every chord is above and every '\n",
    "              'tangent is below (Faces 1 & 2).')\n",
    "    else:\n",
    "        print('  \\u2717 non-convex: at least one chord dips below or '\n",
    "              'a tangent pokes above.')\n",
    "\n",
    "interact(_convexity,\n",
    "         curve=Dropdown(options=list(_W2), description='function'),\n",
    "         seed=IntSlider(value=0, min=0, max=20, step=1,\n",
    "                        description='seed'),\n",
    "         n_lines=IntSlider(value=3, min=1, max=5, step=1,\n",
    "                           description='# lines'));\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2a196fbb",
   "metadata": {},
   "source": [
    "## 3. Convex vs non-convex descent\n",
    "\n",
    "Here is the keystone of the whole book, made visible. Gradient descent only ever steps **downhill**: $x \\leftarrow x - \\eta\\, f'(x)$, with **learning rate** $\\eta$.\n",
    "\n",
    "- **Left — a convex bowl** $f(x)=x^2$. By the theorem, the one point where $f'=0$ is the *global* minimum, and there is nothing else to fall into. Balls dropped from *anywhere* (**purple**) all roll to the same single floor. **No false floors.**\n",
    "- **Right — a two-valley landscape** $f(x)=x^4-3x^2+x$. This is *non-convex*: two separate dips of different depth. Balls (**teal**) **split** — each falls into whichever valley is nearest and stops, even when a deeper one waits across the ridge. This is exactly the Chapter 9 lab where the starting point decided the outcome.\n",
    "\n",
    "Slide the **learning rate** and the **number of starts**. Watch the left panel always agree and the right panel disagree — that disagreement *is* non-convexity, and it is why \"is your problem convex?\" was, for decades, the first question in optimization."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "677e0ea7",
   "metadata": {},
   "outputs": [],
   "source": [
    "def _descend(f, fp, x0, eta, steps=60):\n",
    "    '''Return the gradient-descent path from x0.'''\n",
    "    xs = [x0]\n",
    "    x = x0\n",
    "    for _ in range(steps):\n",
    "        x = x - eta * fp(x)\n",
    "        if not np.isfinite(x) or abs(x) > 1e3:   # diverged\n",
    "            break\n",
    "        xs.append(x)\n",
    "    return np.array(xs)\n",
    "\n",
    "# Left: convex bowl.   Right: two-valley landscape.\n",
    "_fL  = lambda x: x**2;            _fLp = lambda x: 2*x\n",
    "_fR  = lambda x: x**4 - 3*x**2 + x\n",
    "_fRp = lambda x: 4*x**3 - 6*x + 1\n",
    "\n",
    "def _split_demo(eta=0.10, n_starts=7):\n",
    "    fig, (axL, axR) = plt.subplots(1, 2, figsize=(11.5, 4.6))\n",
    "\n",
    "    # ----- Left panel: convex bowl, every ball -> one floor -----\n",
    "    xs = np.linspace(-3.2, 3.2, 500)\n",
    "    axL.plot(xs, _fL(xs), color=BLUE, lw=2.4, zorder=2)\n",
    "    starts = np.linspace(-3.0, 3.0, n_starts)\n",
    "    ends = []\n",
    "    for x0 in starts:\n",
    "        path = _descend(_fL, _fLp, x0, eta)\n",
    "        axL.plot(path, _fL(path), color=PURPLE, lw=1.0, alpha=0.5,\n",
    "                 zorder=3)\n",
    "        axL.scatter([x0], [_fL(x0)], s=28, color=PURPLE, alpha=0.6,\n",
    "                    zorder=4)\n",
    "        axL.scatter([path[-1]], [_fL(path[-1])], s=70, color=PURPLE,\n",
    "                    edgecolors='white', linewidths=1.2, zorder=6)\n",
    "        ends.append(path[-1])\n",
    "    axL.scatter([0], [0], marker='*', s=260, color=GREEN,\n",
    "                edgecolors=DARK, linewidths=1.0, zorder=7,\n",
    "                label='the one floor')\n",
    "    axL.set_title('Convex bowl  $x^2$  \\u2014 no false floors',\n",
    "                  color=GREEN)\n",
    "    axL.set_xlabel('$x$'); axL.set_ylabel('$f(x)$')\n",
    "    axL.legend(loc='upper center', fontsize=9, framealpha=0.9)\n",
    "    n_floors_L = len(np.unique(np.round(ends, 1)))\n",
    "\n",
    "    # ----- Right panel: two valleys, balls split -----\n",
    "    xr = np.linspace(-2.1, 2.0, 600)\n",
    "    axR.plot(xr, _fR(xr), color=BLUE, lw=2.4, zorder=2)\n",
    "    ends = []\n",
    "    for x0 in np.linspace(-1.9, 1.8, n_starts):\n",
    "        path = _descend(_fR, _fRp, x0, eta)\n",
    "        axR.plot(path, _fR(path), color=TEAL, lw=1.0, alpha=0.55,\n",
    "                 zorder=3)\n",
    "        axR.scatter([x0], [_fR(x0)], s=28, color=TEAL, alpha=0.6,\n",
    "                    zorder=4)\n",
    "        axR.scatter([path[-1]], [_fR(path[-1])], s=70, color=TEAL,\n",
    "                    edgecolors='white', linewidths=1.2, zorder=6)\n",
    "        ends.append(path[-1])\n",
    "    # The two true minima of x^4 - 3x^2 + x (roots of 4x^3 - 6x + 1).\n",
    "    roots = np.roots([4, 0, -6, 1])\n",
    "    mins = sorted([r.real for r in roots\n",
    "                   if abs(r.imag) < 1e-9 and (12*r.real**2 - 6) > 0])\n",
    "    axR.scatter(mins, [_fR(m) for m in mins], marker='*', s=220,\n",
    "                color=ORANGE, edgecolors=DARK, linewidths=1.0,\n",
    "                zorder=7, label='two separate minima')\n",
    "    axR.set_title('Two valleys  $x^4-3x^2+x$  \\u2014 balls split',\n",
    "                  color=RED)\n",
    "    axR.set_xlabel('$x$'); axR.set_ylabel('$f(x)$')\n",
    "    axR.legend(loc='upper center', fontsize=9, framealpha=0.9)\n",
    "    n_floors_R = len(np.unique(np.round(ends, 1)))\n",
    "\n",
    "    fig.suptitle(f'learning rate \\u03b7 = {eta:.3f}', color=DARK,\n",
    "                 fontsize=12)\n",
    "    fig.tight_layout()\n",
    "    plt.show()\n",
    "\n",
    "    print(f'Convex bowl : balls landed on {n_floors_L} distinct floor'\n",
    "          f'(s) \\u2014 convexity guarantees just one.')\n",
    "    print(f'Two valleys : balls landed on {n_floors_R} distinct floor'\n",
    "          f'(s) \\u2014 the outcome depends on where you start.')\n",
    "\n",
    "interact(_split_demo,\n",
    "         eta=FloatSlider(value=0.10, min=0.01, max=0.18, step=0.01,\n",
    "                         description='learn rate', readout_format='.3f'),\n",
    "         n_starts=IntSlider(value=7, min=2, max=12, step=1,\n",
    "                            description='# starts'));\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e8dc6f21",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "*Three readings of one chapter. The first sorts critical points with both tests while the curvature is painted on. The second tests convexity by eye — chords above, tangents below. The third makes the keystone unmistakable: on a convex bowl every downhill path ends at the same floor, while a non-convex landscape sends them to different valleys. That single difference — **no false floors** — is the line between optimization that is guaranteed to work and the non-convex world of Chapter 9, where training a model is part science and part art.*"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "pygments_lexer": "ipython3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}