goad-toolkit 0.2.3__tar.gz → 0.2.4__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.
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/PKG-INFO +1 -1
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/docs/03-plot-composition.md +10 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/docs/04-five-families.md +2 -1
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/docs/05-distributions.md +52 -7
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/docs/08-api-reference.md +25 -3
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/pyproject.toml +1 -1
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/src/goad_toolkit/analytics.py +74 -33
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/src/goad_toolkit/distributions.py +16 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/src/goad_toolkit/visualizer.py +124 -0
- goad_toolkit-0.2.4/tests/test_distributions.py +195 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/tests/test_visualizer.py +51 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/uv.lock +1 -1
- goad_toolkit-0.2.3/tests/test_distributions.py +0 -72
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/settings.local.json +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/.gitignore +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/.python-version +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/CHANGELOG.md +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/MCP_SERVER.md +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/README.md +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/demo/linear.py +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/docs/01-goal-oriented-analysis.md +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/docs/02-pipelines.md +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/docs/03-plot-composition.md +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/docs/04-five-families.md +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/docs/05-distributions.md +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/docs/06-models-and-residuals.md +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/docs/07-visual-critique.md +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/docs/08-api-reference.md +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/docs/09-analysis-method.md +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/docs/10-teaching-path.md +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/docs/README.md +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/goad_mcp.py +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/img/distribution_fit.png +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/img/goaded.png +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/img/linear_results.png +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/img/residuals.png +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/img/zscores.png +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/pyproject.toml +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/__init__.py +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/analytics.py +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/cli.py +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/config.py +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/dataprocessor.py +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/datatransforms.py +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/distributions.py +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/filehandler.py +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/models.py +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/visualizer.py +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/tests/test_cli.py +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/tests/test_distributions.py +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/tests/test_filehandler.py +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/tests/test_visualizer.py +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/uv.lock +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.gitignore +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.python-version +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.remember/.gitignore +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.remember/logs/autonomous/save-195516.log +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.remember/logs/autonomous/save-195720.log +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.remember/logs/autonomous/save-195933.log +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.remember/logs/autonomous/save-200831.log +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.remember/logs/autonomous/save-201034.log +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.remember/logs/hook-errors.log +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.remember/logs/memory-2026-08-10.log +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.remember/now.md +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.remember/tmp/capture-alive +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.remember/tmp/capture-alive.d/90574e63-97cd-4c11-87e5-23b656c56fd9 +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.remember/tmp/case-divergence +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.remember/tmp/last-ndc.ts +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.remember/tmp/last-save-ts +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.remember/tmp/last-save.json +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.remember/tmp/now-day +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.remember/tmp/post-tool-ran +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.remember/tmp/save-session.pid +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.remember/tmp/session-slug +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.remember/today-2026-08-10.md +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/CHANGELOG.md +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/MCP_SERVER.md +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/README.md +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/demo/linear.py +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/docs/01-goal-oriented-analysis.md +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/docs/02-pipelines.md +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/docs/06-models-and-residuals.md +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/docs/07-visual-critique.md +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/docs/09-analysis-method.md +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/docs/10-teaching-path.md +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/docs/README.md +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/goad_mcp.py +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/img/distribution_fit.png +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/img/goaded.png +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/img/linear_results.png +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/img/residuals.png +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/img/zscores.png +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/src/goad_toolkit/__init__.py +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/src/goad_toolkit/cli.py +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/src/goad_toolkit/config.py +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/src/goad_toolkit/dataprocessor.py +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/src/goad_toolkit/datatransforms.py +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/src/goad_toolkit/filehandler.py +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/src/goad_toolkit/models.py +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/tests/test_cli.py +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/tests/test_datatransforms.py +0 -0
- {goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/tests/test_filehandler.py +0 -0
|
@@ -156,6 +156,8 @@ step 3.
|
|
|
156
156
|
| `HistogramPlot` | `sns.histplot`, `stat="density"` | density so a pdf can be overlaid |
|
|
157
157
|
| `DistPlot` | a scipy distribution's pdf or pmf | estimates its own x-range from `ppf` |
|
|
158
158
|
| `PlotFits` | histogram + fitted pdf, one panel per fit | see [Distributions](05-distributions.md) |
|
|
159
|
+
| `QQPlot` | sample quantiles vs. a distribution's theoretical quantiles | the tails, not the bulk — see [Distributions §5.5](05-distributions.md) |
|
|
160
|
+
| `ECDFPlot` | one or two empirical CDFs, bin-free | the right tool for comparing two samples |
|
|
159
161
|
|
|
160
162
|
`HistogramPlot` defaults to `sqrt(n)` bins capped at 50. That default is a starting point,
|
|
161
163
|
not an answer — bin count changes what a histogram appears to say, and choosing it is part of
|
|
@@ -166,6 +168,14 @@ falling back to `pmf` for discrete families. Left to itself it picks an x-range
|
|
|
166
168
|
0.1st to the 99.9th percentile, which is right for most things and wrong for anything with a
|
|
167
169
|
very heavy tail — pass `x_range` explicitly there.
|
|
168
170
|
|
|
171
|
+
`QQPlot` takes a sample and a *frozen* distribution and plots sorted data against the
|
|
172
|
+
distribution's quantiles at matching plotting positions, with a y=x reference line —
|
|
173
|
+
where `PlotFits`' histogram is dominated by the bulk of the data, this is where a tail
|
|
174
|
+
mismatch actually shows up.
|
|
175
|
+
|
|
176
|
+
`ECDFPlot` plots one sample, or two with `compare=`, as step functions of the empirical CDF —
|
|
177
|
+
no bin width to choose, so nothing about the shape is a plotting decision.
|
|
178
|
+
|
|
169
179
|
## 3.7 Writing your own plot
|
|
170
180
|
|
|
171
181
|
Two questions decide the shape:
|
|
@@ -116,7 +116,8 @@ fitted pdf drawn over the histogram to see where it agrees and where it does not
|
|
|
116
116
|
sample sizes are near-unreadable. Normalise, or use ECDFs, or do a two-sample test.
|
|
117
117
|
|
|
118
118
|
**In GOAD:** `HistogramPlot` (density-scaled, so a pdf overlays correctly), `DistPlot` for a
|
|
119
|
-
parametric curve,
|
|
119
|
+
parametric curve, `DistributionFitter` + `PlotFits` for the full ranked comparison, and
|
|
120
|
+
`QQPlot` / `ECDFPlot` for the tail diagnostics a histogram cannot show. See
|
|
120
121
|
[Distributions](05-distributions.md).
|
|
121
122
|
|
|
122
123
|
---
|
|
@@ -25,7 +25,7 @@ kind of process you are looking at.
|
|
|
25
25
|
needs: the scipy object, whether it is discrete, and how many parameters it takes.
|
|
26
26
|
|
|
27
27
|
Shipped: `norm`, `uniform`, `lognorm`, `poisson`, `exponential`, `skewnorm`, `gamma`,
|
|
28
|
-
`weibull`.
|
|
28
|
+
`weibull`, `bernoulli`, `binomial`, `nbinom`, `beta`.
|
|
29
29
|
|
|
30
30
|
```python
|
|
31
31
|
from goad_toolkit.distributions import DistributionRegistry
|
|
@@ -36,10 +36,16 @@ registry.get_names()
|
|
|
36
36
|
registry.register_distribution("pareto", stats.pareto, is_discrete=False, num_params=3)
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
`pareto` is deliberately not in the shipped set, even though the chat corpus is exactly the
|
|
40
|
+
kind of data it fits — word frequency and burst lengths are rank-frequency data, the
|
|
41
|
+
textbook case for a power law. Registering it yourself, as above, is the extensibility
|
|
42
|
+
exercise: a fit run without it will call the same data "lognormal" and look convincing, and
|
|
43
|
+
registering `pareto` is what exposes that the winner depends on what you thought to try.
|
|
44
|
+
`zipf` is the same story for discrete rank data and ships the same way.
|
|
45
|
+
|
|
46
|
+
Registering a family is the extension point that matters whenever the shipped set is still
|
|
47
|
+
missing something real data needs — a power-law family among them, `zipf` for discrete
|
|
48
|
+
rank data, or anything scipy has that GOAD has not wrapped.
|
|
43
49
|
|
|
44
50
|
Two things to know before you rely on it:
|
|
45
51
|
|
|
@@ -154,8 +160,7 @@ What to look at, in order:
|
|
|
154
160
|
|
|
155
161
|
1. **The tails.** This is where families differ and where the fit matters, and it is also
|
|
156
162
|
where a histogram has the fewest observations and is least readable. If your question is
|
|
157
|
-
about extreme values, a histogram will not settle it — use
|
|
158
|
-
scale.
|
|
163
|
+
about extreme values, a histogram will not settle it — use `QQPlot` or `ECDFPlot`, below.
|
|
159
164
|
2. **Systematic deviation, not point-by-point wobble.** A curve that is above the histogram
|
|
160
165
|
on the left and below it on the right is a wrong-shape signal. Individual bars being off
|
|
161
166
|
is sampling noise.
|
|
@@ -163,6 +168,43 @@ What to look at, in order:
|
|
|
163
168
|
log-likelihoods have not been distinguished by your data, and picking one is a choice you
|
|
164
169
|
should make on mechanism, not on the fourth decimal place.
|
|
165
170
|
|
|
171
|
+
### `QQPlot`, `ECDFPlot`, and `fit_table`
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
from goad_toolkit.visualizer import QQPlot, ECDFPlot, PlotSettings
|
|
175
|
+
|
|
176
|
+
QQPlot(PlotSettings(title="Residuals vs. normal")).plot(
|
|
177
|
+
data=data["residual"], distribution=best.frozen_dist,
|
|
178
|
+
)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`QQPlot` sorts the sample and plots it against the fitted (frozen) distribution's quantiles
|
|
182
|
+
at matching plotting positions, with a y=x reference line. A fit that looked fine in
|
|
183
|
+
`PlotFits`' histogram can still bend away from that line in the tails — which is the point:
|
|
184
|
+
the histogram is dominated by the bulk, the qq-plot is not.
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
ECDFPlot(PlotSettings()).plot(
|
|
188
|
+
data=group_a["value"], compare=group_b["value"],
|
|
189
|
+
label="group a", compare_label="group b",
|
|
190
|
+
)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`ECDFPlot` draws one empirical CDF, or two with `compare=` for a direct two-sample
|
|
194
|
+
comparison — no bin width to choose, so the shape on screen is entirely the data's, not a
|
|
195
|
+
plotting decision.
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
from goad_toolkit.analytics import fit_table
|
|
199
|
+
|
|
200
|
+
fit_table(results)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
`fit_table` turns a list of `fit()` results into the dataframe a student should hand in
|
|
204
|
+
instead of a screenshot of a picture: distribution, fitted params, log-likelihood, KS
|
|
205
|
+
statistic and p-value, and which criteria (if any) it won — ranked by log-likelihood, with
|
|
206
|
+
`FailedFit` rows sorted last and their message kept instead of a blank row.
|
|
207
|
+
|
|
166
208
|
## 5.6 Outliers, properly
|
|
167
209
|
|
|
168
210
|
The sequence that avoids the usual mistake:
|
|
@@ -195,6 +237,9 @@ data is.
|
|
|
195
237
|
| lognormal | *products* of many factors | message length, income, response time, file size |
|
|
196
238
|
| exponential | waiting time between independent events | time between messages, within a burst |
|
|
197
239
|
| Poisson | counts of independent rare events in a fixed window | messages per hour, arrivals per day |
|
|
240
|
+
| negative binomial | over-dispersed counts — a Poisson whose rate itself varies | messages per day across people, not within a steady process |
|
|
241
|
+
| Bernoulli | a single yes/no trial | did this message get a reply, is this author still active |
|
|
242
|
+
| binomial | a fixed number of independent yes/no trials | replies within N messages, conversions per N views |
|
|
198
243
|
| power law / Pareto | preferential attachment, "rich get richer" | word frequency by rank, followers, city size |
|
|
199
244
|
| gamma / Weibull | sums of exponentials; time-to-failure with changing hazard | durations, lifetimes |
|
|
200
245
|
| beta | a distribution *over a probability* | uncertainty about a rate, A/B test posteriors |
|
|
@@ -139,8 +139,11 @@ class DistributionRegistry:
|
|
|
139
139
|
```
|
|
140
140
|
|
|
141
141
|
Registered by default: `norm`, `uniform`, `lognorm`, `poisson`, `exponential`, `skewnorm`,
|
|
142
|
-
`gamma`, `weibull`. Every instance starts from that
|
|
143
|
-
registry you made it on — hand that registry to
|
|
142
|
+
`gamma`, `weibull`, `bernoulli`, `binomial`, `nbinom`, `beta`. Every instance starts from that
|
|
143
|
+
set, so a registration is scoped to the registry you made it on — hand that registry to
|
|
144
|
+
`DistributionFitter(registry)` to use it. `pareto` is not in the default set — lesson 4 has
|
|
145
|
+
the student register it, since a default fit missing the one family the data actually needs
|
|
146
|
+
is the point of that exercise.
|
|
144
147
|
|
|
145
148
|
---
|
|
146
149
|
|
|
@@ -192,10 +195,21 @@ class DistributionFitter:
|
|
|
192
195
|
|
|
193
196
|
@staticmethod
|
|
194
197
|
def best(results: list[Result], criterion: str = "combined") -> list[Result]
|
|
198
|
+
|
|
199
|
+
def fit_table(results: list[Result]) -> pd.DataFrame
|
|
195
200
|
```
|
|
196
201
|
|
|
197
202
|
`criterion` is one of `"likelihood"`, `"ks"`, `"combined"`. Failures are returned as
|
|
198
|
-
`FailedFit` values rather than raised.
|
|
203
|
+
`FailedFit` values rather than raised. `log_likelihood` is computed with `logpdf` for
|
|
204
|
+
continuous families and `logpmf` for discrete ones. The two criteria are marked
|
|
205
|
+
independently: a `criterion="combined"` fit where every KS p-value comes back exactly 0
|
|
206
|
+
(routine for discrete families on a large, tied sample) still gets a `best_likelihood`
|
|
207
|
+
winner — it just has no `best_ks` winner.
|
|
208
|
+
|
|
209
|
+
`fit_table` turns a list of results into one row per distribution — `distribution`, `params`,
|
|
210
|
+
`log_likelihood`, `ks_stat`, `ks_pvalue`, `best_likelihood`, `best_ks`, `message` — ranked by
|
|
211
|
+
`log_likelihood` descending, with `FailedFit` rows (their `message` kept, metrics `NaN`)
|
|
212
|
+
sorted last.
|
|
199
213
|
|
|
200
214
|
---
|
|
201
215
|
|
|
@@ -273,6 +287,8 @@ class BasePlot(ABC):
|
|
|
273
287
|
| `HistogramPlot` | `data: np.ndarray, bins=None, kde=False, color="skyblue", alpha=0.7, **kwargs` |
|
|
274
288
|
| `DistPlot` | `distribution, x_range=None, samples=1000, color="crimson", linewidth=2, label=None, **kwargs` |
|
|
275
289
|
| `ACFPlot` | `data, nlags=40, alpha=0.05, color="steelblue", band_color="gray", **kwargs` |
|
|
290
|
+
| `QQPlot` | `data, distribution, color="steelblue", line_color="black", alpha=0.6, **kwargs` |
|
|
291
|
+
| `ECDFPlot` | `data, compare=None, label="data", compare_label="compare", color="steelblue", compare_color="crimson", **kwargs` |
|
|
276
292
|
|
|
277
293
|
```python
|
|
278
294
|
class PlotFits(BasePlot):
|
|
@@ -329,6 +345,12 @@ of `likelihood`, `ks`, `combined`; `--method` is passed to `scipy.stats.fit`.
|
|
|
329
345
|
- `ACFPlot` raises `ValueError` if the data contains NaNs, rather than passing them through.
|
|
330
346
|
`statsmodels.tsa.stattools.acf` does not raise on missing values — it silently returns an
|
|
331
347
|
all-NaN result — so a decomposition's residual needs `.dropna()` before it reaches `ACFPlot`.
|
|
348
|
+
- `binomial` and `nbinom` fit two shape parameters (`n`, `p`) from the sample mean and
|
|
349
|
+
variance alone. If `n` is not known independently, it is not identifiable from the data —
|
|
350
|
+
many `(n, p)` pairs with the same `n·p` fit about as well, so a fitted `n` should be treated
|
|
351
|
+
as "consistent with the mean", not as a recovered count of trials. When you know `n` from
|
|
352
|
+
the process (a fixed number of messages, a fixed number of views), trust that `n`, not the
|
|
353
|
+
fitted one.
|
|
332
354
|
|
|
333
355
|
---
|
|
334
356
|
|
|
@@ -2,6 +2,7 @@ from dataclasses import dataclass
|
|
|
2
2
|
from typing import Any, List, Optional, Tuple, Union
|
|
3
3
|
|
|
4
4
|
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
5
6
|
from loguru import logger
|
|
6
7
|
from scipy import stats
|
|
7
8
|
|
|
@@ -105,7 +106,11 @@ class DistributionFitter:
|
|
|
105
106
|
# Create bounds based on parameter count
|
|
106
107
|
if dist_obj.num_params == 1:
|
|
107
108
|
if dist_obj.is_discrete:
|
|
108
|
-
|
|
109
|
+
# A single-parameter discrete family's shape parameter is not always
|
|
110
|
+
# a count-scale rate (poisson's lambda) — it can be a probability
|
|
111
|
+
# (bernoulli's p), which is routinely well under 0.1, so the floor
|
|
112
|
+
# here stays near zero rather than at 0.1.
|
|
113
|
+
mean_lower_bound = max(1e-3, float(data_mean / 10))
|
|
109
114
|
mean_upper_bound = float(data_mean * 10)
|
|
110
115
|
return [(mean_lower_bound, mean_upper_bound)]
|
|
111
116
|
else:
|
|
@@ -134,15 +139,17 @@ class DistributionFitter:
|
|
|
134
139
|
|
|
135
140
|
def _calculate_loglikelihood(self, data: np.ndarray, dist_obj, params) -> float:
|
|
136
141
|
"""Calculate log-likelihood of data given distribution and parameters.
|
|
137
|
-
For every datapoint, calculate the log
|
|
138
|
-
|
|
139
|
-
If there are a lot of datapoints with very
|
|
140
|
-
will be very negative (or -inf if
|
|
142
|
+
For every datapoint, calculate the log density (continuous families, via
|
|
143
|
+
logpdf) or the log mass (discrete families, via logpmf). We sum them to get
|
|
144
|
+
the log-likelihood of the data. If there are a lot of datapoints with very
|
|
145
|
+
low probability, the log-likelihood will be very negative (or -inf if
|
|
146
|
+
probability is 0).
|
|
141
147
|
|
|
142
148
|
We will prefer distributions with higher log-likelihood values.
|
|
143
149
|
"""
|
|
144
150
|
try:
|
|
145
|
-
|
|
151
|
+
log_density = dist_obj.dist.logpmf if dist_obj.is_discrete else dist_obj.dist.logpdf
|
|
152
|
+
return np.sum(log_density(data, *params))
|
|
146
153
|
except Exception as e:
|
|
147
154
|
logger.warning(f"Log-likelihood calculation failed: {str(e)}")
|
|
148
155
|
return -np.inf
|
|
@@ -239,33 +246,24 @@ class DistributionFitter:
|
|
|
239
246
|
best_ks_value = fit_ks
|
|
240
247
|
best_ks_fit = fit
|
|
241
248
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
# Mark both
|
|
261
|
-
for fit in fits:
|
|
262
|
-
if isinstance(fit, FitResult):
|
|
263
|
-
fit.best_likelihood = (
|
|
264
|
-
fit.distribution == best_likelihood_fit.distribution
|
|
265
|
-
)
|
|
266
|
-
fit.best_ks = fit.distribution == best_ks_fit.distribution
|
|
267
|
-
else:
|
|
268
|
-
raise ValueError(f"Unknown criterion '{criterion}'")
|
|
249
|
+
if criterion not in ("likelihood", "ks", "combined"):
|
|
250
|
+
raise ValueError(f"Unknown criterion '{criterion}'")
|
|
251
|
+
|
|
252
|
+
# Each criterion is marked independently: a KS test that never clears the
|
|
253
|
+
# p > 0 threshold (routine for discrete families on a large sample, where
|
|
254
|
+
# ties push the p-value to numerical zero) must not suppress a perfectly
|
|
255
|
+
# good best_likelihood winner, and vice versa.
|
|
256
|
+
if criterion in ("likelihood", "combined") and best_likelihood_fit is not None:
|
|
257
|
+
for fit in fits:
|
|
258
|
+
if isinstance(fit, FitResult):
|
|
259
|
+
fit.best_likelihood = (
|
|
260
|
+
fit.distribution == best_likelihood_fit.distribution
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
if criterion in ("ks", "combined") and best_ks_fit is not None:
|
|
264
|
+
for fit in fits:
|
|
265
|
+
if isinstance(fit, FitResult):
|
|
266
|
+
fit.best_ks = fit.distribution == best_ks_fit.distribution
|
|
269
267
|
|
|
270
268
|
# Return the original list
|
|
271
269
|
return fits
|
|
@@ -327,3 +325,46 @@ class DistributionFitter:
|
|
|
327
325
|
]
|
|
328
326
|
else:
|
|
329
327
|
raise ValueError(f"Unknown criterion '{criterion}'")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def fit_table(results: List[Result]) -> pd.DataFrame:
|
|
331
|
+
"""Turn a list of fit results into a ranked dataframe: what a student hands in.
|
|
332
|
+
|
|
333
|
+
One row per distribution: name, fitted params, log-likelihood, KS statistic and
|
|
334
|
+
KS p-value, plus which criteria (if any) it won. Ranked by log-likelihood,
|
|
335
|
+
descending; `FailedFit` entries carry their failure message in `message` and
|
|
336
|
+
sort last, since a missing likelihood is not a small likelihood.
|
|
337
|
+
"""
|
|
338
|
+
rows = []
|
|
339
|
+
for result in results:
|
|
340
|
+
if isinstance(result, FitResult):
|
|
341
|
+
rows.append(
|
|
342
|
+
{
|
|
343
|
+
"distribution": result.distribution,
|
|
344
|
+
"params": result.params,
|
|
345
|
+
"log_likelihood": result.log_likelihood,
|
|
346
|
+
"ks_stat": result.kstest.statistic if result.kstest else float("nan"),
|
|
347
|
+
"ks_pvalue": result.kstest.p_value if result.kstest else float("nan"),
|
|
348
|
+
"best_likelihood": bool(result.best_likelihood),
|
|
349
|
+
"best_ks": bool(result.best_ks),
|
|
350
|
+
"message": result.message,
|
|
351
|
+
}
|
|
352
|
+
)
|
|
353
|
+
else:
|
|
354
|
+
rows.append(
|
|
355
|
+
{
|
|
356
|
+
"distribution": result.distribution,
|
|
357
|
+
"params": None,
|
|
358
|
+
"log_likelihood": float("nan"),
|
|
359
|
+
"ks_stat": float("nan"),
|
|
360
|
+
"ks_pvalue": float("nan"),
|
|
361
|
+
"best_likelihood": False,
|
|
362
|
+
"best_ks": False,
|
|
363
|
+
"message": result.message,
|
|
364
|
+
}
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
table = pd.DataFrame(rows)
|
|
368
|
+
return table.sort_values(
|
|
369
|
+
"log_likelihood", ascending=False, na_position="last"
|
|
370
|
+
).reset_index(drop=True)
|
|
@@ -54,6 +54,22 @@ class DistributionRegistry:
|
|
|
54
54
|
self.register_distribution(
|
|
55
55
|
"weibull", stats.weibull_min, is_discrete=False, num_params=3
|
|
56
56
|
)
|
|
57
|
+
self.register_distribution(
|
|
58
|
+
"bernoulli", stats.bernoulli, is_discrete=True, num_params=1
|
|
59
|
+
)
|
|
60
|
+
self.register_distribution(
|
|
61
|
+
"binomial", stats.binom, is_discrete=True, num_params=2
|
|
62
|
+
)
|
|
63
|
+
self.register_distribution(
|
|
64
|
+
"nbinom", stats.nbinom, is_discrete=True, num_params=2
|
|
65
|
+
)
|
|
66
|
+
self.register_distribution("beta", stats.beta, is_discrete=False, num_params=4)
|
|
67
|
+
# pareto is deliberately not shipped here: lesson 4 has the student
|
|
68
|
+
# register it themselves as the extensibility exercise, and a fit that
|
|
69
|
+
# silently has pareto available from the start would remove the point
|
|
70
|
+
# of that exercise. Register it yourself when you need it:
|
|
71
|
+
# registry.register_distribution("pareto", stats.pareto,
|
|
72
|
+
# is_discrete=False, num_params=3)
|
|
57
73
|
|
|
58
74
|
def __repr__(self) -> str:
|
|
59
75
|
"""Detailed representation of the registry."""
|
|
@@ -505,6 +505,130 @@ class DistPlot(BasePlot):
|
|
|
505
505
|
return x
|
|
506
506
|
|
|
507
507
|
|
|
508
|
+
class QQPlot(BasePlot):
|
|
509
|
+
"""Quantile-quantile plot: is this fit believable, not just which one wins.
|
|
510
|
+
|
|
511
|
+
A histogram with a pdf overlaid (PlotFits, DistPlot) is dominated by the bulk
|
|
512
|
+
of the data, which is exactly where distributions look most alike. A qq-plot
|
|
513
|
+
puts sorted data against the fitted distribution's theoretical quantiles, so a
|
|
514
|
+
systematic deviation in a tail — where families actually differ — shows up as
|
|
515
|
+
points bending away from the reference line instead of as a few stray bars.
|
|
516
|
+
"""
|
|
517
|
+
|
|
518
|
+
def build(
|
|
519
|
+
self,
|
|
520
|
+
data: np.ndarray,
|
|
521
|
+
distribution: Any,
|
|
522
|
+
color: str = "steelblue",
|
|
523
|
+
line_color: str = "black",
|
|
524
|
+
alpha: float = 0.6,
|
|
525
|
+
**kwargs,
|
|
526
|
+
) -> Tuple[Any, Any]:
|
|
527
|
+
"""
|
|
528
|
+
Plot sample quantiles against a distribution's theoretical quantiles.
|
|
529
|
+
|
|
530
|
+
Parameters:
|
|
531
|
+
-----------
|
|
532
|
+
data : np.ndarray
|
|
533
|
+
Sample to plot
|
|
534
|
+
distribution : scipy.stats distribution
|
|
535
|
+
Fitted (frozen) distribution to compare against; must have a ppf method
|
|
536
|
+
color : str
|
|
537
|
+
Colour of the quantile points
|
|
538
|
+
line_color : str
|
|
539
|
+
Colour of the y=x reference line
|
|
540
|
+
alpha : float
|
|
541
|
+
Transparency of the quantile points
|
|
542
|
+
**kwargs : Additional keyword arguments passed to ax.scatter
|
|
543
|
+
|
|
544
|
+
Returns:
|
|
545
|
+
--------
|
|
546
|
+
fig, ax : The created figure and axes
|
|
547
|
+
"""
|
|
548
|
+
if self.ax is None:
|
|
549
|
+
raise ValueError("No axes available for plotting")
|
|
550
|
+
|
|
551
|
+
sample = np.sort(np.asarray(data))
|
|
552
|
+
n = len(sample)
|
|
553
|
+
if n == 0:
|
|
554
|
+
raise ValueError("QQPlot got no data to plot")
|
|
555
|
+
|
|
556
|
+
# Filliben-style plotting positions: (i - 0.5) / n avoids ppf(0) / ppf(1),
|
|
557
|
+
# which are -inf/inf for most families.
|
|
558
|
+
probabilities = (np.arange(1, n + 1) - 0.5) / n
|
|
559
|
+
theoretical = distribution.ppf(probabilities)
|
|
560
|
+
|
|
561
|
+
self.ax.scatter(theoretical, sample, color=color, alpha=alpha, **kwargs)
|
|
562
|
+
|
|
563
|
+
lo = min(np.min(theoretical), np.min(sample))
|
|
564
|
+
hi = max(np.max(theoretical), np.max(sample))
|
|
565
|
+
self.ax.plot([lo, hi], [lo, hi], color=line_color, linewidth=1, linestyle="--")
|
|
566
|
+
|
|
567
|
+
return self.fig, self.ax
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
class ECDFPlot(BasePlot):
|
|
571
|
+
"""Empirical CDF: bin-free, and the right tool for comparing two samples.
|
|
572
|
+
|
|
573
|
+
A histogram's shape depends on its bin width; an ECDF has no such knob, uses
|
|
574
|
+
every point, and puts two samples on directly comparable axes without either
|
|
575
|
+
one's binning choice hiding or manufacturing a difference.
|
|
576
|
+
"""
|
|
577
|
+
|
|
578
|
+
def build(
|
|
579
|
+
self,
|
|
580
|
+
data: np.ndarray,
|
|
581
|
+
compare: Optional[np.ndarray] = None,
|
|
582
|
+
label: str = "data",
|
|
583
|
+
compare_label: str = "compare",
|
|
584
|
+
color: str = "steelblue",
|
|
585
|
+
compare_color: str = "crimson",
|
|
586
|
+
**kwargs,
|
|
587
|
+
) -> Tuple[Any, Any]:
|
|
588
|
+
"""
|
|
589
|
+
Plot the empirical CDF of `data`, and optionally a second sample over it.
|
|
590
|
+
|
|
591
|
+
Parameters:
|
|
592
|
+
-----------
|
|
593
|
+
data : np.ndarray
|
|
594
|
+
Sample to plot
|
|
595
|
+
compare : Optional[np.ndarray]
|
|
596
|
+
A second sample to overlay, for a direct two-sample comparison
|
|
597
|
+
label : str
|
|
598
|
+
Legend label for `data`
|
|
599
|
+
compare_label : str
|
|
600
|
+
Legend label for `compare`
|
|
601
|
+
color : str
|
|
602
|
+
Colour of the `data` step curve
|
|
603
|
+
compare_color : str
|
|
604
|
+
Colour of the `compare` step curve
|
|
605
|
+
**kwargs : Additional keyword arguments passed to ax.step
|
|
606
|
+
|
|
607
|
+
Returns:
|
|
608
|
+
--------
|
|
609
|
+
fig, ax : The created figure and axes
|
|
610
|
+
"""
|
|
611
|
+
if self.ax is None:
|
|
612
|
+
raise ValueError("No axes available for plotting")
|
|
613
|
+
ax = self.ax
|
|
614
|
+
|
|
615
|
+
self._draw(ax, np.asarray(data), color=color, label=label, **kwargs)
|
|
616
|
+
if compare is not None:
|
|
617
|
+
self._draw(ax, np.asarray(compare), color=compare_color, label=compare_label, **kwargs)
|
|
618
|
+
|
|
619
|
+
if compare is not None or label:
|
|
620
|
+
ax.legend()
|
|
621
|
+
|
|
622
|
+
return self.fig, self.ax
|
|
623
|
+
|
|
624
|
+
def _draw(self, ax: Axes, values: np.ndarray, color: str, label: Optional[str], **kwargs) -> None:
|
|
625
|
+
if len(values) == 0:
|
|
626
|
+
raise ValueError("ECDFPlot got no data to plot")
|
|
627
|
+
sorted_values = np.sort(values)
|
|
628
|
+
y = np.arange(1, len(sorted_values) + 1) / len(sorted_values)
|
|
629
|
+
ax.step(sorted_values, y, where="post", color=color, label=label, **kwargs)
|
|
630
|
+
|
|
631
|
+
|
|
508
632
|
@dataclass
|
|
509
633
|
class FitPlotSettings:
|
|
510
634
|
"""Settings for distribution fit plots."""
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pytest
|
|
3
|
+
from scipy import stats
|
|
4
|
+
|
|
5
|
+
from goad_toolkit.analytics import (
|
|
6
|
+
DistributionFitter,
|
|
7
|
+
FailedFit,
|
|
8
|
+
FitResult,
|
|
9
|
+
KSTestResult,
|
|
10
|
+
fit_table,
|
|
11
|
+
)
|
|
12
|
+
from goad_toolkit.distributions import DistributionRegistry
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_registries_are_independent():
|
|
16
|
+
first = DistributionRegistry()
|
|
17
|
+
second = DistributionRegistry()
|
|
18
|
+
|
|
19
|
+
first.register_distribution("pareto", stats.pareto, is_discrete=False, num_params=3)
|
|
20
|
+
|
|
21
|
+
assert "pareto" in first.get_names()
|
|
22
|
+
assert "pareto" not in second.get_names()
|
|
23
|
+
assert "pareto" not in DistributionRegistry().get_names()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_registry_ships_the_default_families():
|
|
27
|
+
names = DistributionRegistry().get_names()
|
|
28
|
+
|
|
29
|
+
assert names == [
|
|
30
|
+
"norm",
|
|
31
|
+
"uniform",
|
|
32
|
+
"lognorm",
|
|
33
|
+
"poisson",
|
|
34
|
+
"exponential",
|
|
35
|
+
"skewnorm",
|
|
36
|
+
"gamma",
|
|
37
|
+
"weibull",
|
|
38
|
+
"bernoulli",
|
|
39
|
+
"binomial",
|
|
40
|
+
"nbinom",
|
|
41
|
+
"beta",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_pareto_is_not_a_default_family():
|
|
46
|
+
# Lesson 4 has the student register pareto themselves as the extensibility
|
|
47
|
+
# exercise; shipping it by default would remove the point of that exercise.
|
|
48
|
+
assert "pareto" not in DistributionRegistry().get_names()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_unknown_distribution_raises():
|
|
52
|
+
with pytest.raises(ValueError, match="not found in registry"):
|
|
53
|
+
DistributionRegistry().get_distribution("pareto")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_fitter_uses_a_private_registry_by_default():
|
|
57
|
+
fitter = DistributionFitter()
|
|
58
|
+
DistributionRegistry().register_distribution(
|
|
59
|
+
"pareto", stats.pareto, is_discrete=False, num_params=3
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
assert "pareto" not in fitter.registry
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_fitter_accepts_a_registry():
|
|
66
|
+
registry = DistributionRegistry()
|
|
67
|
+
registry.distributions = {}
|
|
68
|
+
registry.register_distribution("norm", stats.norm, is_discrete=False, num_params=2)
|
|
69
|
+
|
|
70
|
+
fitter = DistributionFitter(registry)
|
|
71
|
+
|
|
72
|
+
assert fitter.registry == ["norm"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_fit_with_a_single_family_registry():
|
|
76
|
+
registry = DistributionRegistry()
|
|
77
|
+
registry.distributions = {}
|
|
78
|
+
registry.register_distribution("norm", stats.norm, is_discrete=False, num_params=2)
|
|
79
|
+
rng = np.random.default_rng(42)
|
|
80
|
+
data = rng.normal(loc=3.0, scale=2.0, size=500)
|
|
81
|
+
|
|
82
|
+
results = DistributionFitter(registry).fit(data, discrete=False)
|
|
83
|
+
|
|
84
|
+
assert len(results) == 1
|
|
85
|
+
fit = results[0]
|
|
86
|
+
assert isinstance(fit, FitResult)
|
|
87
|
+
assert fit.best_likelihood
|
|
88
|
+
assert fit.params[0] == pytest.approx(3.0, abs=0.5)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_bernoulli_fits_a_small_p():
|
|
92
|
+
# A single-parameter discrete bound used to floor at 0.1, which is a sane
|
|
93
|
+
# floor for a count-scale rate (poisson's lambda) but clips a genuinely
|
|
94
|
+
# small probability (bernoulli's p) to the floor instead of the true value.
|
|
95
|
+
registry = DistributionRegistry()
|
|
96
|
+
registry.distributions = {}
|
|
97
|
+
registry.register_distribution(
|
|
98
|
+
"bernoulli", stats.bernoulli, is_discrete=True, num_params=1
|
|
99
|
+
)
|
|
100
|
+
rng = np.random.default_rng(7)
|
|
101
|
+
data = stats.bernoulli(p=0.05).rvs(3000, random_state=rng)
|
|
102
|
+
|
|
103
|
+
results = DistributionFitter(registry).fit(data, discrete=True)
|
|
104
|
+
|
|
105
|
+
fit = results[0]
|
|
106
|
+
assert isinstance(fit, FitResult)
|
|
107
|
+
assert fit.params[0] == pytest.approx(0.05, abs=0.02)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_loglikelihood_is_finite_for_discrete_families():
|
|
111
|
+
# dist_obj.dist.logpdf does not exist on discrete scipy distributions
|
|
112
|
+
# (they have logpmf instead), so every discrete fit's log_likelihood used
|
|
113
|
+
# to fall back to -inf regardless of fit quality.
|
|
114
|
+
registry = DistributionRegistry()
|
|
115
|
+
rng = np.random.default_rng(3)
|
|
116
|
+
data = stats.poisson(mu=4.0).rvs(500, random_state=rng)
|
|
117
|
+
|
|
118
|
+
results = DistributionFitter(registry).fit(data, discrete=True)
|
|
119
|
+
|
|
120
|
+
successes = [r for r in results if isinstance(r, FitResult)]
|
|
121
|
+
assert successes, "expected at least one discrete fit to succeed"
|
|
122
|
+
for fit in successes:
|
|
123
|
+
assert fit.log_likelihood is not None
|
|
124
|
+
assert fit.log_likelihood > float("-inf")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_best_likelihood_marked_even_when_no_fit_clears_the_ks_threshold():
|
|
128
|
+
# _mark_best_fits used to require both a likelihood winner AND a ks winner
|
|
129
|
+
# before marking either — so a best_likelihood candidate went unmarked
|
|
130
|
+
# whenever every fit's ks p-value was exactly 0 (routine for discrete
|
|
131
|
+
# families on a large, tied sample).
|
|
132
|
+
fitter = DistributionFitter()
|
|
133
|
+
worse = FitResult(
|
|
134
|
+
distribution="a",
|
|
135
|
+
dist_object=None,
|
|
136
|
+
params=(1.0,),
|
|
137
|
+
frozen_dist=None,
|
|
138
|
+
log_likelihood=-100.0,
|
|
139
|
+
kstest=KSTestResult(statistic=0.5, p_value=0.0),
|
|
140
|
+
)
|
|
141
|
+
better = FitResult(
|
|
142
|
+
distribution="b",
|
|
143
|
+
dist_object=None,
|
|
144
|
+
params=(1.0,),
|
|
145
|
+
frozen_dist=None,
|
|
146
|
+
log_likelihood=-50.0,
|
|
147
|
+
kstest=KSTestResult(statistic=0.5, p_value=0.0),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
fitter._mark_best_fits([worse, better], criterion="combined")
|
|
151
|
+
|
|
152
|
+
assert better.best_likelihood is True
|
|
153
|
+
assert worse.best_likelihood is False
|
|
154
|
+
assert better.best_ks is None or better.best_ks is False
|
|
155
|
+
assert worse.best_ks is None or worse.best_ks is False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_mark_best_fits_rejects_unknown_criterion():
|
|
159
|
+
fitter = DistributionFitter()
|
|
160
|
+
with pytest.raises(ValueError, match="Unknown criterion"):
|
|
161
|
+
fitter._mark_best_fits([], criterion="nonsense")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_fit_table_ranks_by_loglikelihood_and_sorts_failures_last():
|
|
165
|
+
results = [
|
|
166
|
+
FitResult(
|
|
167
|
+
distribution="worse",
|
|
168
|
+
dist_object=None,
|
|
169
|
+
params=(1.0, 2.0),
|
|
170
|
+
frozen_dist=None,
|
|
171
|
+
log_likelihood=-50.0,
|
|
172
|
+
kstest=KSTestResult(statistic=0.2, p_value=0.3),
|
|
173
|
+
best_likelihood=False,
|
|
174
|
+
best_ks=False,
|
|
175
|
+
),
|
|
176
|
+
FailedFit(distribution="broken", message="optimizer did not converge"),
|
|
177
|
+
FitResult(
|
|
178
|
+
distribution="best",
|
|
179
|
+
dist_object=None,
|
|
180
|
+
params=(3.0,),
|
|
181
|
+
frozen_dist=None,
|
|
182
|
+
log_likelihood=-10.0,
|
|
183
|
+
kstest=KSTestResult(statistic=0.05, p_value=0.9),
|
|
184
|
+
best_likelihood=True,
|
|
185
|
+
best_ks=True,
|
|
186
|
+
),
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
table = fit_table(results)
|
|
190
|
+
|
|
191
|
+
assert list(table["distribution"]) == ["best", "worse", "broken"]
|
|
192
|
+
assert table.loc[0, "best_likelihood"]
|
|
193
|
+
assert table.loc[0, "best_ks"]
|
|
194
|
+
assert table.loc[2, "message"] == "optimizer did not converge"
|
|
195
|
+
assert np.isnan(table.loc[2, "log_likelihood"])
|
|
@@ -2,9 +2,11 @@ import matplotlib
|
|
|
2
2
|
|
|
3
3
|
matplotlib.use("Agg")
|
|
4
4
|
|
|
5
|
+
import numpy as np # noqa: E402
|
|
5
6
|
import matplotlib.pyplot as plt # noqa: E402
|
|
6
7
|
import pandas as pd # noqa: E402
|
|
7
8
|
import pytest # noqa: E402
|
|
9
|
+
from scipy import stats # noqa: E402
|
|
8
10
|
|
|
9
11
|
from goad_toolkit.visualizer import ( # noqa: E402
|
|
10
12
|
ACFPlot,
|
|
@@ -12,8 +14,10 @@ from goad_toolkit.visualizer import ( # noqa: E402
|
|
|
12
14
|
ComparePlot,
|
|
13
15
|
ComparePlotDate,
|
|
14
16
|
DecomposePlot,
|
|
17
|
+
ECDFPlot,
|
|
15
18
|
LinePlot,
|
|
16
19
|
PlotSettings,
|
|
20
|
+
QQPlot,
|
|
17
21
|
ResidualPlot,
|
|
18
22
|
VerticalDate,
|
|
19
23
|
)
|
|
@@ -147,3 +151,50 @@ def test_acf_plot_rejects_missing_values():
|
|
|
147
151
|
|
|
148
152
|
with pytest.raises(ValueError, match="missing values"):
|
|
149
153
|
ACFPlot(PlotSettings()).plot(data=series, nlags=2)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_qq_plot_points_fall_near_the_reference_line_for_a_correct_fit():
|
|
157
|
+
rng = np.random.default_rng(11)
|
|
158
|
+
data = rng.normal(loc=5.0, scale=2.0, size=500)
|
|
159
|
+
|
|
160
|
+
fig, ax = QQPlot(PlotSettings()).plot(data=data, distribution=stats.norm(loc=5.0, scale=2.0))
|
|
161
|
+
|
|
162
|
+
assert fig is not None
|
|
163
|
+
offsets = ax.collections[0].get_offsets()
|
|
164
|
+
theoretical, sample = offsets[:, 0], offsets[:, 1]
|
|
165
|
+
assert np.corrcoef(theoretical, sample)[0, 1] > 0.99
|
|
166
|
+
# A y=x reference line is drawn alongside the scatter.
|
|
167
|
+
assert len(ax.lines) == 1
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_qq_plot_rejects_empty_data():
|
|
171
|
+
with pytest.raises(ValueError, match="no data"):
|
|
172
|
+
QQPlot(PlotSettings()).plot(data=np.array([]), distribution=stats.norm())
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_ecdf_plot_single_sample_is_a_valid_step_curve():
|
|
176
|
+
data = np.array([1, 2, 2, 3, 5])
|
|
177
|
+
|
|
178
|
+
fig, ax = ECDFPlot(PlotSettings()).plot(data=data)
|
|
179
|
+
|
|
180
|
+
assert fig is not None
|
|
181
|
+
line = ax.lines[0]
|
|
182
|
+
y = line.get_ydata()
|
|
183
|
+
assert y[-1] == pytest.approx(1.0)
|
|
184
|
+
assert np.all(np.diff(y) >= 0) # monotonically non-decreasing
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_ecdf_plot_compares_two_samples():
|
|
188
|
+
lower = np.array([1, 2, 3, 4, 5])
|
|
189
|
+
higher = lower + 10
|
|
190
|
+
|
|
191
|
+
fig, ax = ECDFPlot(PlotSettings()).plot(data=lower, compare=higher, label="a", compare_label="b")
|
|
192
|
+
|
|
193
|
+
assert len(ax.lines) == 2
|
|
194
|
+
legend_labels = [t.get_text() for t in ax.get_legend().get_texts()]
|
|
195
|
+
assert legend_labels == ["a", "b"]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_ecdf_plot_rejects_empty_data():
|
|
199
|
+
with pytest.raises(ValueError, match="no data"):
|
|
200
|
+
ECDFPlot(PlotSettings()).plot(data=np.array([]))
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import numpy as np
|
|
2
|
-
import pytest
|
|
3
|
-
from scipy import stats
|
|
4
|
-
|
|
5
|
-
from goad_toolkit.analytics import DistributionFitter, FitResult
|
|
6
|
-
from goad_toolkit.distributions import DistributionRegistry
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def test_registries_are_independent():
|
|
10
|
-
first = DistributionRegistry()
|
|
11
|
-
second = DistributionRegistry()
|
|
12
|
-
|
|
13
|
-
first.register_distribution("pareto", stats.pareto, is_discrete=False, num_params=3)
|
|
14
|
-
|
|
15
|
-
assert "pareto" in first.get_names()
|
|
16
|
-
assert "pareto" not in second.get_names()
|
|
17
|
-
assert "pareto" not in DistributionRegistry().get_names()
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def test_registry_ships_the_default_families():
|
|
21
|
-
names = DistributionRegistry().get_names()
|
|
22
|
-
|
|
23
|
-
assert names == [
|
|
24
|
-
"norm",
|
|
25
|
-
"uniform",
|
|
26
|
-
"lognorm",
|
|
27
|
-
"poisson",
|
|
28
|
-
"exponential",
|
|
29
|
-
"skewnorm",
|
|
30
|
-
"gamma",
|
|
31
|
-
"weibull",
|
|
32
|
-
]
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def test_unknown_distribution_raises():
|
|
36
|
-
with pytest.raises(ValueError, match="not found in registry"):
|
|
37
|
-
DistributionRegistry().get_distribution("pareto")
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def test_fitter_uses_a_private_registry_by_default():
|
|
41
|
-
fitter = DistributionFitter()
|
|
42
|
-
DistributionRegistry().register_distribution(
|
|
43
|
-
"pareto", stats.pareto, is_discrete=False, num_params=3
|
|
44
|
-
)
|
|
45
|
-
|
|
46
|
-
assert "pareto" not in fitter.registry
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def test_fitter_accepts_a_registry():
|
|
50
|
-
registry = DistributionRegistry()
|
|
51
|
-
registry.distributions = {}
|
|
52
|
-
registry.register_distribution("norm", stats.norm, is_discrete=False, num_params=2)
|
|
53
|
-
|
|
54
|
-
fitter = DistributionFitter(registry)
|
|
55
|
-
|
|
56
|
-
assert fitter.registry == ["norm"]
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def test_fit_with_a_single_family_registry():
|
|
60
|
-
registry = DistributionRegistry()
|
|
61
|
-
registry.distributions = {}
|
|
62
|
-
registry.register_distribution("norm", stats.norm, is_discrete=False, num_params=2)
|
|
63
|
-
rng = np.random.default_rng(42)
|
|
64
|
-
data = rng.normal(loc=3.0, scale=2.0, size=500)
|
|
65
|
-
|
|
66
|
-
results = DistributionFitter(registry).fit(data, discrete=False)
|
|
67
|
-
|
|
68
|
-
assert len(results) == 1
|
|
69
|
-
fit = results[0]
|
|
70
|
-
assert isinstance(fit, FitResult)
|
|
71
|
-
assert fit.best_likelihood
|
|
72
|
-
assert fit.params[0] == pytest.approx(3.0, abs=0.5)
|
|
File without changes
|
{goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/.gitignore
RENAMED
|
File without changes
|
{goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/.python-version
RENAMED
|
File without changes
|
{goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/CHANGELOG.md
RENAMED
|
File without changes
|
{goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/MCP_SERVER.md
RENAMED
|
File without changes
|
{goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/README.md
RENAMED
|
File without changes
|
{goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/demo/linear.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/docs/README.md
RENAMED
|
File without changes
|
{goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/goad_mcp.py
RENAMED
|
File without changes
|
|
File without changes
|
{goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/img/goaded.png
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/img/zscores.png
RENAMED
|
File without changes
|
{goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/pyproject.toml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{goad_toolkit-0.2.3 → goad_toolkit-0.2.4}/.claude/worktrees/quizzical-solomon-b62511/uv.lock
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|