goad-toolkit 0.2.0__tar.gz → 0.2.2__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.2/.claude/settings.local.json +7 -0
  2. goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511/CHANGELOG.md +37 -0
  3. goad_toolkit-0.2.0/PKG-INFO → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511/README.md +23 -21
  4. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/docs/02-pipelines.md +10 -0
  5. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/docs/03-plot-composition.md +16 -13
  6. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/docs/05-distributions.md +11 -5
  7. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/docs/08-api-reference.md +59 -17
  8. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/docs/README.md +2 -1
  9. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/pyproject.toml +12 -2
  10. goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/__init__.py +6 -0
  11. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/src/goad_toolkit/analytics.py +7 -3
  12. goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/cli.py +92 -0
  13. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/src/goad_toolkit/config.py +15 -1
  14. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/src/goad_toolkit/distributions.py +27 -37
  15. goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511/src/goad_toolkit/filehandler.py +130 -0
  16. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/src/goad_toolkit/visualizer.py +18 -10
  17. goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511/tests/test_cli.py +43 -0
  18. goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511/tests/test_distributions.py +72 -0
  19. goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511/tests/test_filehandler.py +101 -0
  20. goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511/tests/test_visualizer.py +113 -0
  21. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/uv.lock +82 -1
  22. goad_toolkit-0.2.2/.gitignore +14 -0
  23. goad_toolkit-0.2.2/.python-version +1 -0
  24. goad_toolkit-0.2.2/.remember/.gitignore +1 -0
  25. goad_toolkit-0.2.2/.remember/logs/autonomous/save-195516.log +0 -0
  26. goad_toolkit-0.2.2/.remember/logs/autonomous/save-195720.log +0 -0
  27. goad_toolkit-0.2.2/.remember/logs/autonomous/save-195933.log +0 -0
  28. goad_toolkit-0.2.2/.remember/logs/autonomous/save-200831.log +0 -0
  29. goad_toolkit-0.2.2/.remember/logs/autonomous/save-201034.log +0 -0
  30. goad_toolkit-0.2.2/.remember/logs/hook-errors.log +0 -0
  31. goad_toolkit-0.2.2/.remember/logs/memory-2026-08-10.log +154 -0
  32. goad_toolkit-0.2.2/.remember/now.md +5 -0
  33. goad_toolkit-0.2.2/.remember/tmp/capture-alive +1 -0
  34. goad_toolkit-0.2.2/.remember/tmp/capture-alive.d/90574e63-97cd-4c11-87e5-23b656c56fd9 +0 -0
  35. goad_toolkit-0.2.2/.remember/tmp/case-divergence +2 -0
  36. goad_toolkit-0.2.2/.remember/tmp/last-ndc.ts +1 -0
  37. goad_toolkit-0.2.2/.remember/tmp/last-save-ts +1 -0
  38. goad_toolkit-0.2.2/.remember/tmp/last-save.json +1 -0
  39. goad_toolkit-0.2.2/.remember/tmp/now-day +1 -0
  40. goad_toolkit-0.2.2/.remember/tmp/post-tool-ran +0 -0
  41. goad_toolkit-0.2.2/.remember/tmp/save-session.pid +1 -0
  42. goad_toolkit-0.2.2/.remember/tmp/session-slug +7 -0
  43. goad_toolkit-0.2.2/.remember/today-2026-08-10.md +2 -0
  44. goad_toolkit-0.2.2/CHANGELOG.md +37 -0
  45. goad_toolkit-0.2.2/MCP_SERVER.md +205 -0
  46. goad_toolkit-0.2.2/PKG-INFO +268 -0
  47. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2}/README.md +23 -3
  48. goad_toolkit-0.2.2/demo/linear.py +128 -0
  49. goad_toolkit-0.2.2/docs/01-goal-oriented-analysis.md +132 -0
  50. goad_toolkit-0.2.2/docs/02-pipelines.md +209 -0
  51. goad_toolkit-0.2.2/docs/03-plot-composition.md +225 -0
  52. goad_toolkit-0.2.2/docs/04-five-families.md +208 -0
  53. goad_toolkit-0.2.2/docs/05-distributions.md +230 -0
  54. goad_toolkit-0.2.2/docs/06-models-and-residuals.md +163 -0
  55. goad_toolkit-0.2.2/docs/07-visual-critique.md +130 -0
  56. goad_toolkit-0.2.2/docs/08-api-reference.md +321 -0
  57. goad_toolkit-0.2.2/docs/09-analysis-method.md +211 -0
  58. goad_toolkit-0.2.2/docs/10-teaching-path.md +172 -0
  59. goad_toolkit-0.2.2/docs/README.md +95 -0
  60. goad_toolkit-0.2.2/goad_mcp.py +1376 -0
  61. goad_toolkit-0.2.2/img/distribution_fit.png +0 -0
  62. goad_toolkit-0.2.2/img/goaded.png +0 -0
  63. goad_toolkit-0.2.2/img/linear_results.png +0 -0
  64. goad_toolkit-0.2.2/img/residuals.png +0 -0
  65. goad_toolkit-0.2.2/img/zscores.png +0 -0
  66. goad_toolkit-0.2.2/pyproject.toml +48 -0
  67. goad_toolkit-0.2.2/src/goad_toolkit/__init__.py +6 -0
  68. goad_toolkit-0.2.2/src/goad_toolkit/analytics.py +329 -0
  69. goad_toolkit-0.2.2/src/goad_toolkit/cli.py +92 -0
  70. goad_toolkit-0.2.2/src/goad_toolkit/config.py +38 -0
  71. goad_toolkit-0.2.2/src/goad_toolkit/dataprocessor.py +48 -0
  72. goad_toolkit-0.2.2/src/goad_toolkit/datatransforms.py +184 -0
  73. goad_toolkit-0.2.2/src/goad_toolkit/distributions.py +82 -0
  74. goad_toolkit-0.2.2/src/goad_toolkit/filehandler.py +130 -0
  75. goad_toolkit-0.2.2/src/goad_toolkit/models.py +44 -0
  76. goad_toolkit-0.2.2/src/goad_toolkit/visualizer.py +545 -0
  77. goad_toolkit-0.2.2/tests/test_cli.py +43 -0
  78. goad_toolkit-0.2.2/tests/test_distributions.py +72 -0
  79. goad_toolkit-0.2.2/tests/test_filehandler.py +101 -0
  80. goad_toolkit-0.2.2/tests/test_visualizer.py +113 -0
  81. goad_toolkit-0.2.2/uv.lock +2022 -0
  82. goad_toolkit-0.2.0/src/goad_toolkit/__init__.py +0 -1
  83. goad_toolkit-0.2.0/src/goad_toolkit/filehandler.py +0 -65
  84. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/.gitignore +0 -0
  85. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/.python-version +0 -0
  86. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/MCP_SERVER.md +0 -0
  87. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/demo/linear.py +0 -0
  88. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/docs/01-goal-oriented-analysis.md +0 -0
  89. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/docs/04-five-families.md +0 -0
  90. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/docs/06-models-and-residuals.md +0 -0
  91. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/docs/07-visual-critique.md +0 -0
  92. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/docs/09-analysis-method.md +0 -0
  93. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/docs/10-teaching-path.md +0 -0
  94. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/goad_mcp.py +0 -0
  95. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/img/distribution_fit.png +0 -0
  96. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/img/goaded.png +0 -0
  97. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/img/linear_results.png +0 -0
  98. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/img/residuals.png +0 -0
  99. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/img/zscores.png +0 -0
  100. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/src/goad_toolkit/dataprocessor.py +0 -0
  101. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/src/goad_toolkit/datatransforms.py +0 -0
  102. {goad_toolkit-0.2.0 → goad_toolkit-0.2.2/.claude/worktrees/quizzical-solomon-b62511}/src/goad_toolkit/models.py +0 -0
@@ -0,0 +1,7 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(cd /Users/rgrouls/code/courses/goad_toolkit && cat pyproject.toml)"
5
+ ]
6
+ }
7
+ }
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ ## 0.2.1
4
+
5
+ ### Breaking
6
+
7
+ - **`FileConfig` gained `date_column` and `date_index`.** `FileHandler.load` reads csv and
8
+ parquet (reader picked from the suffix), parses `date_column` as dates and, when
9
+ `date_index` is true, moves it into the index. The defaults (`"date"`, `True`) keep the
10
+ covid pipeline working unchanged, but a `FileConfig` subclass that overrode `load` to
11
+ support another shape can now express it as configuration instead. A file whose suffix has
12
+ no reader, or a `date_column` that is not in the frame, raises `ValueError`. Parquet needs
13
+ `pyarrow`, available as the `parquet` extra.
14
+ - **`DistributionRegistry` is a normal class.** Constructing one no longer returns a shared
15
+ process-wide instance, so a registration affects only the registry it was made on. Code
16
+ that registered a family on one registry and relied on a separately constructed
17
+ `DistributionFitter()` picking it up must now pass it: `DistributionFitter(registry)`.
18
+
19
+ ### Added
20
+
21
+ - `goad` console script: fits every family in the registry to one column of a csv or parquet
22
+ file and prints the fits ranked by log-likelihood (`goad data/residuals.csv residual`).
23
+ - `DistributionFitter` accepts an optional `DistributionRegistry`.
24
+ - `filehandler.reader_for`, `filehandler.write_frame` and `filehandler.parse_dates` as
25
+ reusable pieces; `FileHandler.save` writes parquet as well as csv.
26
+ - A pytest suite under `tests/`.
27
+
28
+ ### Fixed
29
+
30
+ - `BarWithDates.build` and `VerticalDate.build` return `(fig, ax)` like every other `build`.
31
+ - Plots draw on `self.ax` / `self.fig` instead of module-level `plt` calls, so a layer placed
32
+ with `plot_on_axes` lands on the axis it was handed rather than on matplotlib's current one.
33
+ - The `goad` console script points at a module that exists.
34
+
35
+ ### Changed
36
+
37
+ - `__version__` is read from the installed package metadata.
@@ -1,21 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: goad-toolkit
3
- Version: 0.2.0
4
- Summary: An extensible toolkit for Goal Oriented Analysis of Data
5
- Project-URL: Github, https://github.com/raoulg/goad_toolkit
6
- Author-email: raoul grouls <Raoul.Grouls@han.nl>
7
- Requires-Python: >=3.12
8
- Requires-Dist: loguru>=0.7.3
9
- Requires-Dist: matplotlib>=3.10.1
10
- Requires-Dist: numpy>=2.2.4
11
- Requires-Dist: pandas>=2.2.3
12
- Requires-Dist: pydantic>=2.10.6
13
- Requires-Dist: requests>=2.32.3
14
- Requires-Dist: scipy>=1.15.2
15
- Requires-Dist: seaborn>=0.13.2
16
- Requires-Dist: tqdm>=4.67.1
17
- Description-Content-Type: text/markdown
18
-
19
1
  # GOAD🐐 is the GOAT - Goal Oriented Analysis of Data
20
2
  [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
21
3
  [![image](https://shields.io/pypi/v/goad_toolkit.svg)](https://pypi.org/project/goad_toolkit/)
@@ -61,6 +43,23 @@ Or, if you prefer your dependencies to be installed 100x slower, with pip:
61
43
  pip install goad-toolkit
62
44
  ```
63
45
 
46
+ Add the `parquet` extra if you want to read and write parquet files:
47
+ ```bash
48
+ uv add "goad-toolkit[parquet]"
49
+ ```
50
+
51
+ ### Command line
52
+
53
+ The install ships a `goad` command that fits every distribution in the registry to one column
54
+ of a csv or parquet file and prints the fits, ranked by log-likelihood:
55
+
56
+ ```bash
57
+ goad data/residuals.csv residual
58
+ ```
59
+
60
+ Use `--discrete` for count data, `--criterion {likelihood,ks,combined}` to pick which fits are
61
+ marked best, and `goad --help` for the rest.
62
+
64
63
  ## 📋 Demo: Linear Model Analysis
65
64
 
66
65
  GOAD🐐 includes a comprehensive [demo](demo/linear.py) that shows how to use its components together.
@@ -147,7 +146,7 @@ class ComparePlot(BasePlot):
147
146
  compare = LinePlot(self.settings)
148
147
  self.plot_on(compare, data=data, x=x, y=y1, label=y1, **kwargs)
149
148
  self.plot_on(compare, data=data, x=x, y=y2, label=y2, **kwargs)
150
- plt.xticks(rotation=45)
149
+ self.ax.tick_params(axis="x", rotation=45)
151
150
 
152
151
  return self.fig, self.ax
153
152
 
@@ -203,12 +202,15 @@ registry.register_distribution(
203
202
  num_params=2
204
203
  )
205
204
 
206
- # Now it will be used automatically in the DistributionFitter for discrete fits
205
+ # Hand the registry to the fitter to use your families
207
206
  from goad_toolkit.analytics import DistributionFitter
208
- fitter = DistributionFitter()
207
+ fitter = DistributionFitter(registry)
209
208
  print(fitter.registry) # shows all registered distributions
210
209
  ```
211
210
 
211
+ Registrations belong to the registry you made them on, so a fitter built without one
212
+ (`DistributionFitter()`) uses a fresh registry with only the shipped families.
213
+
212
214
 
213
215
 
214
216
  ## 🔧 Advanced Usage: Composing Plots
@@ -175,6 +175,16 @@ data = processor.process(save=True) # and writes the result to data/processed/
175
175
  Subclasses implement exactly one method: `config_pipeline`. Everything else — the download,
176
176
  the directory creation, the raw/processed split, the save — is inherited.
177
177
 
178
+ `FileConfig` also says how the file is read: the reader follows the suffix (`.csv` or
179
+ `.parquet`), `date_column` names the column to parse as dates, and `date_index` decides
180
+ whether it becomes the index. For a dataset without a time axis, set `date_column=None`.
181
+
182
+ ```python
183
+ config = FileConfig(
184
+ data_dir=Path("data"), filename=Path("sales.parquet"), date_column="ordered_at"
185
+ )
186
+ ```
187
+
178
188
  The pipeline's parameters come from a pydantic `DataConfig`, which means the tuning knobs of
179
189
  your analysis (the shift period, the smoothing window, the date range) live in one typed
180
190
  object rather than scattered as literals through the steps. When somebody asks "what happens
@@ -98,7 +98,7 @@ class ComparePlot(BasePlot):
98
98
  line = LinePlot(self.settings)
99
99
  self.plot_on(line, data=data, x=x, y=y1, label=y1, **kwargs)
100
100
  self.plot_on(line, data=data, x=x, y=y2, label=y2, **kwargs)
101
- plt.xticks(rotation=45)
101
+ self.ax.tick_params(axis="x", rotation=45)
102
102
  return self.fig, self.ax
103
103
  ```
104
104
 
@@ -113,7 +113,7 @@ class ResidualPlot(BasePlot):
113
113
  def build(self, data, x, y, date, datelabel, interval=1):
114
114
  self.plot_on(BarWithDates(self.settings), data=data, x=x, y=y, interval=interval)
115
115
  self.plot_on(VerticalDate(self.settings), date=date, label=datelabel)
116
- plt.xticks(rotation=45)
116
+ self.ax.tick_params(axis="x", rotation=45)
117
117
  return self.fig, self.ax
118
118
  ```
119
119
 
@@ -205,17 +205,20 @@ scatter.plot_on(TrendLine(settings), data=df, x="weight", y="mpg", order=2)
205
205
  Grey data, one coloured layer carrying the message. That is guideline five, expressed as two
206
206
  lines of code.
207
207
 
208
- ## 3.8 Known rough edges
209
-
210
- - `BarWithDates` and `VerticalDate` do not return `(fig, ax)` from `build`. Everything else
211
- does. Do not rely on their return value.
212
- - `VerticalDate` and several compositions call `plt.*` module-level functions rather than
213
- methods on `self.ax`, which means they act on matplotlib's *current* axes. Inside a
214
- `plot_on` chain that is the axis you want; in a multi-panel figure built by hand it may not
215
- be. When in doubt, use `plot_on_axes` and draw on the axis you were handed.
216
- - `PlotFits` overrides `plot` rather than `build`, because it needs to know how many panels
217
- to create before the figure exists. Its `build` is a no-op. It is the exception, not a
218
- pattern to copy.
208
+ ## 3.8 Two rules that keep compositions composable
209
+
210
+ - **`build` returns `(self.fig, self.ax)`.** Every shipped plot does, layers included, so a
211
+ caller can keep working with the figure whichever piece it got back. Yours should too.
212
+ - **Draw on `self.ax` and `self.fig`, never on `plt`.** Module-level calls like
213
+ `plt.xticks(...)` or `plt.axvline(...)` act on matplotlib's *current* axes, which is not
214
+ necessarily the axis your plot was handed in a multi-panel figure it is usually the wrong
215
+ one. The explicit forms are `self.ax.tick_params(axis="x", rotation=45)`,
216
+ `self.ax.axvline(...)`, `self.fig.suptitle(...)`. Keeping to them is what makes
217
+ `plot_on_axes` reliable.
218
+
219
+ `PlotFits` overrides `plot` rather than `build`, because it needs to know how many panels to
220
+ create before the figure exists. Its `build` is a no-op. It is the exception, not a pattern to
221
+ copy.
219
222
 
220
223
  ---
221
224
 
@@ -45,11 +45,17 @@ Two things to know before you rely on it:
45
45
 
46
46
  - **`num_params` must be right.** It selects the bounds template the fitter builds (see §5.3),
47
47
  so a wrong count produces a confusing optimisation failure rather than a clear error.
48
- - **The registry is a process-wide singleton.** `DistributionRegistry()` returns the same
49
- object every time, and `__init__` is a no-op after the first call. So a registration in one
50
- cell affects every later fit in the session, and re-running the registration cell does
51
- nothing surprising but also does not reset anything. If you need a clean slate, restart the
52
- kernel.
48
+ - **Registrations are scoped to the registry object.** Every `DistributionRegistry()` starts
49
+ from the shipped families, so a registration in one cell affects only the fits that use that
50
+ registry, and a fresh instance is your clean slate no kernel restart needed. Pass the
51
+ registry you extended to the fitter:
52
+
53
+ ```python
54
+ fitter = DistributionFitter(registry)
55
+ ```
56
+
57
+ `DistributionFitter()` without an argument builds its own registry with the shipped
58
+ families.
53
59
 
54
60
  ## 5.3 `DistributionFitter`
55
61
 
@@ -14,6 +14,8 @@ class FileConfig(BaseModel):
14
14
  data_dir: Path = Path.home() / ".cache/mads_datasets/covid"
15
15
  filename: Path = Path("covid.csv")
16
16
  url: str = "https://raw.githubusercontent.com/mzelst/covid-19/master/data/rivm_by_day.csv"
17
+ date_column: Optional[str] = "date"
18
+ date_index: bool = True
17
19
 
18
20
  class DataConfig(BaseModel):
19
21
  period: int = -14
@@ -22,6 +24,9 @@ class DataConfig(BaseModel):
22
24
  end_date: str = "2021-06-01"
23
25
  ```
24
26
 
27
+ `date_column` names the column parsed as dates; `None` for data without a time axis.
28
+ `date_index` decides whether that column becomes the index.
29
+
25
30
  `DataConfig` holds the tuning knobs a pipeline reads. Subclass it for your own analysis
26
31
  rather than passing loose numbers into `Pipeline.add`.
27
32
 
@@ -30,6 +35,13 @@ rather than passing loose numbers into `Pipeline.add`.
30
35
  ## 8.2 `goad_toolkit.filehandler`
31
36
 
32
37
  ```python
38
+ READERS: dict[str, Callable[[Path], pd.DataFrame]] # ".csv", ".parquet"
39
+
40
+ def reader_for(path: Path) -> Callable[[Path], pd.DataFrame]
41
+ def write_frame(data: pd.DataFrame, path: Path) -> None
42
+ def parse_dates(data: pd.DataFrame, date_column: Optional[str],
43
+ as_index: bool) -> pd.DataFrame
44
+
33
45
  class FileHandler:
34
46
  def __init__(self, config: FileConfig)
35
47
  def download(self, filename: Optional[Path] = None) -> None
@@ -41,8 +53,13 @@ class FileHandler:
41
53
  - Creates `data_dir`, `data_dir/raw` and `data_dir/processed` on construction.
42
54
  - `load(raw=True)` reads from `raw/` and downloads first if the file is missing;
43
55
  `load(raw=False)` reads from `processed/`.
44
- - **`load` currently assumes a CSV with a `date` column, parsed as dates and used as the
45
- index.** Other shapes need a subclass overriding `load`.
56
+ - The reader and writer follow the file suffix: `.csv` and `.parquet`. Anything else raises a
57
+ `ValueError` naming the supported suffixes. Parquet needs `pyarrow`, installed with the
58
+ `parquet` extra: `uv add "goad-toolkit[parquet]"`.
59
+ - Date handling follows `FileConfig`: `date_column` is parsed as dates and, when
60
+ `date_index` is true, moved into the index. A frame whose dates already *are* the index is
61
+ accepted too, so a `save` → `load` round trip keeps its shape. A missing `date_column`
62
+ raises a `ValueError` listing the columns that are there.
46
63
 
47
64
  ---
48
65
 
@@ -109,7 +126,7 @@ class Distribution:
109
126
  is_discrete: bool
110
127
  num_params: int
111
128
 
112
- class DistributionRegistry: # process-wide singleton
129
+ class DistributionRegistry:
113
130
  def register_distribution(self, name: str, dist, is_discrete: bool, num_params: int)
114
131
  def get_distribution(self, name: str) -> Distribution
115
132
  def get_names(self) -> List[str]
@@ -117,7 +134,8 @@ class DistributionRegistry: # process-wide singleton
117
134
  ```
118
135
 
119
136
  Registered by default: `norm`, `uniform`, `lognorm`, `poisson`, `exponential`, `skewnorm`,
120
- `gamma`, `weibull`.
137
+ `gamma`, `weibull`. Every instance starts from that set, so a registration is scoped to the
138
+ registry you made it on — hand that registry to `DistributionFitter(registry)` to use it.
121
139
 
122
140
  ---
123
141
 
@@ -156,7 +174,9 @@ Result = Union[FitResult, FailedFit]
156
174
 
157
175
  ```python
158
176
  class DistributionFitter:
159
- registry: list[str] # property
177
+ def __init__(self, registry: Optional[DistributionRegistry] = None)
178
+
179
+ registry: list[str] # property, the names
160
180
 
161
181
  def fit(self, data: np.ndarray, discrete: bool,
162
182
  method: str = "mle",
@@ -255,24 +275,46 @@ class PlotFits(BasePlot):
255
275
  fitplotsettings: FitPlotSettings) -> Figure
256
276
  ```
257
277
 
278
+ Every `build` returns `(self.fig, self.ax)`, and every plot draws on `self.ax` / `self.fig`
279
+ rather than on matplotlib's current axes, so a layer placed with `plot_on_axes` lands on the
280
+ axis it was handed.
281
+
258
282
  `PlotFits` overrides `plot` rather than `build`, because the panel count depends on the fit
259
283
  results. `HistogramPlot` uses `stat="density"` so a pdf overlays correctly; `DistPlot` takes a
260
284
  **frozen** distribution and falls back to `pmf` when there is no `pdf`.
261
285
 
262
286
  ---
263
287
 
264
- ## 8.9 Rough edges worth knowing
265
-
266
- - `BarWithDates.build` and `VerticalDate.build` return `None`; every other `build` returns
267
- `(fig, ax)`.
268
- - Several plots call module-level `plt.*` functions, which act on matplotlib's current axes
269
- rather than `self.ax`. Inside a `plot_on` chain this is the axis you want; when composing
270
- panels by hand, prefer `plot_on_axes`.
271
- - `DistributionRegistry` is a process-wide singleton — registrations persist for the life of
272
- the session and cannot be undone without a restart.
273
- - `FileHandler.load` hardcodes CSV with a `date` index column.
274
- - `pyproject.toml` declares a console script `goad = "goad:main"`; there is no such module in
275
- the package.
288
+ ## 8.9 `goad_toolkit.cli`
289
+
290
+ The `goad` console script fits every family in the registry to one column and prints the fits
291
+ ranked by log-likelihood.
292
+
293
+ ```bash
294
+ goad data/residuals.parquet residual
295
+ goad data/counts.csv events --discrete --criterion ks
296
+ ```
297
+
298
+ ```python
299
+ def parse_args(argv: List[str] | None = None) -> argparse.Namespace
300
+ def load_column(file: Path, column: str) -> np.ndarray # dropna, as an array
301
+ def format_results(results: List[Result]) -> str
302
+ def main(argv: List[str] | None = None) -> None
303
+ ```
304
+
305
+ `--discrete` fits the discrete families instead of the continuous ones; `--criterion` is one
306
+ of `likelihood`, `ks`, `combined`; `--method` is passed to `scipy.stats.fit`.
307
+
308
+ ---
309
+
310
+ ## 8.10 Rough edges worth knowing
311
+
312
+ - `PlotFits` overrides `plot` rather than `build`; its `build` is a no-op. It is the one
313
+ exception to the `build` contract.
314
+ - Fits that fail come back as `FailedFit` values in the result list rather than raising, so
315
+ a list of results can contain both kinds and needs an `isinstance` check.
316
+ - `Pipeline.apply` copies the frame once and then mutates that copy in place, so transforms
317
+ see each other's columns — the order you add steps in matters.
276
318
 
277
319
  ---
278
320
 
@@ -83,7 +83,8 @@ src/goad_toolkit/
83
83
  ├── distributions.py # Distribution, DistributionRegistry
84
84
  ├── analytics.py # DistributionFitter, FitResult, FailedFit, KSTestResult
85
85
  ├── models.py # linear_model, logistic, mse, train_model
86
- └── visualizer.py # PlotSettings + BasePlot and its subclasses, PlotFits
86
+ ├── visualizer.py # PlotSettings + BasePlot and its subclasses, PlotFits
87
+ └── cli.py # the `goad` command: fit the registry to one column
87
88
  ```
88
89
 
89
90
  Plus, outside the package:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "goad-toolkit"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "An extensible toolkit for Goal Oriented Analysis of Data"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -20,8 +20,11 @@ dependencies = [
20
20
  ]
21
21
  urls = {Github = "https://github.com/raoulg/goad_toolkit"}
22
22
 
23
+ [project.optional-dependencies]
24
+ parquet = ["pyarrow>=19.0.0"]
25
+
23
26
  [project.scripts]
24
- goad = "goad:main"
27
+ goad = "goad_toolkit.cli:main"
25
28
 
26
29
  [build-system]
27
30
  requires = ["hatchling"]
@@ -31,8 +34,15 @@ build-backend = "hatchling.build"
31
34
  dev = [
32
35
  "isort>=6.0.1",
33
36
  "jupyter>=1.1.1",
37
+ "pyarrow>=19.0.0",
34
38
  "pyright>=1.1.397",
39
+ "pytest>=8.3.5",
35
40
  "ruff>=0.11.0",
36
41
  ]
37
42
  [tool.ruff.lint]
38
43
  select = ["E4", "E7", "E9", "F", "N", "C4", "DTZ", "PT", "S"]
44
+
45
+ [tool.ruff.lint.per-file-ignores]
46
+ # assert is the assertion mechanism in tests and the demo script
47
+ "tests/**" = ["S101"]
48
+ "demo/**" = ["S101"]
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("goad-toolkit")
5
+ except PackageNotFoundError: # source tree without an installed distribution
6
+ __version__ = "unknown"
@@ -75,9 +75,13 @@ Result = Union[FitResult, FailedFit]
75
75
  class DistributionFitter:
76
76
  """Class to fit distributions from a registry to data."""
77
77
 
78
- def __init__(self) -> None:
79
- """Initialize with a distribution registry."""
80
- self._registry = DistributionRegistry()
78
+ def __init__(self, registry: Optional[DistributionRegistry] = None) -> None:
79
+ """Initialize with a distribution registry.
80
+
81
+ Pass a registry to fit a custom set of families; a fresh registry with
82
+ the shipped families is used otherwise.
83
+ """
84
+ self._registry = registry if registry is not None else DistributionRegistry()
81
85
 
82
86
  @property
83
87
  def registry(self) -> list[str]:
@@ -0,0 +1,92 @@
1
+ """Command line entry point: fit the distribution registry to one column."""
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+ from typing import List
6
+
7
+ import numpy as np
8
+
9
+ from goad_toolkit.analytics import DistributionFitter, FitResult, Result
10
+ from goad_toolkit.filehandler import reader_for
11
+
12
+
13
+ def parse_args(argv: List[str] | None = None) -> argparse.Namespace:
14
+ parser = argparse.ArgumentParser(
15
+ prog="goad",
16
+ description=(
17
+ "Fit every distribution in the registry to one column of a csv or "
18
+ "parquet file and print the fits, ranked by log-likelihood."
19
+ ),
20
+ )
21
+ parser.add_argument("file", type=Path, help="path to a .csv or .parquet file")
22
+ parser.add_argument("column", help="name of the column to fit")
23
+ parser.add_argument(
24
+ "--discrete",
25
+ action="store_true",
26
+ help="fit the discrete families instead of the continuous ones",
27
+ )
28
+ parser.add_argument(
29
+ "--criterion",
30
+ default="combined",
31
+ choices=["likelihood", "ks", "combined"],
32
+ help="which fits to mark as best (default: combined)",
33
+ )
34
+ parser.add_argument(
35
+ "--method", default="mle", help="scipy fitting method (default: mle)"
36
+ )
37
+ return parser.parse_args(argv)
38
+
39
+
40
+ def load_column(file: Path, column: str) -> np.ndarray:
41
+ """Read one column and return it as a float array without missing values."""
42
+ if not file.exists():
43
+ raise SystemExit(f"No such file: {file}")
44
+ data = reader_for(file)(file)
45
+ if column not in data.columns:
46
+ raise SystemExit(
47
+ f"Column '{column}' not found. Available: {list(data.columns)}"
48
+ )
49
+ return data[column].dropna().to_numpy()
50
+
51
+
52
+ def format_results(results: List[Result]) -> str:
53
+ """Render fits as a table, best log-likelihood first, failures last."""
54
+ successes = [fit for fit in results if isinstance(fit, FitResult)]
55
+ failures = [fit for fit in results if not isinstance(fit, FitResult)]
56
+ successes.sort(
57
+ key=lambda fit: fit.log_likelihood
58
+ if fit.log_likelihood is not None
59
+ else -np.inf,
60
+ reverse=True,
61
+ )
62
+
63
+ header = f"{'distribution':<14}{'loglik':>14}{'ks p':>10} best"
64
+ lines = [header, "-" * len(header)]
65
+ for fit in successes:
66
+ loglik = "n/a" if fit.log_likelihood is None else f"{fit.log_likelihood:.2f}"
67
+ ks = "n/a" if fit.kstest is None else f"{fit.kstest.p_value:.4f}"
68
+ marks = []
69
+ if fit.best_likelihood:
70
+ marks.append("likelihood")
71
+ if fit.best_ks:
72
+ marks.append("ks")
73
+ row = f"{fit.distribution:<14}{loglik:>14}{ks:>10} {', '.join(marks)}"
74
+ lines.append(row.rstrip())
75
+ for fit in failures:
76
+ lines.append(f"{fit.distribution:<14}{'failed: ' + fit.message}")
77
+ return "\n".join(lines)
78
+
79
+
80
+ def main(argv: List[str] | None = None) -> None:
81
+ args = parse_args(argv)
82
+ values = load_column(args.file, args.column)
83
+ fitter = DistributionFitter()
84
+ results = fitter.fit(
85
+ values, discrete=args.discrete, method=args.method, criterion=args.criterion
86
+ )
87
+ print(f"\n{len(values)} values from {args.file}:{args.column}\n")
88
+ print(format_results(results))
89
+
90
+
91
+ if __name__ == "__main__":
92
+ main()
@@ -1,17 +1,31 @@
1
1
  from pathlib import Path
2
+ from typing import Optional
2
3
 
3
4
  from pydantic import BaseModel
4
5
 
5
6
 
6
7
  class FileConfig(BaseModel):
8
+ """Where the data lives and how to read it.
9
+
10
+ `date_column` names the column to parse as dates; set it to None for data
11
+ without a time axis. `date_index` decides whether that column becomes the
12
+ index. The reader is picked from the file suffix (`.csv`, `.parquet`).
13
+ """
14
+
7
15
  data_dir: Path = Path.home() / ".cache/mads_datasets/covid"
8
16
  filename: Path = Path("covid.csv")
9
17
  url: str = (
10
18
  "https://raw.githubusercontent.com/mzelst/covid-19/master/data/rivm_by_day.csv"
11
19
  )
20
+ date_column: Optional[str] = "date"
21
+ date_index: bool = True
12
22
 
13
23
  def __repr__(self):
14
- return f"FileConfig(data_dir={self.data_dir},\n filename={self.filename},\nurl={self.url})"
24
+ return (
25
+ f"FileConfig(data_dir={self.data_dir},\n filename={self.filename},\n"
26
+ f"url={self.url},\n date_column={self.date_column},\n"
27
+ f" date_index={self.date_index})"
28
+ )
15
29
 
16
30
 
17
31
  class DataConfig(BaseModel):
@@ -22,48 +22,38 @@ class Distribution:
22
22
  return f"Distribution(name='{self.name}',\n dist={self.dist.__class__.__name__},\n is_discrete={self.is_discrete},\n num_params={self.num_params})"
23
23
 
24
24
 
25
- @dataclass
26
25
  class DistributionRegistry:
27
- """Registry for statistical distributions with metadata."""
28
-
29
- _instance = None
26
+ """Registry for statistical distributions with metadata.
30
27
 
31
- def __new__(cls):
32
- """Ensure only one instance of DistributionRegistry exists."""
33
- if cls._instance is None:
34
- cls._instance = super().__new__(cls)
35
- cls._instance.initialized = False
36
- return cls._instance
28
+ Every instance starts from the shipped set of families, so registrations
29
+ are scoped to the registry you registered them on.
30
+ """
37
31
 
38
32
  def __init__(self):
39
33
  """Initialize the registry with common distributions."""
40
- if not self.initialized:
41
- self.distributions: Dict[str, Distribution] = {}
42
- self.register_distribution(
43
- "norm", stats.norm, is_discrete=False, num_params=2
44
- )
45
- self.register_distribution(
46
- "uniform", stats.uniform, is_discrete=False, num_params=2
47
- )
48
- self.register_distribution(
49
- "lognorm", stats.lognorm, is_discrete=False, num_params=3
50
- )
51
- self.register_distribution(
52
- "poisson", stats.poisson, is_discrete=True, num_params=1
53
- )
54
- self.register_distribution(
55
- "exponential", stats.expon, is_discrete=False, num_params=2
56
- )
57
- self.register_distribution(
58
- "skewnorm", stats.skewnorm, is_discrete=False, num_params=3
59
- )
60
- self.register_distribution(
61
- "gamma", stats.gamma, is_discrete=False, num_params=3
62
- )
63
- self.register_distribution(
64
- "weibull", stats.weibull_min, is_discrete=False, num_params=3
65
- )
66
- self.initialized = True
34
+ self.distributions: Dict[str, Distribution] = {}
35
+ self.register_distribution("norm", stats.norm, is_discrete=False, num_params=2)
36
+ self.register_distribution(
37
+ "uniform", stats.uniform, is_discrete=False, num_params=2
38
+ )
39
+ self.register_distribution(
40
+ "lognorm", stats.lognorm, is_discrete=False, num_params=3
41
+ )
42
+ self.register_distribution(
43
+ "poisson", stats.poisson, is_discrete=True, num_params=1
44
+ )
45
+ self.register_distribution(
46
+ "exponential", stats.expon, is_discrete=False, num_params=2
47
+ )
48
+ self.register_distribution(
49
+ "skewnorm", stats.skewnorm, is_discrete=False, num_params=3
50
+ )
51
+ self.register_distribution(
52
+ "gamma", stats.gamma, is_discrete=False, num_params=3
53
+ )
54
+ self.register_distribution(
55
+ "weibull", stats.weibull_min, is_discrete=False, num_params=3
56
+ )
67
57
 
68
58
  def __repr__(self) -> str:
69
59
  """Detailed representation of the registry."""