goad-toolkit 0.2.4__tar.gz → 0.2.7__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 (105) hide show
  1. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/PKG-INFO +31 -1
  2. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/README.md +30 -0
  3. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/docs/02-pipelines.md +26 -25
  4. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/docs/04-five-families.md +16 -3
  5. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/docs/06-models-and-residuals.md +44 -0
  6. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/docs/08-api-reference.md +54 -0
  7. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/docs/09-analysis-method.md +3 -1
  8. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/goad_mcp.py +2 -2
  9. goad_toolkit-0.2.7/img/null-distribution.png +0 -0
  10. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/pyproject.toml +1 -1
  11. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/src/goad_toolkit/analytics.py +159 -4
  12. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/src/goad_toolkit/datatransforms.py +47 -0
  13. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/src/goad_toolkit/visualizer.py +299 -3
  14. goad_toolkit-0.2.7/tests/test_datatransforms.py +167 -0
  15. goad_toolkit-0.2.7/tests/test_nulldistribution.py +156 -0
  16. goad_toolkit-0.2.7/tests/test_visualizer.py +383 -0
  17. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/uv.lock +1 -1
  18. goad_toolkit-0.2.4/tests/test_datatransforms.py +0 -66
  19. goad_toolkit-0.2.4/tests/test_visualizer.py +0 -200
  20. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/settings.local.json +0 -0
  21. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/.gitignore +0 -0
  22. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/.python-version +0 -0
  23. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/CHANGELOG.md +0 -0
  24. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/MCP_SERVER.md +0 -0
  25. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/README.md +0 -0
  26. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/demo/linear.py +0 -0
  27. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/docs/01-goal-oriented-analysis.md +0 -0
  28. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/docs/02-pipelines.md +0 -0
  29. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/docs/03-plot-composition.md +0 -0
  30. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/docs/04-five-families.md +0 -0
  31. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/docs/05-distributions.md +0 -0
  32. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/docs/06-models-and-residuals.md +0 -0
  33. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/docs/07-visual-critique.md +0 -0
  34. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/docs/08-api-reference.md +0 -0
  35. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/docs/09-analysis-method.md +0 -0
  36. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/docs/10-teaching-path.md +0 -0
  37. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/docs/README.md +0 -0
  38. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/goad_mcp.py +0 -0
  39. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/img/distribution_fit.png +0 -0
  40. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/img/goaded.png +0 -0
  41. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/img/linear_results.png +0 -0
  42. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/img/residuals.png +0 -0
  43. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/img/zscores.png +0 -0
  44. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/pyproject.toml +0 -0
  45. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/__init__.py +0 -0
  46. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/analytics.py +0 -0
  47. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/cli.py +0 -0
  48. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/config.py +0 -0
  49. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/dataprocessor.py +0 -0
  50. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/datatransforms.py +0 -0
  51. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/distributions.py +0 -0
  52. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/filehandler.py +0 -0
  53. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/models.py +0 -0
  54. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/visualizer.py +0 -0
  55. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/tests/test_cli.py +0 -0
  56. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/tests/test_distributions.py +0 -0
  57. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/tests/test_filehandler.py +0 -0
  58. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/tests/test_visualizer.py +0 -0
  59. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/uv.lock +0 -0
  60. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.gitignore +0 -0
  61. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.python-version +0 -0
  62. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/.gitignore +0 -0
  63. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/logs/autonomous/save-195516.log +0 -0
  64. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/logs/autonomous/save-195720.log +0 -0
  65. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/logs/autonomous/save-195933.log +0 -0
  66. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/logs/autonomous/save-200831.log +0 -0
  67. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/logs/autonomous/save-201034.log +0 -0
  68. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/logs/hook-errors.log +0 -0
  69. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/logs/memory-2026-08-10.log +0 -0
  70. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/now.md +0 -0
  71. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/tmp/capture-alive +0 -0
  72. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/tmp/capture-alive.d/90574e63-97cd-4c11-87e5-23b656c56fd9 +0 -0
  73. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/tmp/case-divergence +0 -0
  74. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/tmp/last-ndc.ts +0 -0
  75. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/tmp/last-save-ts +0 -0
  76. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/tmp/last-save.json +0 -0
  77. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/tmp/now-day +0 -0
  78. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/tmp/post-tool-ran +0 -0
  79. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/tmp/save-session.pid +0 -0
  80. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/tmp/session-slug +0 -0
  81. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/today-2026-08-10.md +0 -0
  82. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/CHANGELOG.md +0 -0
  83. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/MCP_SERVER.md +0 -0
  84. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/demo/linear.py +0 -0
  85. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/docs/01-goal-oriented-analysis.md +0 -0
  86. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/docs/03-plot-composition.md +0 -0
  87. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/docs/05-distributions.md +0 -0
  88. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/docs/07-visual-critique.md +0 -0
  89. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/docs/10-teaching-path.md +0 -0
  90. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/docs/README.md +0 -0
  91. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/img/distribution_fit.png +0 -0
  92. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/img/goaded.png +0 -0
  93. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/img/linear_results.png +0 -0
  94. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/img/residuals.png +0 -0
  95. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/img/zscores.png +0 -0
  96. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/src/goad_toolkit/__init__.py +0 -0
  97. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/src/goad_toolkit/cli.py +0 -0
  98. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/src/goad_toolkit/config.py +0 -0
  99. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/src/goad_toolkit/dataprocessor.py +0 -0
  100. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/src/goad_toolkit/distributions.py +0 -0
  101. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/src/goad_toolkit/filehandler.py +0 -0
  102. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/src/goad_toolkit/models.py +0 -0
  103. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/tests/test_cli.py +0 -0
  104. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/tests/test_distributions.py +0 -0
  105. {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/tests/test_filehandler.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: goad-toolkit
3
- Version: 0.2.4
3
+ Version: 0.2.7
4
4
  Summary: An extensible toolkit for Goal Oriented Analysis of Data
5
5
  Project-URL: Github, https://github.com/raoulg/goad_toolkit
6
6
  Author-email: raoul grouls <Raoul.Grouls@han.nl>
@@ -204,6 +204,36 @@ For the [kstest](https://docs.scipy.org/doc/scipy/reference/generated/scipy.stat
204
204
  The plots are sorted by log-likelihood, which means there is no good fit with a distribution in this case.
205
205
  ![residuals](img/distribution_fit.png)
206
206
 
207
+ ### 🎲 Is it real? The shuffle test
208
+
209
+ Shuffle the labels, measure again, and see where the real number lands. No test, no
210
+ assumptions, no table of critical values — just a picture of what your measurement does when
211
+ the label means nothing:
212
+
213
+ ```python
214
+ from goad_toolkit.analytics import NullDistribution
215
+ from goad_toolkit.visualizer import NullPlot, PlotSettings
216
+
217
+ def gap(frame):
218
+ means = frame.groupby("is_bot")["length"].mean()
219
+ return means[True] - means[False]
220
+
221
+ result = NullDistribution(gap, n_iter=2000, seed=1).run(data, label="is_bot")
222
+ settings = PlotSettings(
223
+ title="Mean message length: bots minus humans",
224
+ xlabel="difference in mean length (characters)",
225
+ ylabel="density under shuffled labels",
226
+ )
227
+ NullPlot(settings).plot(result=result)
228
+ ```
229
+
230
+ ![null distribution](img/null-distribution.png)
231
+
232
+ The grey cloud is the statistic under shuffled labels; the line is what the real data did.
233
+ Writing the statistic is your job — that is the claim. See
234
+ [Models and residuals](docs/06-models-and-residuals.md) §6.7 for what the p-value can and
235
+ cannot carry.
236
+
207
237
  ### 🧩 Extending with Custom Distributions
208
238
 
209
239
  You can easily register new distributions:
@@ -183,6 +183,36 @@ For the [kstest](https://docs.scipy.org/doc/scipy/reference/generated/scipy.stat
183
183
  The plots are sorted by log-likelihood, which means there is no good fit with a distribution in this case.
184
184
  ![residuals](img/distribution_fit.png)
185
185
 
186
+ ### 🎲 Is it real? The shuffle test
187
+
188
+ Shuffle the labels, measure again, and see where the real number lands. No test, no
189
+ assumptions, no table of critical values — just a picture of what your measurement does when
190
+ the label means nothing:
191
+
192
+ ```python
193
+ from goad_toolkit.analytics import NullDistribution
194
+ from goad_toolkit.visualizer import NullPlot, PlotSettings
195
+
196
+ def gap(frame):
197
+ means = frame.groupby("is_bot")["length"].mean()
198
+ return means[True] - means[False]
199
+
200
+ result = NullDistribution(gap, n_iter=2000, seed=1).run(data, label="is_bot")
201
+ settings = PlotSettings(
202
+ title="Mean message length: bots minus humans",
203
+ xlabel="difference in mean length (characters)",
204
+ ylabel="density under shuffled labels",
205
+ )
206
+ NullPlot(settings).plot(result=result)
207
+ ```
208
+
209
+ ![null distribution](img/null-distribution.png)
210
+
211
+ The grey cloud is the statistic under shuffled labels; the line is what the real data did.
212
+ Writing the statistic is your job — that is the claim. See
213
+ [Models and residuals](docs/06-models-and-residuals.md) §6.7 for what the p-value can and
214
+ cannot carry.
215
+
186
216
  ### 🧩 Extending with Custom Distributions
187
217
 
188
218
  You can easily register new distributions:
@@ -59,6 +59,7 @@ input frame is safe, and the steps stay cheap.
59
59
  | `RollingAvg` | rolling mean, drops the leading NaNs | `column`, `window`, `rename` |
60
60
  | `ZScaler` | standardise to mean 0, std 1 | `column`, `rename` |
61
61
  | `TimeFeatures` | derive calendar columns from a timestamp | `column`, `features` |
62
+ | `RegexFeature` | count / flag / extract a pattern in a text column | `column`, `pattern`, `feature`, `mode` |
62
63
 
63
64
  `rename=True` writes to a new column (`deaths_shifted`, `deaths_zscore`, …) instead of
64
65
  overwriting. Prefer it. An overwritten column is a step you cannot debug, and the whole
@@ -104,39 +105,39 @@ Two rules for your `transform`:
104
105
  - **Name your parameters explicitly** in the signature. `def transform(self, data, column,
105
106
  window)` documents itself; `**kwargs` does not, and the validation cannot help you.
106
107
 
107
- ## 2.5 A transform for feature enrichment
108
+ ## 2.5 `RegexFeature`: feature enrichment from text
108
109
 
109
- The single most useful custom transform for text data, and a good template:
110
+ The single most useful transform for text data, and the reason `TransformBase` is worth
111
+ subclassing at all. It ships:
110
112
 
111
113
  ```python
112
- class RegexFeature(TransformBase):
113
- """Add a feature extracted from a text column with a regular expression."""
114
-
115
- def transform(
116
- self,
117
- data: pd.DataFrame,
118
- column: str,
119
- pattern: str,
120
- feature: str,
121
- mode: str = "count",
122
- ) -> pd.DataFrame:
123
- text = data[column].fillna("")
124
- if mode == "count":
125
- data[feature] = text.str.count(pattern)
126
- elif mode == "has":
127
- data[feature] = text.str.contains(pattern, regex=True)
128
- elif mode == "extract":
129
- data[feature] = text.str.extract(pattern, expand=False)
130
- else:
131
- raise ValueError(f"mode must be count/has/extract, got {mode!r}")
132
- return data
133
- ```
114
+ from goad_toolkit.datatransforms import RegexFeature
134
115
 
135
- ```python
136
116
  pipeline.add(RegexFeature, name="url_flag",
137
117
  column="message", pattern=r"https?://\S+", feature="has_url", mode="has")
138
118
  ```
139
119
 
120
+ Three modes, each writing one new column named by `feature`:
121
+
122
+ | `mode` | writes | use for |
123
+ |---|---|---|
124
+ | `"count"` | how many times the pattern occurs (int) | how many URLs, how many question marks |
125
+ | `"has"` | whether it occurs at all (bool) | flags you will group or filter on |
126
+ | `"extract"` | the first capture group, NaN where nothing matched | pulling a value *out* of the text |
127
+
128
+ `"extract"` needs exactly one capture group in `pattern`, and it is the mode worth being
129
+ careful with. `count` and `has` fail visibly when a pattern is wrong — a column of all zeros
130
+ or all `False` is hard to miss. Extraction fails *silently*, filling with NaN, so it reports
131
+ its own coverage through `loguru`:
132
+
133
+ ```
134
+ mentions: extracted 'addressed_to' from 92,415/627,172 rows (14.7%); 534,757 rows had no match
135
+ ```
136
+
137
+ That number is the point. A pattern that matches 15% of rows may be exactly right — on IRC,
138
+ most messages do not address anyone — or it may be silently broken. The log line makes you
139
+ decide which, instead of finding out four notebooks later.
140
+
140
141
  Note the two different `name`s: `Pipeline.add(name=...)` names the *step*, and
141
142
  `TransformBase.__init__` consumes it. So the new-column parameter has to be called something
142
143
  else — `feature` here. Any transform that wants to name an output column hits this.
@@ -149,9 +149,22 @@ to look.
149
149
  are not independent evidence. This is exactly why regularisation is the better tool for
150
150
  "which variables matter" — see [Models and residuals](06-models-and-residuals.md).
151
151
 
152
- **In GOAD:** the scatter and the fit are `BasePlot` subclasses; the interesting work is in
153
- [Models and residuals](06-models-and-residuals.md), because a relation you can state as a
154
- model is a relation whose failures you can inspect.
152
+ **In GOAD:** `ScatterPlot`, `RegPlot` and `CorrelationHeatmap`, in that order of use.
153
+
154
+ ```python
155
+ scatter = ScatterPlot(settings)
156
+ scatter.plot(data=data, x="messages", y="length", hue="is_bot")
157
+ scatter.plot_on(RegPlot(settings), data=data, x="messages", y="length", scatter=False)
158
+ ```
159
+
160
+ The layering is the point: the scatter is drawn first and the fit goes on top of it, so the
161
+ choice of `fit_reg`, `order` or `lowess` is made after you have seen the shape rather than
162
+ before. `CorrelationHeatmap` is the entry point when there are too many pairs to plot — it
163
+ tells you which scatter to make next, and nothing more than that.
164
+
165
+ The interesting work is still in [Models and residuals](06-models-and-residuals.md), because
166
+ a relation you can state as a model is a relation whose failures you can inspect — and
167
+ §6.7 is how you check that the relation is not something a shuffle would have produced.
155
168
 
156
169
  ---
157
170
 
@@ -158,6 +158,50 @@ They compose: the residual of a functional model is what you fit a distribution
158
158
  composition is the loop from [Goal oriented analysis](01-goal-oriented-analysis.md) §1.3, and
159
159
  it is where the two halves of this library meet.
160
160
 
161
+ ## 6.7 The shuffle test
162
+
163
+ §6.1 said a model is a statement about what is boring. The most boring statement available is
164
+ *this label explains nothing*, and you can build that model without writing one down: shuffle
165
+ the label column and measure again. Everything else stays as it was — the labels keep their
166
+ proportions, the values keep their shape — and only the pairing between them is destroyed.
167
+ Do it a thousand times and you have a picture of what your measurement does in a world where
168
+ there is nothing to find.
169
+
170
+ ```python
171
+ from goad_toolkit.analytics import NullDistribution
172
+ from goad_toolkit.visualizer import NullPlot, PlotSettings
173
+
174
+ def gap(frame):
175
+ means = frame.groupby("is_bot")["length"].mean()
176
+ return means[True] - means[False]
177
+
178
+ result = NullDistribution(gap, n_iter=2000, seed=1).run(data, label="is_bot")
179
+ NullPlot(PlotSettings(xlabel="difference in mean length")).plot(result=result)
180
+ ```
181
+
182
+ The statistic is yours. `NullDistribution` runs the loop; deciding *what* to measure and
183
+ *which* column to shuffle is the part that cannot be delegated, because it is the claim.
184
+
185
+ Read the picture, not the number. Either the line stands clear of the grey cloud or it sits
186
+ inside it, and that judgement needs no test, no distributional assumption, and no table of
187
+ critical values. A finding sitting in the middle of its own null is not a weak finding; it is
188
+ the null, and the honest write-up says so.
189
+
190
+ Three things worth knowing before you quote a number from it:
191
+
192
+ - **`p_value()` counts, it does not test.** It is the fraction of shuffles that matched or
193
+ beat what you saw, defaulting to two-sided. The observed arrangement is itself one of the
194
+ arrangements, so the count starts at one and the smallest reportable value is
195
+ `1 / (n_iter + 1)`. With 2000 shuffles, "p ≤ 0.001" is the strongest sentence available —
196
+ which is why `NullPlot` writes `≤` on the plot when you are at that floor.
197
+ - **It answers one question.** Run it on the fifteenth thing you measured and it will happily
198
+ report the fifteenth thing as striking. The shuffle test defends against noise, not against
199
+ the garden of forking paths ([Analysis method](09-analysis-method.md) §9.6) — only writing
200
+ the question down first, or confirming on a slice you did not hunt on, does that.
201
+ - **Shuffling the label tests the label.** If a confounder drives both the label and the
202
+ value, shuffling breaks the confounding too, and the test will confirm an association that
203
+ is real but not the mechanism you have in mind.
204
+
161
205
  ---
162
206
 
163
207
  Next: [Visual critique](07-visual-critique.md) — judging the picture you just made.
@@ -84,6 +84,7 @@ class TransformBase(ABC):
84
84
  | `RollingAvg` | `column: str, window: int, rename: bool = False` |
85
85
  | `ZScaler` | `column: str, rename: bool = False` |
86
86
  | `TimeFeatures` | `column: str, features: Optional[List[str]] = None` |
87
+ | `RegexFeature` | `column: str, pattern: str, feature: str, mode: str = "count"` |
87
88
 
88
89
  `rename=True` writes to `{column}_shifted` / `_diff` / `_rolling_avg` / `_zscore` instead of
89
90
  overwriting.
@@ -92,6 +93,12 @@ overwriting.
92
93
  `TimeFeatures.DEFAULT` — used when `features` is omitted — is `("day_name", "isoweek",
93
94
  "year_week")`. A feature named the same as `column` overwrites it.
94
95
 
96
+ `RegexFeature.mode` is one of `"count"` (int), `"has"` (bool) or `"extract"` (the first
97
+ capture group, NaN where nothing matched); anything else raises `ValueError`. `extract`
98
+ requires exactly one capture group in `pattern` and logs its match rate through `loguru`.
99
+ The output column is `feature`, not `name` — `Pipeline.add(name=...)` already claims `name`
100
+ for the step.
101
+
95
102
  ```python
96
103
  class Pipeline:
97
104
  def add(self, transform_class: Type[T], name: Optional[str] = None, **kwargs) -> "Pipeline"
@@ -211,6 +218,33 @@ winner — it just has no `best_ks` winner.
211
218
  `log_likelihood` descending, with `FailedFit` rows (their `message` kept, metrics `NaN`)
212
219
  sorted last.
213
220
 
221
+ The shuffle test — see [Models and residuals](06-models-and-residuals.md) §6.7:
222
+
223
+ ```python
224
+ @dataclass
225
+ class NullResult:
226
+ observed: float
227
+ null: np.ndarray # the statistic under n_iter shuffles
228
+ n_iter: int
229
+ seed: Optional[int] = None
230
+
231
+ def p_value(self, alternative: str = "two-sided") -> float
232
+ quantile: float # property, where observed sits in the null
233
+
234
+ class NullDistribution:
235
+ def __init__(self, statistic: Callable[[pd.DataFrame], float],
236
+ n_iter: int = 1000, seed: Optional[int] = None)
237
+
238
+ def run(self, data: pd.DataFrame, label: str) -> NullResult
239
+ ```
240
+
241
+ `statistic` takes a frame and returns one number; `run` calls it once on the data as given,
242
+ then once per shuffle of the `label` column. The caller's frame is never modified.
243
+ `alternative` is one of `"two-sided"`, `"greater"`, `"less"`. Both one-sided counts include
244
+ the observed arrangement, so `p_value` bottoms out at `1 / (n_iter + 1)` rather than 0. A
245
+ `label` that is not a column raises `ValueError`, as does a statistic that is not finite on
246
+ the unshuffled data; non-finite values among the shuffles are logged, not raised.
247
+
214
248
  ---
215
249
 
216
250
  ## 8.7 `goad_toolkit.models`
@@ -281,14 +315,19 @@ class BasePlot(ABC):
281
315
  | `LinePlot` | `data, **kwargs` → `sns.lineplot` |
282
316
  | `ComparePlot` | `data, x, y1, y2, **kwargs` |
283
317
  | `VerticalDate` | `date: str, label: str` |
318
+ | `VerticalLine` | `x: float, label=None, color="crimson", linestyle="--", linewidth=2, **kwargs` |
284
319
  | `ComparePlotDate` | `data, x, y1, y2, date, datelabel, **kwargs` |
285
320
  | `BarWithDates` | `data, x, y, interval: int = 1, **kwargs` |
286
321
  | `ResidualPlot` | `data, x, y, date, datelabel, interval: int = 1` |
322
+ | `ScatterPlot` | `data, x, y, alpha=0.6, **kwargs` → `sns.scatterplot` (`hue`, `size`, `style`) |
323
+ | `RegPlot` | `data, x, y, fit_reg=True, lowess=False, order=1, scatter=True, color="crimson", **kwargs` → `sns.regplot` |
324
+ | `CorrelationHeatmap` | `data, columns=None, method="pearson", cmap="vlag", annot=True, fmt=".2f", **kwargs` |
287
325
  | `HistogramPlot` | `data: np.ndarray, bins=None, kde=False, color="skyblue", alpha=0.7, **kwargs` |
288
326
  | `DistPlot` | `distribution, x_range=None, samples=1000, color="crimson", linewidth=2, label=None, **kwargs` |
289
327
  | `ACFPlot` | `data, nlags=40, alpha=0.05, color="steelblue", band_color="gray", **kwargs` |
290
328
  | `QQPlot` | `data, distribution, color="steelblue", line_color="black", alpha=0.6, **kwargs` |
291
329
  | `ECDFPlot` | `data, compare=None, label="data", compare_label="compare", color="steelblue", compare_color="crimson", **kwargs` |
330
+ | `NullPlot` | `result: NullResult, bins=None, alternative="two-sided", color="lightgrey", line_color="crimson", label=None, **kwargs` |
292
331
 
293
332
  ```python
294
333
  class PlotFits(BasePlot):
@@ -309,6 +348,17 @@ axis it was handed.
309
348
  results. `HistogramPlot` uses `stat="density"` so a pdf overlays correctly; `DistPlot` takes a
310
349
  **frozen** distribution and falls back to `pmf` when there is no `pdf`.
311
350
 
351
+ `NullPlot` composes `HistogramPlot` and `VerticalLine`: grey cloud, coloured line. It writes
352
+ the observed value and its p-value into the legend, using `≤` when the p-value is at the
353
+ floor set by `n_iter`. Pass `label` to write your own.
354
+
355
+ `RegPlot` wraps `sns.regplot`, the axes-level counterpart to `sns.lmplot` — `lmplot` builds
356
+ its own figure and cannot draw onto a `BasePlot` axis. `lowess=True` with `order != 1` raises:
357
+ both are answers to "what shape", and only one can apply. Pass `scatter=False` to layer a fit
358
+ over an existing `ScatterPlot`. `CorrelationHeatmap` drops non-numeric columns, raises when
359
+ nothing numeric is left, and pins the colour scale to [-1, 1] centred on 0 — override with
360
+ `vmin` / `vmax` / `center` in `**kwargs` if you must.
361
+
312
362
  ---
313
363
 
314
364
  ## 8.9 `goad_toolkit.cli`
@@ -345,6 +395,10 @@ of `likelihood`, `ks`, `combined`; `--method` is passed to `scipy.stats.fit`.
345
395
  - `ACFPlot` raises `ValueError` if the data contains NaNs, rather than passing them through.
346
396
  `statsmodels.tsa.stattools.acf` does not raise on missing values — it silently returns an
347
397
  all-NaN result — so a decomposition's residual needs `.dropna()` before it reaches `ACFPlot`.
398
+ - A seaborn function called with `x=` and `y=` as column names writes those names onto the
399
+ axes, overwriting the `xlabel` / `ylabel` from `PlotSettings`. `create_figure` runs first,
400
+ seaborn runs second, and second wins. Set the labels on the axis after plotting when the
401
+ column name is not the label you want a reader to see.
348
402
  - `binomial` and `nbinom` fit two shape parameters (`n`, `p`) from the sample mean and
349
403
  variance alone. If `n` is not known independently, it is not identifiable from the data —
350
404
  many `(n, p)` pairs with the same `n·p` fit about as well, so a fitted `n` should be treated
@@ -141,7 +141,9 @@ See [Visual critique](07-visual-critique.md).
141
141
  | Does it survive changing the parameter from stage 4? | If it does not, the finding is about the parameter | the sweep you ran |
142
142
  | If you modelled it, what does the residual look like? | Structure in the residual is an unmodelled mechanism, not noise | the residual's shape over time and its distribution |
143
143
 
144
- See [Models and residuals](06-models-and-residuals.md) §6.3.
144
+ See [Models and residuals](06-models-and-residuals.md) §6.3 for the residual loop, and §6.7
145
+ for the shuffle test — `NullDistribution` runs the resampling once you have said what to
146
+ shuffle.
145
147
 
146
148
  ### The garden of forking paths
147
149
 
@@ -697,8 +697,8 @@ STAGE_ROWS: dict[AnalysisStage, list[StageRow]] = {
697
697
  "did not suggest it",
698
698
  ),
699
699
  (
700
- "If you shuffled the labels and re-measured, how often would you see something "
701
- "this strong?",
700
+ f"If you shuffled the labels and re-measured, how often would you see something "
701
+ f"this strong? ({_models()} §6.7)",
702
702
  "The cheapest honest check available, and it needs no distributional theory at "
703
703
  "all.",
704
704
  "what gets shuffled, and a rough frequency",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "goad-toolkit"
3
- version = "0.2.4"
3
+ version = "0.2.7"
4
4
  description = "An extensible toolkit for Goal Oriented Analysis of Data"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,5 +1,5 @@
1
1
  from dataclasses import dataclass
2
- from typing import Any, List, Optional, Tuple, Union
2
+ from typing import Any, Callable, List, Optional, Tuple, Union
3
3
 
4
4
  import numpy as np
5
5
  import pandas as pd
@@ -148,7 +148,9 @@ class DistributionFitter:
148
148
  We will prefer distributions with higher log-likelihood values.
149
149
  """
150
150
  try:
151
- log_density = dist_obj.dist.logpmf if dist_obj.is_discrete else dist_obj.dist.logpdf
151
+ log_density = (
152
+ dist_obj.dist.logpmf if dist_obj.is_discrete else dist_obj.dist.logpdf
153
+ )
152
154
  return np.sum(log_density(data, *params))
153
155
  except Exception as e:
154
156
  logger.warning(f"Log-likelihood calculation failed: {str(e)}")
@@ -327,6 +329,155 @@ class DistributionFitter:
327
329
  raise ValueError(f"Unknown criterion '{criterion}'")
328
330
 
329
331
 
332
+ @dataclass
333
+ class NullResult:
334
+ """A statistic, and the same statistic measured under shuffled labels.
335
+
336
+ `observed` is the number you found. `null` holds the same measurement taken
337
+ `n_iter` times on data where the labels were shuffled — data in which, by
338
+ construction, the label explains nothing. If `observed` sits inside that
339
+ cloud, a world where nothing is happening produces your finding routinely.
340
+ """
341
+
342
+ observed: float
343
+ null: np.ndarray
344
+ n_iter: int
345
+ seed: Optional[int] = None
346
+
347
+ def p_value(self, alternative: str = "two-sided") -> float:
348
+ """How often a shuffle matched or beat the observed value.
349
+
350
+ Args:
351
+ alternative: `"greater"` counts shuffles at or above `observed`,
352
+ `"less"` counts shuffles at or below it, and `"two-sided"`
353
+ (the default) is twice the smaller of the two, capped at 1.
354
+
355
+ Both one-sided counts include the observed arrangement itself — it is
356
+ one of the arrangements the labels could take — so the smallest value
357
+ this can return is `1 / (n_iter + 1)` rather than zero. With 1000
358
+ shuffles, "p < 0.001" is the strongest statement available; report it
359
+ that way rather than as an exact number.
360
+ """
361
+ greater = (int(np.sum(self.null >= self.observed)) + 1) / (self.n_iter + 1)
362
+ less = (int(np.sum(self.null <= self.observed)) + 1) / (self.n_iter + 1)
363
+
364
+ if alternative == "greater":
365
+ return greater
366
+ if alternative == "less":
367
+ return less
368
+ if alternative == "two-sided":
369
+ return min(1.0, 2 * min(greater, less))
370
+ raise ValueError(
371
+ f"Unknown alternative '{alternative}', "
372
+ "expected 'two-sided', 'greater' or 'less'"
373
+ )
374
+
375
+ @property
376
+ def quantile(self) -> float:
377
+ """Where `observed` sits in the null, as a fraction between 0 and 1."""
378
+ return float(np.mean(self.null < self.observed))
379
+
380
+ def __repr__(self) -> str:
381
+ return (
382
+ f"NullResult(observed={self.observed:.4g}, "
383
+ f"null_mean={float(np.mean(self.null)):.4g}, "
384
+ f"quantile={self.quantile:.3f}, "
385
+ f"p={self.p_value():.4f}, n_iter={self.n_iter})"
386
+ )
387
+
388
+
389
+ class NullDistribution:
390
+ """Shuffle the labels, measure again, and see where the real number lands.
391
+
392
+ The cheapest honest check in the method (§9.6): it assumes nothing about the
393
+ shape of the data, needs no table of critical values, and answers in a
394
+ picture rather than a test. Pair it with `NullPlot` — a student who has seen
395
+ their finding sitting in the middle of the null does not need a p-value
396
+ explained.
397
+
398
+ The statistic is yours to write. Naming what you are measuring is the
399
+ thinking part; running it a thousand times is the part worth delegating.
400
+
401
+ ```python
402
+ def gap(frame):
403
+ means = frame.groupby("is_bot")["length"].mean()
404
+ return means[True] - means[False]
405
+
406
+ result = NullDistribution(gap, n_iter=1000, seed=42).run(data, label="is_bot")
407
+ ```
408
+ """
409
+
410
+ def __init__(
411
+ self,
412
+ statistic: Callable[[pd.DataFrame], float],
413
+ n_iter: int = 1000,
414
+ seed: Optional[int] = None,
415
+ ) -> None:
416
+ """
417
+ Args:
418
+ statistic: Takes a dataframe, returns one number. Called once on the
419
+ data as given, then once per shuffle.
420
+ n_iter: Number of shuffles. 1000 is enough to see the shape; go to
421
+ 10_000 when the observed value sits far out in the tail.
422
+ seed: Passed to `numpy.random.default_rng`. Set it, or the picture
423
+ changes every time the cell is run.
424
+ """
425
+ if n_iter < 1:
426
+ raise ValueError(f"n_iter must be at least 1, got {n_iter}")
427
+ self.statistic = statistic
428
+ self.n_iter = n_iter
429
+ self.seed = seed
430
+
431
+ def run(self, data: pd.DataFrame, label: str) -> NullResult:
432
+ """Measure `statistic` on `data`, then on `n_iter` shuffles of `label`.
433
+
434
+ Shuffling one column breaks the link between it and every other column
435
+ while leaving both distributions exactly as they were: the labels keep
436
+ their proportions, the values keep their shape, and only the pairing is
437
+ destroyed. That is the "nothing is happening" world to compare against.
438
+
439
+ Args:
440
+ data: The frame to measure. It is never modified.
441
+ label: Column to shuffle — the one whose association with the rest
442
+ of the frame is in question.
443
+
444
+ Returns:
445
+ A `NullResult` holding the observed value and the shuffled cloud.
446
+ """
447
+ if label not in data.columns:
448
+ raise ValueError(
449
+ f"Column '{label}' is not in the data; got {list(data.columns)}"
450
+ )
451
+
452
+ observed = float(self.statistic(data))
453
+ if not np.isfinite(observed):
454
+ raise ValueError(
455
+ f"The statistic returned {observed} on the unshuffled data; "
456
+ "there is nothing to compare a null against."
457
+ )
458
+
459
+ rng = np.random.default_rng(self.seed)
460
+ values = data[label].to_numpy()
461
+ shuffled = data.copy()
462
+
463
+ null = np.empty(self.n_iter)
464
+ for i in range(self.n_iter):
465
+ # A plain array, not a Series: assigning a permuted Series realigns
466
+ # on the index and puts every label straight back where it started.
467
+ shuffled[label] = rng.permutation(values)
468
+ null[i] = float(self.statistic(shuffled))
469
+
470
+ if not np.all(np.isfinite(null)):
471
+ logger.warning(
472
+ f"{int(np.sum(~np.isfinite(null)))} of {self.n_iter} shuffles gave a "
473
+ "non-finite statistic; a small group may be emptying under shuffling."
474
+ )
475
+
476
+ return NullResult(
477
+ observed=observed, null=null, n_iter=self.n_iter, seed=self.seed
478
+ )
479
+
480
+
330
481
  def fit_table(results: List[Result]) -> pd.DataFrame:
331
482
  """Turn a list of fit results into a ranked dataframe: what a student hands in.
332
483
 
@@ -343,8 +494,12 @@ def fit_table(results: List[Result]) -> pd.DataFrame:
343
494
  "distribution": result.distribution,
344
495
  "params": result.params,
345
496
  "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"),
497
+ "ks_stat": result.kstest.statistic
498
+ if result.kstest
499
+ else float("nan"),
500
+ "ks_pvalue": result.kstest.p_value
501
+ if result.kstest
502
+ else float("nan"),
348
503
  "best_likelihood": bool(result.best_likelihood),
349
504
  "best_ks": bool(result.best_ks),
350
505
  "message": result.message,
@@ -2,6 +2,7 @@ from abc import ABC, abstractmethod
2
2
  from typing import Any, Dict, List, Optional, Type, TypeVar
3
3
 
4
4
  import pandas as pd
5
+ from loguru import logger
5
6
  from tqdm import tqdm
6
7
 
7
8
  T = TypeVar("T", bound="TransformBase")
@@ -161,6 +162,52 @@ class TimeFeatures(TransformBase):
161
162
  return data
162
163
 
163
164
 
165
+ class RegexFeature(TransformBase):
166
+ """Add a feature extracted from a text column with a regular expression.
167
+
168
+ Three modes, all writing a single new column named by `feature`:
169
+
170
+ - `"count"` — how many times the pattern occurs per row (int);
171
+ - `"has"` — whether it occurs at all (bool);
172
+ - `"extract"` — the first capture group's text, or NaN where nothing matched.
173
+ `pattern` must contain exactly one capture group in this mode.
174
+
175
+ The new column is `feature`, not `name`: `Pipeline.add(name=...)` already claims
176
+ `name` for the step, and `TransformBase.__init__` consumes it. Any transform that
177
+ names an output column has to spell that parameter something else.
178
+
179
+ `mode="extract"` reports its own coverage through `loguru`. `count` and `has` fail
180
+ visibly when a pattern is wrong — a column of all zeros or all False. Extraction
181
+ fails silently, filling with NaN, so it says out loud how much it matched.
182
+ """
183
+
184
+ def transform(
185
+ self,
186
+ data: pd.DataFrame,
187
+ column: str,
188
+ pattern: str,
189
+ feature: str,
190
+ mode: str = "count",
191
+ ) -> pd.DataFrame:
192
+ text = data[column].fillna("")
193
+ if mode == "count":
194
+ data[feature] = text.str.count(pattern)
195
+ elif mode == "has":
196
+ data[feature] = text.str.contains(pattern, regex=True)
197
+ elif mode == "extract":
198
+ data[feature] = text.str.extract(pattern, expand=False)
199
+ matched = data[feature].notna().sum()
200
+ total = len(data)
201
+ share = f"{matched / total:.1%}" if total else "n/a"
202
+ logger.info(
203
+ f"{self.name}: extracted '{feature}' from {matched:,}/{total:,} rows "
204
+ f"({share}); {total - matched:,} rows had no match"
205
+ )
206
+ else:
207
+ raise ValueError(f"mode must be count/has/extract, got {mode!r}")
208
+ return data
209
+
210
+
164
211
  class Pipeline:
165
212
  """Pipeline for chaining data transformations."""
166
213