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.
@@ -0,0 +1,241 @@
1
+ import numpy as np
2
+ from numpy import ndarray
3
+ from pandas import DataFrame, MultiIndex, Series
4
+
5
+ from acteval.density.features.pid_features import PidFeatures
6
+ from acteval.density.features.utils import weighted_features
7
+ from acteval.population import Population
8
+
9
+
10
+ def _build_ngrams(
11
+ population: Population, n: int, min_count: int = 0
12
+ ) -> dict[str, tuple[ndarray, ndarray]]:
13
+ """Build n-gram transition features from a Population.
14
+
15
+ Uses integer encoding to avoid string operations in the inner loop.
16
+ Activity codes are packed into a single integer per n-gram, then
17
+ converted back to string labels at the end.
18
+ """
19
+ codes = population.act_codes
20
+ pids = population.pids
21
+ base = population.n_act_types
22
+
23
+ # Build integer-encoded n-grams using vectorised arithmetic
24
+ # Each n-gram is encoded as code[0]*base^(n-1) + code[1]*base^(n-2) + ... + code[n-1]
25
+ powers = base ** np.arange(n - 1, -1, -1)
26
+ ngram_codes = np.zeros(len(codes), dtype=np.int64)
27
+ for i in range(n):
28
+ shifted = np.roll(codes, -i)
29
+ ngram_codes += shifted * powers[i]
30
+
31
+ # Identify valid positions (not crossing pid boundaries)
32
+ valid_mask = np.ones(len(codes), dtype=bool)
33
+ # Exclude trailing positions within last pid
34
+ if n > 1:
35
+ valid_mask[-(n - 1) :] = False
36
+ # Mark positions near pid boundaries
37
+ pid_boundaries = population.pid_boundaries
38
+ if len(pid_boundaries) > 0:
39
+ for offset in range(-(n - 1), 0):
40
+ positions = pid_boundaries + offset
41
+ positions = positions[(positions >= 0) & (positions < len(codes))]
42
+ valid_mask[positions] = False
43
+
44
+ valid_ngrams = ngram_codes[valid_mask]
45
+ valid_pids = pids[valid_mask]
46
+
47
+ # Early return if no valid n-grams
48
+ if len(valid_ngrams) == 0:
49
+ return {}
50
+
51
+ # Count n-grams per pid using numpy
52
+ unique_ngrams, ngram_indices = np.unique(valid_ngrams, return_inverse=True)
53
+ unique_pids, pid_indices = np.unique(valid_pids, return_inverse=True)
54
+
55
+ # Build count matrix (pids x ngrams)
56
+ count_matrix = np.zeros((len(unique_pids), len(unique_ngrams)), dtype=int)
57
+ np.add.at(count_matrix, (pid_indices, ngram_indices), 1)
58
+
59
+ # Filter rare n-grams
60
+ if min_count > 0:
61
+ col_totals = count_matrix.sum(axis=0)
62
+ keep = col_totals >= min_count
63
+ count_matrix = count_matrix[:, keep]
64
+ unique_ngrams = unique_ngrams[keep]
65
+
66
+ # Decode integer n-grams back to string labels
67
+ int_to_act = population.int_to_act
68
+
69
+ def _decode_ngram(code):
70
+ labels = []
71
+ for i in range(n):
72
+ labels.append(int_to_act[code // powers[i]])
73
+ code %= powers[i]
74
+ return ">".join(str(label) for label in labels)
75
+
76
+ # Build result dict with pid counts as lists (matching original format)
77
+ # todo: maybe don't need these by pid?
78
+ result = {}
79
+ for j, ng_code in enumerate(unique_ngrams):
80
+ label = _decode_ngram(ng_code)
81
+ result[label] = count_matrix[:, j].tolist()
82
+
83
+ return weighted_features(result)
84
+
85
+
86
+ def transitions_by_act(
87
+ population: Population, min_count: int = 0
88
+ ) -> dict[str, tuple[ndarray, ndarray]]:
89
+ return _build_ngrams(population, 2, min_count=min_count)
90
+
91
+
92
+ def transition_3s_by_act(
93
+ population: Population, min_count: int = 0
94
+ ) -> dict[str, tuple[ndarray, ndarray]]:
95
+ return _build_ngrams(population, 3, min_count=min_count)
96
+
97
+
98
+ def transition_4s_by_act(
99
+ population: Population, min_count: int = 0
100
+ ) -> dict[str, tuple[ndarray, ndarray]]:
101
+ return _build_ngrams(population, 4, min_count=min_count)
102
+
103
+
104
+ def _build_ngrams_per_pid(
105
+ population: Population, n: int, min_count: int = 0
106
+ ) -> PidFeatures:
107
+ """Build per-pid n-gram transition features returning PidFeatures.
108
+
109
+ Uses the same integer-encoding logic as ``_build_ngrams`` but keeps
110
+ per-person counts with pid tracking instead of compressing immediately.
111
+ ``min_count`` filtering is applied on the full population to keep a
112
+ consistent feature space across subsets.
113
+ """
114
+ codes = population.act_codes
115
+ pids = population.pids
116
+ base = population.n_act_types
117
+
118
+ powers = base ** np.arange(n - 1, -1, -1)
119
+ ngram_codes = np.zeros(len(codes), dtype=np.int64)
120
+ for i in range(n):
121
+ shifted = np.roll(codes, -i)
122
+ ngram_codes += shifted * powers[i]
123
+
124
+ valid_mask = np.ones(len(codes), dtype=bool)
125
+ if n > 1:
126
+ valid_mask[-(n - 1) :] = False
127
+ pid_boundaries = population.pid_boundaries
128
+ if len(pid_boundaries) > 0:
129
+ for offset in range(-(n - 1), 0):
130
+ positions = pid_boundaries + offset
131
+ positions = positions[(positions >= 0) & (positions < len(codes))]
132
+ valid_mask[positions] = False
133
+
134
+ valid_ngrams = ngram_codes[valid_mask]
135
+ valid_pids = pids[valid_mask]
136
+
137
+ if len(valid_ngrams) == 0:
138
+ return PidFeatures(data={}, bin_size=None, factor=1)
139
+
140
+ unique_ngrams, ngram_indices = np.unique(valid_ngrams, return_inverse=True)
141
+ unique_pids, pid_indices = np.unique(valid_pids, return_inverse=True)
142
+
143
+ count_matrix = np.zeros((len(unique_pids), len(unique_ngrams)), dtype=int)
144
+ np.add.at(count_matrix, (pid_indices, ngram_indices), 1)
145
+
146
+ if min_count > 0:
147
+ col_totals = count_matrix.sum(axis=0)
148
+ keep = col_totals >= min_count
149
+ count_matrix = count_matrix[:, keep]
150
+ unique_ngrams = unique_ngrams[keep]
151
+
152
+ int_to_act = population.int_to_act
153
+
154
+ def _decode_ngram(code):
155
+ labels = []
156
+ for i in range(n):
157
+ labels.append(int_to_act[code // powers[i]])
158
+ code %= powers[i]
159
+ return ">".join(str(label) for label in labels)
160
+
161
+ # Build PidFeatures: each key maps to (per-person counts, pids)
162
+ # All keys share the same pid array for efficient mask caching.
163
+ result_data: dict[str, tuple[ndarray, ndarray]] = {}
164
+ for j, ng_code in enumerate(unique_ngrams):
165
+ label = _decode_ngram(ng_code)
166
+ result_data[label] = (count_matrix[:, j], unique_pids)
167
+
168
+ return PidFeatures(data=result_data, bin_size=None, factor=1)
169
+
170
+
171
+ def transitions_by_act_per_pid(
172
+ population: Population, min_count: int = 0
173
+ ) -> PidFeatures:
174
+ return _build_ngrams_per_pid(population, 2, min_count=min_count)
175
+
176
+
177
+ def transition_3s_by_act_per_pid(
178
+ population: Population, min_count: int = 0
179
+ ) -> PidFeatures:
180
+ return _build_ngrams_per_pid(population, 3, min_count=min_count)
181
+
182
+
183
+ def transition_4s_by_act_per_pid(
184
+ population: Population, min_count: int = 0
185
+ ) -> PidFeatures:
186
+ return _build_ngrams_per_pid(population, 4, min_count=min_count)
187
+
188
+
189
+ def tour(acts: Series) -> str:
190
+ """
191
+ Extracts the tour from the given Series of activities.
192
+
193
+ Args:
194
+ acts (Series): A Series containing the activities.
195
+
196
+ Returns:
197
+ str: A string representation of the tour.
198
+ """
199
+ return ">".join(acts.str[0])
200
+
201
+
202
+ def full_sequences(population: DataFrame) -> dict[str, tuple[ndarray, ndarray]]:
203
+ transitions = population.reset_index()
204
+ transitions = transitions.set_index(["index", "pid"])
205
+ transitions.act = transitions.act.astype(str)
206
+ transitions = transitions.groupby("pid").act.apply(tour)
207
+ transitions = (
208
+ transitions.groupby("pid")
209
+ .value_counts()
210
+ .unstack()
211
+ .fillna(0)
212
+ .astype(int)
213
+ .to_dict(orient="list")
214
+ )
215
+ return weighted_features(transitions)
216
+
217
+
218
+ def collect_sequence(acts: Series) -> str:
219
+ return ">".join(acts)
220
+
221
+
222
+ def sequence_probs(population: DataFrame) -> DataFrame:
223
+ """
224
+ Calculates the sequence probabilities in the given population DataFrame.
225
+
226
+ Args:
227
+ population (DataFrame): A DataFrame containing the population data.
228
+
229
+ Returns:
230
+ DataFrame: A DataFrame containing the probability of each sequence.
231
+ """
232
+ metrics = (
233
+ population.groupby("pid")
234
+ .act.apply(collect_sequence)
235
+ .value_counts(normalize=True)
236
+ )
237
+ metrics = metrics.sort_values(ascending=False)
238
+ metrics.index = MultiIndex.from_tuples(
239
+ [("sequence rate", acts) for acts in metrics.index]
240
+ )
241
+ return metrics
@@ -0,0 +1,197 @@
1
+ from typing import Optional, Union
2
+
3
+ import numpy as np
4
+ from numpy import array, ndarray, unique
5
+
6
+
7
+ def _collect_by_group(keys, values):
8
+ """Group values by keys into a dict of arrays.
9
+
10
+ Args:
11
+ keys: 1D array of group keys.
12
+ values: 1D or 2D array of values to group.
13
+
14
+ Returns:
15
+ dict mapping each unique key to the corresponding array of values.
16
+ """
17
+ order = np.argsort(keys, kind="stable")
18
+ sorted_keys = keys[order]
19
+ sorted_vals = values[order]
20
+ unique_keys, start_idx = np.unique(sorted_keys, return_index=True)
21
+ splits = np.split(sorted_vals, start_idx[1:])
22
+ return {k: v for k, v in zip(unique_keys, splits)}
23
+
24
+
25
+ def _collect_by_group_with_pids(keys, values, pids):
26
+ """Group values by keys, tracking which pid each value belongs to.
27
+
28
+ Args:
29
+ keys: 1D array of group keys.
30
+ values: 1D or 2D array of values to group.
31
+ pids: 1D array of pid ints (same length as keys/values).
32
+
33
+ Returns:
34
+ dict mapping each unique key to ``(grouped_values, grouped_pids)``.
35
+ """
36
+ order = np.argsort(keys, kind="stable")
37
+ sorted_keys = keys[order]
38
+ sorted_vals = values[order]
39
+ sorted_pids = pids[order]
40
+ unique_keys, start_idx = np.unique(sorted_keys, return_index=True)
41
+ val_splits = np.split(sorted_vals, start_idx[1:])
42
+ pid_splits = np.split(sorted_pids, start_idx[1:])
43
+ return {k: (v, p) for k, v, p in zip(unique_keys, val_splits, pid_splits)}
44
+
45
+
46
+ def _count_matrix(row_keys, col_keys):
47
+ """Build a count matrix (crosstab) from row and column key arrays.
48
+
49
+ Args:
50
+ row_keys: 1D array of row group keys.
51
+ col_keys: 1D array of column group keys.
52
+
53
+ Returns:
54
+ tuple of (matrix, unique_rows, unique_cols).
55
+ """
56
+ unique_rows, row_codes = np.unique(row_keys, return_inverse=True)
57
+ unique_cols, col_codes = np.unique(col_keys, return_inverse=True)
58
+ matrix = np.zeros((len(unique_rows), len(unique_cols)), dtype=np.int64)
59
+ np.add.at(matrix, (row_codes, col_codes), 1)
60
+ return matrix, unique_rows, unique_cols
61
+
62
+
63
+ def _cumcount(group_keys):
64
+ """Compute cumulative count within each group (like groupby().cumcount()).
65
+
66
+ Args:
67
+ group_keys: 1D array of group keys.
68
+
69
+ Returns:
70
+ 1D int array with the within-group ordinal for each element.
71
+ """
72
+ n = len(group_keys)
73
+ order = np.argsort(group_keys, kind="stable")
74
+ sorted_keys = group_keys[order]
75
+ # Find group start positions in the sorted array
76
+ change_positions = np.concatenate(
77
+ [[0], np.where(sorted_keys[1:] != sorted_keys[:-1])[0] + 1]
78
+ )
79
+ counts = np.diff(np.concatenate([change_positions, [n]]))
80
+ group_starts = np.repeat(change_positions, counts)
81
+ sorted_cumcounts = np.arange(n) - group_starts
82
+ # Map back to original order
83
+ result = np.empty(n, dtype=np.int64)
84
+ result[order] = sorted_cumcounts
85
+ return result
86
+
87
+
88
+ def _grouped_sum(keys, values):
89
+ """Sum values per group key.
90
+
91
+ Args:
92
+ keys: 1D array of group keys.
93
+ values: 1D numeric array.
94
+
95
+ Returns:
96
+ tuple of (unique_keys, sums).
97
+ """
98
+ order = np.argsort(keys, kind="stable")
99
+ sorted_keys = keys[order]
100
+ sorted_vals = values[order]
101
+ unique_keys, start_idx = np.unique(sorted_keys, return_index=True)
102
+ sums = np.add.reduceat(sorted_vals, start_idx)
103
+ return unique_keys, sums
104
+
105
+
106
+ def _first_last_per_group(keys):
107
+ """Find the original indices of the first and last element per group.
108
+
109
+ Args:
110
+ keys: 1D array of group keys.
111
+
112
+ Returns:
113
+ tuple of (unique_keys, first_indices, last_indices) where indices
114
+ refer to positions in the original array.
115
+ """
116
+ order = np.argsort(keys, kind="stable")
117
+ sorted_keys = keys[order]
118
+ unique_keys, start_idx = np.unique(sorted_keys, return_index=True)
119
+ end_idx = np.empty_like(start_idx)
120
+ end_idx[:-1] = start_idx[1:] - 1
121
+ end_idx[-1] = len(keys) - 1
122
+ return unique_keys, order[start_idx], order[end_idx]
123
+
124
+
125
+ def equals(
126
+ a: dict[str, tuple[ndarray, ndarray]], b: dict[str, tuple[ndarray, ndarray]]
127
+ ) -> bool:
128
+ if set(a.keys()) != set(b.keys()):
129
+ return False
130
+ for k in a.keys():
131
+ if not len(a[k][0]) == len(b[k][0]):
132
+ return False
133
+ if not len(a[k][1]) == len(b[k][1]):
134
+ return False
135
+ if not (a[k][0] == b[k][0]).all():
136
+ return False
137
+ if not (a[k][1] == b[k][1]).all():
138
+ return False
139
+ return True
140
+
141
+
142
+ def bin_values(values: array, bin_size: Union[int, float]) -> ndarray:
143
+ """
144
+ Bins the input values based on the given bin size.
145
+
146
+ Args:
147
+ values (array): Input values to be binned.
148
+ bin_size (int, float): Size of each bin.
149
+
150
+ Returns:
151
+ array: Binned values.
152
+ """
153
+ return (values // bin_size * bin_size) + (bin_size / 2)
154
+
155
+
156
+ def compress_feature(
157
+ feature: list, bin_size: Optional[int] = None, factor: int = 1440
158
+ ) -> tuple[ndarray, ndarray]:
159
+ """
160
+ Compresses a feature by optionally binning its values and returning unique values with counts.
161
+
162
+ Args:
163
+ feature (list): The feature to compress.
164
+ bin_size (int, optional): The size of each bin. If None, no binning is performed.
165
+ factor (int): Factor to apply to convert output values.
166
+
167
+ Returns:
168
+ tuple: A tuple containing two arrays and the total weight. The first array contains the unique
169
+ values, and the second array contains the counts of each value.
170
+ """
171
+ s = array(feature)
172
+ if bin_size is not None:
173
+ s = bin_values(s, bin_size)
174
+ ks, ws = unique(s, axis=0, return_counts=True)
175
+ ks = ks / factor
176
+ return ks, ws
177
+
178
+
179
+ def weighted_features(
180
+ features: dict[str, ndarray],
181
+ bin_size: Optional[int] = None,
182
+ factor: int = 1,
183
+ ) -> dict[str, tuple[ndarray, ndarray]]:
184
+ """
185
+ Apply optional binning and value counting to dictionary of features.
186
+
187
+ Args:
188
+ features (dict[array): A dictionary of features to compress.
189
+ bin_size (Optional[int]): The size of the bin to use for compression. Defaults to None.
190
+ factor (int): Factor to apply to convert output values.
191
+
192
+ Returns:
193
+ dict[str, tuple[array, array[int]]]: A dictionary of features and weights.
194
+ """
195
+ return {
196
+ k: compress_feature(values, bin_size, factor) for k, values in features.items()
197
+ }
@@ -0,0 +1,16 @@
1
+ from acteval.distance.scalar import (
2
+ abs_av_diff as abs_av_diff,
3
+ )
4
+ from acteval.distance.scalar import (
5
+ mae as mae,
6
+ )
7
+ from acteval.distance.scalar import (
8
+ mape as mape,
9
+ )
10
+ from acteval.distance.scalar import (
11
+ mape_scalar as mape_scalar,
12
+ )
13
+ from acteval.distance.scalar import (
14
+ mse as mse,
15
+ )
16
+ from acteval.distance.wasserstein import emd as emd
@@ -0,0 +1,68 @@
1
+ import numpy as np
2
+
3
+
4
+ def mape(a: tuple[np.ndarray, np.ndarray], b: tuple[np.ndarray, np.ndarray]) -> float:
5
+ """Calculate mean average percentage error between distributions a and b.
6
+
7
+ Clipped at 1.0.
8
+
9
+ Args:
10
+ a (tuple[np.ndarray, np.ndarray]): Distribution a.
11
+ b (tuple[np.ndarray, np.ndarray]): Distribution b.
12
+
13
+ Returns:
14
+ float: MAPE.
15
+ """
16
+ # TODO test this
17
+ # unpack
18
+ ak, aw = a
19
+ bk, bw = b
20
+ # calc weighted average
21
+ akw = (ak * aw).sum() / aw.sum()
22
+ bkw = (bk * bw).sum() / bw.sum()
23
+ diff = np.abs(akw - bkw)
24
+ if diff == 0:
25
+ return 0.0
26
+ if bkw == 0:
27
+ return clamp(diff / akw)
28
+ return clamp(diff / bkw)
29
+
30
+
31
+ def clamp(x):
32
+ if x > 1.0:
33
+ return 1.0
34
+ return x
35
+
36
+
37
+ def mape_scalar(a, b):
38
+ return np.abs((a - b) / a).mean()
39
+
40
+
41
+ def mse(a: tuple[np.ndarray, np.ndarray], b: tuple[np.ndarray, np.ndarray]) -> float:
42
+ # requires and b have same support.
43
+ # unpack
44
+ _, aw = a
45
+ _, bw = b
46
+ return ((aw - bw) ** 2).mean()
47
+
48
+
49
+ def mae(a: tuple[np.ndarray, np.ndarray], b: tuple[np.ndarray, np.ndarray]) -> float:
50
+ # TODO test this
51
+ # requires and b have same support.
52
+ # unpack
53
+ _, aw = a
54
+ _, bw = b
55
+ return (np.abs(aw - bw)).mean()
56
+
57
+
58
+ def abs_av_diff(
59
+ a: tuple[np.ndarray, np.ndarray], b: tuple[np.ndarray, np.ndarray]
60
+ ) -> float:
61
+ # TODO test this
62
+ # unpack
63
+ ak, aw = a
64
+ bk, bw = b
65
+ a_average = (ak * aw).sum() / aw.sum()
66
+ b_average = (bk * bw).sum() / bw.sum()
67
+
68
+ return np.abs(a_average - b_average)