goad-toolkit 0.2.4__tar.gz → 0.2.5__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 (102) hide show
  1. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/PKG-INFO +1 -1
  2. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/docs/02-pipelines.md +26 -25
  3. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/docs/08-api-reference.md +7 -0
  4. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/pyproject.toml +1 -1
  5. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/src/goad_toolkit/datatransforms.py +47 -0
  6. goad_toolkit-0.2.5/tests/test_datatransforms.py +167 -0
  7. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/uv.lock +1 -1
  8. goad_toolkit-0.2.4/tests/test_datatransforms.py +0 -66
  9. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/settings.local.json +0 -0
  10. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/.gitignore +0 -0
  11. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/.python-version +0 -0
  12. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/CHANGELOG.md +0 -0
  13. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/MCP_SERVER.md +0 -0
  14. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/README.md +0 -0
  15. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/demo/linear.py +0 -0
  16. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/docs/01-goal-oriented-analysis.md +0 -0
  17. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/docs/02-pipelines.md +0 -0
  18. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/docs/03-plot-composition.md +0 -0
  19. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/docs/04-five-families.md +0 -0
  20. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/docs/05-distributions.md +0 -0
  21. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/docs/06-models-and-residuals.md +0 -0
  22. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/docs/07-visual-critique.md +0 -0
  23. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/docs/08-api-reference.md +0 -0
  24. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/docs/09-analysis-method.md +0 -0
  25. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/docs/10-teaching-path.md +0 -0
  26. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/docs/README.md +0 -0
  27. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/goad_mcp.py +0 -0
  28. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/img/distribution_fit.png +0 -0
  29. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/img/goaded.png +0 -0
  30. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/img/linear_results.png +0 -0
  31. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/img/residuals.png +0 -0
  32. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/img/zscores.png +0 -0
  33. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/pyproject.toml +0 -0
  34. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/__init__.py +0 -0
  35. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/analytics.py +0 -0
  36. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/cli.py +0 -0
  37. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/config.py +0 -0
  38. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/dataprocessor.py +0 -0
  39. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/datatransforms.py +0 -0
  40. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/distributions.py +0 -0
  41. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/filehandler.py +0 -0
  42. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/models.py +0 -0
  43. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/visualizer.py +0 -0
  44. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/tests/test_cli.py +0 -0
  45. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/tests/test_distributions.py +0 -0
  46. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/tests/test_filehandler.py +0 -0
  47. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/tests/test_visualizer.py +0 -0
  48. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.claude/worktrees/quizzical-solomon-b62511/uv.lock +0 -0
  49. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.gitignore +0 -0
  50. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.python-version +0 -0
  51. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.remember/.gitignore +0 -0
  52. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.remember/logs/autonomous/save-195516.log +0 -0
  53. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.remember/logs/autonomous/save-195720.log +0 -0
  54. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.remember/logs/autonomous/save-195933.log +0 -0
  55. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.remember/logs/autonomous/save-200831.log +0 -0
  56. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.remember/logs/autonomous/save-201034.log +0 -0
  57. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.remember/logs/hook-errors.log +0 -0
  58. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.remember/logs/memory-2026-08-10.log +0 -0
  59. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.remember/now.md +0 -0
  60. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.remember/tmp/capture-alive +0 -0
  61. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.remember/tmp/capture-alive.d/90574e63-97cd-4c11-87e5-23b656c56fd9 +0 -0
  62. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.remember/tmp/case-divergence +0 -0
  63. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.remember/tmp/last-ndc.ts +0 -0
  64. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.remember/tmp/last-save-ts +0 -0
  65. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.remember/tmp/last-save.json +0 -0
  66. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.remember/tmp/now-day +0 -0
  67. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.remember/tmp/post-tool-ran +0 -0
  68. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.remember/tmp/save-session.pid +0 -0
  69. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.remember/tmp/session-slug +0 -0
  70. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/.remember/today-2026-08-10.md +0 -0
  71. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/CHANGELOG.md +0 -0
  72. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/MCP_SERVER.md +0 -0
  73. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/README.md +0 -0
  74. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/demo/linear.py +0 -0
  75. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/docs/01-goal-oriented-analysis.md +0 -0
  76. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/docs/03-plot-composition.md +0 -0
  77. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/docs/04-five-families.md +0 -0
  78. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/docs/05-distributions.md +0 -0
  79. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/docs/06-models-and-residuals.md +0 -0
  80. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/docs/07-visual-critique.md +0 -0
  81. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/docs/09-analysis-method.md +0 -0
  82. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/docs/10-teaching-path.md +0 -0
  83. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/docs/README.md +0 -0
  84. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/goad_mcp.py +0 -0
  85. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/img/distribution_fit.png +0 -0
  86. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/img/goaded.png +0 -0
  87. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/img/linear_results.png +0 -0
  88. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/img/residuals.png +0 -0
  89. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/img/zscores.png +0 -0
  90. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/src/goad_toolkit/__init__.py +0 -0
  91. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/src/goad_toolkit/analytics.py +0 -0
  92. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/src/goad_toolkit/cli.py +0 -0
  93. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/src/goad_toolkit/config.py +0 -0
  94. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/src/goad_toolkit/dataprocessor.py +0 -0
  95. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/src/goad_toolkit/distributions.py +0 -0
  96. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/src/goad_toolkit/filehandler.py +0 -0
  97. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/src/goad_toolkit/models.py +0 -0
  98. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/src/goad_toolkit/visualizer.py +0 -0
  99. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/tests/test_cli.py +0 -0
  100. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/tests/test_distributions.py +0 -0
  101. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/tests/test_filehandler.py +0 -0
  102. {goad_toolkit-0.2.4 → goad_toolkit-0.2.5}/tests/test_visualizer.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.5
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>
@@ -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.
@@ -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"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "goad-toolkit"
3
- version = "0.2.4"
3
+ version = "0.2.5"
4
4
  description = "An extensible toolkit for Goal Oriented Analysis of Data"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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
 
@@ -0,0 +1,167 @@
1
+ import pandas as pd
2
+ import pytest
3
+
4
+ from goad_toolkit.datatransforms import Pipeline, RegexFeature, TimeFeatures
5
+
6
+
7
+ @pytest.fixture
8
+ def data() -> pd.DataFrame:
9
+ # 2024-01-01 is a Monday
10
+ timestamps = pd.date_range("2024-01-01 08:00", periods=10, freq="6h")
11
+ return pd.DataFrame({"timestamp": timestamps, "value": range(10)})
12
+
13
+
14
+ def test_default_features_match_lesson_one(data):
15
+ result = TimeFeatures(column="timestamp")(data.copy())
16
+
17
+ assert "day_name" in result.columns
18
+ assert "isoweek" in result.columns
19
+ assert "year_week" in result.columns
20
+ assert "date" not in result.columns
21
+ assert "hour" not in result.columns
22
+
23
+
24
+ def test_selected_features_only(data):
25
+ result = TimeFeatures(column="timestamp", features=["date", "hour"])(data.copy())
26
+
27
+ assert "date" in result.columns
28
+ assert "hour" in result.columns
29
+ assert "day_name" not in result.columns
30
+ assert result["hour"].tolist() == [8, 14, 20, 2, 8, 14, 20, 2, 8, 14]
31
+
32
+
33
+ def test_all_features(data):
34
+ result = TimeFeatures(
35
+ column="timestamp",
36
+ features=list(TimeFeatures.AVAILABLE),
37
+ )(data.copy())
38
+
39
+ for feature in TimeFeatures.AVAILABLE:
40
+ assert feature in result.columns
41
+ assert result["day_name"].iloc[0] == "Monday"
42
+ assert result["isoweek"].iloc[0] == 1
43
+
44
+
45
+ def test_unknown_feature_raises(data):
46
+ with pytest.raises(ValueError, match="Unknown feature"):
47
+ TimeFeatures(column="timestamp", features=["not_a_feature"])(data.copy())
48
+
49
+
50
+ def test_feature_overwrites_same_named_source_column():
51
+ # mirrors lesson 1's own pipeline: source column is called "date"
52
+ df = pd.DataFrame({"date": pd.date_range("2024-01-01", periods=3, freq="D")})
53
+
54
+ result = TimeFeatures(column="date", features=["date"])(df)
55
+
56
+ assert result["date"].iloc[0] == pd.Timestamp("2024-01-01").date()
57
+
58
+
59
+ def test_works_in_a_pipeline(data):
60
+ pipeline = Pipeline()
61
+ pipeline.add(TimeFeatures, column="timestamp", features=["day_name", "hour"])
62
+
63
+ result = pipeline.apply(data)
64
+
65
+ assert "day_name" in result.columns
66
+ assert "hour" in result.columns
67
+
68
+
69
+ @pytest.fixture
70
+ def messages() -> pd.DataFrame:
71
+ return pd.DataFrame(
72
+ {
73
+ "message": [
74
+ "daftykins: try rebooting?",
75
+ "see https://example.com and https://other.example",
76
+ "no url, no mention, no question",
77
+ None,
78
+ ]
79
+ }
80
+ )
81
+
82
+
83
+ def test_count_mode(messages):
84
+ result = RegexFeature(
85
+ column="message", pattern=r"https?://\S+", feature="n_url", mode="count"
86
+ )(messages.copy())
87
+
88
+ assert result["n_url"].tolist() == [0, 2, 0, 0]
89
+
90
+
91
+ def test_has_mode(messages):
92
+ result = RegexFeature(
93
+ column="message", pattern=r"\?", feature="has_question", mode="has"
94
+ )(messages.copy())
95
+
96
+ assert result["has_question"].tolist() == [True, False, False, False]
97
+
98
+
99
+ def test_extract_mode_leaves_nan_where_nothing_matched(messages):
100
+ result = RegexFeature(
101
+ column="message",
102
+ pattern=r"^(\S+)[:,]\s",
103
+ feature="addressed_to",
104
+ mode="extract",
105
+ )(messages.copy())
106
+
107
+ assert result["addressed_to"].iloc[0] == "daftykins"
108
+ assert result["addressed_to"].isna().tolist() == [False, True, True, True]
109
+
110
+
111
+ def test_count_mode_defaults(messages):
112
+ # mode is the one parameter with a default; it counts
113
+ result = RegexFeature(column="message", pattern=r"\?", feature="n_question")(
114
+ messages.copy()
115
+ )
116
+
117
+ assert result["n_question"].tolist() == [1, 0, 0, 0]
118
+
119
+
120
+ def test_na_rows_are_treated_as_empty_text(messages):
121
+ result = RegexFeature(
122
+ column="message", pattern=r"\?", feature="has_question", mode="has"
123
+ )(messages.copy())
124
+
125
+ # the None row must not propagate NaN into a boolean column
126
+ assert result["has_question"].dtype == bool
127
+
128
+
129
+ def test_unknown_mode_raises(messages):
130
+ with pytest.raises(ValueError, match="mode must be count/has/extract"):
131
+ RegexFeature(column="message", pattern=r"\?", feature="x", mode="extractt")(
132
+ messages.copy()
133
+ )
134
+
135
+
136
+ def test_missing_column_raises(messages):
137
+ with pytest.raises(ValueError, match="does not exist"):
138
+ RegexFeature(column="nope", pattern=r"\?", feature="x")(messages.copy())
139
+
140
+
141
+ def test_extract_on_an_empty_frame_does_not_divide_by_zero():
142
+ empty = pd.DataFrame({"message": pd.Series([], dtype="object")})
143
+
144
+ result = RegexFeature(
145
+ column="message", pattern=r"^(\S+):", feature="addressed_to", mode="extract"
146
+ )(empty)
147
+
148
+ assert len(result) == 0
149
+ assert "addressed_to" in result.columns
150
+
151
+
152
+ def test_feature_is_the_column_name_and_name_is_the_step(messages):
153
+ pipeline = Pipeline()
154
+ pipeline.add(
155
+ RegexFeature,
156
+ name="mentions",
157
+ column="message",
158
+ pattern=r"^(\S+)[:,]\s",
159
+ feature="addressed_to",
160
+ mode="extract",
161
+ )
162
+
163
+ result = pipeline.apply(messages)
164
+
165
+ assert "addressed_to" in result.columns
166
+ assert "mentions" not in result.columns
167
+ assert "mentions" in repr(pipeline)
@@ -383,7 +383,7 @@ wheels = [
383
383
 
384
384
  [[package]]
385
385
  name = "goad-toolkit"
386
- version = "0.2.4"
386
+ version = "0.2.5"
387
387
  source = { editable = "." }
388
388
  dependencies = [
389
389
  { name = "loguru" },
@@ -1,66 +0,0 @@
1
- import pandas as pd
2
- import pytest
3
-
4
- from goad_toolkit.datatransforms import Pipeline, TimeFeatures
5
-
6
-
7
- @pytest.fixture
8
- def data() -> pd.DataFrame:
9
- # 2024-01-01 is a Monday
10
- timestamps = pd.date_range("2024-01-01 08:00", periods=10, freq="6h")
11
- return pd.DataFrame({"timestamp": timestamps, "value": range(10)})
12
-
13
-
14
- def test_default_features_match_lesson_one(data):
15
- result = TimeFeatures(column="timestamp")(data.copy())
16
-
17
- assert "day_name" in result.columns
18
- assert "isoweek" in result.columns
19
- assert "year_week" in result.columns
20
- assert "date" not in result.columns
21
- assert "hour" not in result.columns
22
-
23
-
24
- def test_selected_features_only(data):
25
- result = TimeFeatures(column="timestamp", features=["date", "hour"])(data.copy())
26
-
27
- assert "date" in result.columns
28
- assert "hour" in result.columns
29
- assert "day_name" not in result.columns
30
- assert result["hour"].tolist() == [8, 14, 20, 2, 8, 14, 20, 2, 8, 14]
31
-
32
-
33
- def test_all_features(data):
34
- result = TimeFeatures(
35
- column="timestamp",
36
- features=list(TimeFeatures.AVAILABLE),
37
- )(data.copy())
38
-
39
- for feature in TimeFeatures.AVAILABLE:
40
- assert feature in result.columns
41
- assert result["day_name"].iloc[0] == "Monday"
42
- assert result["isoweek"].iloc[0] == 1
43
-
44
-
45
- def test_unknown_feature_raises(data):
46
- with pytest.raises(ValueError, match="Unknown feature"):
47
- TimeFeatures(column="timestamp", features=["not_a_feature"])(data.copy())
48
-
49
-
50
- def test_feature_overwrites_same_named_source_column():
51
- # mirrors lesson 1's own pipeline: source column is called "date"
52
- df = pd.DataFrame({"date": pd.date_range("2024-01-01", periods=3, freq="D")})
53
-
54
- result = TimeFeatures(column="date", features=["date"])(df)
55
-
56
- assert result["date"].iloc[0] == pd.Timestamp("2024-01-01").date()
57
-
58
-
59
- def test_works_in_a_pipeline(data):
60
- pipeline = Pipeline()
61
- pipeline.add(TimeFeatures, column="timestamp", features=["day_name", "hour"])
62
-
63
- result = pipeline.apply(data)
64
-
65
- assert "day_name" in result.columns
66
- assert "hour" in result.columns
File without changes
File without changes
File without changes
File without changes
File without changes