acteval 0.1.0__py3-none-any.whl

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/evaluate.py ADDED
@@ -0,0 +1,811 @@
1
+ import time
2
+ import warnings
3
+ from concurrent.futures import ThreadPoolExecutor
4
+ from pathlib import Path
5
+ from typing import Callable
6
+
7
+ import numpy as np
8
+ from pandas import DataFrame, MultiIndex, Series, concat
9
+
10
+ from acteval.creativity.features import creativity
11
+ from acteval.density.features.pid_features import PidFeatures
12
+ from acteval.filters import filter_novel
13
+ from acteval.jobs import get_jobs
14
+ from acteval.population import Population
15
+ from acteval.structural.features import structural
16
+
17
+ _REMOVE_FEATURES = [
18
+ ("feasibility", "not home based", "starts"),
19
+ ("feasibility", "not home based", "ends"),
20
+ ("feasibility", "consecutive", "home"),
21
+ ("feasibility", "consecutive", "work"),
22
+ ("feasibility", "consecutive", "education"),
23
+ ]
24
+
25
+ _REMOVE_GROUPS = [
26
+ ("feasibility", "not home based"),
27
+ ("feasibility", "consecutive"),
28
+ ]
29
+
30
+
31
+ def _drop_features(df: DataFrame, features: list[tuple]) -> DataFrame:
32
+ for f in features:
33
+ df = df.drop(f, axis=0)
34
+ return df
35
+
36
+
37
+ def _summarize(
38
+ descriptions: DataFrame,
39
+ distances: DataFrame,
40
+ prefix: str = "",
41
+ feature_grouper: list[str] | None = None,
42
+ group_grouper: list[str] | None = None,
43
+ domain_grouper: list[str] | None = None,
44
+ ) -> dict[str, DataFrame]:
45
+ """Unified 3-tier aggregation for describe() and describe_labels()."""
46
+ frames = {}
47
+
48
+ if feature_grouper is not None:
49
+ # Feature tier
50
+ feat_desc = (
51
+ descriptions.drop("unit", axis=1)
52
+ .groupby(feature_grouper)
53
+ .apply(weighted_av)
54
+ )
55
+ feat_desc["unit"] = descriptions["unit"].groupby(feature_grouper).first()
56
+ feat_dist = (
57
+ distances.drop("unit", axis=1)
58
+ .groupby(feature_grouper)
59
+ .apply(distance_weighted_av)
60
+ )
61
+ feat_dist["unit"] = descriptions["unit"].groupby(feature_grouper).first()
62
+ frames[f"{prefix}descriptions"] = feat_desc
63
+ frames[f"{prefix}distances"] = feat_dist
64
+ else:
65
+ # No feature tier — raw data goes in as descriptions/distances
66
+ frames[f"{prefix}descriptions"] = descriptions
67
+ frames[f"{prefix}distances"] = distances
68
+
69
+ # Group tier
70
+ group_desc = descriptions.drop("unit", axis=1)
71
+ group_desc = _drop_features(group_desc, _REMOVE_FEATURES)
72
+ group_desc = group_desc.groupby(group_grouper).apply(weighted_av)
73
+ group_desc["unit"] = descriptions["unit"].groupby(group_grouper).first()
74
+
75
+ group_dist = distances.drop("unit", axis=1)
76
+ group_dist = _drop_features(group_dist, _REMOVE_FEATURES)
77
+ group_dist = group_dist.groupby(group_grouper).apply(distance_weighted_av)
78
+ group_dist["unit"] = descriptions["unit"].groupby(group_grouper).first()
79
+
80
+ frames[f"{prefix}group_descriptions"] = group_desc
81
+ frames[f"{prefix}group_distances"] = group_dist
82
+
83
+ # Domain tier
84
+ domain_desc = group_desc.drop("unit", axis=1)
85
+ domain_desc = _drop_features(domain_desc, _REMOVE_GROUPS)
86
+ domain_desc = domain_desc.groupby(domain_grouper).mean()
87
+
88
+ domain_dist = group_dist.drop("unit", axis=1)
89
+ domain_dist = _drop_features(domain_dist, _REMOVE_GROUPS)
90
+ domain_dist = domain_dist.groupby(domain_grouper).mean()
91
+
92
+ frames[f"{prefix}domain_descriptions"] = domain_desc
93
+ frames[f"{prefix}domain_distances"] = domain_dist
94
+
95
+ return frames
96
+
97
+
98
+ def subsample_and_evaluate(
99
+ synthetic_schedules: dict[str, DataFrame],
100
+ synthetic_attributes: dict[str, DataFrame],
101
+ target_schedules: DataFrame,
102
+ target_attributes: DataFrame,
103
+ split_on: list[str],
104
+ report_stats: bool = True,
105
+ verbose: bool = False,
106
+ ):
107
+ """Deprecated: use ``compare_splits`` instead for better performance."""
108
+ warnings.warn(
109
+ "subsample_and_evaluate is deprecated, use compare_splits instead",
110
+ DeprecationWarning,
111
+ stacklevel=2,
112
+ )
113
+ return compare_splits(
114
+ observed=target_schedules,
115
+ synthetic_schedules=synthetic_schedules,
116
+ synthetic_attributes=synthetic_attributes,
117
+ target_attributes=target_attributes,
118
+ split_on=split_on,
119
+ report_stats=report_stats,
120
+ verbose=verbose,
121
+ )
122
+
123
+
124
+ def evaluate(
125
+ synthetic_schedules: dict[str, DataFrame],
126
+ target_schedules: DataFrame,
127
+ report_stats: bool = True,
128
+ verbose: bool = False,
129
+ ):
130
+ descriptions, distances = process_metrics(
131
+ synthetic_schedules, target_schedules, verbose=verbose
132
+ )
133
+ frames = describe(descriptions, distances)
134
+
135
+ if report_stats:
136
+ columns = list(synthetic_schedules.keys())
137
+ for frame in frames.values():
138
+ add_stats(data=frame, columns=columns)
139
+
140
+ return frames
141
+
142
+
143
+ def process_metrics(
144
+ synthetic_schedules: dict[str, DataFrame],
145
+ target_schedules: DataFrame,
146
+ verbose: bool = False,
147
+ config_path=None,
148
+ target_pop: Population | None = None,
149
+ synthetic_pops: dict[str, Population] | None = None,
150
+ cached_features: dict[tuple, dict] | None = None,
151
+ ) -> tuple[DataFrame, DataFrame]:
152
+ density_jobs, run_creativity, run_structural = get_jobs(config_path)
153
+
154
+ descriptions, distances = [], []
155
+ timings = {}
156
+
157
+ if run_creativity:
158
+ if verbose:
159
+ print(">>> Evaluating creativity")
160
+ t0 = time.perf_counter()
161
+ creativity_descriptions, creativity_distances = eval_creativity(
162
+ synthetic_schedules=synthetic_schedules,
163
+ target_schedules=target_schedules,
164
+ )
165
+ timings["creativity"] = time.perf_counter() - t0
166
+ descriptions.append(creativity_descriptions)
167
+ distances.append(creativity_distances)
168
+
169
+ if run_structural:
170
+ if verbose:
171
+ print(">>> Evaluating sample quality")
172
+ t0 = time.perf_counter()
173
+ sample_quality = eval_sample_quality(
174
+ synthetic_schedules=synthetic_schedules,
175
+ target_schedules=target_schedules,
176
+ )
177
+ timings["sample_quality"] = time.perf_counter() - t0
178
+ descriptions.append(sample_quality)
179
+ distances.append(sample_quality)
180
+
181
+ if target_pop is None:
182
+ target_pop = Population(target_schedules)
183
+ if synthetic_pops is None:
184
+ synthetic_pops = {m: Population(y) for m, y in synthetic_schedules.items()}
185
+
186
+ for domain, jobs in density_jobs:
187
+ for feature, size, description_job, distance_job in jobs:
188
+ feature_name, feature_fn = feature[0], feature[1]
189
+ if verbose:
190
+ print(f">>> Evaluating {domain} {feature_name}")
191
+ t0 = time.perf_counter()
192
+ # use cached target features if available
193
+ observed_features = None
194
+ if cached_features is not None:
195
+ observed_features = cached_features.get((domain, feature_name))
196
+ if observed_features is None:
197
+ observed_features = feature_fn(target_pop)
198
+ feature_descriptions, feature_distances = eval_jobs(
199
+ synthetic_schedules=synthetic_pops,
200
+ target_schedules=target_pop,
201
+ domain=domain,
202
+ feature=(feature_name, feature_fn),
203
+ size=size,
204
+ description_job=description_job,
205
+ distance_job=distance_job,
206
+ observed_features=observed_features,
207
+ )
208
+ timings[f"{domain}/{feature_name}"] = time.perf_counter() - t0
209
+ descriptions.append(feature_descriptions)
210
+ distances.append(feature_distances)
211
+
212
+ descriptions = concat(descriptions, axis=0)
213
+ distances = concat(distances, axis=0)
214
+
215
+ # remove nans
216
+ descriptions = descriptions.fillna(0.0)
217
+ distances = distances.fillna(0.0)
218
+
219
+ if verbose:
220
+ print("\n--- Job timings ---")
221
+ for job_name, elapsed in sorted(timings.items(), key=lambda x: -x[1]):
222
+ print(f" {job_name:40s} {elapsed:.3f}s")
223
+ print(f" {'TOTAL':40s} {sum(timings.values()):.3f}s")
224
+
225
+ return descriptions, distances
226
+
227
+
228
+ def describe(descriptions: DataFrame, distances: DataFrame) -> dict[str, DataFrame]:
229
+ return _summarize(
230
+ descriptions,
231
+ distances,
232
+ prefix="",
233
+ feature_grouper=["domain", "feature", "segment"],
234
+ group_grouper=["domain", "feature"],
235
+ domain_grouper=["domain"],
236
+ )
237
+
238
+
239
+ def describe_labels(
240
+ descriptions: DataFrame, distances: DataFrame
241
+ ) -> dict[str, DataFrame]:
242
+ return _summarize(
243
+ descriptions,
244
+ distances,
245
+ prefix="label_",
246
+ feature_grouper=None,
247
+ group_grouper=["domain", "feature", "label"],
248
+ domain_grouper=["domain", "label"],
249
+ )
250
+
251
+
252
+ def eval_creativity(
253
+ synthetic_schedules: dict[str, DataFrame], target_schedules: DataFrame
254
+ ) -> tuple[DataFrame, DataFrame]:
255
+ observed_hash = creativity.hash_population(target_schedules)
256
+ observed_diversity = creativity.diversity(target_schedules, observed_hash)
257
+ feature_count = target_schedules.pid.nunique()
258
+
259
+ desc_cols = {
260
+ "observed__weight": [feature_count] * 2,
261
+ "observed": [observed_diversity, 1],
262
+ }
263
+ dist_cols = {
264
+ "observed__weight": [feature_count] * 2,
265
+ "observed": [1 - observed_diversity, 0],
266
+ }
267
+
268
+ for model, y in synthetic_schedules.items():
269
+ y_hash = creativity.hash_population(y)
270
+ y_diversity = creativity.diversity(y, y_hash)
271
+ y_count = y.pid.nunique()
272
+ desc_cols[f"{model}__weight"] = [y_count, y_count]
273
+ desc_cols[model] = [y_diversity, creativity.novelty(observed_hash, y_hash)]
274
+ dist_cols[f"{model}__weight"] = [y_count, y_count]
275
+ dist_cols[model] = [
276
+ 1 - y_diversity,
277
+ creativity.conservatism(observed_hash, y_hash),
278
+ ]
279
+
280
+ desc_cols["unit"] = ["prob. unique", "prob. novel"]
281
+ dist_cols["unit"] = ["prob. not unique", "prob. conservative"]
282
+
283
+ descriptions = DataFrame(desc_cols)
284
+ dist = DataFrame(dist_cols)
285
+
286
+ descriptions.index = MultiIndex.from_tuples(
287
+ [("creativity", "diversity", "all"), ("creativity", "novelty", "all")],
288
+ names=["domain", "feature", "segment"],
289
+ )
290
+ dist.index = MultiIndex.from_tuples(
291
+ [
292
+ ("creativity", "homogeneity", "all"),
293
+ ("creativity", "conservatism", "all"),
294
+ ],
295
+ names=["domain", "feature", "segment"],
296
+ )
297
+ return descriptions, dist
298
+
299
+
300
+ def eval_sample_quality(
301
+ synthetic_schedules: dict[str, DataFrame], target_schedules: DataFrame
302
+ ) -> tuple[DataFrame, DataFrame]:
303
+ observed_weights, observed_metrics = structural.feasibility_eval(
304
+ Population(target_schedules), name="observed"
305
+ )
306
+ results = [observed_weights, observed_metrics]
307
+ for model, y in synthetic_schedules.items():
308
+ y_df = filter_novel(y, target_schedules)
309
+ weights, metrics = structural.feasibility_eval(Population(y_df), name=model)
310
+ results.append(weights)
311
+ results.append(metrics)
312
+ results = concat(results, axis=1)
313
+ results["unit"] = "prob. infeasible"
314
+ return results
315
+
316
+
317
+ def eval_jobs(
318
+ synthetic_schedules: dict[str, DataFrame],
319
+ target_schedules: DataFrame,
320
+ domain: str,
321
+ feature: tuple[str, Callable],
322
+ size: Callable,
323
+ description_job: tuple[str, Callable],
324
+ distance_job: tuple[str, Callable],
325
+ observed_features=None,
326
+ ) -> tuple[DataFrame, DataFrame]:
327
+ # unpack tuples
328
+ feature_name, feature_fn = feature
329
+ description_name, describe = description_job
330
+ distance_name, distance_metric = distance_job
331
+
332
+ # build observed features (use cached if provided)
333
+ if observed_features is None:
334
+ observed_features = feature_fn(target_schedules)
335
+
336
+ # need to create a default feature for missing sampled features
337
+ default = extract_default(observed_features)
338
+
339
+ # create an observed feature count and description
340
+ observed_weight = size(observed_features)
341
+ observed_weight.name = "observed__weight"
342
+ description_observed = describe(observed_features)
343
+ base = DataFrame(
344
+ {"observed__weight": observed_weight, "observed": description_observed}
345
+ )
346
+
347
+ # sort by count and description
348
+ base = base.sort_values(ascending=False, by=["observed__weight", "observed"])
349
+
350
+ # collect parts in lists, concat once after the loop (avoids O(M²) concat)
351
+ desc_parts = [base]
352
+ dist_parts = [base]
353
+ for model, y in synthetic_schedules.items():
354
+ synth_features = feature_fn(y)
355
+ synth_weight = size(synth_features)
356
+ synth_weight.name = f"{model}__weight"
357
+ desc_parts.append(synth_weight)
358
+ desc_parts.append(describe_feature(model, synth_features, describe))
359
+ dist_parts.append(synth_weight)
360
+ dist_parts.append(
361
+ score_features(
362
+ model,
363
+ observed_features,
364
+ synth_features,
365
+ distance_metric,
366
+ default,
367
+ )
368
+ )
369
+
370
+ feature_descriptions = concat(desc_parts, axis=1)
371
+ feature_distances = concat(dist_parts, axis=1)
372
+
373
+ # add domain and feature name to index
374
+ feature_descriptions["unit"] = description_name
375
+ feature_distances["unit"] = distance_name
376
+ feature_descriptions.index = MultiIndex.from_tuples(
377
+ [(domain, feature_name, f) for f in feature_descriptions.index],
378
+ name=["domain", "feature", "segment"],
379
+ )
380
+ feature_distances.index = MultiIndex.from_tuples(
381
+ [(domain, feature_name, f) for f in feature_distances.index],
382
+ name=["domain", "feature", "segment"],
383
+ )
384
+
385
+ return feature_descriptions, feature_distances
386
+
387
+
388
+ def rank(data: DataFrame) -> DataFrame:
389
+ # feature rank
390
+ rank = data.drop(["observed", "unit"], axis=1, errors="ignore").rank(
391
+ axis=1, method="min"
392
+ )
393
+ col_ranks = rank.sum(axis=0)
394
+ ranked = [i for _, i in sorted(zip(col_ranks, col_ranks.index))]
395
+ return rank[ranked]
396
+
397
+
398
+ def _report_impl(
399
+ frames: dict[str, DataFrame],
400
+ prefix: str,
401
+ head_grouper: list[str],
402
+ log_dir: Path | None = None,
403
+ head: int | None = None,
404
+ verbose: bool = True,
405
+ suffix: str = "",
406
+ ranking: bool = False,
407
+ ):
408
+ if head is not None:
409
+ frames[f"{prefix}descriptions_short"] = (
410
+ frames[f"{prefix}descriptions"].groupby(head_grouper).head(head)
411
+ )
412
+ frames[f"{prefix}distances_short"] = (
413
+ frames[f"{prefix}distances"].groupby(head_grouper).head(head)
414
+ )
415
+ else:
416
+ frames[f"{prefix}descriptions_short"] = frames[f"{prefix}descriptions"]
417
+ frames[f"{prefix}distances_short"] = frames[f"{prefix}distances"]
418
+
419
+ if log_dir is not None:
420
+ for name, frame in frames.items():
421
+ frame.to_csv(Path(log_dir, f"{name}{suffix}.csv"))
422
+
423
+ if verbose:
424
+ print("\nDescriptions:")
425
+ print_markdown(frames[f"{prefix}descriptions_short"])
426
+ print("\nEvalutions (Distance):")
427
+ print_markdown(frames[f"{prefix}distances_short"])
428
+
429
+ print("\nGroup Descriptions:")
430
+ print_markdown(frames[f"{prefix}group_descriptions"])
431
+ print("\nGroup Evaluations (Distance):")
432
+ print_markdown(frames[f"{prefix}group_distances"])
433
+ if ranking:
434
+ print("\nGroup Evaluations (Ranked):")
435
+ print_markdown(rank(frames[f"{prefix}group_distances"]))
436
+
437
+ print("\nDomain Descriptions:")
438
+ print_markdown(frames[f"{prefix}domain_descriptions"])
439
+ print("\nDomain Evaluations (Distance):")
440
+ print_markdown(frames[f"{prefix}domain_distances"])
441
+ if ranking:
442
+ print("\nDomain Evaluations (Ranked):")
443
+ print_markdown(rank(frames[f"{prefix}domain_distances"]))
444
+
445
+
446
+ def report(
447
+ frames: dict[str, DataFrame],
448
+ log_dir: Path | None = None,
449
+ head: int | None = None,
450
+ verbose: bool = True,
451
+ suffix: str = "",
452
+ ranking: bool = False,
453
+ ):
454
+ _report_impl(
455
+ frames, "", ["domain", "feature"], log_dir, head, verbose, suffix, ranking
456
+ )
457
+
458
+
459
+ def report_splits(
460
+ frames: dict[str, DataFrame],
461
+ log_dir: Path | None = None,
462
+ head: int | None = None,
463
+ verbose: bool = True,
464
+ suffix: str = "",
465
+ ranking: bool = False,
466
+ ):
467
+ _report_impl(
468
+ frames,
469
+ "label_",
470
+ ["domain", "feature", "label"],
471
+ log_dir,
472
+ head,
473
+ verbose,
474
+ suffix,
475
+ ranking,
476
+ )
477
+
478
+
479
+ def add_stats(data: DataFrame, columns: dict[str, DataFrame]):
480
+ data["mean"] = data[columns].mean(axis=1)
481
+ data["std"] = data[columns].std(axis=1)
482
+
483
+
484
+ def print_markdown(data: DataFrame):
485
+ print(data.to_markdown(tablefmt="fancy_grid", floatfmt=".3f"))
486
+
487
+
488
+ def describe_feature(
489
+ model: str,
490
+ features: dict[str, tuple[np.array, np.array]],
491
+ describe: Callable,
492
+ ):
493
+ feature_description = describe(features)
494
+ feature_description.name = model
495
+ return feature_description
496
+
497
+
498
+ _PARALLEL_THRESHOLD = 50
499
+
500
+
501
+ def score_features(
502
+ model: str,
503
+ a: dict[str, tuple[np.array, np.array]],
504
+ b: dict[str, tuple[np.array, np.array]],
505
+ distance: Callable,
506
+ default: tuple[np.array, np.array],
507
+ ):
508
+ index = list(set(a.keys()) | set(b.keys()))
509
+
510
+ if len(index) > _PARALLEL_THRESHOLD:
511
+ # POT's C extensions release the GIL — threads give real parallelism
512
+ def _compute(k):
513
+ return distance(
514
+ defaulting_get(a, k, default), defaulting_get(b, k, default)
515
+ )
516
+
517
+ with ThreadPoolExecutor() as executor:
518
+ values = list(executor.map(_compute, index))
519
+ metrics = Series(dict(zip(index, values)), name=model)
520
+ else:
521
+ metrics = Series(
522
+ {
523
+ k: distance(
524
+ defaulting_get(a, k, default),
525
+ defaulting_get(b, k, default),
526
+ )
527
+ for k in index
528
+ },
529
+ name=model,
530
+ )
531
+ metrics = metrics.fillna(0)
532
+ return metrics
533
+
534
+
535
+ def defaulting_get(
536
+ features: dict[str, tuple[np.array, np.array]],
537
+ key: str,
538
+ default: tuple[np.array, np.array],
539
+ ):
540
+ feature = features.get(key)
541
+ if feature is None:
542
+ return default
543
+ support, _ = feature
544
+ if len(support) == 0:
545
+ return default
546
+ return feature
547
+
548
+
549
+ def extract_default(features: dict[str, tuple[np.array, np.array]]):
550
+ # we use a single feature of zeros as required
551
+ # look for a size
552
+ default_shape = extract_default_shape(features)
553
+ default_support = np.zeros(default_shape)
554
+ return (default_support, np.array([1]))
555
+
556
+
557
+ def extract_default_shape(features: dict[str, tuple[np.array, np.array]]) -> np.array:
558
+ for k, _ in iter(features.values()):
559
+ if len(k) > 0:
560
+ default_shape = list(k.shape)
561
+ default_shape[0] = 1
562
+ return default_shape
563
+ warnings.warn(
564
+ f"No features found in the given dictionary: {features}, return [1].",
565
+ stacklevel=2,
566
+ )
567
+ return np.array([1])
568
+
569
+
570
+ def weighted_av(report: DataFrame, suffix: str = "__weight") -> Series:
571
+ """Weighted average of dataframe using weights in the weight column."""
572
+ cols = list(report.columns)
573
+ cols = [c for c in cols if not c.endswith(suffix)]
574
+ scores = DataFrame()
575
+ for c in cols:
576
+ weights = report[f"{c}{suffix}"]
577
+ total = weights.sum()
578
+ scores[c] = report[c] * weights / total
579
+ return scores.sum()
580
+
581
+
582
+ def distance_weighted_av(
583
+ report: DataFrame,
584
+ base_col: str = "observed__weight",
585
+ suffix: str = "__weight",
586
+ ) -> Series:
587
+ """Weighted average of dataframe using weights in the weight column and a base column.
588
+ This deals with cases where models have different features.
589
+ """
590
+ cols = list(report.columns)
591
+ cols = [c for c in cols if not c.endswith(suffix)]
592
+ base_weights = report[base_col]
593
+ scores = DataFrame()
594
+ for c in cols:
595
+ weights = report[f"{c}{suffix}"]
596
+ weights = (weights + base_weights) / 2
597
+ total = weights.sum()
598
+ scores[c] = report[c] * weights / total
599
+ return scores.sum()
600
+
601
+
602
+ def _all_feature_jobs(config_path=None):
603
+ """Yield (domain, feature_tuple, size, desc_job, dist_job) for all active jobs."""
604
+ density_jobs, _, _ = get_jobs(config_path)
605
+ for domain, jobs in density_jobs:
606
+ for feature, size, description_job, distance_job in jobs:
607
+ yield domain, feature, size, description_job, distance_job
608
+
609
+
610
+ class Evaluator:
611
+ """Pre-computes target features once; compare multiple synthetic populations."""
612
+
613
+ def __init__(self, target: DataFrame, config_path=None):
614
+ self._target = target
615
+ self._target_pop = Population(target)
616
+ self._config_path = config_path
617
+ self._target_features: dict[tuple, dict] = {}
618
+ self._target_pid_features: dict[tuple, PidFeatures] = {}
619
+ self._precompute()
620
+
621
+ def _precompute(self) -> None:
622
+ for domain, feature, size, desc_job, dist_job in _all_feature_jobs(
623
+ self._config_path
624
+ ):
625
+ feature_name = feature[0]
626
+ feature_fn = feature[1]
627
+ per_pid_fn = feature[2] if len(feature) > 2 else None
628
+ key = (domain, feature_name)
629
+ self._target_features[key] = feature_fn(self._target_pop)
630
+ if per_pid_fn is not None:
631
+ self._target_pid_features[key] = per_pid_fn(self._target_pop)
632
+
633
+ def compare(
634
+ self,
635
+ synthetic: dict[str, DataFrame],
636
+ report_stats: bool = True,
637
+ ) -> dict[str, DataFrame]:
638
+ """Compare synthetic populations against pre-computed target features."""
639
+ synthetic_pops = {m: Population(y) for m, y in synthetic.items()}
640
+
641
+ descriptions, distances = process_metrics(
642
+ synthetic_schedules=synthetic,
643
+ target_schedules=self._target,
644
+ config_path=self._config_path,
645
+ target_pop=self._target_pop,
646
+ synthetic_pops=synthetic_pops,
647
+ cached_features=self._target_features,
648
+ )
649
+
650
+ frames = describe(descriptions, distances)
651
+
652
+ if report_stats:
653
+ columns = list(synthetic.keys())
654
+ for frame in frames.values():
655
+ add_stats(data=frame, columns=columns)
656
+
657
+ return frames
658
+
659
+ def compare_splits(
660
+ self,
661
+ synthetic_schedules: dict[str, DataFrame],
662
+ synthetic_attributes: dict[str, DataFrame],
663
+ target_attributes: DataFrame,
664
+ split_on: list[str],
665
+ report_stats: bool = True,
666
+ verbose: bool = False,
667
+ ) -> dict[str, DataFrame]:
668
+ """Compare synthetic populations against target, split by attribute categories.
669
+
670
+ Target density features are computed once (at init) and subset per split
671
+ using per-pid features. Creativity and structural features are recomputed
672
+ per split.
673
+
674
+ Args:
675
+ synthetic_schedules: ``{model_name: schedules_df}``.
676
+ synthetic_attributes: ``{model_name: attributes_df}`` with ``pid`` column.
677
+ target_attributes: Target attributes DataFrame with ``pid`` column.
678
+ split_on: Attribute columns to split on.
679
+ report_stats: Whether to append mean/std columns.
680
+ verbose: Print progress.
681
+
682
+ Returns:
683
+ Dict of result DataFrames with per-split and per-label summaries.
684
+ """
685
+ # Build mapping from original target pids to dense Population pids
686
+ orig_to_dense = {
687
+ orig: dense
688
+ for dense, orig in enumerate(self._target_pop.unique_pids_original)
689
+ }
690
+
691
+ density_jobs, run_creativity, run_structural = get_jobs(self._config_path)
692
+
693
+ descriptions = []
694
+ distances = []
695
+
696
+ for split in split_on:
697
+ target_cats = target_attributes[split].unique()
698
+ for cat in target_cats:
699
+ # --- target pid subset ---
700
+ target_orig_pids = target_attributes[
701
+ target_attributes[split] == cat
702
+ ].pid.values
703
+ target_dense_pids = np.array(
704
+ [orig_to_dense[p] for p in target_orig_pids if p in orig_to_dense],
705
+ dtype=np.int64,
706
+ )
707
+
708
+ sub_target = self._target[self._target.pid.isin(target_orig_pids)]
709
+
710
+ # --- synthetic subsets ---
711
+ sub_schedules: dict[str, DataFrame] = {}
712
+ for model, attributes in synthetic_attributes.items():
713
+ sample_pids = attributes[attributes[split] == cat].pid
714
+ if verbose:
715
+ print(
716
+ f">>> Subsampled {model} {split}={cat} with {len(sample_pids)}"
717
+ )
718
+ sample_schedules = synthetic_schedules[model]
719
+ sub_schedules[model] = sample_schedules[
720
+ sample_schedules.pid.isin(sample_pids)
721
+ ]
722
+
723
+ # --- build cached target features for this subset ---
724
+ cached_subset: dict[tuple, dict] = {}
725
+ for key, pid_feat in self._target_pid_features.items():
726
+ cached_subset[key] = pid_feat.subset(target_dense_pids).aggregate()
727
+
728
+ # --- run process_metrics with cached subset ---
729
+ sub_reports = process_metrics(
730
+ synthetic_schedules=sub_schedules,
731
+ target_schedules=sub_target,
732
+ verbose=verbose,
733
+ config_path=self._config_path,
734
+ cached_features=cached_subset,
735
+ )
736
+ for r in sub_reports:
737
+ names = list(r.index.names) + ["label", "cat"]
738
+ r.index = MultiIndex.from_tuples(
739
+ [(*i, split, cat) for i in r.index], names=names
740
+ )
741
+ descriptions.append(sub_reports[0])
742
+ distances.append(sub_reports[1])
743
+
744
+ descriptions = concat(descriptions, axis=0)
745
+ distances = concat(distances, axis=0)
746
+
747
+ frames = describe(descriptions, distances)
748
+ frames.update(describe_labels(descriptions, distances))
749
+
750
+ if report_stats:
751
+ columns = list(synthetic_schedules.keys())
752
+ for frame in frames.values():
753
+ add_stats(data=frame, columns=columns)
754
+
755
+ return frames
756
+
757
+
758
+ def compare(
759
+ observed: DataFrame,
760
+ synthetic,
761
+ report_stats: bool = True,
762
+ ) -> dict[str, DataFrame]:
763
+ """Compare observed and synthetic activity schedule populations.
764
+
765
+ Args:
766
+ observed: Observed schedules with columns pid, act, start, end, duration.
767
+ synthetic: Single synthetic DataFrame or dict mapping model names to DataFrames.
768
+ report_stats: Whether to append mean/std columns.
769
+
770
+ Returns:
771
+ Dict of result DataFrames (descriptions, distances, grouped variants).
772
+ """
773
+ if isinstance(synthetic, DataFrame):
774
+ synthetic = {"synthetic": synthetic}
775
+ return Evaluator(observed).compare(synthetic, report_stats=report_stats)
776
+
777
+
778
+ def compare_splits(
779
+ observed: DataFrame,
780
+ synthetic_schedules: dict[str, DataFrame],
781
+ synthetic_attributes: dict[str, DataFrame],
782
+ target_attributes: DataFrame,
783
+ split_on: list[str],
784
+ report_stats: bool = True,
785
+ verbose: bool = False,
786
+ ) -> dict[str, DataFrame]:
787
+ """Compare observed and synthetic populations, split by attribute categories.
788
+
789
+ Convenience wrapper around ``Evaluator.compare_splits``.
790
+
791
+ Args:
792
+ observed: Observed schedules with columns pid, act, start, end, duration.
793
+ synthetic_schedules: ``{model_name: schedules_df}``.
794
+ synthetic_attributes: ``{model_name: attributes_df}`` with ``pid`` column.
795
+ target_attributes: Target attributes DataFrame with ``pid`` column.
796
+ split_on: Attribute columns to split on.
797
+ report_stats: Whether to append mean/std columns.
798
+ verbose: Print progress.
799
+
800
+ Returns:
801
+ Dict of result DataFrames with per-split and per-label summaries.
802
+ """
803
+ evaluator = Evaluator(observed)
804
+ return evaluator.compare_splits(
805
+ synthetic_schedules=synthetic_schedules,
806
+ synthetic_attributes=synthetic_attributes,
807
+ target_attributes=target_attributes,
808
+ split_on=split_on,
809
+ report_stats=report_stats,
810
+ verbose=verbose,
811
+ )