acteval 0.1.1__tar.gz → 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {acteval-0.1.1 → acteval-0.1.2}/PKG-INFO +2 -2
- {acteval-0.1.1 → acteval-0.1.2}/README.md +44 -28
- acteval-0.1.2/llms.txt +68 -0
- {acteval-0.1.1 → acteval-0.1.2}/pyproject.toml +7 -4
- acteval-0.1.2/src/acteval/__main__.py +4 -0
- acteval-0.1.2/src/acteval/cli.py +206 -0
- acteval-0.1.2/tests/test_cli.py +280 -0
- {acteval-0.1.1 → acteval-0.1.2}/.github/workflows/benchmark.yml +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/.github/workflows/ci.yml +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/.github/workflows/publish.yml +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/.gitignore +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/scripts/generate_blog_plots.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/__init__.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/_aggregation.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/_compat.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/_filters.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/_jobs.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/_pipeline.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/_report.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/_result_frame.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/_splits.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/config.toml +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/describe/__init__.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/describe/frequency.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/describe/plot.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/describe/results.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/describe/times.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/describe/transitions.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/describe/utils.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/distance/__init__.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/distance/scalar.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/distance/wasserstein.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/evaluate.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/features/__init__.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/features/_discretise.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/features/_pid_features.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/features/_utils.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/features/catalogue.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/features/creativity.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/features/frequency.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/features/participation.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/features/structural.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/features/times.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/features/transitions.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/pairwise.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/src/acteval/population.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/tests/conftest.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/tests/test_bench_evaluate.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/tests/test_bench_pairwise.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/tests/test_compare.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/tests/test_creativity.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/tests/test_describe_features.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/tests/test_distance_emd.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/tests/test_distance_scalar.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/tests/test_features_catalogue.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/tests/test_features_participation.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/tests/test_features_structural.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/tests/test_features_times.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/tests/test_features_transitions.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/tests/test_features_utils.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/tests/test_filter.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/tests/test_frequency.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/tests/test_integration.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/tests/test_jobs.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/tests/test_missing_features.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/tests/test_pairwise.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/tests/test_pid_features.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/tests/test_polars_compat.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/tests/test_population.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/tests/test_report.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/tests/test_validation.py +0 -0
- {acteval-0.1.1 → acteval-0.1.2}/uv.lock +0 -0
|
@@ -14,6 +14,36 @@ Or with [uv](https://github.com/astral-sh/uv):
|
|
|
14
14
|
uv add acteval
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
+
## CLI
|
|
18
|
+
|
|
19
|
+
`acteval` ships with a command-line interface for comparing models without writing Python.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Compare one model to observed data
|
|
23
|
+
acteval observed.csv --model my_model synthetic.csv
|
|
24
|
+
|
|
25
|
+
# Compare multiple models side-by-side
|
|
26
|
+
acteval observed.csv \
|
|
27
|
+
--model model_a synthetic_a.csv \
|
|
28
|
+
--model model_b synthetic_b.csv
|
|
29
|
+
|
|
30
|
+
# Save full results to CSV files
|
|
31
|
+
acteval observed.csv --model my_model synthetic.csv --output results/
|
|
32
|
+
|
|
33
|
+
# Show group-level detail instead of domain summary
|
|
34
|
+
acteval observed.csv --model my_model synthetic.csv --level groups
|
|
35
|
+
|
|
36
|
+
# Split evaluation by attribute (e.g. gender)
|
|
37
|
+
acteval observed.csv \
|
|
38
|
+
--target-attrs target_attrs.csv --split-on gender \
|
|
39
|
+
--model my_model synthetic.csv --attrs my_model synth_attrs.csv
|
|
40
|
+
|
|
41
|
+
# Use a custom config file
|
|
42
|
+
acteval observed.csv --model my_model synthetic.csv --config custom.toml
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Input files can be CSV or Parquet (detected by extension). Run `acteval --help` for the full option list.
|
|
46
|
+
|
|
17
47
|
## Quick start
|
|
18
48
|
|
|
19
49
|
```python
|
|
@@ -59,6 +89,9 @@ Data is passed as a pandas DataFrame with one row per activity episode:
|
|
|
59
89
|
| `act` | str | Activity label (e.g. `"home"`, `"work"`, `"shop"`) |
|
|
60
90
|
| `start` | numeric | Start time (any consistent unit, e.g. hours) |
|
|
61
91
|
| `end` | numeric | End time |
|
|
92
|
+
| `duration` | numeric | Duration (`end - start`); can be omitted when both `start` and `end` are provided |
|
|
93
|
+
|
|
94
|
+
Any two of `start`, `end`, and `duration` are sufficient — the third is derived automatically.
|
|
62
95
|
|
|
63
96
|
|
|
64
97
|
```python
|
|
@@ -89,18 +122,7 @@ result = compare(observed, synthetic)
|
|
|
89
122
|
result = compare(observed, {"model_a": synthetic_a, "model_b": synthetic_b})
|
|
90
123
|
```
|
|
91
124
|
|
|
92
|
-
`result` is
|
|
93
|
-
|
|
94
|
-
| key | description |
|
|
95
|
-
|-----|-------------|
|
|
96
|
-
| `"distances"` | Per-feature distances |
|
|
97
|
-
| `"group_distances"` | Distances aggregated by feature group |
|
|
98
|
-
| `"domain_distances"` | Distances aggregated by domain (participations, transitions, timing) |
|
|
99
|
-
| `"descriptions"` | Per-feature descriptive statistics |
|
|
100
|
-
| `"group_descriptions"` | Group-level descriptive statistics |
|
|
101
|
-
| `"domain_descriptions"` | Domain-level descriptive statistics |
|
|
102
|
-
|
|
103
|
-
Pass `report_stats=True` to include additional statistics in the output.
|
|
125
|
+
`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.
|
|
104
126
|
|
|
105
127
|
### `Evaluator`
|
|
106
128
|
|
|
@@ -115,14 +137,6 @@ result_v1 = evaluator.compare({"v1": synthetic_v1})
|
|
|
115
137
|
result_v2 = evaluator.compare({"v2": synthetic_v2})
|
|
116
138
|
```
|
|
117
139
|
|
|
118
|
-
For fine-grained control, compare one model at a time then assemble:
|
|
119
|
-
|
|
120
|
-
```python
|
|
121
|
-
evaluator.compare_population("v1", synthetic_v1)
|
|
122
|
-
evaluator.compare_population("v2", synthetic_v2)
|
|
123
|
-
result = evaluator.report()
|
|
124
|
-
```
|
|
125
|
-
|
|
126
140
|
### `pairwise_distances(schedules, specs=None)`
|
|
127
141
|
|
|
128
142
|
Compute a single NxN distance matrix between individual schedules. Useful for clustering, outlier detection, or directly comparing a small batch of schedules.
|
|
@@ -190,18 +204,20 @@ print(pop.durations) # durations as numpy array
|
|
|
190
204
|
|
|
191
205
|
## Reading the results
|
|
192
206
|
|
|
193
|
-
###
|
|
207
|
+
### Accessing results
|
|
194
208
|
|
|
195
|
-
`compare()` returns an `EvalResult` with
|
|
209
|
+
`compare()` returns an `EvalResult` with distances and descriptions at three levels of aggregation. Each level is accessed via a property that returns a view object with `.distances` and `.descriptions` DataFrames:
|
|
196
210
|
|
|
197
211
|
| Property | Index | Content |
|
|
198
212
|
|----------|-------|---------|
|
|
199
|
-
| `result.distances` | `(domain, feature, segment)` | Per-feature distances — lower is closer to observed |
|
|
200
|
-
| `result.descriptions` | `(domain, feature, segment)` | Per-feature descriptive statistics (e.g. average start time) |
|
|
201
|
-
| `result.
|
|
202
|
-
| `result.
|
|
203
|
-
| `result.
|
|
204
|
-
| `result.
|
|
213
|
+
| `result.features.combined.distances` | `(domain, feature, segment)` | Per-feature distances — lower is closer to observed |
|
|
214
|
+
| `result.features.combined.descriptions` | `(domain, feature, segment)` | Per-feature descriptive statistics (e.g. average start time) |
|
|
215
|
+
| `result.groups.combined.distances` | `(domain, feature)` | Distances averaged across segments |
|
|
216
|
+
| `result.groups.combined.descriptions` | `(domain, feature)` | Descriptions averaged across segments |
|
|
217
|
+
| `result.domains.combined.distances` | `(domain,)` | Distances averaged across features — one row per domain |
|
|
218
|
+
| `result.domains.combined.descriptions` | `(domain,)` | Descriptions averaged across features |
|
|
219
|
+
|
|
220
|
+
Save all levels to CSV at once with `result.save("output_dir/")`.
|
|
205
221
|
|
|
206
222
|
Distances are in the range **0–1** (lower is better). A distance of `0.0` means the synthetic distribution perfectly matches observed; `1.0` is the maximum penalty.
|
|
207
223
|
|
acteval-0.1.2/llms.txt
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# acteval
|
|
2
|
+
|
|
3
|
+
> Python library for evaluating synthetic activity schedules: given daily sequences of who-did-what-and-when, measures how well a synthetic population reproduces observed distributions across frequencies, timing, transitions, participation rates, and novelty. Distances are 0–1 (lower is better).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
pip install acteval
|
|
8
|
+
|
|
9
|
+
## Input format
|
|
10
|
+
|
|
11
|
+
pandas DataFrame, one row per activity episode:
|
|
12
|
+
- pid — person identifier (int or str)
|
|
13
|
+
- act — activity label (str, e.g. "home", "work", "shop")
|
|
14
|
+
- start — episode start time (numeric, any consistent unit)
|
|
15
|
+
- end — episode end time (numeric)
|
|
16
|
+
- duration — episode duration (numeric)
|
|
17
|
+
|
|
18
|
+
## Key API
|
|
19
|
+
|
|
20
|
+
- compare(observed, synthetic, **kwargs) → EvalResult
|
|
21
|
+
Compare one or more synthetic DataFrames to observed.
|
|
22
|
+
synthetic can be a single DataFrame or {"name": df, ...} dict.
|
|
23
|
+
|
|
24
|
+
- Evaluator(observed) — cache observed features for repeated comparisons.
|
|
25
|
+
evaluator.compare({"v1": df_v1}) re-uses cached observed features.
|
|
26
|
+
|
|
27
|
+
- pairwise_distances(schedules, specs=None) → PairwiseResult
|
|
28
|
+
NxN distance matrix between individual schedules.
|
|
29
|
+
result.matrix (numpy), result.to_dataframe() (labeled).
|
|
30
|
+
|
|
31
|
+
## EvalResult structure
|
|
32
|
+
|
|
33
|
+
Six DataFrames at three aggregation levels:
|
|
34
|
+
- distances / descriptions — per-feature (index: domain, feature, segment)
|
|
35
|
+
- group_distances / group_descriptions — per feature group (index: domain, feature)
|
|
36
|
+
- domain_distances / domain_descriptions — per domain (index: domain)
|
|
37
|
+
|
|
38
|
+
Convenience: result.summary(), result.rank_models(), result.best_model
|
|
39
|
+
|
|
40
|
+
## Evaluation domains
|
|
41
|
+
|
|
42
|
+
- participations — who does what and how often; participation rates, joint participation
|
|
43
|
+
- transitions — activity sequence patterns (2-, 3-, 4-gram bigrams)
|
|
44
|
+
- timing — start times and durations (EMD via Wasserstein distance)
|
|
45
|
+
- creativity — novelty and diversity of synthetic schedules vs observed
|
|
46
|
+
- feasibility — structural validity: home-based, no consecutive duplicate activities
|
|
47
|
+
|
|
48
|
+
Missing activity penalty: 1.0 (maximum) for timing; None (compute on available data) for participation/transitions.
|
|
49
|
+
|
|
50
|
+
## Pairwise distance specs
|
|
51
|
+
|
|
52
|
+
from acteval.pairwise import chamfer_spec, soft_dtw_spec, default_pairwise_specs
|
|
53
|
+
- default_pairwise_specs() — MAE on participation counts, bigram counts, mean durations
|
|
54
|
+
- chamfer_spec(max_len, weight) — Chamfer on EOS-padded (N, L, 2) sequences
|
|
55
|
+
- soft_dtw_spec(max_len, gamma, weight) — Soft-DTW on EOS-padded sequences
|
|
56
|
+
|
|
57
|
+
## Source layout
|
|
58
|
+
|
|
59
|
+
src/acteval/
|
|
60
|
+
- evaluate.py — public API: compare(), Evaluator, evaluate()
|
|
61
|
+
- _pipeline.py — evaluation engine; builds features → groups → domains output
|
|
62
|
+
- population.py — Population: core data structure, integer-encodes activities/persons
|
|
63
|
+
- _jobs.py — JobSpec dataclass, get_jobs(); reads config.toml
|
|
64
|
+
- pairwise.py — pairwise_distances(), PairwiseResult, spec factories
|
|
65
|
+
- features/ — structural, frequency, times, transitions, participation, creativity
|
|
66
|
+
- distance/ — wasserstein.py (EMD via POT), scalar.py (MAE/MSE/MAPE)
|
|
67
|
+
- describe/ — descriptive stats alongside distance metrics
|
|
68
|
+
- _filters.py — filter_novel(): removes sequences seen in base population
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "acteval"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.2"
|
|
4
4
|
description = "Standalone library for evaluating activity sequences"
|
|
5
|
-
requires-python = ">=3.
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
6
|
dependencies = [
|
|
7
7
|
"pandas",
|
|
8
8
|
"numpy",
|
|
@@ -26,6 +26,9 @@ dev-dependencies = [
|
|
|
26
26
|
"polars>=1.8.2",
|
|
27
27
|
]
|
|
28
28
|
|
|
29
|
+
[project.scripts]
|
|
30
|
+
acteval = "acteval.cli:main"
|
|
31
|
+
|
|
29
32
|
[build-system]
|
|
30
33
|
requires = ["hatchling"]
|
|
31
34
|
build-backend = "hatchling.build"
|
|
@@ -38,7 +41,7 @@ testpaths = ["tests"]
|
|
|
38
41
|
addopts = "--ignore=tests/test_bench_evaluate.py --ignore=tests/test_bench_pairwise.py --benchmark-columns=mean,stddev,rounds --benchmark-sort=name"
|
|
39
42
|
|
|
40
43
|
[tool.ruff]
|
|
41
|
-
target-version = "
|
|
44
|
+
target-version = "py310"
|
|
42
45
|
|
|
43
46
|
[tool.ruff.lint]
|
|
44
47
|
select = ["E", "F", "I", "W"]
|
|
@@ -48,4 +51,4 @@ ignore = ["E501"]
|
|
|
48
51
|
"tests/test_distance_emd.py" = ["E402"]
|
|
49
52
|
|
|
50
53
|
[tool.black]
|
|
51
|
-
target-version = ["
|
|
54
|
+
target-version = ["py310"]
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from acteval._report import print_markdown
|
|
8
|
+
from acteval.evaluate import Evaluator
|
|
9
|
+
|
|
10
|
+
_REQUIRED_SCHEDULE_COLS = {"pid", "act"}
|
|
11
|
+
_TIMING_COLS = {"start", "end", "duration"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _load_df(path: str) -> pd.DataFrame:
|
|
15
|
+
p = Path(path)
|
|
16
|
+
if p.suffix in (".parquet", ".pq"):
|
|
17
|
+
try:
|
|
18
|
+
return pd.read_parquet(p)
|
|
19
|
+
except ImportError:
|
|
20
|
+
sys.exit(f"Reading {p} requires pyarrow: pip install pyarrow")
|
|
21
|
+
return pd.read_csv(p)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _validate_schedule(df: pd.DataFrame, path: str) -> None:
|
|
25
|
+
cols = set(df.columns)
|
|
26
|
+
missing_required = _REQUIRED_SCHEDULE_COLS - cols
|
|
27
|
+
if missing_required:
|
|
28
|
+
sys.exit(f"{path}: missing required columns {sorted(missing_required)}")
|
|
29
|
+
timing_present = _TIMING_COLS & cols
|
|
30
|
+
if len(timing_present) < 2:
|
|
31
|
+
sys.exit(
|
|
32
|
+
f"{path}: at least two of {sorted(_TIMING_COLS)} are required; "
|
|
33
|
+
f"found only {sorted(timing_present)}"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _derive_timing(df: pd.DataFrame) -> pd.DataFrame:
|
|
38
|
+
"""Derive the missing timing column when only two of start/end/duration are present."""
|
|
39
|
+
has_start = "start" in df.columns
|
|
40
|
+
has_end = "end" in df.columns
|
|
41
|
+
has_dur = "duration" in df.columns
|
|
42
|
+
if has_start and has_end and not has_dur:
|
|
43
|
+
df = df.copy()
|
|
44
|
+
df["duration"] = df["end"] - df["start"]
|
|
45
|
+
elif has_start and has_dur and not has_end:
|
|
46
|
+
df = df.copy()
|
|
47
|
+
df["end"] = df["start"] + df["duration"]
|
|
48
|
+
elif has_end and has_dur and not has_start:
|
|
49
|
+
df = df.copy()
|
|
50
|
+
df["start"] = df["end"] - df["duration"]
|
|
51
|
+
return df
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _validate_attrs(df: pd.DataFrame, path: str, split_on: list | None = None) -> None:
|
|
55
|
+
if "pid" not in df.columns:
|
|
56
|
+
sys.exit(f"{path}: attributes file missing required column 'pid'")
|
|
57
|
+
if split_on:
|
|
58
|
+
missing = [c for c in split_on if c not in df.columns]
|
|
59
|
+
if missing:
|
|
60
|
+
sys.exit(f"{path}: missing split-on columns {missing}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
64
|
+
p = argparse.ArgumentParser(
|
|
65
|
+
prog="acteval",
|
|
66
|
+
description="Compare synthetic activity schedules against observed data.",
|
|
67
|
+
)
|
|
68
|
+
p.add_argument("target", help="Path to observed schedules (CSV or Parquet)")
|
|
69
|
+
p.add_argument(
|
|
70
|
+
"--model",
|
|
71
|
+
nargs=2,
|
|
72
|
+
metavar=("NAME", "PATH"),
|
|
73
|
+
action="append",
|
|
74
|
+
required=True,
|
|
75
|
+
help="Synthetic model to compare (repeatable)",
|
|
76
|
+
)
|
|
77
|
+
p.add_argument(
|
|
78
|
+
"--attrs",
|
|
79
|
+
nargs=2,
|
|
80
|
+
metavar=("NAME", "PATH"),
|
|
81
|
+
action="append",
|
|
82
|
+
help="Per-model attributes file (repeatable; NAME must match a --model NAME)",
|
|
83
|
+
)
|
|
84
|
+
p.add_argument(
|
|
85
|
+
"--target-attrs", metavar="PATH", help="Attributes for the target population"
|
|
86
|
+
)
|
|
87
|
+
p.add_argument(
|
|
88
|
+
"--split-on",
|
|
89
|
+
nargs="+",
|
|
90
|
+
metavar="COL",
|
|
91
|
+
help="Attribute columns to split evaluation on (requires --target-attrs)",
|
|
92
|
+
)
|
|
93
|
+
p.add_argument("--config", metavar="PATH", help="Path to custom config.toml")
|
|
94
|
+
p.add_argument(
|
|
95
|
+
"--level",
|
|
96
|
+
choices=["domains", "groups", "features"],
|
|
97
|
+
default="domains",
|
|
98
|
+
help="Aggregation level to display (default: domains)",
|
|
99
|
+
)
|
|
100
|
+
p.add_argument("--output", metavar="DIR", help="Directory to save CSV results")
|
|
101
|
+
p.add_argument(
|
|
102
|
+
"--verbose", action="store_true", help="Print all aggregation levels"
|
|
103
|
+
)
|
|
104
|
+
return p
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _run(args: argparse.Namespace) -> None:
|
|
108
|
+
# --- load target ---
|
|
109
|
+
target = _load_df(args.target)
|
|
110
|
+
_validate_schedule(target, args.target)
|
|
111
|
+
target = _derive_timing(target)
|
|
112
|
+
|
|
113
|
+
# --- load synthetic models ---
|
|
114
|
+
synthetic: dict[str, pd.DataFrame] = {}
|
|
115
|
+
for name, path in args.model:
|
|
116
|
+
if name in synthetic:
|
|
117
|
+
sys.exit(f"Duplicate --model name '{name}'")
|
|
118
|
+
df = _load_df(path)
|
|
119
|
+
_validate_schedule(df, path)
|
|
120
|
+
synthetic[name] = _derive_timing(df)
|
|
121
|
+
|
|
122
|
+
# --- load per-model attributes ---
|
|
123
|
+
attributes: dict[str, pd.DataFrame] | None = None
|
|
124
|
+
if args.attrs:
|
|
125
|
+
attributes = {}
|
|
126
|
+
for name, path in args.attrs:
|
|
127
|
+
if name not in synthetic:
|
|
128
|
+
sys.exit(f"--attrs NAME '{name}' does not match any --model NAME")
|
|
129
|
+
if name in attributes:
|
|
130
|
+
sys.exit(f"Duplicate --attrs name '{name}'")
|
|
131
|
+
df = _load_df(path)
|
|
132
|
+
if args.split_on:
|
|
133
|
+
_validate_attrs(df, path, args.split_on)
|
|
134
|
+
else:
|
|
135
|
+
_validate_attrs(df, path)
|
|
136
|
+
attributes[name] = df
|
|
137
|
+
|
|
138
|
+
# --- validate split-on consistency ---
|
|
139
|
+
if bool(args.split_on) != bool(args.target_attrs):
|
|
140
|
+
sys.exit("--split-on and --target-attrs must be supplied together")
|
|
141
|
+
|
|
142
|
+
if args.split_on:
|
|
143
|
+
missing_attrs = [n for n in synthetic if n not in (attributes or {})]
|
|
144
|
+
if missing_attrs:
|
|
145
|
+
sys.exit(
|
|
146
|
+
f"--split-on requires --attrs for every model; missing: {missing_attrs}"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# --- load target attributes ---
|
|
150
|
+
target_attributes: pd.DataFrame | None = None
|
|
151
|
+
if args.target_attrs:
|
|
152
|
+
target_attributes = _load_df(args.target_attrs)
|
|
153
|
+
_validate_attrs(target_attributes, args.target_attrs, args.split_on)
|
|
154
|
+
|
|
155
|
+
# --- evaluate ---
|
|
156
|
+
print(f"acteval — comparing {len(synthetic)} model(s) to {args.target}\n")
|
|
157
|
+
evaluator = Evaluator(
|
|
158
|
+
target,
|
|
159
|
+
target_attributes=target_attributes,
|
|
160
|
+
split_on=args.split_on,
|
|
161
|
+
config_path=args.config,
|
|
162
|
+
)
|
|
163
|
+
result = evaluator.compare(synthetic, attributes=attributes, verbose=args.verbose)
|
|
164
|
+
|
|
165
|
+
# --- print results ---
|
|
166
|
+
levels_to_print = {"domains"}
|
|
167
|
+
if args.verbose:
|
|
168
|
+
levels_to_print = {"features", "groups", "domains"}
|
|
169
|
+
elif args.level != "domains":
|
|
170
|
+
levels_to_print = {args.level, "domains"}
|
|
171
|
+
|
|
172
|
+
if "features" in levels_to_print:
|
|
173
|
+
print("Feature distances:")
|
|
174
|
+
print_markdown(result.features.combined.distances)
|
|
175
|
+
if result.has_splits:
|
|
176
|
+
print("\nFeature distances by attribute:")
|
|
177
|
+
print_markdown(result.features.by_attribute.distances)
|
|
178
|
+
|
|
179
|
+
if "groups" in levels_to_print:
|
|
180
|
+
print("\nGroup distances:")
|
|
181
|
+
print_markdown(result.groups.combined.distances)
|
|
182
|
+
if result.has_splits:
|
|
183
|
+
print("\nGroup distances by attribute:")
|
|
184
|
+
print_markdown(result.groups.by_attribute.distances)
|
|
185
|
+
|
|
186
|
+
if "domains" in levels_to_print:
|
|
187
|
+
print("\nDomain distances (lower is better):")
|
|
188
|
+
print_markdown(result.domains.combined.distances)
|
|
189
|
+
if result.has_splits:
|
|
190
|
+
print("\nDomain distances by attribute:")
|
|
191
|
+
print_markdown(result.domains.by_attribute.distances)
|
|
192
|
+
|
|
193
|
+
ranked = result.rank_models()
|
|
194
|
+
print(f"\nMean distance: {dict(ranked)}")
|
|
195
|
+
print(f"Best model: {result.best_model}")
|
|
196
|
+
|
|
197
|
+
# --- save ---
|
|
198
|
+
if args.output:
|
|
199
|
+
result.save(args.output)
|
|
200
|
+
print(f"\nResults saved to {args.output}")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def main() -> None:
|
|
204
|
+
parser = _build_parser()
|
|
205
|
+
args = parser.parse_args()
|
|
206
|
+
_run(args)
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Tests for the acteval CLI (_build_parser and _run)."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from acteval.cli import _build_parser, _run, _validate_schedule
|
|
10
|
+
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
# Fixtures
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
_OBSERVED_ROWS = [
|
|
16
|
+
{"pid": 0, "act": "home", "start": 0, "end": 8, "duration": 8},
|
|
17
|
+
{"pid": 0, "act": "work", "start": 8, "end": 16, "duration": 8},
|
|
18
|
+
{"pid": 0, "act": "home", "start": 16, "end": 24, "duration": 8},
|
|
19
|
+
{"pid": 1, "act": "home", "start": 0, "end": 10, "duration": 10},
|
|
20
|
+
{"pid": 1, "act": "work", "start": 10, "end": 20, "duration": 10},
|
|
21
|
+
{"pid": 1, "act": "home", "start": 20, "end": 24, "duration": 4},
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
_SYNTHETIC_ROWS = [
|
|
25
|
+
{"pid": 0, "act": "home", "start": 0, "end": 9, "duration": 9},
|
|
26
|
+
{"pid": 0, "act": "work", "start": 9, "end": 17, "duration": 8},
|
|
27
|
+
{"pid": 0, "act": "home", "start": 17, "end": 24, "duration": 7},
|
|
28
|
+
{"pid": 1, "act": "home", "start": 0, "end": 8, "duration": 8},
|
|
29
|
+
{"pid": 1, "act": "work", "start": 8, "end": 16, "duration": 8},
|
|
30
|
+
{"pid": 1, "act": "home", "start": 16, "end": 24, "duration": 8},
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture
|
|
35
|
+
def csv_files(tmp_path):
|
|
36
|
+
"""Write observed and synthetic schedule CSVs; return their paths."""
|
|
37
|
+
obs_path = tmp_path / "observed.csv"
|
|
38
|
+
syn_path = tmp_path / "synthetic.csv"
|
|
39
|
+
pd.DataFrame(_OBSERVED_ROWS).to_csv(obs_path, index=False)
|
|
40
|
+
pd.DataFrame(_SYNTHETIC_ROWS).to_csv(syn_path, index=False)
|
|
41
|
+
return str(obs_path), str(syn_path)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.fixture
|
|
45
|
+
def attrs_csv(tmp_path):
|
|
46
|
+
"""Write a simple attrs CSV and return its path."""
|
|
47
|
+
attrs = pd.DataFrame({"pid": [0, 1], "gender": ["m", "f"]})
|
|
48
|
+
p = tmp_path / "attrs.csv"
|
|
49
|
+
attrs.to_csv(p, index=False)
|
|
50
|
+
return str(p)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _make_args(target, model_pairs, **kwargs):
|
|
54
|
+
"""Build a minimal argparse.Namespace for _run."""
|
|
55
|
+
defaults = dict(
|
|
56
|
+
target=target,
|
|
57
|
+
model=list(model_pairs),
|
|
58
|
+
attrs=None,
|
|
59
|
+
target_attrs=None,
|
|
60
|
+
split_on=None,
|
|
61
|
+
config=None,
|
|
62
|
+
level="domains",
|
|
63
|
+
output=None,
|
|
64
|
+
verbose=False,
|
|
65
|
+
)
|
|
66
|
+
defaults.update(kwargs)
|
|
67
|
+
return argparse.Namespace(**defaults)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Parser tests
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_build_parser_returns_parser():
|
|
76
|
+
p = _build_parser()
|
|
77
|
+
assert isinstance(p, argparse.ArgumentParser)
|
|
78
|
+
assert p.prog == "acteval"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_parser_requires_target_and_model():
|
|
82
|
+
p = _build_parser()
|
|
83
|
+
with pytest.raises(SystemExit):
|
|
84
|
+
p.parse_args([])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_parser_minimal():
|
|
88
|
+
p = _build_parser()
|
|
89
|
+
args = p.parse_args(["obs.csv", "--model", "m1", "syn.csv"])
|
|
90
|
+
assert args.target == "obs.csv"
|
|
91
|
+
assert args.model == [["m1", "syn.csv"]]
|
|
92
|
+
assert args.level == "domains"
|
|
93
|
+
assert args.verbose is False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_parser_multiple_models():
|
|
97
|
+
p = _build_parser()
|
|
98
|
+
args = p.parse_args(["obs.csv", "--model", "a", "a.csv", "--model", "b", "b.csv"])
|
|
99
|
+
assert args.model == [["a", "a.csv"], ["b", "b.csv"]]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_parser_level_choices():
|
|
103
|
+
p = _build_parser()
|
|
104
|
+
for level in ("domains", "groups", "features"):
|
|
105
|
+
args = p.parse_args(["obs.csv", "--model", "m", "s.csv", "--level", level])
|
|
106
|
+
assert args.level == level
|
|
107
|
+
|
|
108
|
+
with pytest.raises(SystemExit):
|
|
109
|
+
p.parse_args(["obs.csv", "--model", "m", "s.csv", "--level", "invalid"])
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# _validate_schedule tests
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_validate_schedule_all_columns_ok():
|
|
118
|
+
df = pd.DataFrame(_OBSERVED_ROWS)
|
|
119
|
+
_validate_schedule(df, "dummy.csv") # should not raise or exit
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_validate_schedule_start_end_only():
|
|
123
|
+
df = pd.DataFrame([{"pid": 0, "act": "home", "start": 0, "end": 8}])
|
|
124
|
+
_validate_schedule(df, "dummy.csv") # start + end → no error
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_validate_schedule_start_duration_only():
|
|
128
|
+
df = pd.DataFrame([{"pid": 0, "act": "home", "start": 0, "duration": 8}])
|
|
129
|
+
_validate_schedule(df, "dummy.csv") # start + duration → no error
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_validate_schedule_end_duration_only():
|
|
133
|
+
df = pd.DataFrame([{"pid": 0, "act": "home", "end": 8, "duration": 8}])
|
|
134
|
+
_validate_schedule(df, "dummy.csv") # end + duration → no error
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_validate_schedule_missing_pid_exits():
|
|
138
|
+
df = pd.DataFrame([{"act": "home", "start": 0, "end": 8}])
|
|
139
|
+
with pytest.raises(SystemExit) as exc:
|
|
140
|
+
_validate_schedule(df, "bad.csv")
|
|
141
|
+
assert "pid" in str(exc.value.code)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_validate_schedule_missing_act_exits():
|
|
145
|
+
df = pd.DataFrame([{"pid": 0, "start": 0, "end": 8}])
|
|
146
|
+
with pytest.raises(SystemExit):
|
|
147
|
+
_validate_schedule(df, "bad.csv")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_validate_schedule_only_one_timing_col_exits():
|
|
151
|
+
df = pd.DataFrame([{"pid": 0, "act": "home", "start": 0}])
|
|
152
|
+
with pytest.raises(SystemExit):
|
|
153
|
+
_validate_schedule(df, "bad.csv")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_validate_schedule_no_timing_cols_exits():
|
|
157
|
+
df = pd.DataFrame([{"pid": 0, "act": "home"}])
|
|
158
|
+
with pytest.raises(SystemExit):
|
|
159
|
+
_validate_schedule(df, "bad.csv")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
# _run — success paths
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_run_basic(csv_files, capsys):
|
|
168
|
+
obs_path, syn_path = csv_files
|
|
169
|
+
args = _make_args(obs_path, [["m1", syn_path]])
|
|
170
|
+
_run(args)
|
|
171
|
+
out = capsys.readouterr().out
|
|
172
|
+
assert "Domain distances" in out
|
|
173
|
+
assert "Best model" in out
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_run_two_models(csv_files, capsys):
|
|
177
|
+
obs_path, syn_path = csv_files
|
|
178
|
+
args = _make_args(obs_path, [["m1", syn_path], ["m2", syn_path]])
|
|
179
|
+
_run(args)
|
|
180
|
+
out = capsys.readouterr().out
|
|
181
|
+
assert "m1" in out
|
|
182
|
+
assert "m2" in out
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_run_start_end_only_input(tmp_path, capsys):
|
|
186
|
+
"""_run should work with schedules that only have start + end (no duration)."""
|
|
187
|
+
rows = [
|
|
188
|
+
{"pid": 0, "act": "home", "start": 0, "end": 8},
|
|
189
|
+
{"pid": 0, "act": "work", "start": 8, "end": 16},
|
|
190
|
+
{"pid": 1, "act": "home", "start": 0, "end": 12},
|
|
191
|
+
{"pid": 1, "act": "work", "start": 12, "end": 24},
|
|
192
|
+
]
|
|
193
|
+
p = tmp_path / "sched.csv"
|
|
194
|
+
pd.DataFrame(rows).to_csv(p, index=False)
|
|
195
|
+
args = _make_args(str(p), [["m1", str(p)]])
|
|
196
|
+
_run(args)
|
|
197
|
+
out = capsys.readouterr().out
|
|
198
|
+
assert "Domain distances" in out
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_run_saves_output(csv_files, tmp_path, capsys):
|
|
202
|
+
obs_path, syn_path = csv_files
|
|
203
|
+
out_dir = str(tmp_path / "results")
|
|
204
|
+
args = _make_args(obs_path, [["m1", syn_path]], output=out_dir)
|
|
205
|
+
_run(args)
|
|
206
|
+
assert os.path.isdir(out_dir)
|
|
207
|
+
out = capsys.readouterr().out
|
|
208
|
+
assert "Results saved" in out
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
# _run — validation/error paths
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_run_duplicate_model_name_exits(csv_files):
|
|
217
|
+
obs_path, syn_path = csv_files
|
|
218
|
+
args = _make_args(obs_path, [["m1", syn_path], ["m1", syn_path]])
|
|
219
|
+
with pytest.raises(SystemExit):
|
|
220
|
+
_run(args)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_run_attrs_name_not_in_model_exits(csv_files, attrs_csv):
|
|
224
|
+
obs_path, syn_path = csv_files
|
|
225
|
+
args = _make_args(obs_path, [["m1", syn_path]])
|
|
226
|
+
args.attrs = [["unknown", attrs_csv]]
|
|
227
|
+
with pytest.raises(SystemExit):
|
|
228
|
+
_run(args)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_run_duplicate_attrs_name_exits(csv_files, attrs_csv):
|
|
232
|
+
obs_path, syn_path = csv_files
|
|
233
|
+
args = _make_args(obs_path, [["m1", syn_path]])
|
|
234
|
+
args.attrs = [["m1", attrs_csv], ["m1", attrs_csv]]
|
|
235
|
+
with pytest.raises(SystemExit):
|
|
236
|
+
_run(args)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def test_run_split_on_without_target_attrs_exits(csv_files):
|
|
240
|
+
obs_path, syn_path = csv_files
|
|
241
|
+
args = _make_args(obs_path, [["m1", syn_path]])
|
|
242
|
+
args.split_on = ["gender"]
|
|
243
|
+
with pytest.raises(SystemExit):
|
|
244
|
+
_run(args)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_run_target_attrs_without_split_on_exits(csv_files, attrs_csv):
|
|
248
|
+
obs_path, syn_path = csv_files
|
|
249
|
+
args = _make_args(obs_path, [["m1", syn_path]])
|
|
250
|
+
args.target_attrs = attrs_csv
|
|
251
|
+
with pytest.raises(SystemExit):
|
|
252
|
+
_run(args)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_run_split_on_missing_model_attrs_exits(csv_files, attrs_csv):
|
|
256
|
+
obs_path, syn_path = csv_files
|
|
257
|
+
args = _make_args(obs_path, [["m1", syn_path]])
|
|
258
|
+
args.target_attrs = attrs_csv
|
|
259
|
+
args.split_on = ["gender"]
|
|
260
|
+
# No per-model attrs supplied — should exit
|
|
261
|
+
with pytest.raises(SystemExit):
|
|
262
|
+
_run(args)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_run_target_schedule_missing_pid_exits(tmp_path, csv_files):
|
|
266
|
+
_, syn_path = csv_files
|
|
267
|
+
bad = tmp_path / "bad.csv"
|
|
268
|
+
pd.DataFrame([{"act": "home", "start": 0, "end": 8}]).to_csv(bad, index=False)
|
|
269
|
+
args = _make_args(str(bad), [["m1", syn_path]])
|
|
270
|
+
with pytest.raises(SystemExit):
|
|
271
|
+
_run(args)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def test_run_model_schedule_only_one_timing_col_exits(tmp_path, csv_files):
|
|
275
|
+
obs_path, _ = csv_files
|
|
276
|
+
bad = tmp_path / "bad.csv"
|
|
277
|
+
pd.DataFrame([{"pid": 0, "act": "home", "start": 0}]).to_csv(bad, index=False)
|
|
278
|
+
args = _make_args(obs_path, [["m1", str(bad)]])
|
|
279
|
+
with pytest.raises(SystemExit):
|
|
280
|
+
_run(args)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|