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.
Files changed (83) hide show
  1. acteval-0.3.0/.github/workflows/benchmark.yml +71 -0
  2. {acteval-0.2.0 → acteval-0.3.0}/PKG-INFO +1 -1
  3. {acteval-0.2.0 → acteval-0.3.0}/README.md +56 -23
  4. {acteval-0.2.0 → acteval-0.3.0}/llms.txt +11 -3
  5. {acteval-0.2.0 → acteval-0.3.0}/pyproject.toml +3 -3
  6. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/_aggregation.py +7 -4
  7. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/_jobs.py +13 -0
  8. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/_pipeline.py +4 -11
  9. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/_result_frame.py +33 -12
  10. acteval-0.3.0/src/acteval/_splits.py +133 -0
  11. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/cli.py +28 -0
  12. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/config.toml +10 -0
  13. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/distance/wasserstein.py +12 -4
  14. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/evaluate.py +154 -60
  15. acteval-0.3.0/src/acteval/features/_pid_features.py +132 -0
  16. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/features/_utils.py +23 -0
  17. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/features/creativity.py +17 -4
  18. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/features/participation.py +22 -16
  19. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/features/transitions.py +6 -3
  20. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/population.py +14 -9
  21. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/results.py +222 -46
  22. {acteval-0.2.0 → acteval-0.3.0}/tests/test_bench_evaluate.py +44 -3
  23. {acteval-0.2.0 → acteval-0.3.0}/tests/test_cli.py +2 -0
  24. acteval-0.3.0/tests/test_compare.py +207 -0
  25. {acteval-0.2.0 → acteval-0.3.0}/tests/test_creativity.py +14 -3
  26. acteval-0.3.0/tests/test_distance_emd1d.py +37 -0
  27. {acteval-0.2.0 → acteval-0.3.0}/tests/test_features_participation.py +26 -0
  28. {acteval-0.2.0 → acteval-0.3.0}/tests/test_features_transitions.py +7 -0
  29. {acteval-0.2.0 → acteval-0.3.0}/tests/test_pid_features.py +26 -0
  30. {acteval-0.2.0 → acteval-0.3.0}/tests/test_population.py +59 -0
  31. acteval-0.3.0/tests/test_split_order.py +316 -0
  32. {acteval-0.2.0 → acteval-0.3.0}/tests/test_validation.py +37 -0
  33. {acteval-0.2.0 → acteval-0.3.0}/uv.lock +1101 -1097
  34. acteval-0.2.0/.github/workflows/benchmark.yml +0 -40
  35. acteval-0.2.0/src/acteval/_splits.py +0 -66
  36. acteval-0.2.0/src/acteval/features/_pid_features.py +0 -73
  37. acteval-0.2.0/tests/test_compare.py +0 -93
  38. {acteval-0.2.0 → acteval-0.3.0}/.github/workflows/ci.yml +0 -0
  39. {acteval-0.2.0 → acteval-0.3.0}/.github/workflows/publish.yml +0 -0
  40. {acteval-0.2.0 → acteval-0.3.0}/.gitignore +0 -0
  41. {acteval-0.2.0 → acteval-0.3.0}/LICENSE +0 -0
  42. {acteval-0.2.0 → acteval-0.3.0}/assets/density-estimation.png +0 -0
  43. {acteval-0.2.0 → acteval-0.3.0}/scripts/generate_blog_plots.py +0 -0
  44. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/__init__.py +0 -0
  45. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/__main__.py +0 -0
  46. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/_compat.py +0 -0
  47. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/_progress.py +0 -0
  48. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/_report.py +0 -0
  49. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/distance/__init__.py +0 -0
  50. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/distance/scalar.py +0 -0
  51. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/features/__init__.py +0 -0
  52. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/features/_discretise.py +0 -0
  53. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/features/catalogue.py +0 -0
  54. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/features/frequency.py +0 -0
  55. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/features/structural.py +0 -0
  56. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/features/times.py +0 -0
  57. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/pairwise.py +0 -0
  58. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/plot/__init__.py +0 -0
  59. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/plot/frequency.py +0 -0
  60. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/plot/plot.py +0 -0
  61. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/plot/results.py +0 -0
  62. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/plot/times.py +0 -0
  63. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/plot/transitions.py +0 -0
  64. {acteval-0.2.0 → acteval-0.3.0}/src/acteval/plot/utils.py +0 -0
  65. {acteval-0.2.0 → acteval-0.3.0}/tests/conftest.py +0 -0
  66. {acteval-0.2.0 → acteval-0.3.0}/tests/test_bench_pairwise.py +0 -0
  67. {acteval-0.2.0 → acteval-0.3.0}/tests/test_describe_features.py +0 -0
  68. {acteval-0.2.0 → acteval-0.3.0}/tests/test_disable_config.py +0 -0
  69. {acteval-0.2.0 → acteval-0.3.0}/tests/test_distance_emd.py +0 -0
  70. {acteval-0.2.0 → acteval-0.3.0}/tests/test_distance_scalar.py +0 -0
  71. {acteval-0.2.0 → acteval-0.3.0}/tests/test_features_catalogue.py +0 -0
  72. {acteval-0.2.0 → acteval-0.3.0}/tests/test_features_structural.py +0 -0
  73. {acteval-0.2.0 → acteval-0.3.0}/tests/test_features_times.py +0 -0
  74. {acteval-0.2.0 → acteval-0.3.0}/tests/test_features_utils.py +0 -0
  75. {acteval-0.2.0 → acteval-0.3.0}/tests/test_filter_cmd.py +0 -0
  76. {acteval-0.2.0 → acteval-0.3.0}/tests/test_frequency.py +0 -0
  77. {acteval-0.2.0 → acteval-0.3.0}/tests/test_integration.py +0 -0
  78. {acteval-0.2.0 → acteval-0.3.0}/tests/test_jobs.py +0 -0
  79. {acteval-0.2.0 → acteval-0.3.0}/tests/test_missing_features.py +0 -0
  80. {acteval-0.2.0 → acteval-0.3.0}/tests/test_pairwise.py +0 -0
  81. {acteval-0.2.0 → acteval-0.3.0}/tests/test_polars_compat.py +0 -0
  82. {acteval-0.2.0 → acteval-0.3.0}/tests/test_report.py +0 -0
  83. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: acteval
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Standalone library for evaluating activity sequences
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -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, "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},
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, "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},
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.162037
155
- # timing 0.082728
156
- # transitions 0.380952
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 (any consistent unit, e.g. hours) |
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. A polars DataFrame in the same shape works anywhere a pandas one does; it's converted internally.
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, any consistent unit)
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.2.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
- [tool.uv]
22
- dev-dependencies = [
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, for blending (see
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
- rf = distances.drop_rows(drop) if drop else distances
138
- return rf.aggregate_distances(grouper, target_weights=target_weights)
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 blend into each model's own weight before aggregating, is tracked
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
- if len(index) > _PARALLEL_THRESHOLD:
353
- with ThreadPoolExecutor() as executor:
354
- values = list(executor.map(_compute, index))
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
- n = len(keys[0])
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
- """Asymmetric weighted average aggregation (for distances).
92
+ """Weighted average aggregation for distances, using both sides' weights.
86
93
 
87
- Averages each model's weight with the target's weight before computing
88
- the weighted mean. This handles asymmetric feature coverage: a feature
89
- present in only one side gets half-weight rather than zero-weight.
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] + target_weights) / 2``
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, aligned to this
96
- ``ResultFrame``'s index.
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 = (self.weights[col] + target_weights) / 2
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