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/__init__.py +10 -0
- acteval/config.toml +24 -0
- acteval/creativity/__init__.py +2 -0
- acteval/creativity/features/__init__.py +0 -0
- acteval/creativity/features/creativity.py +88 -0
- acteval/density/__init__.py +2 -0
- acteval/density/describe/__init__.py +0 -0
- acteval/density/describe/frequency.py +101 -0
- acteval/density/describe/times.py +269 -0
- acteval/density/describe/transitions.py +93 -0
- acteval/density/features/__init__.py +0 -0
- acteval/density/features/_discretise.py +97 -0
- acteval/density/features/frequency.py +51 -0
- acteval/density/features/participation.py +192 -0
- acteval/density/features/pid_features.py +73 -0
- acteval/density/features/times.py +171 -0
- acteval/density/features/transitions.py +241 -0
- acteval/density/features/utils.py +197 -0
- acteval/distance/__init__.py +16 -0
- acteval/distance/scalar.py +68 -0
- acteval/distance/wasserstein.py +200 -0
- acteval/evaluate.py +811 -0
- acteval/filters.py +16 -0
- acteval/jobs.py +215 -0
- acteval/ops.py +49 -0
- acteval/population.py +165 -0
- acteval/structural/__init__.py +2 -0
- acteval/structural/features/__init__.py +0 -0
- acteval/structural/features/structural.py +210 -0
- acteval-0.1.0.dist-info/METADATA +12 -0
- acteval-0.1.0.dist-info/RECORD +32 -0
- acteval-0.1.0.dist-info/WHEEL +4 -0
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
|
+
)
|