acteval 0.2.0__tar.gz → 0.3.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.
- acteval-0.3.0/.github/workflows/benchmark.yml +71 -0
- {acteval-0.2.0 → acteval-0.3.0}/PKG-INFO +1 -1
- {acteval-0.2.0 → acteval-0.3.0}/README.md +56 -23
- {acteval-0.2.0 → acteval-0.3.0}/llms.txt +11 -3
- {acteval-0.2.0 → acteval-0.3.0}/pyproject.toml +3 -3
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/_aggregation.py +7 -4
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/_jobs.py +13 -0
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/_pipeline.py +4 -11
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/_result_frame.py +33 -12
- acteval-0.3.0/src/acteval/_splits.py +133 -0
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/cli.py +28 -0
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/config.toml +10 -0
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/distance/wasserstein.py +12 -4
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/evaluate.py +154 -60
- acteval-0.3.0/src/acteval/features/_pid_features.py +132 -0
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/features/_utils.py +23 -0
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/features/creativity.py +17 -4
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/features/participation.py +22 -16
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/features/transitions.py +6 -3
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/population.py +14 -9
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/results.py +222 -46
- {acteval-0.2.0 → acteval-0.3.0}/tests/test_bench_evaluate.py +44 -3
- {acteval-0.2.0 → acteval-0.3.0}/tests/test_cli.py +2 -0
- acteval-0.3.0/tests/test_compare.py +207 -0
- {acteval-0.2.0 → acteval-0.3.0}/tests/test_creativity.py +14 -3
- acteval-0.3.0/tests/test_distance_emd1d.py +37 -0
- {acteval-0.2.0 → acteval-0.3.0}/tests/test_features_participation.py +26 -0
- {acteval-0.2.0 → acteval-0.3.0}/tests/test_features_transitions.py +7 -0
- {acteval-0.2.0 → acteval-0.3.0}/tests/test_pid_features.py +26 -0
- {acteval-0.2.0 → acteval-0.3.0}/tests/test_population.py +59 -0
- acteval-0.3.0/tests/test_split_order.py +316 -0
- {acteval-0.2.0 → acteval-0.3.0}/tests/test_validation.py +37 -0
- {acteval-0.2.0 → acteval-0.3.0}/uv.lock +1101 -1097
- acteval-0.2.0/.github/workflows/benchmark.yml +0 -40
- acteval-0.2.0/src/acteval/_splits.py +0 -66
- acteval-0.2.0/src/acteval/features/_pid_features.py +0 -73
- acteval-0.2.0/tests/test_compare.py +0 -93
- {acteval-0.2.0 → acteval-0.3.0}/.github/workflows/ci.yml +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/.github/workflows/publish.yml +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/.gitignore +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/LICENSE +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/assets/density-estimation.png +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/scripts/generate_blog_plots.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/__init__.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/__main__.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/_compat.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/_progress.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/_report.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/distance/__init__.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/distance/scalar.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/features/__init__.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/features/_discretise.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/features/catalogue.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/features/frequency.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/features/structural.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/features/times.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/pairwise.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/plot/__init__.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/plot/frequency.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/plot/plot.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/plot/results.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/plot/times.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/plot/transitions.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/src/acteval/plot/utils.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/tests/conftest.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/tests/test_bench_pairwise.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/tests/test_describe_features.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/tests/test_disable_config.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/tests/test_distance_emd.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/tests/test_distance_scalar.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/tests/test_features_catalogue.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/tests/test_features_structural.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/tests/test_features_times.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/tests/test_features_utils.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/tests/test_filter_cmd.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/tests/test_frequency.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/tests/test_integration.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/tests/test_jobs.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/tests/test_missing_features.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/tests/test_pairwise.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/tests/test_polars_compat.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/tests/test_report.py +0 -0
- {acteval-0.2.0 → acteval-0.3.0}/tests/test_results.py +0 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
name: Benchmark
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
benchmark:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
permissions:
|
|
11
|
+
contents: write
|
|
12
|
+
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
with:
|
|
16
|
+
fetch-depth: 0
|
|
17
|
+
|
|
18
|
+
- uses: astral-sh/setup-uv@v4
|
|
19
|
+
|
|
20
|
+
- name: Install Python 3.13
|
|
21
|
+
run: uv python install 3.13
|
|
22
|
+
|
|
23
|
+
# Baseline and candidate run on the same runner, so the comparison is not
|
|
24
|
+
# thrown off by hardware differences between GitHub-hosted machines.
|
|
25
|
+
# The baseline also runs the candidate's benchmark files, so a commit that
|
|
26
|
+
# changes the benchmark workload compares like with like. Benchmarks the
|
|
27
|
+
# baseline can't run (e.g. new API) aren't saved, so they're not compared.
|
|
28
|
+
- name: Run baseline benchmarks (previous commit)
|
|
29
|
+
env:
|
|
30
|
+
BASE_SHA: ${{ github.event.before }}
|
|
31
|
+
run: |
|
|
32
|
+
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
|
|
33
|
+
BASE_SHA=$(git rev-parse HEAD~1)
|
|
34
|
+
fi
|
|
35
|
+
echo "Baseline: $BASE_SHA"
|
|
36
|
+
git worktree add "$RUNNER_TEMP/baseline" "$BASE_SHA"
|
|
37
|
+
cd "$RUNNER_TEMP/baseline"
|
|
38
|
+
cp "$GITHUB_WORKSPACE"/tests/test_bench_*.py tests/
|
|
39
|
+
uv sync
|
|
40
|
+
uv run pytest tests/test_bench_evaluate.py tests/test_bench_pairwise.py \
|
|
41
|
+
--benchmark-only \
|
|
42
|
+
--benchmark-save=baseline \
|
|
43
|
+
--continue-on-collection-errors \
|
|
44
|
+
|| echo "Some baseline benchmarks failed; they are skipped in the comparison"
|
|
45
|
+
cp -r .benchmarks "$GITHUB_WORKSPACE/.benchmarks"
|
|
46
|
+
|
|
47
|
+
- name: Install dependencies
|
|
48
|
+
run: uv sync
|
|
49
|
+
|
|
50
|
+
- name: Run candidate benchmarks and compare
|
|
51
|
+
run: >
|
|
52
|
+
uv run pytest tests/test_bench_evaluate.py tests/test_bench_pairwise.py
|
|
53
|
+
--benchmark-only
|
|
54
|
+
--benchmark-compare=0001
|
|
55
|
+
--benchmark-compare-fail=min:50%
|
|
56
|
+
--benchmark-json output.json
|
|
57
|
+
|
|
58
|
+
# History for the gh-pages chart only; regressions are gated by the
|
|
59
|
+
# same-runner comparison above, as cross-runner alerts are too noisy.
|
|
60
|
+
- name: Store benchmark results
|
|
61
|
+
if: ${{ !cancelled() && hashFiles('output.json') != '' }}
|
|
62
|
+
uses: benchmark-action/github-action-benchmark@v1
|
|
63
|
+
with:
|
|
64
|
+
tool: pytest
|
|
65
|
+
output-file-path: output.json
|
|
66
|
+
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
67
|
+
auto-push: true
|
|
68
|
+
alert-threshold: "150%"
|
|
69
|
+
comment-on-alert: false
|
|
70
|
+
fail-on-alert: false
|
|
71
|
+
benchmark-data-dir-path: dev/bench
|
|
@@ -126,23 +126,23 @@ import pandas as pd
|
|
|
126
126
|
from acteval import compare
|
|
127
127
|
|
|
128
128
|
observed = pd.DataFrame([
|
|
129
|
-
{"pid": 0, "act": "home", "start": 0,
|
|
130
|
-
{"pid": 0, "act": "work", "start":
|
|
131
|
-
{"pid": 0, "act": "home", "start":
|
|
132
|
-
{"pid": 1, "act": "home", "start": 0,
|
|
133
|
-
{"pid": 1, "act": "shop", "start":
|
|
134
|
-
{"pid": 1, "act": "home", "start":
|
|
129
|
+
{"pid": 0, "act": "home", "start": 0, "end": 480},
|
|
130
|
+
{"pid": 0, "act": "work", "start": 480, "end": 960},
|
|
131
|
+
{"pid": 0, "act": "home", "start": 960, "end": 1440},
|
|
132
|
+
{"pid": 1, "act": "home", "start": 0, "end": 720},
|
|
133
|
+
{"pid": 1, "act": "shop", "start": 720, "end": 780},
|
|
134
|
+
{"pid": 1, "act": "home", "start": 780, "end": 1440},
|
|
135
135
|
])
|
|
136
136
|
|
|
137
137
|
synthetic = pd.DataFrame([
|
|
138
|
-
{"pid": 0, "act": "home", "start": 0,
|
|
139
|
-
{"pid": 0, "act": "work", "start":
|
|
140
|
-
{"pid": 0, "act": "home", "start":
|
|
141
|
-
{"pid": 1, "act": "home", "start": 0,
|
|
142
|
-
{"pid": 1, "act": "work", "start":
|
|
143
|
-
{"pid": 1, "act": "home", "start":
|
|
144
|
-
{"pid": 2, "act": "home", "start": 0,
|
|
145
|
-
{"pid": 2, "act": "home", "start":
|
|
138
|
+
{"pid": 0, "act": "home", "start": 0, "end": 540},
|
|
139
|
+
{"pid": 0, "act": "work", "start": 540, "end": 1020},
|
|
140
|
+
{"pid": 0, "act": "home", "start": 1020, "end": 1440},
|
|
141
|
+
{"pid": 1, "act": "home", "start": 0, "end": 480},
|
|
142
|
+
{"pid": 1, "act": "work", "start": 480, "end": 960},
|
|
143
|
+
{"pid": 1, "act": "home", "start": 960, "end": 1440},
|
|
144
|
+
{"pid": 2, "act": "home", "start": 0, "end": 480},
|
|
145
|
+
{"pid": 2, "act": "home", "start": 480, "end": 1440},
|
|
146
146
|
])
|
|
147
147
|
|
|
148
148
|
result = compare(observed, synthetic)
|
|
@@ -151,9 +151,9 @@ print(result.summary())
|
|
|
151
151
|
# domain
|
|
152
152
|
# creativity 0.166667
|
|
153
153
|
# feasibility 0.333333
|
|
154
|
-
# participations 0.
|
|
155
|
-
# timing 0.
|
|
156
|
-
# transitions 0.
|
|
154
|
+
# participations 0.333333
|
|
155
|
+
# timing 0.261429
|
|
156
|
+
# transitions 0.416667
|
|
157
157
|
```
|
|
158
158
|
|
|
159
159
|
The `compare()` function also supports **batch** comparisons using `{name: DataFrame}` dict to compare several samples:
|
|
@@ -215,11 +215,13 @@ A DataFrame with one row per activity:
|
|
|
215
215
|
|--------|------|-------------|
|
|
216
216
|
| `pid` | int/str | Person identifier |
|
|
217
217
|
| `act` | str | Activity label (e.g. `"home"`, `"work"`, `"shop"`) |
|
|
218
|
-
| `start` | numeric | Start time
|
|
219
|
-
| `end` | numeric | End time |
|
|
220
|
-
| `duration` | numeric | Duration (`end - start`) |
|
|
218
|
+
| `start` | numeric | Start time in minutes since midnight (e.g. `480` = 08:00) |
|
|
219
|
+
| `end` | numeric | End time in minutes |
|
|
220
|
+
| `duration` | numeric | Duration in minutes (`end - start`) |
|
|
221
221
|
|
|
222
|
-
Any two of `start`, `end`, and `duration` are sufficient — the third is derived automatically.
|
|
222
|
+
Any two of `start`, `end`, and `duration` are sufficient — the third is derived automatically.
|
|
223
|
+
|
|
224
|
+
> **Times must be in minutes.** Timing features bin values into 15-minute buckets and normalise by a 1440-minute day, and `time_consistency` checks that schedules end at 1440. Other units (e.g. hours) won't raise an error but give distorted timing scores, so convert first (e.g. `df[["start", "end", "duration"]] *= 60`). A polars DataFrame in the same shape works anywhere a pandas one does; it's converted internally.
|
|
223
225
|
|
|
224
226
|
### Attributes
|
|
225
227
|
|
|
@@ -276,6 +278,30 @@ This makes the `by_attribute`/`by_category` splits available at every level via
|
|
|
276
278
|
> categorical/string beforehand (e.g. your own age bands) to control the
|
|
277
279
|
> buckets yourself and suppress the warning.
|
|
278
280
|
|
|
281
|
+
**Interaction slices (`split_order`).** By default each `split_on` attribute is
|
|
282
|
+
sliced on its own (`gender=M`, `income=high`). Pass `split_order=n` to slice on
|
|
283
|
+
every combination of `n` attributes instead, e.g. `split_order=2` scores every
|
|
284
|
+
`gender & income` cell (labelled `label="gender&income"`, `cat="M&high"`):
|
|
285
|
+
|
|
286
|
+
```python
|
|
287
|
+
result = compare(
|
|
288
|
+
observed, {"my_model": synthetic},
|
|
289
|
+
target_attributes=target_attrs,
|
|
290
|
+
synthetic_attributes={"my_model": synthetic_attributes},
|
|
291
|
+
split_on=["gender", "income", "car"],
|
|
292
|
+
split_order=2,
|
|
293
|
+
min_split_size=30, # skip cells with < 30 target persons (default above order 1)
|
|
294
|
+
)
|
|
295
|
+
result.at("domains", "by_category") # one row per gender&income cell, etc.
|
|
296
|
+
result.at("domains", "by_category", order=1) # gender=M pooled over every gender=M & * cell
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Only order `n` is computed; lower-order views (`order=k`) pool the order-`n`
|
|
300
|
+
cells that contain each `k`-attribute assignment. Note that `combined` pools
|
|
301
|
+
different slices at different orders, so its values change with `split_order`.
|
|
302
|
+
Both settings can also be set in `config.toml` under `[splits]`. Slice counts
|
|
303
|
+
grow combinatorially, so keep `n` small (2 is usually plenty).
|
|
304
|
+
|
|
279
305
|
|
|
280
306
|
#### `Evaluator`
|
|
281
307
|
|
|
@@ -450,7 +476,7 @@ We generally therefore do not aggregate them further into a "meta score". Howeve
|
|
|
450
476
|
|
|
451
477
|
Save all (low-level features, via groups to top-level domains) to CSV with `result.save("output_dir/")`.
|
|
452
478
|
|
|
453
|
-
#### `EvalResult.at(level, split)`
|
|
479
|
+
#### `EvalResult.at(level, split, order=None)`
|
|
454
480
|
|
|
455
481
|
For anything more detailed than the summary table — a specific aggregation level, or a specific split — `result.at(...)`:
|
|
456
482
|
|
|
@@ -463,12 +489,13 @@ result.at("domains", "by_category") # domains × by_category (requires sp
|
|
|
463
489
|
|
|
464
490
|
- `level` is one of `"features"`, `"groups"`, `"domains"`
|
|
465
491
|
- `split` is one of `"combined"`, `"by_attribute"`, `"by_category"`
|
|
492
|
+
- `order` (split views only) pools results down to `k`-attribute slices, for `1 <= k <= split_order`; it defaults to `split_order`
|
|
466
493
|
|
|
467
494
|
Each call returns an `AggregatedResult` with `.distances` and `.descriptions` DataFrames.
|
|
468
495
|
|
|
469
496
|
#### `EvalResult.raw`
|
|
470
497
|
|
|
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.
|
|
498
|
+
`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. Model distance weights are rescaled to the target's size within each slice, and each segment is weighted by the larger of the model and target weights when aggregating, so a segment only one side has keeps its full weight.
|
|
472
499
|
|
|
473
500
|
> **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
501
|
|
|
@@ -513,11 +540,17 @@ acteval compare observed.csv --target-attrs target_attrs.csv \
|
|
|
513
540
|
-m model_a synthetic_a.csv synth_attrs_a.csv \
|
|
514
541
|
-m model_b synthetic_b.csv synth_attrs_b.csv \
|
|
515
542
|
-s gender age
|
|
543
|
+
|
|
544
|
+
# Score every gender & age combination (also prints the pooled single-attribute view)
|
|
545
|
+
acteval compare observed.csv --target-attrs target_attrs.csv \
|
|
546
|
+
-m model_a synthetic_a.csv synth_attrs_a.csv \
|
|
547
|
+
-s gender age income --split-order 2 --min-split-size 30
|
|
516
548
|
```
|
|
517
549
|
|
|
518
550
|
**Attribute rules:**
|
|
519
551
|
- Attributes must be provided for **all** inputs (target + every model) or **none**. Partial specification raises an error.
|
|
520
552
|
- `--split-on` (`-s`) and `--target-attrs` must be specified together.
|
|
553
|
+
- `--split-order` and `--min-split-size` require `--split-on`. With `-o` and order > 1, extra `*_by_attribute_order1`/`*_by_category_order1` directories hold the pooled single-attribute views.
|
|
521
554
|
- In batch mode, if any model subdirectory contains an attributes file, all subdirectories must contain one.
|
|
522
555
|
|
|
523
556
|
For the lazy - there is also an auto-discovery mode for batch experiments, but use with care:
|
|
@@ -11,7 +11,7 @@ pip install acteval
|
|
|
11
11
|
pandas (or polars) DataFrame, one row per activity episode:
|
|
12
12
|
- pid — person identifier (int or str)
|
|
13
13
|
- act — activity label (str, e.g. "home", "work", "shop")
|
|
14
|
-
- start — episode start time (numeric,
|
|
14
|
+
- start — episode start time (numeric, minutes since midnight; other units distort timing scores)
|
|
15
15
|
- end — episode end time (numeric)
|
|
16
16
|
- duration — episode duration (numeric)
|
|
17
17
|
|
|
@@ -23,6 +23,9 @@ Any two of start/end/duration are enough — the third is derived.
|
|
|
23
23
|
Compare one or more synthetic DataFrames to observed.
|
|
24
24
|
synthetic can be a single DataFrame (result column "synthetic") or {"name": df, ...} dict.
|
|
25
25
|
kwargs: attributes, target_attributes, split_on (attribute-based splitting),
|
|
26
|
+
split_order (combine n split_on attributes per slice, e.g. 2 → "gender&income"
|
|
27
|
+
labels with "m&high" cats; default 1 or config [splits] order), min_split_size
|
|
28
|
+
(skip slices with fewer target persons; default none at order 1, 30 above),
|
|
26
29
|
config_path (custom config.toml), disable (dotted config.toml keys to switch
|
|
27
30
|
off, applied on top of config_path), progress (tqdm bars), verbose.
|
|
28
31
|
|
|
@@ -69,9 +72,14 @@ Three aggregation levels, each further split three ways:
|
|
|
69
72
|
Dispatcher: result.at(level="domains", split="combined") reaches any of the 9
|
|
70
73
|
combinations; result.at() == result.at("domains", "combined") == result.summary()
|
|
71
74
|
as a distances-only shortcut.
|
|
75
|
+
result.at(level, split, order=k) (split views only, 1 <= k <= result.split_order)
|
|
76
|
+
pools the computed order-n slices down to k-attribute slices; e.g. at n=2,
|
|
77
|
+
order=1 by_category "gender=m" pools every gender=m & * cell.
|
|
78
|
+
result.slice_assignments maps (label, cat) → ((attr, value), ...).
|
|
72
79
|
|
|
73
80
|
Convenience: result.summary(), result.rank_models(), result.best_model,
|
|
74
|
-
result.save(path) (writes every level/split to CSV
|
|
81
|
+
result.save(path) (writes every level/split to CSV; plus *_order1 split
|
|
82
|
+
views when split_order > 1).
|
|
75
83
|
|
|
76
84
|
Advanced: result.raw → {"descriptions": ResultFrame, "distances": ResultFrame}
|
|
77
85
|
(pre-aggregation values/weights/units, before any tier collapse); pair with
|
|
@@ -94,7 +102,7 @@ list_disable_keys() enumerates every dotted disable=[...] path (e.g. "jobs.creat
|
|
|
94
102
|
## CLI
|
|
95
103
|
|
|
96
104
|
acteval compare TARGET -m NAME SCHEDULE [ATTRS] [-m ...] [--target-attrs PATH
|
|
97
|
-
--split-on COL] [-c CONFIG] [--disable KEY] [-o DIR] [-l features|groups|domains] [--batch DIR]
|
|
105
|
+
--split-on COL [--split-order N] [--min-split-size N]] [-c CONFIG] [--disable KEY] [-o DIR] [-l features|groups|domains] [--batch DIR]
|
|
98
106
|
|
|
99
107
|
acteval filter non-home-based|consecutive SCHEDULE [-o OUT] — spot-check a
|
|
100
108
|
synthetic population for structural issues before running compare.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "acteval"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Standalone library for evaluating activity sequences"
|
|
5
5
|
requires-python = ">=3.10"
|
|
6
6
|
license = "MIT"
|
|
@@ -18,8 +18,8 @@ dependencies = [
|
|
|
18
18
|
"tqdm",
|
|
19
19
|
]
|
|
20
20
|
|
|
21
|
-
[
|
|
22
|
-
dev
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
dev = [
|
|
23
23
|
"pytest",
|
|
24
24
|
"pytest-cov",
|
|
25
25
|
"pytest-benchmark",
|
|
@@ -126,16 +126,19 @@ def distances_to_group_level(
|
|
|
126
126
|
|
|
127
127
|
Args:
|
|
128
128
|
distances: ``ResultFrame`` at the segment level.
|
|
129
|
-
target_weights: Raw per-row target weight,
|
|
130
|
-
``ResultFrame.aggregate_distances``).
|
|
129
|
+
target_weights: Raw per-row target weight, in the same row order as
|
|
130
|
+
``distances`` (see ``ResultFrame.aggregate_distances``).
|
|
131
131
|
extra: Additional index levels to preserve (e.g. ``["label"]``).
|
|
132
132
|
drop: Index prefix tuples to exclude before aggregating.
|
|
133
133
|
Defaults to ``DEFAULT_REMOVE_FEATURES``. Pass ``None`` or ``[]``
|
|
134
134
|
to skip filtering.
|
|
135
135
|
"""
|
|
136
136
|
grouper = ["domain", "feature"] + extra
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
if drop:
|
|
138
|
+
# Drop the same rows from target_weights: they're combined by position.
|
|
139
|
+
target_weights = target_weights.iloc[distances.kept_positions(drop)]
|
|
140
|
+
distances = distances.drop_rows(drop)
|
|
141
|
+
return distances.aggregate_distances(grouper, target_weights=target_weights)
|
|
139
142
|
|
|
140
143
|
|
|
141
144
|
def descriptions_to_domain_level(
|
|
@@ -62,11 +62,18 @@ class StructuralConfig:
|
|
|
62
62
|
return self.home_based_novel or self.consecutive_novel
|
|
63
63
|
|
|
64
64
|
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class SplitConfig:
|
|
67
|
+
order: int = 1 # number of split_on attributes combined per slice
|
|
68
|
+
min_size: int | None = None # min target persons per slice; None = auto
|
|
69
|
+
|
|
70
|
+
|
|
65
71
|
@dataclass(frozen=True)
|
|
66
72
|
class EvalConfig:
|
|
67
73
|
density: list[JobSpec]
|
|
68
74
|
creativity: CreativityConfig
|
|
69
75
|
structural: StructuralConfig
|
|
76
|
+
splits: SplitConfig = SplitConfig()
|
|
70
77
|
|
|
71
78
|
|
|
72
79
|
def load_config(path=None) -> dict:
|
|
@@ -361,6 +368,11 @@ def build_structural_config(cfg: dict) -> StructuralConfig:
|
|
|
361
368
|
)
|
|
362
369
|
|
|
363
370
|
|
|
371
|
+
def build_split_config(cfg: dict) -> SplitConfig:
|
|
372
|
+
s = cfg.get("splits", {})
|
|
373
|
+
return SplitConfig(order=s.get("order", 1), min_size=s.get("min_size"))
|
|
374
|
+
|
|
375
|
+
|
|
364
376
|
def get_jobs(config_path=None, disable: list[str] | None = None) -> EvalConfig:
|
|
365
377
|
"""Load config and return active job specification.
|
|
366
378
|
|
|
@@ -377,4 +389,5 @@ def get_jobs(config_path=None, disable: list[str] | None = None) -> EvalConfig:
|
|
|
377
389
|
density=build_density_jobs(cfg),
|
|
378
390
|
creativity=build_creativity_config(cfg),
|
|
379
391
|
structural=build_structural_config(cfg),
|
|
392
|
+
splits=build_split_config(cfg),
|
|
380
393
|
)
|
|
@@ -25,7 +25,7 @@ same MultiIndex ``(domain, feature, segment)`` and column set, plus a
|
|
|
25
25
|
``units`` Series. ``descriptions`` has one column per model plus a ``"target"``
|
|
26
26
|
column; ``distances`` has one column per model only (there's no such thing as
|
|
27
27
|
the target's distance to itself) — the target's distance-side weight, used
|
|
28
|
-
only to
|
|
28
|
+
only to combine with each model's own weight (their max) before aggregating, is tracked
|
|
29
29
|
separately (see ``Evaluator._target_distance_weights`` / ``EvalResult``).
|
|
30
30
|
|
|
31
31
|
## ``missing_distance``
|
|
@@ -36,7 +36,6 @@ Each ``JobSpec`` carries a ``missing_distance`` value:
|
|
|
36
36
|
- ``None`` (participation, transitions): EMD is computed on whatever data exists.
|
|
37
37
|
"""
|
|
38
38
|
|
|
39
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
40
39
|
from typing import Callable
|
|
41
40
|
|
|
42
41
|
import numpy as np
|
|
@@ -77,9 +76,6 @@ def _aggregate_features(
|
|
|
77
76
|
return feat_desc, feat_dist
|
|
78
77
|
|
|
79
78
|
|
|
80
|
-
_PARALLEL_THRESHOLD = 50
|
|
81
|
-
|
|
82
|
-
|
|
83
79
|
# ---------------------------------------------------------------------------
|
|
84
80
|
# Low-level helpers: density features
|
|
85
81
|
# ---------------------------------------------------------------------------
|
|
@@ -349,12 +345,9 @@ def _score_features(
|
|
|
349
345
|
return missing_distance
|
|
350
346
|
return distance(_get_or_default(a, k, default), _get_or_default(b, k, default))
|
|
351
347
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
metrics = Series(dict(zip(index, values)), name=model)
|
|
356
|
-
else:
|
|
357
|
-
metrics = Series({k: _compute(k) for k in index}, name=model)
|
|
348
|
+
# Serial on purpose: individual EMDs are small and GIL-bound, so a thread
|
|
349
|
+
# pool costs more in scheduling overhead than it saves.
|
|
350
|
+
metrics = Series({k: _compute(k) for k in index}, name=model)
|
|
358
351
|
metrics = metrics.fillna(0)
|
|
359
352
|
return metrics
|
|
360
353
|
|
|
@@ -18,6 +18,7 @@ from __future__ import annotations
|
|
|
18
18
|
from dataclasses import dataclass
|
|
19
19
|
from typing import Sequence
|
|
20
20
|
|
|
21
|
+
import numpy as np
|
|
21
22
|
from pandas import DataFrame, Series
|
|
22
23
|
|
|
23
24
|
|
|
@@ -50,16 +51,22 @@ class ResultFrame:
|
|
|
50
51
|
"""
|
|
51
52
|
if not keys:
|
|
52
53
|
return self
|
|
53
|
-
|
|
54
|
-
key_set = set(keys)
|
|
55
|
-
mask = [idx[:n] not in key_set for idx in self.values.index]
|
|
56
|
-
iloc = [i for i, m in enumerate(mask) if m]
|
|
54
|
+
iloc = self.kept_positions(keys)
|
|
57
55
|
return ResultFrame(
|
|
58
56
|
values=self.values.iloc[iloc],
|
|
59
57
|
weights=self.weights.iloc[iloc],
|
|
60
58
|
units=self.units.iloc[iloc] if self.units is not None else None,
|
|
61
59
|
)
|
|
62
60
|
|
|
61
|
+
def kept_positions(self, keys: Sequence[tuple]) -> list[int]:
|
|
62
|
+
"""Row positions ``drop_rows(keys)`` keeps, for dropping the same rows
|
|
63
|
+
from a Series aligned to this frame (e.g. target weights)."""
|
|
64
|
+
if not keys:
|
|
65
|
+
return list(range(len(self.values)))
|
|
66
|
+
n = len(keys[0])
|
|
67
|
+
key_set = set(keys)
|
|
68
|
+
return [i for i, idx in enumerate(self.values.index) if idx[:n] not in key_set]
|
|
69
|
+
|
|
63
70
|
# ------------------------------------------------------------------
|
|
64
71
|
# Aggregation
|
|
65
72
|
# ------------------------------------------------------------------
|
|
@@ -82,23 +89,37 @@ class ResultFrame:
|
|
|
82
89
|
def aggregate_distances(
|
|
83
90
|
self, groupby: list[str], target_weights: Series
|
|
84
91
|
) -> "ResultFrame":
|
|
85
|
-
"""
|
|
92
|
+
"""Weighted average aggregation for distances, using both sides' weights.
|
|
86
93
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
94
|
+
Each row is weighted by the larger of the model's and the target's
|
|
95
|
+
weight, so a segment present on only one side (e.g. an activity the
|
|
96
|
+
model never produces, or one it invents) keeps that side's full weight.
|
|
97
|
+
Model weights are already rescaled to the target's slice size (see
|
|
98
|
+
``Evaluator.compare_population``), so neither side dominates because
|
|
99
|
+
of its sample size.
|
|
90
100
|
|
|
91
|
-
``combined_w = (weights[c]
|
|
101
|
+
``combined_w = max(weights[c], target_weights)``
|
|
92
102
|
``agg[c] = sum(values[c] * combined_w) / sum(combined_w)``
|
|
93
103
|
|
|
94
104
|
Args:
|
|
95
|
-
target_weights: Raw per-row target weight,
|
|
96
|
-
``ResultFrame
|
|
105
|
+
target_weights: Raw per-row target weight, in the same row order
|
|
106
|
+
as this ``ResultFrame``. Combined by position, not by label:
|
|
107
|
+
lower-order views repeat index labels, which label alignment
|
|
108
|
+
would cross-multiply.
|
|
97
109
|
"""
|
|
110
|
+
if len(target_weights) != len(self.values):
|
|
111
|
+
raise ValueError(
|
|
112
|
+
f"target_weights has {len(target_weights)} rows, expected "
|
|
113
|
+
f"{len(self.values)} (one per row, in the same order)"
|
|
114
|
+
)
|
|
115
|
+
target = target_weights.to_numpy(dtype=float)
|
|
98
116
|
agg_values: dict[str, Series] = {}
|
|
99
117
|
agg_weights: dict[str, Series] = {}
|
|
100
118
|
for col in self.values.columns:
|
|
101
|
-
combined_w = (
|
|
119
|
+
combined_w = Series(
|
|
120
|
+
np.maximum(self.weights[col].to_numpy(dtype=float), target),
|
|
121
|
+
index=self.values.index,
|
|
122
|
+
)
|
|
102
123
|
total = combined_w.groupby(groupby).sum()
|
|
103
124
|
weighted = (self.values[col].mul(combined_w)).groupby(groupby).sum()
|
|
104
125
|
agg_values[col] = (weighted / total).fillna(0.0)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import itertools
|
|
2
|
+
import warnings
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from pandas import DataFrame
|
|
7
|
+
|
|
8
|
+
# Default minimum target persons per slice for order >= 2 (order 1 keeps every
|
|
9
|
+
# observed category, matching pre-``split_order`` behaviour).
|
|
10
|
+
DEFAULT_HIGHER_ORDER_MIN_SIZE = 30
|
|
11
|
+
|
|
12
|
+
# Warn when a run would evaluate more slices than this.
|
|
13
|
+
_MANY_SLICES = 1000
|
|
14
|
+
|
|
15
|
+
LABEL_SEP = "&"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Slice:
|
|
20
|
+
"""One population slice: a joint assignment of ``order`` attributes.
|
|
21
|
+
|
|
22
|
+
``attrs`` follow ``split_on`` order; ``values`` are aligned with ``attrs``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
attrs: tuple[str, ...]
|
|
26
|
+
values: tuple
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def order(self) -> int:
|
|
30
|
+
return len(self.attrs)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def label(self) -> str:
|
|
34
|
+
"""Index ``label`` string, e.g. ``"gender"`` or ``"gender&income"``."""
|
|
35
|
+
return LABEL_SEP.join(self.attrs)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def cat(self):
|
|
39
|
+
"""Index ``cat`` value: the raw value at order 1 (unchanged from
|
|
40
|
+
pre-``split_order`` output), else the joined string, e.g. ``"male&high"``."""
|
|
41
|
+
if self.order == 1:
|
|
42
|
+
return self.values[0]
|
|
43
|
+
return LABEL_SEP.join(str(v) for v in self.values)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def key(self) -> tuple[str, object]:
|
|
47
|
+
return (self.label, self.cat)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def assignment(self) -> tuple[tuple[str, object], ...]:
|
|
51
|
+
"""``((attr, value), ...)`` — what containment views expand from."""
|
|
52
|
+
return tuple(zip(self.attrs, self.values))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def resolve_min_size(order: int, min_size: int | None) -> int:
|
|
56
|
+
"""``None`` means auto: no minimum at order 1, else
|
|
57
|
+
``DEFAULT_HIGHER_ORDER_MIN_SIZE``."""
|
|
58
|
+
if min_size is not None:
|
|
59
|
+
return min_size
|
|
60
|
+
return 0 if order == 1 else DEFAULT_HIGHER_ORDER_MIN_SIZE
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def group_pids(
|
|
64
|
+
attributes: DataFrame, attrs: tuple[str, ...]
|
|
65
|
+
) -> dict[tuple, np.ndarray]:
|
|
66
|
+
"""``{values_tuple: original_pids}`` for every observed combination of
|
|
67
|
+
``attrs``, in order of first appearance."""
|
|
68
|
+
grouped = attributes.groupby(list(attrs), sort=False, dropna=True).pid
|
|
69
|
+
return {(k if isinstance(k, tuple) else (k,)): pids.values for k, pids in grouped}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_slices(
|
|
73
|
+
attributes: DataFrame,
|
|
74
|
+
split_on: list[str],
|
|
75
|
+
order: int,
|
|
76
|
+
min_size: int | None = None,
|
|
77
|
+
) -> list[tuple[Slice, np.ndarray]]:
|
|
78
|
+
"""Build every order-``order`` slice observed in *attributes*.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
attributes: Target attributes with a ``pid`` column and every
|
|
82
|
+
``split_on`` column.
|
|
83
|
+
split_on: Attribute columns to combine.
|
|
84
|
+
order: Number of attributes per slice (``1 <= order <= len(split_on)``).
|
|
85
|
+
min_size: Minimum target persons per slice; ``None`` for the default
|
|
86
|
+
(see ``resolve_min_size``).
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
``[(slice, target_original_pids), ...]``, grouped by attribute
|
|
90
|
+
combination in ``itertools.combinations(split_on, order)`` order.
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
ValueError: If ``order`` is out of range, or every slice is smaller
|
|
94
|
+
than ``min_size``.
|
|
95
|
+
"""
|
|
96
|
+
if not 1 <= order <= len(split_on):
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"split_order must be between 1 and len(split_on)={len(split_on)}, "
|
|
99
|
+
f"got {order}"
|
|
100
|
+
)
|
|
101
|
+
min_size = resolve_min_size(order, min_size)
|
|
102
|
+
slices: list[tuple[Slice, np.ndarray]] = []
|
|
103
|
+
dropped: list[str] = []
|
|
104
|
+
for attrs in itertools.combinations(split_on, order):
|
|
105
|
+
for values, pids in group_pids(attributes, attrs).items():
|
|
106
|
+
s = Slice(attrs=attrs, values=values)
|
|
107
|
+
if len(np.unique(pids)) < min_size:
|
|
108
|
+
dropped.append(f"{s.label}={s.cat}")
|
|
109
|
+
continue
|
|
110
|
+
slices.append((s, pids))
|
|
111
|
+
if dropped:
|
|
112
|
+
if not slices:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"every order-{order} slice has fewer than min_split_size={min_size} "
|
|
115
|
+
"target persons; lower split_order or min_split_size"
|
|
116
|
+
)
|
|
117
|
+
preview = ", ".join(dropped[:5]) + (", ..." if len(dropped) > 5 else "")
|
|
118
|
+
warnings.warn(
|
|
119
|
+
f"skipped {len(dropped)} of {len(dropped) + len(slices)} order-{order} "
|
|
120
|
+
f"slices with fewer than min_split_size={min_size} target persons: "
|
|
121
|
+
f"{preview}",
|
|
122
|
+
UserWarning,
|
|
123
|
+
stacklevel=3,
|
|
124
|
+
)
|
|
125
|
+
if len(slices) > _MANY_SLICES:
|
|
126
|
+
warnings.warn(
|
|
127
|
+
f"split_order={order} produces {len(slices)} slices; expect roughly "
|
|
128
|
+
"0.1-0.25s per slice for the target and again per model. Consider a "
|
|
129
|
+
"lower split_order, fewer split_on columns, or a larger min_split_size.",
|
|
130
|
+
UserWarning,
|
|
131
|
+
stacklevel=3,
|
|
132
|
+
)
|
|
133
|
+
return slices
|
|
@@ -264,6 +264,22 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
264
264
|
action="extend",
|
|
265
265
|
help="Attribute columns to split evaluation on (requires --target-attrs and per-model attrs)",
|
|
266
266
|
)
|
|
267
|
+
compare.add_argument(
|
|
268
|
+
"--split-order",
|
|
269
|
+
type=int,
|
|
270
|
+
metavar="N",
|
|
271
|
+
default=None,
|
|
272
|
+
help="Combine N split-on attributes per slice, e.g. 2 for gender & income "
|
|
273
|
+
"(requires --split-on; default from config, 1)",
|
|
274
|
+
)
|
|
275
|
+
compare.add_argument(
|
|
276
|
+
"--min-split-size",
|
|
277
|
+
type=int,
|
|
278
|
+
metavar="N",
|
|
279
|
+
default=None,
|
|
280
|
+
help="Skip slices with fewer than N target persons "
|
|
281
|
+
"(default from config; none at order 1, 30 above)",
|
|
282
|
+
)
|
|
267
283
|
compare.add_argument(
|
|
268
284
|
"--config", "-c", metavar="PATH", help="Path to custom config.toml"
|
|
269
285
|
)
|
|
@@ -345,6 +361,10 @@ def _run(args: argparse.Namespace) -> None:
|
|
|
345
361
|
# --- split-on requires attrs (and vice versa) ---
|
|
346
362
|
if bool(args.split_on) and not bool(args.target_attrs):
|
|
347
363
|
sys.exit("--split-on requires --target-attrs to be specified")
|
|
364
|
+
if (
|
|
365
|
+
args.split_order is not None or args.min_split_size is not None
|
|
366
|
+
) and not args.split_on:
|
|
367
|
+
sys.exit("--split-order and --min-split-size require --split-on")
|
|
348
368
|
|
|
349
369
|
# --- load target attributes ---
|
|
350
370
|
target_attributes: pd.DataFrame | None = None
|
|
@@ -418,6 +438,8 @@ def _run(args: argparse.Namespace) -> None:
|
|
|
418
438
|
config_path=args.config,
|
|
419
439
|
progress=not args.no_progress,
|
|
420
440
|
disable=args.disable,
|
|
441
|
+
split_order=args.split_order,
|
|
442
|
+
min_split_size=args.min_split_size,
|
|
421
443
|
)
|
|
422
444
|
# Attrs are only meaningful for splitting; don't pass them when split_on is absent
|
|
423
445
|
attrs_for_evaluator = attributes if args.split_on else None
|
|
@@ -450,6 +472,12 @@ def _run(args: argparse.Namespace) -> None:
|
|
|
450
472
|
if result.has_splits:
|
|
451
473
|
print("\nDomain distances by attribute:")
|
|
452
474
|
print_markdown(result.domains.by_attribute.distances)
|
|
475
|
+
if result.split_order > 1:
|
|
476
|
+
print(
|
|
477
|
+
f"\nDomain distances by single attribute "
|
|
478
|
+
f"(pooled over order-{result.split_order} slices):"
|
|
479
|
+
)
|
|
480
|
+
print_markdown(result.at("domains", "by_attribute", order=1).distances)
|
|
453
481
|
print("\nDomain distances (lower is better):")
|
|
454
482
|
print_markdown(result.domains.combined.distances)
|
|
455
483
|
|