coeftable 0.1.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {coeftable-0.1.0 → coeftable-0.2.1}/.github/workflows/post-release.yml +1 -0
  2. coeftable-0.2.1/.github/workflows/publish.yml +60 -0
  3. coeftable-0.2.1/.github/workflows/release.yml +116 -0
  4. {coeftable-0.1.0 → coeftable-0.2.1}/.gitignore +2 -0
  5. coeftable-0.2.1/CHANGELOG.md +42 -0
  6. {coeftable-0.1.0 → coeftable-0.2.1}/PKG-INFO +183 -13
  7. coeftable-0.2.1/README.md +321 -0
  8. coeftable-0.2.1/docs/images/trend-example.png +0 -0
  9. {coeftable-0.1.0 → coeftable-0.2.1}/pyproject.toml +1 -0
  10. {coeftable-0.1.0 → coeftable-0.2.1}/src/coeftable/__init__.py +14 -1
  11. {coeftable-0.1.0 → coeftable-0.2.1}/src/coeftable/_version.py +2 -2
  12. coeftable-0.2.1/src/coeftable/format.py +418 -0
  13. coeftable-0.2.1/src/coeftable/frame.py +245 -0
  14. coeftable-0.2.1/src/coeftable/grid.py +268 -0
  15. {coeftable-0.1.0 → coeftable-0.2.1}/src/coeftable/render.py +4 -4
  16. coeftable-0.2.1/src/coeftable/series.py +365 -0
  17. coeftable-0.2.1/src/coeftable/spec.py +1293 -0
  18. coeftable-0.2.1/src/coeftable/svg.py +1408 -0
  19. {coeftable-0.1.0 → coeftable-0.2.1}/tests/test_format.py +52 -1
  20. {coeftable-0.1.0 → coeftable-0.2.1}/tests/test_frame.py +29 -15
  21. {coeftable-0.1.0 → coeftable-0.2.1}/tests/test_public_api.py +2 -0
  22. {coeftable-0.1.0 → coeftable-0.2.1}/tests/test_render.py +2 -2
  23. coeftable-0.2.1/tests/test_series.py +274 -0
  24. coeftable-0.2.1/tests/test_sparkline.py +842 -0
  25. {coeftable-0.1.0 → coeftable-0.2.1}/tests/test_spec.py +37 -0
  26. coeftable-0.2.1/tests/test_svg.py +1506 -0
  27. {coeftable-0.1.0 → coeftable-0.2.1}/uv.lock +38 -0
  28. coeftable-0.1.0/.github/workflows/publish.yml +0 -38
  29. coeftable-0.1.0/README.md +0 -152
  30. coeftable-0.1.0/src/coeftable/format.py +0 -237
  31. coeftable-0.1.0/src/coeftable/frame.py +0 -442
  32. coeftable-0.1.0/src/coeftable/spec.py +0 -437
  33. coeftable-0.1.0/src/coeftable/svg.py +0 -210
  34. coeftable-0.1.0/tests/test_svg.py +0 -90
  35. {coeftable-0.1.0 → coeftable-0.2.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  36. {coeftable-0.1.0 → coeftable-0.2.1}/.github/release.yml +0 -0
  37. {coeftable-0.1.0 → coeftable-0.2.1}/.github/workflows/ci.yml +0 -0
  38. {coeftable-0.1.0 → coeftable-0.2.1}/.pre-commit-config.yaml +0 -0
  39. {coeftable-0.1.0 → coeftable-0.2.1}/LICENSE +0 -0
  40. {coeftable-0.1.0 → coeftable-0.2.1}/Makefile +0 -0
  41. {coeftable-0.1.0 → coeftable-0.2.1}/docs/images/example.png +0 -0
  42. {coeftable-0.1.0 → coeftable-0.2.1}/noxfile.py +0 -0
  43. {coeftable-0.1.0 → coeftable-0.2.1}/src/coeftable/theme.py +0 -0
  44. {coeftable-0.1.0 → coeftable-0.2.1}/tests/test_package.py +0 -0
  45. {coeftable-0.1.0 → coeftable-0.2.1}/tests/test_theme.py +0 -0
@@ -4,6 +4,7 @@ on:
4
4
  release:
5
5
  types: [published]
6
6
  workflow_dispatch:
7
+ workflow_call:
7
8
 
8
9
  jobs:
9
10
  changelog:
@@ -0,0 +1,60 @@
1
+ name: Publish library
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ tags:
7
+ - "v*"
8
+ workflow_call:
9
+ inputs:
10
+ ref:
11
+ description: "Git ref to build. Defaults to the triggering ref."
12
+ required: false
13
+ type: string
14
+ publish:
15
+ description: "Upload the built package to PyPI."
16
+ required: false
17
+ default: false
18
+ type: boolean
19
+ workflow_dispatch:
20
+
21
+ jobs:
22
+ build-package:
23
+ runs-on: ubuntu-latest
24
+ permissions:
25
+ attestations: write
26
+ contents: read
27
+ id-token: write
28
+ steps:
29
+ - uses: actions/checkout@v6
30
+ with:
31
+ # The version is derived from the tag by hatch-vcs, so the build
32
+ # must happen on the tag itself -- building the branch head would
33
+ # produce a development version instead of the release version.
34
+ ref: ${{ inputs.ref }}
35
+ fetch-depth: 0
36
+ persist-credentials: false
37
+ - uses: hynek/build-and-inspect-python-package@v2
38
+ with:
39
+ attest-build-provenance-github: true
40
+
41
+ publish:
42
+ runs-on: ubuntu-latest
43
+ # A tag pushed by a workflow using the default token does not raise a
44
+ # push event, so a release cut by automation asks for the upload
45
+ # explicitly. Any other run sitting on a tag -- a hand-pushed tag, or a
46
+ # manual run selecting one, which is how a stranded release gets
47
+ # published -- is also a release build. A run on a branch never is.
48
+ if: inputs.publish || startsWith(github.ref, 'refs/tags')
49
+ environment:
50
+ name: publish
51
+ url: https://pypi.org/p/coeftable
52
+ needs: build-package
53
+ permissions:
54
+ id-token: write
55
+ steps:
56
+ - uses: actions/download-artifact@v8
57
+ with:
58
+ name: Packages
59
+ path: dist
60
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,116 @@
1
+ name: Release
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ bump:
7
+ description: "Version bump"
8
+ required: true
9
+ type: choice
10
+ options:
11
+ - patch
12
+ - minor
13
+ - major
14
+ default: patch
15
+ version:
16
+ description: "Explicit version override (e.g. 1.2.3) -- takes precedence over bump"
17
+ required: false
18
+ type: string
19
+
20
+ permissions:
21
+ contents: write
22
+
23
+ concurrency:
24
+ group: release
25
+ cancel-in-progress: false
26
+
27
+ jobs:
28
+ tag:
29
+ runs-on: ubuntu-latest
30
+ outputs:
31
+ tag: ${{ steps.version.outputs.tag }}
32
+ steps:
33
+ - uses: actions/checkout@v6
34
+ with:
35
+ fetch-depth: 0
36
+
37
+ - name: Compute next version
38
+ id: version
39
+ env:
40
+ BUMP: ${{ inputs.bump }}
41
+ VERSION_OVERRIDE: ${{ inputs.version }}
42
+ run: |
43
+ set -euo pipefail
44
+
45
+ latest=$(git tag -l 'v*' --sort=-v:refname | sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || true)
46
+ latest="${latest:-0.0.0}"
47
+
48
+ if [ -n "$VERSION_OVERRIDE" ]; then
49
+ next="${VERSION_OVERRIDE#v}"
50
+ if ! [[ "$next" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
51
+ echo "::error::Invalid version '$next' -- must be MAJOR.MINOR.PATCH"
52
+ exit 1
53
+ fi
54
+
55
+ IFS='.' read -r next_major next_minor next_patch <<< "$next"
56
+ IFS='.' read -r latest_major latest_minor latest_patch <<< "$latest"
57
+ if [ "$((next_major * 1000000 + next_minor * 1000 + next_patch))" -le \
58
+ "$((latest_major * 1000000 + latest_minor * 1000 + latest_patch))" ]; then
59
+ echo "::error::Explicit version '$next' is not greater than the latest release 'v$latest'"
60
+ exit 1
61
+ fi
62
+ else
63
+ IFS='.' read -r major minor patch <<< "$latest"
64
+ case "$BUMP" in
65
+ major) next="$((major + 1)).0.0" ;;
66
+ minor) next="${major}.$((minor + 1)).0" ;;
67
+ patch) next="${major}.${minor}.$((patch + 1))" ;;
68
+ *) echo "::error::Unknown bump type '$BUMP'"; exit 1 ;;
69
+ esac
70
+ fi
71
+
72
+ tag="v${next}"
73
+ if git rev-parse "$tag" >/dev/null 2>&1; then
74
+ echo "::error::Tag $tag already exists"
75
+ exit 1
76
+ fi
77
+
78
+ echo "Latest: v$latest -> Next: $tag"
79
+ echo "tag=$tag" >> "$GITHUB_OUTPUT"
80
+
81
+ - name: Create and push tag
82
+ run: |
83
+ git config user.name "github-actions[bot]"
84
+ git config user.email "github-actions[bot]@users.noreply.github.com"
85
+ git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}"
86
+ git push origin "${{ steps.version.outputs.tag }}"
87
+
88
+ - name: Create GitHub release
89
+ env:
90
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
91
+ run: gh release create "${{ steps.version.outputs.tag }}" --generate-notes
92
+
93
+ publish:
94
+ # Called directly rather than left to the tag-push trigger on the publish
95
+ # workflow: the tag above is pushed with the workflow token, and GitHub
96
+ # does not raise workflow-triggering events for it.
97
+ needs: tag
98
+ uses: ./.github/workflows/publish.yml
99
+ permissions:
100
+ attestations: write
101
+ contents: read
102
+ id-token: write
103
+ with:
104
+ ref: ${{ needs.tag.outputs.tag }}
105
+ publish: true
106
+
107
+ changelog:
108
+ # Same reason as the publish job above: the release is created with the
109
+ # workflow token, so its published event never reaches the changelog
110
+ # workflow. Runs after the release exists, since the changelog is built
111
+ # from the published release notes.
112
+ needs: tag
113
+ uses: ./.github/workflows/post-release.yml
114
+ permissions:
115
+ contents: write
116
+ pull-requests: write
@@ -16,3 +16,5 @@ src/coeftable/_version.py
16
16
  .kata.local.toml
17
17
  # roborev snapshots
18
18
  /.roborev/
19
+ # internal planning docs (not shipped, not for the public repo)
20
+ docs/superpowers/
@@ -0,0 +1,42 @@
1
+ <a id="v0.2.0"></a>
2
+ # [v0.2.0](https://github.com/kylejcaron/coeftable/releases/tag/v0.2.0) - 2026-08-04
3
+
4
+ <!-- Release notes generated using configuration in .github/release.yml at v0.2.0 -->
5
+
6
+ ## What's Changed
7
+ ### Maintenance
8
+ * ci: add workflow_dispatch release workflow with semver bump by [@kylejcaron](https://github.com/kylejcaron) in [#7](https://github.com/kylejcaron/coeftable/pull/7)
9
+ * Sparkline column: inline line plots with uncertainty by [@kylejcaron](https://github.com/kylejcaron) in [#10](https://github.com/kylejcaron/coeftable/pull/10)
10
+
11
+
12
+ **Full Changelog**: https://github.com/kylejcaron/coeftable/compare/v0.1.0...v0.2.0
13
+
14
+ [Changes][v0.2.0]
15
+
16
+
17
+ <a id="v0.1.0"></a>
18
+ # [v0.1.0](https://github.com/kylejcaron/coeftable/releases/tag/v0.1.0) - 2026-07-28
19
+
20
+ <!-- Release notes generated using configuration in .github/release.yml at main -->
21
+
22
+ ## What's Changed
23
+ ### Maintenance
24
+ * chore: install pre-commit hooks during setup by [@kylejcaron](https://github.com/kylejcaron) in [#1](https://github.com/kylejcaron/coeftable/pull/1)
25
+ * ci: drop redundant lint and typecheck jobs by [@kylejcaron](https://github.com/kylejcaron) in [#2](https://github.com/kylejcaron/coeftable/pull/2)
26
+ * Theming by [@kylejcaron](https://github.com/kylejcaron) in [#3](https://github.com/kylejcaron/coeftable/pull/3)
27
+ * docs patch: restore Parameters section header on Forest docstring by [@kylejcaron](https://github.com/kylejcaron) in [#4](https://github.com/kylejcaron/coeftable/pull/4)
28
+ * chore: complete package metadata for PyPI publishing by [@kylejcaron](https://github.com/kylejcaron) in [#5](https://github.com/kylejcaron/coeftable/pull/5)
29
+ * docs: add hero image, fix stale theming section in README by [@kylejcaron](https://github.com/kylejcaron) in [#6](https://github.com/kylejcaron/coeftable/pull/6)
30
+
31
+ ## New Contributors
32
+ * [@kylejcaron](https://github.com/kylejcaron) made their first contribution in [#1](https://github.com/kylejcaron/coeftable/pull/1)
33
+
34
+ **Full Changelog**: https://github.com/kylejcaron/coeftable/commits/v0.1.0
35
+
36
+ [Changes][v0.1.0]
37
+
38
+
39
+ [v0.2.0]: https://github.com/kylejcaron/coeftable/compare/v0.1.0...v0.2.0
40
+ [v0.1.0]: https://github.com/kylejcaron/coeftable/tree/v0.1.0
41
+
42
+ <!-- Generated by https://github.com/rhysd/changelog-from-release v3.9.1 -->
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coeftable
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: Publication-quality summary tables for estimates with uncertainty.
5
5
  Project-URL: Homepage, https://github.com/kylejcaron/coeftable
6
6
  Project-URL: Repository, https://github.com/kylejcaron/coeftable
@@ -45,6 +45,7 @@ Requires-Dist: nox>=2025.5; extra == 'dev'
45
45
  Requires-Dist: pandas>=2.2; extra == 'dev'
46
46
  Requires-Dist: polars>=1.0; extra == 'dev'
47
47
  Requires-Dist: prek>=0.4.5; extra == 'dev'
48
+ Requires-Dist: pyarrow>=25.0.0; extra == 'dev'
48
49
  Requires-Dist: pytest-cov>=5.0; extra == 'dev'
49
50
  Requires-Dist: pytest>=8.0; extra == 'dev'
50
51
  Requires-Dist: ruff>=0.15; extra == 'dev'
@@ -91,6 +92,31 @@ leave it as the last expression in a cell, no extra call needed. Outside a noteb
91
92
  object: `table.gt().as_raw_html()` for an HTML string, `table.gt().save("t.png")` for an
92
93
  image, `table.gt().tab_options(...)` to keep styling with great_tables' own API.
93
94
 
95
+ ## Data shape
96
+
97
+ coeftable expects a dataframe where every row is a single comparison. The
98
+ resolution logic maps pairs of upper / lower bound columns to each estimate, so
99
+ your data should be **wide in triples** — one point-estimate column and (when
100
+ applicable) its lower and upper bound columns — rather than in long format with
101
+ a `parameter` column.
102
+
103
+ **Dimensions:**
104
+ - `rows` — the label for each row in the table (e.g. a metric name).
105
+ - `nest` — an optional secondary label stacked below each row.
106
+ - `groups` — an optional column whose values produce section headers.
107
+ - `split_columns` — an optional column whose values produce repeated column
108
+ groups side by side, useful for comparing methods.
109
+
110
+ **Series columns bend this rule.** A point estimate is one number (plus
111
+ bounds), so a triple of scalar columns holds it. A `.sparkline(...)` series
112
+ is N points, not one -- most naturally via the companion-frame door, a
113
+ separate long frame with one row per point, joined by the table's row/nest/
114
+ split keys. Or, when the series is already collapsed onto its row, its
115
+ `value` / `ci` columns can instead hold a *list* per row directly. Either
116
+ way a row of the table is still one row; the series column just carries
117
+ more data per row than an estimate column does. See
118
+ [Trend over time](#trend-over-time) for both shapes.
119
+
94
120
  ## Experiment table
95
121
 
96
122
  Build a complete experiment results table with multiple estimates, a forest
@@ -189,17 +215,161 @@ table = (
189
215
  )
190
216
  ```
191
217
 
192
- ## Data shape
218
+ ## Trend over time
193
219
 
194
- coeftable expects a dataframe where every row is a single comparison. The
195
- resolution logic maps pairs of upper / lower bound columns to each estimate, so
196
- your data should be **wide in triples** — one point-estimate column and (when
197
- applicable) its lower and upper bound columns — rather than in long format with
198
- a `parameter` column.
220
+ ![Rendered experiment results table with a 30-day trend column showing favorable, unfavorable, and inconclusive series with narrowing uncertainty bands](docs/images/trend-example.png)
199
221
 
200
- **Dimensions:**
201
- - `rows` — the label for each row in the table (e.g. a metric name).
202
- - `nest` — an optional secondary label stacked below each row.
203
- - `groups` — an optional column whose values produce section headers.
204
- - `split_columns` — an optional column whose values produce repeated column
205
- groups side by side, useful for comparing methods.
222
+ Add a `.sparkline(...)` column to plot a metric's trajectory next to its
223
+ point estimate: an inline SVG line with a shaded credible interval and a
224
+ dashed reference line (pass `show_endpoint=True` to also label the last
225
+ value). Below, `ref=0.0` draws the reference line that Latency's series
226
+ crosses as its credible interval narrows over three weeks of data:
227
+
228
+ There are two front doors for the series data, the same list-columns vs.
229
+ companion-frame choice used elsewhere in coeftable:
230
+
231
+ **A long companion frame** — the shape most real series data already
232
+ arrives in: a SQL export, a dbt model, an experimentation platform's daily
233
+ metrics table. Pass `data=` a separate frame with one row per point, and
234
+ `value` / `ci` / `x` name *scalar* columns on it. coeftable groups the
235
+ companion frame by the table's `rows` (+ `nest`, + `split_columns`) keys
236
+ and collapses each group into a series internally:
237
+
238
+ ```python
239
+ import datetime as dt
240
+ import pandas as pd
241
+ import polars as pl
242
+ import coeftable as ct
243
+
244
+ dates = [dt.date(2024, 1, 1), dt.date(2024, 1, 8), dt.date(2024, 1, 15)]
245
+
246
+ trend = pl.DataFrame(
247
+ {
248
+ "metric": ["Revenue", "Latency"],
249
+ "lift": [3.4, 0.5],
250
+ "lift_lb": [1.2, -1.0],
251
+ "lift_ub": [5.7, 2.0],
252
+ }
253
+ )
254
+
255
+ history = pd.DataFrame(
256
+ {
257
+ "metric": ["Revenue", "Revenue", "Revenue", "Latency", "Latency", "Latency"],
258
+ "date": dates + dates,
259
+ "lift": [1.5, 2.4, 3.4, -1.0, 0.2, 1.5],
260
+ "lift_lb": [0.3, 1.4, 2.6, -2.5, -0.6, 1.0],
261
+ "lift_ub": [2.7, 3.4, 4.2, 0.5, 1.0, 2.0],
262
+ }
263
+ )
264
+
265
+ (
266
+ ct.CoefTable(trend, rows="metric")
267
+ .estimate("Lift %", "lift", ci=("lift_lb", "lift_ub"), fmt=ct.Percent(signed=True))
268
+ .sparkline(
269
+ "Trend",
270
+ value="lift",
271
+ ci=("lift_lb", "lift_ub"),
272
+ x="date",
273
+ data=history,
274
+ ref=0.0,
275
+ axis_fmt=ct.DateAxis(),
276
+ )
277
+ )
278
+ ```
279
+
280
+ **List columns on the main frame** — if the series is already collapsed
281
+ onto its row (e.g. from a prior `.group_by(...).agg(...)`, or a source that
282
+ natively stores arrays), `value` / `ci` / `x` can instead name columns
283
+ whose cells each hold one list of points per row:
284
+
285
+ ```python
286
+ import datetime as dt
287
+ import polars as pl
288
+ import coeftable as ct
289
+
290
+ dates = [dt.date(2024, 1, 1), dt.date(2024, 1, 8), dt.date(2024, 1, 15)]
291
+
292
+ trend = pl.DataFrame(
293
+ {
294
+ "metric": ["Revenue", "Latency"],
295
+ "lift": [3.4, 0.5],
296
+ "lift_lb": [1.2, -1.0],
297
+ "lift_ub": [5.7, 2.0],
298
+ "history": [
299
+ [1.5, 2.4, 3.4],
300
+ [-1.0, 0.2, 1.5],
301
+ ],
302
+ "history_lb": [
303
+ [0.3, 1.4, 2.6],
304
+ [-2.5, -0.6, 1.0],
305
+ ],
306
+ "history_ub": [
307
+ [2.7, 3.4, 4.2],
308
+ [0.5, 1.0, 2.0],
309
+ ],
310
+ "date": [dates, dates],
311
+ }
312
+ )
313
+
314
+ (
315
+ ct.CoefTable(trend, rows="metric")
316
+ .estimate("Lift %", "lift", ci=("lift_lb", "lift_ub"), fmt=ct.Percent(signed=True))
317
+ .sparkline(
318
+ "Trend",
319
+ value="history",
320
+ ci=("history_lb", "history_ub"),
321
+ x="date",
322
+ ref=0.0,
323
+ axis_fmt=ct.DateAxis(),
324
+ )
325
+ )
326
+ ```
327
+
328
+ Both render the same column. Reach for the companion frame first — it
329
+ matches how most series data actually arrives, one row per observation.
330
+ Reach for list columns only when the series is already collapsed onto its
331
+ row.
332
+
333
+ Since `x` is always shared table-wide (dates must line up across rows), a
334
+ series with fewer points than its neighbours visibly occupies only part of
335
+ its cell's width rather than stretching to fill it — this is intentional,
336
+ not a bug: `x` position reflects where a point falls in the shared domain,
337
+ never the row's own extent.
338
+
339
+ Want a plain trend line with no uncertainty band — no `ci`, no ribbon?
340
+ great_tables' own `.gt().fmt_nanoplot(...)` covers that directly.
341
+ `.sparkline(...)` exists specifically for the estimate-with-interval case.
342
+
343
+ **Shaping the y-axis.** Each row's domain fits tightly to its own data by
344
+ default (`scale="row"`, `autoscale="tight"`). Four ways to change that,
345
+ shown together against the same noisy series:
346
+
347
+ ```python
348
+ import polars as pl
349
+ import coeftable as ct
350
+
351
+ trend = pl.DataFrame(
352
+ {
353
+ "metric": ["Revenue"],
354
+ "lift": [[1.0, 1.05, 0.95, 1.02, 0.98, 300.0]],
355
+ }
356
+ )
357
+
358
+ (
359
+ ct.CoefTable(trend, rows="metric")
360
+ # Default: fits tightly to this row's own min/max. A single outlier
361
+ # like the 300.0 here dominates and flattens the rest of the series.
362
+ .sparkline("Tight (default)", value="lift", ref=1.0)
363
+ # autoscale="robust" fits an IQR/Tukey fence instead of raw min/max,
364
+ # so the outlier doesn't flatten the rest. It still draws -- clipped
365
+ # to the domain edge and flagged with a clip-cap marker, never hidden.
366
+ .sparkline("Robust", value="lift", ref=1.0, autoscale="robust")
367
+ # max_ylim=N narrows whatever domain scale/autoscale would have
368
+ # produced -- clamping to `ref +/- N`, only if the natural domain
369
+ # would have exceeded that ceiling. Composes with autoscale.
370
+ .sparkline("Ceiling", value="lift", ref=1.0, max_ylim=0.5)
371
+ # ylim=(lo, hi) is an absolute override, replacing scale/autoscale/
372
+ # max_ylim entirely.
373
+ .sparkline("Override", value="lift", ref=1.0, ylim=(0.9, 1.1))
374
+ )
375
+ ```