acteval 0.1.2__tar.gz → 0.2.0__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 (91) hide show
  1. {acteval-0.1.2 → acteval-0.2.0}/.github/workflows/benchmark.yml +1 -1
  2. acteval-0.2.0/LICENSE +21 -0
  3. {acteval-0.1.2 → acteval-0.2.0}/PKG-INFO +6 -2
  4. acteval-0.2.0/README.md +621 -0
  5. acteval-0.2.0/assets/density-estimation.png +0 -0
  6. acteval-0.2.0/llms.txt +122 -0
  7. {acteval-0.1.2 → acteval-0.2.0}/pyproject.toml +5 -3
  8. {acteval-0.1.2 → acteval-0.2.0}/scripts/generate_blog_plots.py +29 -41
  9. acteval-0.2.0/src/acteval/__init__.py +50 -0
  10. {acteval-0.1.2 → acteval-0.2.0}/src/acteval/_aggregation.py +31 -116
  11. {acteval-0.1.2 → acteval-0.2.0}/src/acteval/_jobs.py +128 -14
  12. acteval-0.2.0/src/acteval/_pipeline.py +393 -0
  13. acteval-0.2.0/src/acteval/_progress.py +99 -0
  14. acteval-0.2.0/src/acteval/_report.py +5 -0
  15. {acteval-0.1.2 → acteval-0.2.0}/src/acteval/_result_frame.py +24 -79
  16. {acteval-0.1.2 → acteval-0.2.0}/src/acteval/_splits.py +1 -2
  17. acteval-0.2.0/src/acteval/cli.py +514 -0
  18. {acteval-0.1.2 → acteval-0.2.0}/src/acteval/config.toml +9 -4
  19. acteval-0.2.0/src/acteval/evaluate.py +809 -0
  20. {acteval-0.1.2 → acteval-0.2.0}/src/acteval/features/creativity.py +22 -10
  21. acteval-0.2.0/src/acteval/features/structural.py +208 -0
  22. {acteval-0.1.2/src/acteval/describe → acteval-0.2.0/src/acteval/plot}/frequency.py +8 -3
  23. {acteval-0.1.2/src/acteval/describe → acteval-0.2.0/src/acteval/plot}/plot.py +1 -1
  24. {acteval-0.1.2/src/acteval/describe → acteval-0.2.0/src/acteval/plot}/results.py +2 -2
  25. {acteval-0.1.2/src/acteval/describe → acteval-0.2.0/src/acteval/plot}/times.py +1 -1
  26. {acteval-0.1.2/src/acteval/describe → acteval-0.2.0/src/acteval/plot}/transitions.py +1 -1
  27. {acteval-0.1.2 → acteval-0.2.0}/src/acteval/population.py +15 -2
  28. acteval-0.2.0/src/acteval/results.py +528 -0
  29. acteval-0.2.0/tests/test_cli.py +626 -0
  30. acteval-0.2.0/tests/test_compare.py +93 -0
  31. {acteval-0.1.2 → acteval-0.2.0}/tests/test_creativity.py +53 -10
  32. acteval-0.2.0/tests/test_disable_config.py +136 -0
  33. {acteval-0.1.2 → acteval-0.2.0}/tests/test_features_structural.py +75 -30
  34. acteval-0.2.0/tests/test_filter_cmd.py +161 -0
  35. {acteval-0.1.2 → acteval-0.2.0}/tests/test_integration.py +94 -15
  36. acteval-0.2.0/tests/test_jobs.py +80 -0
  37. {acteval-0.1.2 → acteval-0.2.0}/tests/test_pid_features.py +43 -19
  38. acteval-0.2.0/tests/test_results.py +202 -0
  39. acteval-0.2.0/uv.lock +1619 -0
  40. acteval-0.1.2/README.md +0 -319
  41. acteval-0.1.2/llms.txt +0 -68
  42. acteval-0.1.2/src/acteval/__init__.py +0 -35
  43. acteval-0.1.2/src/acteval/_filters.py +0 -16
  44. acteval-0.1.2/src/acteval/_pipeline.py +0 -395
  45. acteval-0.1.2/src/acteval/_report.py +0 -97
  46. acteval-0.1.2/src/acteval/cli.py +0 -206
  47. acteval-0.1.2/src/acteval/evaluate.py +0 -794
  48. acteval-0.1.2/src/acteval/features/structural.py +0 -202
  49. acteval-0.1.2/tests/test_cli.py +0 -280
  50. acteval-0.1.2/tests/test_compare.py +0 -39
  51. acteval-0.1.2/tests/test_filter.py +0 -76
  52. acteval-0.1.2/tests/test_jobs.py +0 -39
  53. acteval-0.1.2/uv.lock +0 -3817
  54. {acteval-0.1.2 → acteval-0.2.0}/.github/workflows/ci.yml +0 -0
  55. {acteval-0.1.2 → acteval-0.2.0}/.github/workflows/publish.yml +0 -0
  56. {acteval-0.1.2 → acteval-0.2.0}/.gitignore +0 -0
  57. {acteval-0.1.2 → acteval-0.2.0}/src/acteval/__main__.py +0 -0
  58. {acteval-0.1.2 → acteval-0.2.0}/src/acteval/_compat.py +0 -0
  59. {acteval-0.1.2 → acteval-0.2.0}/src/acteval/distance/__init__.py +0 -0
  60. {acteval-0.1.2 → acteval-0.2.0}/src/acteval/distance/scalar.py +0 -0
  61. {acteval-0.1.2 → acteval-0.2.0}/src/acteval/distance/wasserstein.py +0 -0
  62. {acteval-0.1.2 → acteval-0.2.0}/src/acteval/features/__init__.py +0 -0
  63. {acteval-0.1.2 → acteval-0.2.0}/src/acteval/features/_discretise.py +0 -0
  64. {acteval-0.1.2 → acteval-0.2.0}/src/acteval/features/_pid_features.py +0 -0
  65. {acteval-0.1.2 → acteval-0.2.0}/src/acteval/features/_utils.py +0 -0
  66. {acteval-0.1.2 → acteval-0.2.0}/src/acteval/features/catalogue.py +0 -0
  67. {acteval-0.1.2 → acteval-0.2.0}/src/acteval/features/frequency.py +0 -0
  68. {acteval-0.1.2 → acteval-0.2.0}/src/acteval/features/participation.py +0 -0
  69. {acteval-0.1.2 → acteval-0.2.0}/src/acteval/features/times.py +0 -0
  70. {acteval-0.1.2 → acteval-0.2.0}/src/acteval/features/transitions.py +0 -0
  71. {acteval-0.1.2 → acteval-0.2.0}/src/acteval/pairwise.py +0 -0
  72. {acteval-0.1.2/src/acteval/describe → acteval-0.2.0/src/acteval/plot}/__init__.py +0 -0
  73. {acteval-0.1.2/src/acteval/describe → acteval-0.2.0/src/acteval/plot}/utils.py +0 -0
  74. {acteval-0.1.2 → acteval-0.2.0}/tests/conftest.py +0 -0
  75. {acteval-0.1.2 → acteval-0.2.0}/tests/test_bench_evaluate.py +0 -0
  76. {acteval-0.1.2 → acteval-0.2.0}/tests/test_bench_pairwise.py +0 -0
  77. {acteval-0.1.2 → acteval-0.2.0}/tests/test_describe_features.py +0 -0
  78. {acteval-0.1.2 → acteval-0.2.0}/tests/test_distance_emd.py +0 -0
  79. {acteval-0.1.2 → acteval-0.2.0}/tests/test_distance_scalar.py +0 -0
  80. {acteval-0.1.2 → acteval-0.2.0}/tests/test_features_catalogue.py +0 -0
  81. {acteval-0.1.2 → acteval-0.2.0}/tests/test_features_participation.py +0 -0
  82. {acteval-0.1.2 → acteval-0.2.0}/tests/test_features_times.py +0 -0
  83. {acteval-0.1.2 → acteval-0.2.0}/tests/test_features_transitions.py +0 -0
  84. {acteval-0.1.2 → acteval-0.2.0}/tests/test_features_utils.py +0 -0
  85. {acteval-0.1.2 → acteval-0.2.0}/tests/test_frequency.py +0 -0
  86. {acteval-0.1.2 → acteval-0.2.0}/tests/test_missing_features.py +0 -0
  87. {acteval-0.1.2 → acteval-0.2.0}/tests/test_pairwise.py +0 -0
  88. {acteval-0.1.2 → acteval-0.2.0}/tests/test_polars_compat.py +0 -0
  89. {acteval-0.1.2 → acteval-0.2.0}/tests/test_population.py +0 -0
  90. {acteval-0.1.2 → acteval-0.2.0}/tests/test_report.py +0 -0
  91. {acteval-0.1.2 → acteval-0.2.0}/tests/test_validation.py +0 -0
@@ -34,7 +34,7 @@ jobs:
34
34
  output-file-path: output.json
35
35
  github-token: ${{ secrets.GITHUB_TOKEN }}
36
36
  auto-push: true
37
- alert-threshold: "120%"
37
+ alert-threshold: "150%"
38
38
  comment-on-alert: true
39
39
  fail-on-alert: true
40
40
  benchmark-data-dir-path: dev/bench
acteval-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fred Shone
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,13 +1,17 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: acteval
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Standalone library for evaluating activity sequences
5
+ License-Expression: MIT
6
+ License-File: LICENSE
5
7
  Requires-Python: >=3.10
6
8
  Requires-Dist: matplotlib
7
9
  Requires-Dist: numpy
8
10
  Requires-Dist: pandas
9
11
  Requires-Dist: pot
10
12
  Requires-Dist: scipy
13
+ Requires-Dist: seaborn
11
14
  Requires-Dist: tabulate
12
15
  Requires-Dist: tomli>=1.1.0; python_version < '3.11'
16
+ Requires-Dist: tqdm
13
17
  Requires-Dist: tslearn
@@ -0,0 +1,621 @@
1
+ # Acteval
2
+
3
+ [![CI](https://github.com/fredshone/acteval/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fredshone/acteval/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/acteval)](https://pypi.org/project/acteval/)
5
+ [![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13-blue)](https://pypi.org/project/acteval/)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/fredshone/acteval/blob/main/LICENSE)
7
+ [![Benchmarks](https://img.shields.io/badge/benchmarks-dashboard-orange)](https://fredshone.github.io/acteval/dev/bench/)
8
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
9
+ [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
10
+ [![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)
11
+
12
+ **Act**ivity schedule **eval**uation. A density estimation framework, and supplementary metrics, for comprehensively comparing or evaluating samples of activity schedules and associated attributes. CLI and python API.
13
+
14
+ **Why Acteval?**
15
+
16
+ - **Agnostic** - Acteval provides a thorough evaluation framework agnostic of downstream application.
17
+ - **Explainable** - High-level quantitative evaluation is composed of a hierarchy of interpretable metrics.
18
+
19
+ > **Work in progress.** Acteval has been in use since 2024, and included in various publications. But is still under active development. We therefore do not commit to backward compatibility of outputs until version 1 is released.
20
+
21
+ Skip to:
22
+ - [About](#about)
23
+ - [Quick Start](#quick-start)
24
+ - [Data Formats](#data-formats)
25
+ - [API](#api)
26
+ - [CLI](#cli)
27
+ - [Development](#development)
28
+ - [Benchmarks](#benchmarks)
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install acteval # or uv pip install...
34
+ ```
35
+
36
+ Or for your own python project (I recommend using [uv](https://github.com/astral-sh/uv)):
37
+
38
+ ```bash
39
+ uv add acteval
40
+ ```
41
+
42
+ ## About
43
+
44
+ ### Density estimation
45
+
46
+ Activity schedules are complex, high-dimensional, sequences of activity participations and times. Comparing the (probability) densities of high dimensional data samples is hard. Acteval tackles density estimation through comprehensive *slicing*. An example of a single *slice* might be a histogram of trips rates. The histograms of trips rates, between an **observed** or target sample, can be compared to a candidate or **synthetic** sample using earth movers distance (EMD) such that lower distances show a closer match.
47
+
48
+ To give a comprehensive estimation of density numerous slices are considered, for example:
49
+
50
+ - The participation rates of shop activities
51
+ - The transition rates of changing from home to work activities
52
+ - The start times of education activities.
53
+
54
+ ![density estimation explainer](assets/density-estimation.png)
55
+
56
+ ### Domains
57
+
58
+ The default [configuration](https://github.com/fredshone/acteval/blob/main/src/acteval/config.toml) of Acteval disaggregates slices by activity types and includes joint distributions such as start times and durations of an activity type. This results in *a lot* of slices. For convenience, these are aggregated (using weighted averages) into top-level **domain** metrics and mid-level **group** metrics, as follows:
59
+
60
+ - **Participations** *- do activities take place the correct number of times?*:
61
+ - **Trip rates** *- are sequences the correct length?*
62
+ - **Activity rates** *- how often do people go to e.g. work?*
63
+ - **Joint activity rates** *- how often do people go to e.g. work and shop?*
64
+ - **Transitions** *- do activities take place in the correct order?*:
65
+ - **Activity 2-grams** *- how often do people transition from e.g. work to shop?*
66
+ - **Activity 3-grams** *- how often do people transition from e.g. home to work to home?*
67
+ - **Activity 4-grams** *- how often do people transition from e.g. home to shop to work to home?*
68
+ - **Timings** *- do activities take place at the correct times and for the correct durations?*:
69
+ - **Activity start times** *- when do people start e.g. work?*
70
+ - **Activity durations** *- how long do people e.g. shop?*
71
+ - **Activity joint start times and durations** *- when do people start and how long do they e.g. work?*
72
+ - **Activity 2-gram durations** *- how long do people e.g. shop and work?*
73
+
74
+ ### Joint attributes-schedules density estimation
75
+
76
+ The above metrics consider the density estimates of samples of activity schedules. These can be further sliced based on associated attributes or labels so that the joint distribution of samples and schedules with associated attributes, such as *the income of the person undertaking the schedule*, or *the day of the week the schedule took place on*, can be considered.
77
+
78
+ This is achieved by additionally supplying **observed** and **synthetic** attributes that can be joined to their corresponding schedules using unique `pid` identifiers. As with the regular density estimations, category-level distances are typically aggregated to attribute-level and then domain-level.
79
+
80
+
81
+ ### Supplementary metrics
82
+
83
+ We also supplement these density estimation distances with creativity and feasibility metrics for the synthetic sample as follows:
84
+
85
+ - **Creativity** *- is my model realistically diverse and does it avoid memorisation?*:
86
+ - **Diversity** *- are generated samples unique from each other?*
87
+ - **Novelty** *- are generated samples unique from the observed samples?*
88
+ - **Feasibility** *- does my model avoid structural zeros i.e. unrealistic schedules?*:
89
+ - **Home-based** *- do samples start and end at home?*
90
+ - **Sequences** *- do samples include consecutive work, education or home activities?*
91
+
92
+ In all cases we calculate probabilities and report the complementary, so that smaller is better. Note that the feasibility metrics are highly opinionated and may not suit all use cases.
93
+
94
+
95
+ ## Quick start
96
+
97
+ There is both a command line interface and python interface:
98
+
99
+ ### CLI in brief
100
+
101
+ Once installed, run the following on your command line, for example using `uv run` or install as an executable using `uv tool install acteval`.
102
+
103
+ ```bash
104
+ # Compare one model to observed activity schedules
105
+ acteval compare observed.csv -m my_model synthetic.csv
106
+
107
+ # Compare multiple models side-by-side
108
+ acteval compare observed.csv -m model_a synthetic_a.csv -m model_b synthetic_b.csv
109
+
110
+ # Save results to a specific output dir (the default is ./results/)
111
+ acteval compare observed.csv -m my_model synthetic.csv -o my_results/
112
+
113
+ # Split evaluation by attributes (e.g. gender and age)
114
+ acteval compare observed.csv --target-attrs target_attrs.csv \
115
+ -m model_a synthetic_a.csv synth_attrs_a.csv \
116
+ -m model_b synthetic_b.csv synth_attrs_b.csv \
117
+ --split-on gender age
118
+ ```
119
+
120
+ Explore the CLI further using `acteval --help` or refer to more details [below](#api), such as batch mode.
121
+
122
+ ### Python API in brief
123
+
124
+ ```python
125
+ import pandas as pd
126
+ from acteval import compare
127
+
128
+ observed = pd.DataFrame([
129
+ {"pid": 0, "act": "home", "start": 0, "end": 8},
130
+ {"pid": 0, "act": "work", "start": 8, "end": 16},
131
+ {"pid": 0, "act": "home", "start": 16, "end": 24},
132
+ {"pid": 1, "act": "home", "start": 0, "end": 12},
133
+ {"pid": 1, "act": "shop", "start": 12, "end": 13},
134
+ {"pid": 1, "act": "home", "start": 13, "end": 24},
135
+ ])
136
+
137
+ synthetic = pd.DataFrame([
138
+ {"pid": 0, "act": "home", "start": 0, "end": 9},
139
+ {"pid": 0, "act": "work", "start": 9, "end": 17},
140
+ {"pid": 0, "act": "home", "start": 17, "end": 24},
141
+ {"pid": 1, "act": "home", "start": 0, "end": 8},
142
+ {"pid": 1, "act": "work", "start": 8, "end": 16},
143
+ {"pid": 1, "act": "home", "start": 16, "end": 24},
144
+ {"pid": 2, "act": "home", "start": 0, "end": 8},
145
+ {"pid": 2, "act": "home", "start": 8, "end": 24},
146
+ ])
147
+
148
+ result = compare(observed, synthetic)
149
+ print(result.summary())
150
+ # synthetic
151
+ # domain
152
+ # creativity 0.166667
153
+ # feasibility 0.333333
154
+ # participations 0.162037
155
+ # timing 0.082728
156
+ # transitions 0.380952
157
+ ```
158
+
159
+ The `compare()` function also supports **batch** comparisons using `{name: DataFrame}` dict to compare several samples:
160
+
161
+ ```python
162
+ result = compare(
163
+ observed,
164
+ {
165
+ "model_a": synthetic_a,
166
+ "model_b": synthetic_b
167
+ },
168
+ )
169
+
170
+ ```
171
+
172
+ The `compare()` function also supports **joint** attribute-schedule comparisons:
173
+
174
+ ```python
175
+ result = compare(
176
+ target_schedules: observed,
177
+ target_attributes=target_attrs,
178
+ synthetic_schedules: synthetic,
179
+ synthetic_attributes=synthetic_attributes,
180
+ split_on=["gender", "weather"],
181
+ )
182
+
183
+ ```
184
+
185
+ ...and **batching** of **joint** attribute-schedule comparisons:
186
+
187
+ ```python
188
+ result = compare(
189
+ target_schedules: observed,
190
+ target_attributes=target_attrs,
191
+ synthetic_schedules: {
192
+ "model_a": synthetic_a,
193
+ "model_b": synthetic_b
194
+ },
195
+ synthetic_attributes={
196
+ "model_a": attributes_a,
197
+ "model_b": attributes_b
198
+ },
199
+ split_on=["gender", "weather"],
200
+ )
201
+
202
+ ```
203
+
204
+ `result` is an `EvalResult`. See [Reading the results](#reading-the-results) for more detail.
205
+
206
+ ## Data formats
207
+
208
+ Data is passed as a pandas (or [polars](https://pola.rs)) DataFrames with the API. The CLI will accept both .csv and .parquet.
209
+
210
+ ### Activity schedules
211
+
212
+ A DataFrame with one row per activity:
213
+
214
+ | column | type | description |
215
+ |--------|------|-------------|
216
+ | `pid` | int/str | Person identifier |
217
+ | `act` | str | Activity label (e.g. `"home"`, `"work"`, `"shop"`) |
218
+ | `start` | numeric | Start time (any consistent unit, e.g. hours) |
219
+ | `end` | numeric | End time |
220
+ | `duration` | numeric | Duration (`end - start`) |
221
+
222
+ Any two of `start`, `end`, and `duration` are sufficient — the third is derived automatically. A polars DataFrame in the same shape works anywhere a pandas one does; it's converted internally.
223
+
224
+ ### Attributes
225
+
226
+ Attributes can be as a pandas (or polars) DataFrame with one row per activity schedule. These should be joinable using a `pid` column. Attributes themselves can be arbitrarily named and used, though we suggest restricting yourself string column names and to categorical variables.
227
+
228
+ ## API
229
+
230
+ ### Comparing populations
231
+
232
+ #### `compare(target_schedules, synthetic_schedules: DataFrame, **kwargs)`
233
+
234
+ ```python
235
+ result = compare(observed, synthetic)
236
+ ```
237
+ `result` is an `EvalResult` object. See [Reading the results](#reading-the-results) for how to access distances and descriptions at feature, group, and domain level.
238
+
239
+ #### `compare(target_schedules, synthetic_schedules: dict[str, DataFrame], **kwargs)`
240
+
241
+ Name a synthetic sample, or provide multiple named samples using a dict:
242
+
243
+ ```python
244
+ result = compare(observed, {"model_a": synthetic_a, "model_b": synthetic_b})
245
+ ```
246
+
247
+ #### `compare(target_schedules, synthetic_schedules, target_attributes: DataFrame, synthetic_attributes: dict[str, DataFrame], split_on: list[str], **kwargs)`
248
+
249
+ Pass `target_attributes` and `synthetic_attributes` and `split_on` to evaluate separately within each category of an attribute (e.g. gender) instead of over the whole population:
250
+
251
+ ```python
252
+ target_attrs = pd.DataFrame({
253
+ "pid": [0, 1],
254
+ "gender": ["M", "F"]
255
+ })
256
+ synthetic_attributes = pd.DataFrame({
257
+ "pid": [0, 1],
258
+ "gender": ["F", "M"]
259
+ })
260
+
261
+ result = compare(
262
+ target_schedules=observed, # <- target or observed activity schedules
263
+ synthetic_schedules={"my_model": synthetic}, # <- dict of synthetic activity schedules
264
+ target_attributes=target_attrs, # <- target or observed attributes
265
+ synthetic_attributes={"my_model": synthetic_attributes}, # <- dict of synthetic attributes
266
+ split_on=["gender"],
267
+ )
268
+ ```
269
+
270
+ This makes the `by_attribute`/`by_category` splits available at every level via `result.at(level, split)` — see [Reading the results](#reading-the-results) for how to read them.
271
+
272
+ > **Numeric split columns are auto-binned.** If a `split_on` column is numeric
273
+ > (float, or an integer with more than 10 unique values), it's automatically
274
+ > bucketed into up to 5 ordinal bins (`"lowest"`...`"highest"`) via `pd.qcut`,
275
+ > with a `UserWarning` noting the bin edges chosen. Encode the column as a
276
+ > categorical/string beforehand (e.g. your own age bands) to control the
277
+ > buckets yourself and suppress the warning.
278
+
279
+
280
+ #### `Evaluator`
281
+
282
+ `compare` is a wrapper for `Evaluator` — it computes and caches the observed features as required.
283
+
284
+ ```python
285
+ from acteval import Evaluator
286
+
287
+ evaluator = Evaluator(observed)
288
+
289
+ result_v1 = evaluator.compare({"v1": synthetic_v1})
290
+ result_v2 = evaluator.compare({"v2": synthetic_v2})
291
+ ```
292
+
293
+ #### `**kwargs`
294
+
295
+ Pass `progress=True` to either `compare()` or `Evaluator(...)` to show tqdm progress bars.
296
+
297
+ Pass `disable` with a list of dotted `section.key` paths matching `config.toml` to switch off individual metrics without writing a custom config file:
298
+
299
+ ```python
300
+ result = compare(
301
+ ...
302
+ disable=["jobs.creativity.novelty", "jobs.transitions.4-gram"],
303
+ )
304
+ ```
305
+
306
+ Call `list_disable_keys()` to see every valid dotted path up front, instead of
307
+ reading `config.toml` or triggering the `ValueError` an unknown key raises (which
308
+ also lists the valid paths). `disable` also works on `Evaluator(observed,
309
+ disable=[...])` and the CLI's `--disable` flag.
310
+
311
+ For anything more involved than a metric or two, pass your own configuration file
312
+ rather than using the [default](https://github.com/fredshone/acteval/blob/main/src/acteval/config.toml),
313
+ e.g. `compare(observed, synthetic, config_path=PATH)` (also accepted by
314
+ `compare_grid()`, `compare_many()`, `Evaluator(...)` and the CLI's `--config`).
315
+ `disable` is applied on top of whatever `config_path` loads. `Evaluator` also
316
+ accepts a pre-built `jobs` (`EvalConfig`), which cannot be combined with
317
+ `config_path` or `disable`.
318
+
319
+ #### `compare_grid()` — every target × every model
320
+
321
+ To compare the *same* synthetic models against several targets (e.g. several
322
+ observed populations) and see them side-by-side, use `compare_grid()`:
323
+
324
+ ```python
325
+ from acteval import compare_grid
326
+
327
+ result = compare_grid(
328
+ {"target_a": observed_a, "target_b": observed_b},
329
+ {"model_1": synthetic_1, "model_2": synthetic_2},
330
+ )
331
+ ```
332
+
333
+ ##### `compare_many()` — targets and models paired one-to-one
334
+
335
+ To compare each target against just its corresponding synthetic model — e.g.
336
+ model 1 was fit against target A, model 2 against target B, and you want each
337
+ scored only against its own target — use `compare_many()`. The two dicts are
338
+ paired up positionally (first with first, second with second, ...) and must be
339
+ the same length:
340
+
341
+ ```python
342
+ from acteval import compare_many
343
+
344
+ result = compare_many(
345
+ {"target_a": observed_a, "target_b": observed_b},
346
+ {"model_a": synthetic_a, "model_b": synthetic_b},
347
+ )
348
+
349
+ print(result.model_names)
350
+ # ['target_a::model_a', 'target_b::model_b']
351
+ ```
352
+
353
+ Both helpers accept `attributes_a`/`attributes_b` (per-target/per-model
354
+ attributes DataFrames) and `split_on`, and pass any other keyword arguments
355
+ (e.g. `config_path`, `disable`, `progress`) through to `compare()`.
356
+
357
+ For per-target attributes/`split_on` with more control, or to inspect
358
+ intermediate per-target results before merging, call `compare()` yourself in a
359
+ loop and pass the results to `acteval.results.combine()`:
360
+
361
+ ```python
362
+ from acteval.results import combine
363
+
364
+ results = {
365
+ "target_a": compare(observed_a, synthetic),
366
+ "target_b": compare(observed_b, synthetic),
367
+ }
368
+ result = combine(results)
369
+ ```
370
+
371
+ > **Known limitation:** the shared `target`/`unit` values underlying feature →
372
+ > group → domain aggregation are taken from the *first* result only, so
373
+ > re-aggregating a non-first target's columns uses that first target's weights
374
+ > rather than its own. Each model's distances are still computed against its own
375
+ > target — this only affects aggregation weighting, and will be resolved by a
376
+ > future refactor to carry one weight base per source.
377
+
378
+
379
+ #### `pairwise_distances(schedules, specs=None)`
380
+
381
+ > **Work in progress only!**
382
+
383
+ Compute a single NxN distance matrix between individual schedules. Useful for clustering, outlier detection, or directly comparing a small batch of schedules — a standalone code path, independent of `compare()`/`Evaluator`/`config.toml`.
384
+
385
+ ```python
386
+ from acteval import pairwise_distances
387
+
388
+ result = pairwise_distances(schedules)
389
+ result.matrix # numpy array, shape (N, N)
390
+ result.pids # original pid values, length N
391
+ df = result.to_dataframe() # labeled DataFrame, original pid values as index/columns
392
+ ```
393
+
394
+ The result matrix is symmetric with zeros on the diagonal. All values are in **0–1**.
395
+
396
+ By default, three equal-weight semantic-distance specs are used (participations,
397
+ transitions, timing via MAE). Pass a custom `specs` list to change the metrics or
398
+ their relative weights, e.g. `pairwise_distances(schedules, specs=[chamfer_spec()])`.
399
+ Each spec defines a `feature_fn` (extracts a `(N, ...)` array from the population)
400
+ and a `distance_fn` (computes the `(N, N)` matrix); the final matrix is a weighted
401
+ average across all active specs. See `acteval.pairwise` for the available factories —
402
+ `default_pairwise_specs()`, `chamfer_spec(max_len, weight)`, `soft_dtw_spec(max_len, gamma, weight)`.
403
+
404
+ `Population` is the internal numpy-precomputation layer `compare()`/`pairwise_distances()` are built on — most users never construct it directly; see its docstring (`acteval.population.Population`) if you need it.
405
+
406
+ #### `acteval.plot`
407
+
408
+ Matplotlib plotting helpers for interactive/notebook use — Gantt charts, time-use
409
+ and participation-rate breakdowns, bigram heatmaps, start/end/duration histogram
410
+ grids, sequence-probability waterfalls, and heatmap/bar-chart views of a
411
+ `compare()` result. Not imported by `compare()`/`Evaluator`/`pairwise_distances`,
412
+ so it never runs unless you call into it. Submodules: `frequency`, `times`,
413
+ `transitions`, `plot` (schedule-level charts), `results` (result-level charts) —
414
+ see each module's docstring for the full function list.
415
+
416
+ ```python
417
+ from acteval.plot.plot import gantt
418
+
419
+ gantt(observed)
420
+ ```
421
+
422
+ ## Reading the results
423
+
424
+ #### `EvalResult`
425
+
426
+ `compare` and `Evaluator` return large amounts of metrics. These are exposed via an `EvalResult` object, which you can use, for example, to extract quick domain summaries with `summary`:
427
+
428
+ #### `EvalResult.summary()`
429
+
430
+ For comparing models against each other, `summary()` is usually all you need:
431
+
432
+ ```python
433
+ # Domain-level summary table
434
+ print(result.summary())
435
+ # model_a model_b
436
+ # domain
437
+ # creativity 0.166667 0.333333
438
+ # feasibility 0.333333 1.000000
439
+ # participations 0.162037 0.988889
440
+ # timing 0.082728 0.669676
441
+ # transitions 0.380952 0.500000
442
+
443
+
444
+ ```
445
+
446
+ > **Known limitation:** The density estimation domain distances and supplementary metrics have different units, supports and typical values.
447
+ We generally therefore do not aggregate them further into a "meta score". However you can access `rank_models()` and `best_model()` which do simply sum metrics to allow meta comparison. Use with care. For safer comparison we suggest normalisinng metrics against a baseline, for example, to report % improvement my each metric.
448
+
449
+ #### `EvalResult.save()`
450
+
451
+ Save all (low-level features, via groups to top-level domains) to CSV with `result.save("output_dir/")`.
452
+
453
+ #### `EvalResult.at(level, split)`
454
+
455
+ For anything more detailed than the summary table — a specific aggregation level, or a specific split — `result.at(...)`:
456
+
457
+ ```python
458
+ result.at() # domains × combined (result.at().distances == result.summary())
459
+ result.at("groups") # groups × combined
460
+ result.at("features", "by_attribute") # features × by_attribute (requires split_on)
461
+ result.at("domains", "by_category") # domains × by_category (requires split_on)
462
+ ```
463
+
464
+ - `level` is one of `"features"`, `"groups"`, `"domains"`
465
+ - `split` is one of `"combined"`, `"by_attribute"`, `"by_category"`
466
+
467
+ Each call returns an `AggregatedResult` with `.distances` and `.descriptions` DataFrames.
468
+
469
+ #### `EvalResult.raw`
470
+
471
+ `EvalResult.raw` exposes the pre-aggregation data (one `ResultFrame` each for descriptions and distances) that every level above is aggregated from — `distances` covers models only (there's no such thing as the target's distance to itself); pair it with `result.target_distance_weights` if you're building custom distance aggregations of your own.
472
+
473
+ > **Note on timing features:** A distance of `1.0` for a timing feature means the activity is *entirely absent* from the synthetic population — not just timed differently. This is treated as a maximum-penalty missing feature rather than a distributional difference.
474
+
475
+
476
+ ## CLI
477
+
478
+ `acteval` ships with a command-line interface for comparing models without writing
479
+ Python. It has two subcommands: `compare` (the primary one) and `filter`.
480
+
481
+ #### `acteval compare`
482
+
483
+
484
+ ```bash
485
+ # Compare one model to observed data
486
+ acteval compare observed.csv -m my_model synthetic.csv
487
+
488
+ # Compare multiple models side-by-side
489
+ acteval compare observed.csv -m model_a synthetic_a.csv -m model_b synthetic_b.csv
490
+
491
+ # Save results to CSV files
492
+ acteval compare observed.csv -m my_model synthetic.csv -o results/
493
+
494
+ # Show group-level detail instead of domain summary
495
+ acteval compare observed.csv -m my_model synthetic.csv -l groups
496
+
497
+ # Use a custom config file
498
+ acteval compare observed.csv -m my_model synthetic.csv -c custom.toml
499
+
500
+ # Disable specific metrics without a custom config file
501
+ acteval compare observed.csv -m my_model synthetic.csv --disable jobs.creativity.novelty
502
+ ```
503
+
504
+ Input files can be CSV or Parquet (detected by extension). Run `acteval compare --help` for the full option list.
505
+
506
+ As per the API it is also possible to do joint density estimation by providing attributes:
507
+
508
+ ```bash
509
+ # Split evaluation by attribute (e.g. gender and age)
510
+ # Per-model attrs are the third argument to -m
511
+ # Attributes must be provided for the target and ALL models, or not at all
512
+ acteval compare observed.csv --target-attrs target_attrs.csv \
513
+ -m model_a synthetic_a.csv synth_attrs_a.csv \
514
+ -m model_b synthetic_b.csv synth_attrs_b.csv \
515
+ -s gender age
516
+ ```
517
+
518
+ **Attribute rules:**
519
+ - Attributes must be provided for **all** inputs (target + every model) or **none**. Partial specification raises an error.
520
+ - `--split-on` (`-s`) and `--target-attrs` must be specified together.
521
+ - In batch mode, if any model subdirectory contains an attributes file, all subdirectories must contain one.
522
+
523
+ For the lazy - there is also an auto-discovery mode for batch experiments, but use with care:
524
+
525
+ ```bash
526
+ # Batch mode: auto-discover model subdirectories
527
+ # Each subdir becomes a model (name = dir name); schedule and attrs files are
528
+ # classified by their columns (pid + act → schedule; pid + other cols → attrs)
529
+ acteval compare observed.csv --batch models/
530
+
531
+ # Batch mode with attribute splitting
532
+ acteval compare observed.csv --target-attrs target_attrs.csv --batch models/ --split-on gender
533
+ ```
534
+
535
+
536
+ **Batch directory layout:**
537
+ ```
538
+ models/
539
+ model_a/
540
+ schedules.csv ← has pid, act → classified as schedule
541
+ attributes.csv ← has pid + other cols, no act → classified as attrs
542
+ model_b/
543
+ ...
544
+ ```
545
+
546
+ #### `acteval filter`
547
+
548
+ Filter a schedule file down to persons with a specific structural issue — useful for
549
+ spot-checking a synthetic population before running `compare`.
550
+
551
+ ```bash
552
+ # Schedules that don't start and end at home
553
+ acteval filter non-home-based synthetic.csv -o flagged.csv
554
+
555
+ # Schedules with consecutive duplicate activities (default: home, work, education)
556
+ acteval filter consecutive synthetic.csv --act home shop
557
+ ```
558
+
559
+ Without `-o/--output`, filtered rows are printed to stdout as CSV. Run
560
+ `acteval filter --help` for the full option list.
561
+
562
+ ## Development
563
+
564
+ ```bash
565
+ # Install dependencies (including dev tools)
566
+ uv sync
567
+
568
+ # Run tests (benchmarks excluded by default)
569
+ uv run pytest tests/
570
+
571
+ # Run tests with coverage
572
+ uv run pytest --cov=src/acteval tests/
573
+
574
+ # Lint with ruff
575
+ ruff check src/ tests/
576
+
577
+ # Auto-fix lint issues
578
+ ruff check --fix src/ tests/
579
+
580
+ # Check formatting with black
581
+ black --check src/ tests/
582
+
583
+ # Auto-format
584
+ black src/ tests/
585
+ ```
586
+
587
+ ## Benchmarks
588
+
589
+ Two benchmark suites are included, both using [pytest-benchmark](https://pytest-benchmark.readthedocs.io/).
590
+
591
+ ### Population evaluation (`compare`)
592
+
593
+ Tests `compare()` at 1k, 20k, and 100k rows (observed + synthetic populations):
594
+
595
+ ```bash
596
+ uv run pytest tests/test_bench_evaluate.py --benchmark-only -v
597
+ ```
598
+
599
+ ### Pairwise distances (`pairwise_distances`)
600
+
601
+ Tests `pairwise_distances()` at 256, 512, and 1024 schedules:
602
+
603
+ ```bash
604
+ uv run pytest tests/test_bench_pairwise.py --benchmark-only -v
605
+ ```
606
+
607
+ ### Both suites together
608
+
609
+ ```bash
610
+ uv run pytest tests/test_bench_evaluate.py tests/test_bench_pairwise.py --benchmark-only -v
611
+ ```
612
+
613
+ ### Comparing runs
614
+
615
+ ```bash
616
+ # Save a baseline
617
+ uv run pytest tests/test_bench_evaluate.py tests/test_bench_pairwise.py --benchmark-only --benchmark-save=baseline
618
+
619
+ # Compare against it after making changes
620
+ uv run pytest tests/test_bench_evaluate.py tests/test_bench_pairwise.py --benchmark-only --benchmark-compare=baseline
621
+ ```