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.
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/PKG-INFO +31 -1
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/README.md +30 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/docs/02-pipelines.md +26 -25
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/docs/04-five-families.md +16 -3
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/docs/06-models-and-residuals.md +44 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/docs/08-api-reference.md +54 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/docs/09-analysis-method.md +3 -1
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/goad_mcp.py +2 -2
- goad_toolkit-0.2.7/img/null-distribution.png +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/pyproject.toml +1 -1
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/src/goad_toolkit/analytics.py +159 -4
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/src/goad_toolkit/datatransforms.py +47 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/src/goad_toolkit/visualizer.py +299 -3
- goad_toolkit-0.2.7/tests/test_datatransforms.py +167 -0
- goad_toolkit-0.2.7/tests/test_nulldistribution.py +156 -0
- goad_toolkit-0.2.7/tests/test_visualizer.py +383 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/uv.lock +1 -1
- goad_toolkit-0.2.4/tests/test_datatransforms.py +0 -66
- goad_toolkit-0.2.4/tests/test_visualizer.py +0 -200
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/settings.local.json +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/.gitignore +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/.python-version +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/CHANGELOG.md +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/MCP_SERVER.md +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/README.md +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/demo/linear.py +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/docs/01-goal-oriented-analysis.md +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/docs/02-pipelines.md +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/docs/03-plot-composition.md +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/docs/04-five-families.md +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/docs/05-distributions.md +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/docs/06-models-and-residuals.md +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/docs/07-visual-critique.md +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/docs/08-api-reference.md +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/docs/09-analysis-method.md +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/docs/10-teaching-path.md +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/docs/README.md +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/goad_mcp.py +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/img/distribution_fit.png +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/img/goaded.png +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/img/linear_results.png +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/img/residuals.png +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/img/zscores.png +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/pyproject.toml +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/__init__.py +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/analytics.py +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/cli.py +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/config.py +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/dataprocessor.py +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/datatransforms.py +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/distributions.py +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/filehandler.py +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/models.py +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/visualizer.py +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/tests/test_cli.py +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/tests/test_distributions.py +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/tests/test_filehandler.py +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/tests/test_visualizer.py +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.claude/worktrees/quizzical-solomon-b62511/uv.lock +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.gitignore +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.python-version +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/.gitignore +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/logs/autonomous/save-195516.log +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/logs/autonomous/save-195720.log +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/logs/autonomous/save-195933.log +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/logs/autonomous/save-200831.log +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/logs/autonomous/save-201034.log +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/logs/hook-errors.log +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/logs/memory-2026-08-10.log +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/now.md +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/tmp/capture-alive +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/tmp/capture-alive.d/90574e63-97cd-4c11-87e5-23b656c56fd9 +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/tmp/case-divergence +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/tmp/last-ndc.ts +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/tmp/last-save-ts +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/tmp/last-save.json +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/tmp/now-day +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/tmp/post-tool-ran +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/tmp/save-session.pid +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/tmp/session-slug +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/.remember/today-2026-08-10.md +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/CHANGELOG.md +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/MCP_SERVER.md +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/demo/linear.py +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/docs/01-goal-oriented-analysis.md +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/docs/03-plot-composition.md +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/docs/05-distributions.md +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/docs/07-visual-critique.md +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/docs/10-teaching-path.md +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/docs/README.md +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/img/distribution_fit.png +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/img/goaded.png +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/img/linear_results.png +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/img/residuals.png +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/img/zscores.png +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/src/goad_toolkit/__init__.py +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/src/goad_toolkit/cli.py +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/src/goad_toolkit/config.py +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/src/goad_toolkit/dataprocessor.py +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/src/goad_toolkit/distributions.py +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/src/goad_toolkit/filehandler.py +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/src/goad_toolkit/models.py +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/tests/test_cli.py +0 -0
- {goad_toolkit-0.2.4 → goad_toolkit-0.2.7}/tests/test_distributions.py +0 -0
- {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.
|
|
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
|

|
|
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
|
+

|
|
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
|

|
|
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
|
+

|
|
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
|
|
108
|
+
## 2.5 `RegexFeature`: feature enrichment from text
|
|
108
109
|
|
|
109
|
-
The single most useful
|
|
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
|
-
|
|
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:**
|
|
153
|
-
|
|
154
|
-
|
|
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",
|
|
Binary file
|
|
@@ -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 =
|
|
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
|
|
347
|
-
|
|
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
|
|