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/filters.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from pandas import DataFrame
|
|
2
|
+
|
|
3
|
+
from acteval.creativity.features import creativity
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def no_filter(scenario: DataFrame, base: DataFrame) -> DataFrame:
|
|
7
|
+
return scenario
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def filter_novel(scenario: DataFrame, base: DataFrame) -> DataFrame:
|
|
11
|
+
base_hashed = creativity.hash_population(base)
|
|
12
|
+
# Vectorized: hash all synthetic schedules at once
|
|
13
|
+
act_hash = scenario.act.astype(str) + scenario.duration.astype(str)
|
|
14
|
+
synth_hashes = act_hash.groupby(scenario.pid).agg("".join)
|
|
15
|
+
novel_pids = synth_hashes.index[~synth_hashes.isin(base_hashed)]
|
|
16
|
+
return scenario[scenario.pid.isin(novel_pids)].reset_index(drop=True)
|
acteval/jobs.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from functools import partial
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
if sys.version_info >= (3, 11):
|
|
6
|
+
import tomllib
|
|
7
|
+
else:
|
|
8
|
+
import tomli as tomllib
|
|
9
|
+
|
|
10
|
+
from acteval.density.features import participation, times, transitions
|
|
11
|
+
from acteval.distance import emd
|
|
12
|
+
from acteval.ops import average, average2d, feature_weight
|
|
13
|
+
from acteval.structural.features import structural
|
|
14
|
+
|
|
15
|
+
_DEFAULT_CONFIG = Path(__file__).parent / "config.toml"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_config(path=None) -> dict:
|
|
19
|
+
if path is None:
|
|
20
|
+
path = _DEFAULT_CONFIG
|
|
21
|
+
with open(path, "rb") as f:
|
|
22
|
+
return tomllib.load(f)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_density_jobs(cfg: dict) -> list[tuple]:
|
|
26
|
+
"""Returns active jobs for participations, transitions, and timing domains.
|
|
27
|
+
|
|
28
|
+
Each job is a 4-tuple of ``(feature, size, description_job, distance_job)``
|
|
29
|
+
where *feature* is a 3-tuple ``(name, fn, per_pid_fn | None)``.
|
|
30
|
+
The per-pid function, when present, returns a ``PidFeatures`` instance.
|
|
31
|
+
"""
|
|
32
|
+
ngram_min_count = cfg.get("ngrams", {}).get("min_count", 3)
|
|
33
|
+
participations_cfg = cfg.get("jobs", {}).get("participations", {})
|
|
34
|
+
transitions_cfg = cfg.get("jobs", {}).get("transitions", {})
|
|
35
|
+
timing_cfg = cfg.get("jobs", {}).get("timing", {})
|
|
36
|
+
|
|
37
|
+
participation_rate_jobs = []
|
|
38
|
+
if participations_cfg.get("lengths", True):
|
|
39
|
+
participation_rate_jobs.append(
|
|
40
|
+
(
|
|
41
|
+
(
|
|
42
|
+
"lengths",
|
|
43
|
+
structural.sequence_lengths,
|
|
44
|
+
structural.sequence_lengths_per_pid,
|
|
45
|
+
),
|
|
46
|
+
(feature_weight),
|
|
47
|
+
("length.", average),
|
|
48
|
+
("EMD", emd),
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
if participations_cfg.get("rates", True):
|
|
52
|
+
participation_rate_jobs.append(
|
|
53
|
+
(
|
|
54
|
+
(
|
|
55
|
+
"participation rate",
|
|
56
|
+
participation.participation_rates_by_act,
|
|
57
|
+
participation.participation_rates_by_act_per_pid,
|
|
58
|
+
),
|
|
59
|
+
(feature_weight),
|
|
60
|
+
("av. rate", average),
|
|
61
|
+
("EMD", emd),
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
if participations_cfg.get("pair_rates", True):
|
|
65
|
+
participation_rate_jobs.append(
|
|
66
|
+
(
|
|
67
|
+
(
|
|
68
|
+
"pair participation rate",
|
|
69
|
+
participation.joint_participation_rate,
|
|
70
|
+
participation.joint_participation_rate_per_pid,
|
|
71
|
+
),
|
|
72
|
+
(feature_weight),
|
|
73
|
+
("av rate.", average),
|
|
74
|
+
("EMD", emd),
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
transition_jobs = []
|
|
79
|
+
if transitions_cfg.get("2-gram", True):
|
|
80
|
+
transition_jobs.append(
|
|
81
|
+
(
|
|
82
|
+
(
|
|
83
|
+
"2-gram",
|
|
84
|
+
partial(transitions.transitions_by_act, min_count=ngram_min_count),
|
|
85
|
+
partial(
|
|
86
|
+
transitions.transitions_by_act_per_pid,
|
|
87
|
+
min_count=ngram_min_count,
|
|
88
|
+
),
|
|
89
|
+
),
|
|
90
|
+
(feature_weight),
|
|
91
|
+
("av. rate", average),
|
|
92
|
+
("EMD", emd),
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
if transitions_cfg.get("3-gram", True):
|
|
96
|
+
transition_jobs.append(
|
|
97
|
+
(
|
|
98
|
+
(
|
|
99
|
+
"3-gram",
|
|
100
|
+
partial(
|
|
101
|
+
transitions.transition_3s_by_act, min_count=ngram_min_count
|
|
102
|
+
),
|
|
103
|
+
partial(
|
|
104
|
+
transitions.transition_3s_by_act_per_pid,
|
|
105
|
+
min_count=ngram_min_count,
|
|
106
|
+
),
|
|
107
|
+
),
|
|
108
|
+
(feature_weight),
|
|
109
|
+
("av. rate", average),
|
|
110
|
+
("EMD", emd),
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
if transitions_cfg.get("4-gram", True):
|
|
114
|
+
transition_jobs.append(
|
|
115
|
+
(
|
|
116
|
+
(
|
|
117
|
+
"4-gram",
|
|
118
|
+
partial(
|
|
119
|
+
transitions.transition_4s_by_act, min_count=ngram_min_count
|
|
120
|
+
),
|
|
121
|
+
partial(
|
|
122
|
+
transitions.transition_4s_by_act_per_pid,
|
|
123
|
+
min_count=ngram_min_count,
|
|
124
|
+
),
|
|
125
|
+
),
|
|
126
|
+
(feature_weight),
|
|
127
|
+
("av. rate", average),
|
|
128
|
+
("EMD", emd),
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
time_jobs = []
|
|
133
|
+
if timing_cfg.get("start_times", True):
|
|
134
|
+
time_jobs.append(
|
|
135
|
+
(
|
|
136
|
+
(
|
|
137
|
+
"start times",
|
|
138
|
+
times.start_times_by_act_plan_enum,
|
|
139
|
+
times.start_times_by_act_plan_enum_per_pid,
|
|
140
|
+
),
|
|
141
|
+
(feature_weight),
|
|
142
|
+
("average", average),
|
|
143
|
+
("EMD", emd),
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
if timing_cfg.get("durations", True):
|
|
147
|
+
time_jobs.append(
|
|
148
|
+
(
|
|
149
|
+
(
|
|
150
|
+
"durations",
|
|
151
|
+
times.durations_by_act_plan_enum,
|
|
152
|
+
times.durations_by_act_plan_enum_per_pid,
|
|
153
|
+
),
|
|
154
|
+
(feature_weight),
|
|
155
|
+
("average", average),
|
|
156
|
+
("EMD", emd),
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
if timing_cfg.get("start_durations", True):
|
|
160
|
+
time_jobs.append(
|
|
161
|
+
(
|
|
162
|
+
(
|
|
163
|
+
"start-durations",
|
|
164
|
+
times.start_and_duration_by_act_bins,
|
|
165
|
+
times.start_and_duration_by_act_bins_per_pid,
|
|
166
|
+
),
|
|
167
|
+
(feature_weight),
|
|
168
|
+
("average", average2d),
|
|
169
|
+
("EMD", emd),
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
if timing_cfg.get("joint_durations", True):
|
|
173
|
+
time_jobs.append(
|
|
174
|
+
(
|
|
175
|
+
(
|
|
176
|
+
"joint-durations",
|
|
177
|
+
times.joint_durations_by_act_bins,
|
|
178
|
+
times.joint_durations_by_act_bins_per_pid,
|
|
179
|
+
),
|
|
180
|
+
(feature_weight),
|
|
181
|
+
("average", average2d),
|
|
182
|
+
("EMD", emd),
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return [
|
|
187
|
+
("participations", participation_rate_jobs),
|
|
188
|
+
("transitions", transition_jobs),
|
|
189
|
+
("timing", time_jobs),
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def build_creativity_jobs(cfg: dict) -> bool:
|
|
194
|
+
"""Returns whether to run creativity evaluation."""
|
|
195
|
+
return cfg.get("jobs", {}).get("creativity", {}).get("enabled", True)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def build_structural_jobs(cfg: dict) -> bool:
|
|
199
|
+
"""Returns whether to run structural/feasibility evaluation."""
|
|
200
|
+
return cfg.get("jobs", {}).get("structural", {}).get("enabled", True)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def get_jobs(config_path=None):
|
|
204
|
+
"""Load config and return active job specification.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
tuple of (density_domain_jobs, run_creativity, run_structural)
|
|
208
|
+
where density_domain_jobs is [(domain_name, [jobs])].
|
|
209
|
+
"""
|
|
210
|
+
cfg = load_config(config_path)
|
|
211
|
+
return (
|
|
212
|
+
build_density_jobs(cfg),
|
|
213
|
+
build_creativity_jobs(cfg),
|
|
214
|
+
build_structural_jobs(cfg),
|
|
215
|
+
)
|
acteval/ops.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from numpy import ndarray
|
|
3
|
+
from pandas import Series
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def actual(features: dict[str, float]) -> Series:
|
|
7
|
+
return Series(features)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def feature_value(features: dict[str, tuple[ndarray, ndarray]]) -> Series:
|
|
11
|
+
return Series({k: v[0] for k, (v, w) in features.items()}, dtype=int)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def feature_length(features: dict[str, tuple[ndarray, ndarray]]) -> Series:
|
|
15
|
+
return Series({k: len(v) for k, (v, w) in features.items()}, dtype=int)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def feature_weight(features: dict[str, tuple[ndarray, ndarray]]) -> Series:
|
|
19
|
+
return Series({k: w.sum() for k, (v, w) in features.items()}, dtype=int)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def average_weight(features: dict[str, tuple[ndarray, ndarray]]) -> Series:
|
|
23
|
+
return Series({k: w.mean() for k, (v, w) in features.items()}, dtype=float)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def average_density(features: dict[str, tuple[ndarray, ndarray]]) -> Series:
|
|
27
|
+
total = sum(w.sum() for _, w in features.values())
|
|
28
|
+
return Series({k: w.sum() / total for k, (v, w) in features.items()}, dtype=float)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def average(features: dict[str, tuple[ndarray, ndarray]]) -> Series:
|
|
32
|
+
weighted_average = {}
|
|
33
|
+
for k, (v, w) in features.items():
|
|
34
|
+
if w.sum() > 0:
|
|
35
|
+
weighted_average[k] = np.average(v, axis=0, weights=w).sum()
|
|
36
|
+
else:
|
|
37
|
+
weighted_average[k] = 0
|
|
38
|
+
return Series(weighted_average, dtype=float)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def average2d(features: dict[str, tuple[ndarray, ndarray]]) -> Series:
|
|
42
|
+
return Series(
|
|
43
|
+
{
|
|
44
|
+
k: np.average(v, axis=0, weights=w).sum().sum()
|
|
45
|
+
for k, (v, w) in features.items()
|
|
46
|
+
if w.sum() > 0
|
|
47
|
+
},
|
|
48
|
+
dtype=float,
|
|
49
|
+
)
|
acteval/population.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Population: precomputed numpy representation of a schedule population DataFrame."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from numpy import ndarray
|
|
5
|
+
from pandas import DataFrame
|
|
6
|
+
|
|
7
|
+
from acteval.density.features.utils import _cumcount
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Population:
|
|
11
|
+
"""Precomputed numpy representation of a schedule population DataFrame.
|
|
12
|
+
|
|
13
|
+
Converts a DataFrame once and caches all derived quantities to avoid
|
|
14
|
+
redundant computation across feature extraction functions.
|
|
15
|
+
|
|
16
|
+
Eager attributes (always computed):
|
|
17
|
+
acts: activity strings.
|
|
18
|
+
act_codes: integer-encoded activities (0, 1, ...).
|
|
19
|
+
starts: start times.
|
|
20
|
+
ends: end times.
|
|
21
|
+
durations: durations.
|
|
22
|
+
pids: dense integer-encoded pids (0, 1, ...).
|
|
23
|
+
unique_acts: sorted unique activity strings.
|
|
24
|
+
n_act_types: number of unique activity types.
|
|
25
|
+
act_to_int: dict mapping activity string → int code.
|
|
26
|
+
int_to_act: ndarray indexed by int code → activity string.
|
|
27
|
+
n: number of unique persons.
|
|
28
|
+
pid_counts: activity count per pid.
|
|
29
|
+
pid_starts: first row index per pid.
|
|
30
|
+
pid_ends: exclusive end row index per pid.
|
|
31
|
+
pid_boundaries: row indices where pid changes (== pid_starts[1:]).
|
|
32
|
+
first_idx: first row index per pid (== pid_starts).
|
|
33
|
+
last_idx: last row index per pid (== pid_ends - 1).
|
|
34
|
+
|
|
35
|
+
Lazy properties (computed on first access):
|
|
36
|
+
act_enum_key: cumcount within (pid, act), e.g. "home0", "work0", "home1".
|
|
37
|
+
seq_key: cumcount within pid, e.g. "0home", "1work", "2home".
|
|
38
|
+
count_matrix: shape (n, n_act_types) — activity counts per pid.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, df: DataFrame):
|
|
42
|
+
"""Construct a Population from a schedule DataFrame.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
df: DataFrame with columns pid, act, start, end, duration.
|
|
46
|
+
Rows are assumed sorted by pid; a defensive sort is applied if not.
|
|
47
|
+
The act, start, end, and duration columns are optional — absent
|
|
48
|
+
columns result in empty arrays.
|
|
49
|
+
"""
|
|
50
|
+
if len(df) == 0:
|
|
51
|
+
self._init_empty()
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
raw_pids = df["pid"].values
|
|
55
|
+
|
|
56
|
+
# Sort by pid if needed (defensive)
|
|
57
|
+
if len(raw_pids) > 1 and not np.all(raw_pids[:-1] <= raw_pids[1:]):
|
|
58
|
+
order = np.argsort(raw_pids, kind="stable")
|
|
59
|
+
raw_pids = raw_pids[order]
|
|
60
|
+
else:
|
|
61
|
+
order = None
|
|
62
|
+
|
|
63
|
+
def _col(name, default_dtype=float):
|
|
64
|
+
if name not in df.columns:
|
|
65
|
+
return np.empty(0, dtype=default_dtype)
|
|
66
|
+
vals = df[name].values
|
|
67
|
+
return vals[order] if order is not None else vals
|
|
68
|
+
|
|
69
|
+
self.acts = _col("act", object)
|
|
70
|
+
self.starts = _col("start", float)
|
|
71
|
+
self.ends = _col("end", float)
|
|
72
|
+
self.durations = _col("duration", float)
|
|
73
|
+
|
|
74
|
+
# Activity encoding
|
|
75
|
+
if len(self.acts) > 0:
|
|
76
|
+
self.unique_acts, self.act_codes = np.unique(self.acts, return_inverse=True)
|
|
77
|
+
else:
|
|
78
|
+
self.unique_acts = np.array([], dtype=object)
|
|
79
|
+
self.act_codes = np.array([], dtype=np.int64)
|
|
80
|
+
self.n_act_types = len(self.unique_acts)
|
|
81
|
+
self.act_to_int = {a: i for i, a in enumerate(self.unique_acts)}
|
|
82
|
+
self.int_to_act = self.unique_acts # indexed by int code → act string
|
|
83
|
+
|
|
84
|
+
# Pid grouping
|
|
85
|
+
self.unique_pids_original, self.pids, self.pid_counts = np.unique(
|
|
86
|
+
raw_pids, return_inverse=True, return_counts=True
|
|
87
|
+
)
|
|
88
|
+
self.n = len(self.pid_counts)
|
|
89
|
+
self.pid_starts = np.concatenate([[0], np.cumsum(self.pid_counts)[:-1]])
|
|
90
|
+
self.pid_ends = self.pid_starts + self.pid_counts
|
|
91
|
+
self.pid_boundaries = self.pid_starts[1:] # free — no extra computation
|
|
92
|
+
self.first_idx = self.pid_starts
|
|
93
|
+
self.last_idx = self.pid_ends - 1
|
|
94
|
+
|
|
95
|
+
self._lazy_act_enum_key = None
|
|
96
|
+
self._lazy_seq_key = None
|
|
97
|
+
self._lazy_count_matrix = None
|
|
98
|
+
|
|
99
|
+
def _init_empty(self) -> None:
|
|
100
|
+
"""Initialise all arrays to empty for an empty DataFrame."""
|
|
101
|
+
self.acts = np.array([], dtype=object)
|
|
102
|
+
self.starts = np.empty(0, dtype=float)
|
|
103
|
+
self.ends = np.empty(0, dtype=float)
|
|
104
|
+
self.durations = np.empty(0, dtype=float)
|
|
105
|
+
self.unique_acts = np.array([], dtype=object)
|
|
106
|
+
self.act_codes = np.array([], dtype=np.int64)
|
|
107
|
+
self.n_act_types = 0
|
|
108
|
+
self.act_to_int = {}
|
|
109
|
+
self.int_to_act = np.array([], dtype=object)
|
|
110
|
+
self.unique_pids_original = np.array([], dtype=object)
|
|
111
|
+
self.pids = np.array([], dtype=np.int64)
|
|
112
|
+
self.pid_counts = np.array([], dtype=np.int64)
|
|
113
|
+
self.n = 0
|
|
114
|
+
self.pid_starts = np.array([], dtype=np.int64)
|
|
115
|
+
self.pid_ends = np.array([], dtype=np.int64)
|
|
116
|
+
self.pid_boundaries = np.array([], dtype=np.int64)
|
|
117
|
+
self.first_idx = np.array([], dtype=np.int64)
|
|
118
|
+
self.last_idx = np.array([], dtype=np.int64)
|
|
119
|
+
self._lazy_act_enum_key = None
|
|
120
|
+
self._lazy_seq_key = None
|
|
121
|
+
self._lazy_count_matrix = None
|
|
122
|
+
|
|
123
|
+
def __len__(self) -> int:
|
|
124
|
+
"""Return total number of activity rows."""
|
|
125
|
+
return len(self.acts)
|
|
126
|
+
|
|
127
|
+
def __bool__(self) -> bool:
|
|
128
|
+
"""Return False for an empty population."""
|
|
129
|
+
return len(self.acts) > 0
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def is_empty(self) -> bool:
|
|
133
|
+
"""True when the population contains no rows."""
|
|
134
|
+
return len(self.acts) == 0
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def act_enum_key(self) -> ndarray:
|
|
138
|
+
"""Cumcount within (pid, act) group, e.g. ["home0", "work0", "home1", ...]."""
|
|
139
|
+
if self._lazy_act_enum_key is None:
|
|
140
|
+
compound = self.pids * self.n_act_types + self.act_codes
|
|
141
|
+
cumcounts = _cumcount(compound)
|
|
142
|
+
self._lazy_act_enum_key = np.array(
|
|
143
|
+
[str(a) + str(c) for a, c in zip(self.acts, cumcounts)], dtype=object
|
|
144
|
+
)
|
|
145
|
+
return self._lazy_act_enum_key
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def seq_key(self) -> ndarray:
|
|
149
|
+
"""Cumcount within pid group, e.g. ["0home", "1work", "2home", ...]."""
|
|
150
|
+
if self._lazy_seq_key is None:
|
|
151
|
+
cumcounts = _cumcount(self.pids)
|
|
152
|
+
self._lazy_seq_key = np.array(
|
|
153
|
+
[str(c) + str(a) for c, a in zip(cumcounts, self.acts)], dtype=object
|
|
154
|
+
)
|
|
155
|
+
return self._lazy_seq_key
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def count_matrix(self) -> ndarray:
|
|
159
|
+
"""Activity count matrix, shape (n, n_act_types)."""
|
|
160
|
+
if self._lazy_count_matrix is None:
|
|
161
|
+
matrix = np.zeros((self.n, self.n_act_types), dtype=np.int64)
|
|
162
|
+
if self.n > 0 and self.n_act_types > 0:
|
|
163
|
+
np.add.at(matrix, (self.pids, self.act_codes), 1)
|
|
164
|
+
self._lazy_count_matrix = matrix
|
|
165
|
+
return self._lazy_count_matrix
|
|
File without changes
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from numpy import array, ndarray
|
|
5
|
+
from pandas import DataFrame, MultiIndex, Series
|
|
6
|
+
|
|
7
|
+
from acteval.density.features.pid_features import PidFeatures
|
|
8
|
+
from acteval.density.features.utils import (
|
|
9
|
+
_grouped_sum,
|
|
10
|
+
weighted_features,
|
|
11
|
+
)
|
|
12
|
+
from acteval.population import Population
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def feasibility_eval(population: Population, name: str) -> tuple[Series, Series]:
|
|
16
|
+
index = MultiIndex.from_tuples(
|
|
17
|
+
[
|
|
18
|
+
("feasibility", "invalid", "all"),
|
|
19
|
+
("feasibility", "not home based", "all"),
|
|
20
|
+
("feasibility", "not home based", "starts"),
|
|
21
|
+
("feasibility", "not home based", "ends"),
|
|
22
|
+
("feasibility", "consecutive", "all"),
|
|
23
|
+
("feasibility", "consecutive", "home"),
|
|
24
|
+
("feasibility", "consecutive", "work"),
|
|
25
|
+
("feasibility", "consecutive", "education"),
|
|
26
|
+
],
|
|
27
|
+
names=["domain", "feature", "segment"],
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if population.is_empty:
|
|
31
|
+
print(f"Warning: {name} has no novel schedules for quality evaluation.")
|
|
32
|
+
weights = Series([0] * len(index), index=index, name=f"{name}__weight")
|
|
33
|
+
metrics = Series([0] * len(index), index=index, name=name)
|
|
34
|
+
return weights, metrics
|
|
35
|
+
|
|
36
|
+
pids = population.pids
|
|
37
|
+
acts = population.acts
|
|
38
|
+
unique_pids = np.arange(population.n)
|
|
39
|
+
|
|
40
|
+
# home based feasibility
|
|
41
|
+
first_acts = acts[population.first_idx]
|
|
42
|
+
last_acts = acts[population.last_idx]
|
|
43
|
+
|
|
44
|
+
not_start_at_home = first_acts != "home"
|
|
45
|
+
not_end_at_home = last_acts != "home"
|
|
46
|
+
not_home_based = not_start_at_home | not_end_at_home
|
|
47
|
+
|
|
48
|
+
# consecutive feasibility
|
|
49
|
+
consecutive_home = _get_consecutives(pids, acts, unique_pids, "home")
|
|
50
|
+
consecutive_work = _get_consecutives(pids, acts, unique_pids, "work")
|
|
51
|
+
consecutive_education = _get_consecutives(pids, acts, unique_pids, "education")
|
|
52
|
+
|
|
53
|
+
consecutive = consecutive_home | consecutive_work | consecutive_education
|
|
54
|
+
|
|
55
|
+
# combined
|
|
56
|
+
invalid = not_home_based | consecutive
|
|
57
|
+
|
|
58
|
+
n = population.n
|
|
59
|
+
|
|
60
|
+
metrics = Series(
|
|
61
|
+
[
|
|
62
|
+
invalid.sum() / n,
|
|
63
|
+
not_home_based.sum() / n,
|
|
64
|
+
not_start_at_home.sum() / n,
|
|
65
|
+
not_end_at_home.sum() / n,
|
|
66
|
+
consecutive.sum() / n,
|
|
67
|
+
consecutive_home.sum() / n,
|
|
68
|
+
consecutive_work.sum() / n,
|
|
69
|
+
consecutive_education.sum() / n,
|
|
70
|
+
],
|
|
71
|
+
index=index,
|
|
72
|
+
name=name,
|
|
73
|
+
dtype=float,
|
|
74
|
+
)
|
|
75
|
+
weights = Series([n] * len(index), index=index, name=f"{name}__weight", dtype=int)
|
|
76
|
+
return weights, metrics
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _get_consecutives(
|
|
80
|
+
pids: ndarray, acts: ndarray, unique_pids: ndarray, target: str
|
|
81
|
+
) -> ndarray:
|
|
82
|
+
"""Check which pids have consecutive occurrences of target activity.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
pids: 1D array of pid values (in schedule order).
|
|
86
|
+
acts: 1D array of activity values (in schedule order).
|
|
87
|
+
unique_pids: 1D array of unique pid values.
|
|
88
|
+
target: Activity to check for consecutive occurrences.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Boolean array of length len(unique_pids), True if pid has consecutive target.
|
|
92
|
+
"""
|
|
93
|
+
is_target = acts == target
|
|
94
|
+
# Previous row is same pid and also target
|
|
95
|
+
prev_same_pid = np.empty(len(pids), dtype=bool)
|
|
96
|
+
prev_same_pid[0] = False
|
|
97
|
+
prev_same_pid[1:] = pids[1:] == pids[:-1]
|
|
98
|
+
|
|
99
|
+
prev_is_target = np.empty(len(is_target), dtype=bool)
|
|
100
|
+
prev_is_target[0] = False
|
|
101
|
+
prev_is_target[1:] = is_target[:-1]
|
|
102
|
+
|
|
103
|
+
consecutive = is_target & prev_is_target & prev_same_pid
|
|
104
|
+
if consecutive.any():
|
|
105
|
+
bad_pids = np.unique(pids[consecutive])
|
|
106
|
+
return np.isin(unique_pids, bad_pids)
|
|
107
|
+
return np.zeros(len(unique_pids), dtype=bool)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_consecutives(population: DataFrame, act: str) -> Series:
|
|
111
|
+
"""Legacy wrapper for backwards compatibility."""
|
|
112
|
+
pids = population.pid.values
|
|
113
|
+
acts = population.act.values
|
|
114
|
+
unique_pids = np.unique(pids)
|
|
115
|
+
result = _get_consecutives(pids, acts, unique_pids, act)
|
|
116
|
+
return Series(result, index=unique_pids, name="consecutive")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def start_and_end_acts(
|
|
120
|
+
population: Population, target: str = "home"
|
|
121
|
+
) -> dict[str, tuple[ndarray, ndarray]]:
|
|
122
|
+
acts = population.acts
|
|
123
|
+
n = population.n
|
|
124
|
+
first = int((acts[population.first_idx] == target).sum())
|
|
125
|
+
last = int((acts[population.last_idx] == target).sum())
|
|
126
|
+
return {
|
|
127
|
+
f"first act {target}": (array([0, 1]), array([(n - first), first])),
|
|
128
|
+
f"last act {target}": (array([0, 1]), array([(n - last), last])),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def act_consecutive(
|
|
133
|
+
population: Population, targets: List[str] = ["home", "work", "education"]
|
|
134
|
+
) -> dict[str, tuple[ndarray, ndarray]]:
|
|
135
|
+
pids = population.pids
|
|
136
|
+
acts = population.acts
|
|
137
|
+
unique_pids = np.arange(population.n)
|
|
138
|
+
n = population.n
|
|
139
|
+
result = {}
|
|
140
|
+
for target in targets:
|
|
141
|
+
consecutive_mask = _get_consecutives(pids, acts, unique_pids, target)
|
|
142
|
+
c = int(consecutive_mask.sum())
|
|
143
|
+
result[f"consecutive {target}"] = (
|
|
144
|
+
array([0, 1]),
|
|
145
|
+
array([n - c, c]),
|
|
146
|
+
)
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def contains_consecutive(schedule: DataFrame, act: str) -> int:
|
|
151
|
+
mask = schedule.act.eq(act)
|
|
152
|
+
consecutive = mask == mask.shift(1)
|
|
153
|
+
return (mask & consecutive).sum() > 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def time_consistency(
|
|
157
|
+
population: Population, target: int = 1440
|
|
158
|
+
) -> dict[str, tuple[ndarray, ndarray]]:
|
|
159
|
+
pids = population.pids
|
|
160
|
+
n = population.n
|
|
161
|
+
|
|
162
|
+
starts_val = population.starts
|
|
163
|
+
ends_val = population.ends
|
|
164
|
+
durations_val = population.durations
|
|
165
|
+
|
|
166
|
+
starts = int((starts_val[population.first_idx] == 0).sum())
|
|
167
|
+
ends = int((ends_val[population.last_idx] == target).sum())
|
|
168
|
+
|
|
169
|
+
_, dur_sums = _grouped_sum(pids, durations_val)
|
|
170
|
+
duration = int((dur_sums == target).sum())
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
"starts at 0": (array([0, 1]), array([(n - starts), starts])),
|
|
174
|
+
f"ends at {target}": (array([0, 1]), array([(n - ends), ends])),
|
|
175
|
+
f"duration is {target}": (
|
|
176
|
+
array([0, 1]),
|
|
177
|
+
array([(n - duration), duration]),
|
|
178
|
+
),
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def duration_consistency(
|
|
183
|
+
population: Population, factor: int = 1440
|
|
184
|
+
) -> dict[str, tuple[ndarray, ndarray]]:
|
|
185
|
+
_, dur_sums = _grouped_sum(population.pids, population.durations)
|
|
186
|
+
return weighted_features({"total duration": dur_sums / factor})
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def sequence_lengths(
|
|
190
|
+
population: Population,
|
|
191
|
+
) -> dict[str, tuple[ndarray, ndarray]]:
|
|
192
|
+
lengths, length_counts = np.unique(population.pid_counts, return_counts=True)
|
|
193
|
+
return {"sequence lengths": (lengths, length_counts)}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def sequence_lengths_per_pid(
|
|
197
|
+
population: Population,
|
|
198
|
+
) -> PidFeatures:
|
|
199
|
+
pids = np.arange(population.n)
|
|
200
|
+
return PidFeatures(
|
|
201
|
+
data={"sequence lengths": (population.pid_counts, pids)},
|
|
202
|
+
bin_size=None,
|
|
203
|
+
factor=1,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def trip_consistency(
|
|
208
|
+
population: Population,
|
|
209
|
+
) -> dict[str, tuple[ndarray, ndarray]]:
|
|
210
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: acteval
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Standalone library for evaluating activity sequences
|
|
5
|
+
Requires-Python: >=3.8
|
|
6
|
+
Requires-Dist: matplotlib
|
|
7
|
+
Requires-Dist: numpy
|
|
8
|
+
Requires-Dist: pandas
|
|
9
|
+
Requires-Dist: pot
|
|
10
|
+
Requires-Dist: scipy<=1.13
|
|
11
|
+
Requires-Dist: tabulate
|
|
12
|
+
Requires-Dist: tomli>=1.1.0; python_version < '3.11'
|