activitymodel 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,45 @@
1
+ """activitymodel: occupant activity calendars from the French time-use survey.
2
+
3
+ Three layers, three consumers:
4
+
5
+ * ``survey`` + ``clustering`` + ``pipeline`` build a :class:`DiaryLibrary`
6
+ from the Enquete Emploi du temps (run inside the buildingdata pipeline).
7
+ * ``harmonise`` puts census individuals in the vocabulary the library was
8
+ built with (used by the census-occupant dataset and by building_eload).
9
+ * ``matching`` + ``calendar`` turn occupants and a library into annual
10
+ activity calendars (used by building_eload at simulation time).
11
+
12
+ No module resolves a data path at import time; data comes in as frames.
13
+ """
14
+
15
+ from importlib.metadata import PackageNotFoundError, version
16
+
17
+ try:
18
+ __version__ = version("activitymodel")
19
+ except PackageNotFoundError: # editable checkout without metadata
20
+ __version__ = "0.0.0"
21
+
22
+ from .calendar import AnnualCalendar, day_types_for_dates, generate_calendar
23
+ from .harmonise import CensusColumns, harmonise_census_individuals, harmonise_survey_individuals
24
+ from .library import DiaryLibrary
25
+ from .matching import assign_clusters
26
+ from .taxonomy import DETAILED_ACTIVITIES, LEVELS, get_level
27
+ from .vocabulary import DEFAULT_MATCHING_KEYS, DEFAULT_MATCHING_LADDER, AgeClasses
28
+
29
+ __all__ = [
30
+ "AgeClasses",
31
+ "AnnualCalendar",
32
+ "CensusColumns",
33
+ "DEFAULT_MATCHING_KEYS",
34
+ "DEFAULT_MATCHING_LADDER",
35
+ "DETAILED_ACTIVITIES",
36
+ "DiaryLibrary",
37
+ "LEVELS",
38
+ "__version__",
39
+ "assign_clusters",
40
+ "day_types_for_dates",
41
+ "generate_calendar",
42
+ "get_level",
43
+ "harmonise_census_individuals",
44
+ "harmonise_survey_individuals",
45
+ ]
@@ -0,0 +1,286 @@
1
+ """Expand cluster assignments into annual activity calendars.
2
+
3
+ An :class:`AnnualCalendar` is stored as an ``int32`` matrix ``day_index`` of
4
+ shape ``(n_occupants, n_dates)`` pointing into the library's day pool: about
5
+ 1.5 KiB per occupant-year instead of 51 KiB for the expanded ``uint8`` codes
6
+ and several hundred KiB for one polars column per occupant. Codes are
7
+ materialised per occupant slice with :meth:`AnnualCalendar.codes`, which is the
8
+ shape building_eload's chunked dwelling model wants (occupant-major, one shared
9
+ time axis, 144 slots per day).
10
+
11
+ The time axis is the survey's clock (``Europe/Paris`` wall-clock, no DST
12
+ handling here); building_eload re-places it on its UTC axis with
13
+ ``place_local_clock_curve_on_utc_axis``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from datetime import date, datetime, timedelta
20
+
21
+ import numpy as np
22
+ import polars as pl
23
+
24
+ from .library import CLOCK, SLOT_MINUTES, SLOTS_PER_DAY, DiaryLibrary, cluster_column
25
+ from .matching import MATCH_LEVEL_COLUMN, assign_clusters
26
+ from .taxonomy import DETAILED_ACTIVITIES, get_level
27
+ from .vocabulary import DEFAULT_MATCHING_LADDER
28
+
29
+ # --------------------------------------------------------------------------- #
30
+ # Calendar of day types
31
+ # --------------------------------------------------------------------------- #
32
+
33
+
34
+ def easter_sunday(year: int) -> date:
35
+ """Gregorian Easter (Meeus/Jones/Butcher)."""
36
+ a = year % 19
37
+ b, c = divmod(year, 100)
38
+ d, e = divmod(b, 4)
39
+ f = (b + 8) // 25
40
+ g = (b - f + 1) // 3
41
+ h = (19 * a + b - d - g + 15) % 30
42
+ i, k = divmod(c, 4)
43
+ m = (32 + 2 * e + 2 * i - h - k) % 7
44
+ n = (a + 11 * h + 22 * m) // 451
45
+ month, day = divmod(h + m - 7 * n + 114, 31)
46
+ return date(year, month, day + 1)
47
+
48
+
49
+ def french_public_holidays(year: int) -> list[date]:
50
+ """The eleven national public holidays of metropolitan France."""
51
+ easter = easter_sunday(year)
52
+ return sorted(
53
+ [
54
+ date(year, 1, 1),
55
+ easter + timedelta(days=1),
56
+ date(year, 5, 1),
57
+ date(year, 5, 8),
58
+ easter + timedelta(days=39),
59
+ easter + timedelta(days=50),
60
+ date(year, 7, 14),
61
+ date(year, 8, 15),
62
+ date(year, 11, 1),
63
+ date(year, 11, 11),
64
+ date(year, 12, 25),
65
+ ]
66
+ )
67
+
68
+
69
+ def year_dates(year: int) -> np.ndarray:
70
+ """All dates of ``year`` as ``datetime64[D]``."""
71
+ return np.arange(np.datetime64(f"{year}-01-01"), np.datetime64(f"{year + 1}-01-01"))
72
+
73
+
74
+ def day_types_for_dates(
75
+ dates: np.ndarray,
76
+ holiday_day_type: str | None = "sunday",
77
+ holidays: list[date] | None = None,
78
+ ) -> np.ndarray:
79
+ """``weekday`` / ``saturday`` / ``sunday`` per date, holidays optionally remapped.
80
+
81
+ Args:
82
+ dates: ``datetime64[D]`` array.
83
+ holiday_day_type: day type given to public holidays (``None`` = keep
84
+ the calendar day type).
85
+ holidays: explicit holiday dates; default is the French national list
86
+ of every year present in ``dates``.
87
+ """
88
+ dates = np.asarray(dates, dtype="datetime64[D]")
89
+ weekday = (dates.astype("datetime64[D]").astype(np.int64) + 3) % 7 # 1970-01-01 was a Thursday
90
+ types = np.where(weekday == 5, "saturday", np.where(weekday == 6, "sunday", "weekday")).astype(
91
+ object
92
+ )
93
+ if holiday_day_type is not None:
94
+ if holidays is None:
95
+ years = np.unique(dates.astype("datetime64[Y]").astype(int) + 1970)
96
+ holidays = [d for y in years for d in french_public_holidays(int(y))]
97
+ holiday_set = np.array([np.datetime64(d) for d in holidays], dtype="datetime64[D]")
98
+ types[np.isin(dates, holiday_set)] = holiday_day_type
99
+ return types
100
+
101
+
102
+ # --------------------------------------------------------------------------- #
103
+ # Day sampling
104
+ # --------------------------------------------------------------------------- #
105
+
106
+
107
+ def sample_days(
108
+ library: DiaryLibrary,
109
+ assignments: pl.DataFrame,
110
+ date_day_types: np.ndarray,
111
+ rng: np.random.Generator,
112
+ ) -> np.ndarray:
113
+ """Draw one library day per (occupant, date) from the assigned cluster.
114
+
115
+ Args:
116
+ library: the diary library.
117
+ assignments: output of :func:`~activitymodel.matching.assign_clusters`,
118
+ in occupant order.
119
+ date_day_types: day type of each date (see :func:`day_types_for_dates`).
120
+ rng: generator driving the draw.
121
+
122
+ Returns:
123
+ ``int32`` matrix ``(n_occupants, n_dates)`` of row positions in
124
+ ``library.codes``. Within a cluster, days are drawn with replacement,
125
+ proportionally to their survey weight.
126
+ """
127
+ n_occ, n_dates = assignments.height, len(date_day_types)
128
+ day_index = np.full((n_occ, n_dates), -1, dtype=np.int32)
129
+ day_type_col = library.days.get_column("day_type").to_numpy().astype(object)
130
+ cluster_col = library.days.get_column("cluster_id").to_numpy()
131
+ weight_col = library.days.get_column("weight").to_numpy()
132
+ for day_type in library.day_types:
133
+ date_mask = date_day_types == day_type
134
+ if not date_mask.any():
135
+ continue
136
+ occupant_clusters = assignments.get_column(cluster_column(day_type)).to_numpy()
137
+ for cluster_id in np.unique(occupant_clusters):
138
+ pool = np.flatnonzero((cluster_col == cluster_id) & (day_type_col == day_type))
139
+ if pool.size == 0:
140
+ raise ValueError(f"cluster {cluster_id} has no {day_type} day in the library")
141
+ occupant_mask = occupant_clusters == cluster_id
142
+ count = int(occupant_mask.sum()) * int(date_mask.sum())
143
+ p = weight_col[pool] / weight_col[pool].sum()
144
+ draws = rng.choice(pool, size=count, p=p).astype(np.int32)
145
+ day_index[np.ix_(occupant_mask, date_mask)] = draws.reshape(
146
+ int(occupant_mask.sum()), -1
147
+ )
148
+ unmapped = date_day_types[(day_index < 0).any(axis=0)]
149
+ if unmapped.size:
150
+ raise ValueError(f"dates with day types outside the library: {sorted(set(unmapped))}")
151
+ return day_index
152
+
153
+
154
+ # --------------------------------------------------------------------------- #
155
+ # Annual calendar object
156
+ # --------------------------------------------------------------------------- #
157
+
158
+
159
+ @dataclass
160
+ class AnnualCalendar:
161
+ """Compact annual calendars for a set of occupants.
162
+
163
+ Attributes:
164
+ library: the diary library the days come from.
165
+ occupant_ids: occupant identifiers, in row order.
166
+ dates: ``datetime64[D]`` dates covered (a whole year by default).
167
+ day_index: ``int32`` ``(n_occupants, n_dates)`` positions in ``library.codes``.
168
+ assignments: cluster per day type and match level per occupant.
169
+ time_zone: clock the slots are expressed in.
170
+ """
171
+
172
+ library: DiaryLibrary
173
+ occupant_ids: np.ndarray
174
+ dates: np.ndarray
175
+ day_index: np.ndarray
176
+ assignments: pl.DataFrame
177
+ time_zone: str = CLOCK
178
+
179
+ @property
180
+ def n_occupants(self) -> int:
181
+ return self.day_index.shape[0]
182
+
183
+ @property
184
+ def n_steps(self) -> int:
185
+ return self.day_index.shape[1] * SLOTS_PER_DAY
186
+
187
+ def timeline(self) -> pl.Series:
188
+ """Naive ``Datetime`` axis, one stamp per slot, local clock."""
189
+ start = datetime.combine(self.dates[0].astype(date), datetime.min.time())
190
+ end = start + timedelta(days=len(self.dates))
191
+ return pl.datetime_range(
192
+ start, end, interval=f"{SLOT_MINUTES}m", closed="left", eager=True, time_unit="us"
193
+ ).alias("datetime")
194
+
195
+ def codes(self, start: int = 0, stop: int | None = None, level: str = "detailed") -> np.ndarray:
196
+ """``uint8`` codes ``(n, n_steps)`` for occupants ``start:stop``, at ``level``."""
197
+ block = self.library.codes[self.day_index[start:stop]]
198
+ block = block.reshape(block.shape[0], -1)
199
+ if level != "detailed":
200
+ block = get_level(level).project(block)
201
+ return block
202
+
203
+ def to_long(self, start: int = 0, stop: int | None = None) -> pl.DataFrame:
204
+ """Occupant-major long frame: ``occupant_id``, ``datetime``, ``activity`` (Enum).
205
+
206
+ This is the layout building_eload's dwelling model consumes: every
207
+ occupant's whole year contiguous, time ascending.
208
+ """
209
+ codes = self.codes(start, stop)
210
+ n = codes.shape[0]
211
+ timeline = self.timeline()
212
+ activity = (
213
+ pl.Series("activity", codes.ravel(), dtype=pl.UInt8)
214
+ .to_frame()
215
+ .select(
216
+ pl.col("activity").replace_strict(
217
+ dict(enumerate(DETAILED_ACTIVITIES)),
218
+ return_dtype=pl.Enum(list(DETAILED_ACTIVITIES)),
219
+ )
220
+ )
221
+ .to_series()
222
+ )
223
+ ids = pl.Series("occupant_id", np.repeat(self.occupant_ids[start:stop], timeline.len()))
224
+ return pl.DataFrame([ids, pl.concat([timeline] * n) if n else timeline.head(0), activity])
225
+
226
+ def to_wide_book(self, start: int = 0, stop: int | None = None) -> pl.DataFrame:
227
+ """``datetime`` + one ``String`` column per occupant (``occupant_<i>``).
228
+
229
+ The legacy building_eload / buildingdata "occupant book" layout. Kept
230
+ for comparisons and for the current ``activity_file`` parameter; it is
231
+ the memory-hungry representation, not the one to build on.
232
+ """
233
+ codes = self.codes(start, stop)
234
+ names = np.array(DETAILED_ACTIVITIES, dtype=object)
235
+ columns = [self.timeline()]
236
+ for i, row in enumerate(codes, start=start):
237
+ columns.append(pl.Series(f"occupant_{i}", names[row], dtype=pl.String))
238
+ return pl.DataFrame(columns)
239
+
240
+ def match_level_counts(self) -> pl.DataFrame:
241
+ return (
242
+ self.assignments.group_by(MATCH_LEVEL_COLUMN)
243
+ .agg(pl.len().alias("n"))
244
+ .sort(MATCH_LEVEL_COLUMN)
245
+ )
246
+
247
+
248
+ def generate_calendar(
249
+ library: DiaryLibrary,
250
+ occupants: pl.DataFrame,
251
+ year: int,
252
+ seed: int | None = 0,
253
+ ladder=DEFAULT_MATCHING_LADDER,
254
+ holiday_day_type: str | None = "sunday",
255
+ occupant_id: str = "occupant_id",
256
+ ) -> AnnualCalendar:
257
+ """Match occupants to clusters and draw a whole year of days for each.
258
+
259
+ Args:
260
+ library: the diary library.
261
+ occupants: one row per occupant with ``occupant_id`` and the harmonised
262
+ matching columns (see :mod:`activitymodel.harmonise`).
263
+ year: calendar year to generate (leap years give 366 days).
264
+ seed: master seed; matching and day sampling use two child streams so
265
+ changing the year does not change the cluster assignment.
266
+ ladder: matching fallback ladder.
267
+ holiday_day_type: day type used for French public holidays.
268
+ occupant_id: identifier column.
269
+
270
+ Returns:
271
+ An :class:`AnnualCalendar`.
272
+ """
273
+ match_rng, day_rng = (np.random.default_rng(s) for s in np.random.SeedSequence(seed).spawn(2))
274
+ assignments = assign_clusters(
275
+ library, occupants, seed=match_rng, ladder=ladder, occupant_id=occupant_id
276
+ )
277
+ dates = year_dates(year)
278
+ day_types = day_types_for_dates(dates, holiday_day_type=holiday_day_type)
279
+ day_index = sample_days(library, assignments, day_types, day_rng)
280
+ return AnnualCalendar(
281
+ library=library,
282
+ occupant_ids=occupants.get_column(occupant_id).to_numpy(),
283
+ dates=dates,
284
+ day_index=day_index,
285
+ assignments=assignments,
286
+ )
@@ -0,0 +1,220 @@
1
+ """Cluster coded diaries into daily activity patterns.
2
+
3
+ The previous implementation ran Ward linkage on integer activity labels with a
4
+ Jaccard metric, which is not a metric on ordinal codes and not the distance
5
+ Ward minimises. Here a day is a one-hot matrix (144 slots x activities); the
6
+ Euclidean distance between two such rows is a scaled Hamming distance between
7
+ the two days, which is exactly what Ward's within-cluster variance measures.
8
+
9
+ Two methods share the same representation:
10
+
11
+ * ``ward`` (default): principal components of the one-hot matrix, then Ward
12
+ linkage (``fastcluster.linkage_vector`` when installed, scipy otherwise), cut
13
+ at the requested number of clusters. Deterministic.
14
+ * ``kmeans``: weighted k-means++ on the same components. Honours the survey
15
+ weights, cheaper on very large pools, seeded.
16
+
17
+ Cluster counts are a *choice* recorded in the library manifest;
18
+ :func:`distance_growth` and :func:`silhouette_by_k` give the curves used to
19
+ choose them.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass, field
25
+
26
+ import numpy as np
27
+ from scipy.cluster.hierarchy import fcluster
28
+ from scipy.cluster.hierarchy import linkage as _scipy_linkage
29
+
30
+ from .taxonomy import get_level
31
+
32
+ try: # optional memory-saving linkage
33
+ from fastcluster import linkage_vector as _fast_linkage
34
+ except ModuleNotFoundError: # pragma: no cover - exercised only without the extra
35
+ _fast_linkage = None
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class ClusteringOptions:
40
+ """How diaries are clustered, per day type."""
41
+
42
+ #: Number of clusters per day type.
43
+ n_clusters: dict[str, int] = field(
44
+ default_factory=lambda: {"weekday": 16, "saturday": 10, "sunday": 10}
45
+ )
46
+ method: str = "ward"
47
+ #: Taxonomy level whose activities define the one-hot representation.
48
+ level: str = "detailed"
49
+ #: Consecutive slots averaged together (3 = 30-minute resolution).
50
+ slot_step: int = 1
51
+ #: Principal components kept before clustering (``None`` = no reduction).
52
+ n_components: int | None = 50
53
+ seed: int = 0
54
+
55
+
56
+ def one_hot(codes: np.ndarray, level: str = "detailed", slot_step: int = 1) -> np.ndarray:
57
+ """``uint8`` diaries ``(n, 144)`` -> float32 ``(n, slots * activities)``.
58
+
59
+ With ``slot_step > 1`` the one-hot vectors of consecutive slots are
60
+ averaged, giving the share of each activity within the coarser slot.
61
+ """
62
+ lvl = get_level(level)
63
+ projected = lvl.project(codes)
64
+ n, slots = projected.shape
65
+ if slots % slot_step:
66
+ raise ValueError(f"slot_step {slot_step} does not divide {slots} slots")
67
+ eye = np.eye(lvl.n_activities, dtype=np.float32)
68
+ matrix = eye[projected] # (n, slots, activities)
69
+ if slot_step > 1:
70
+ matrix = matrix.reshape(n, slots // slot_step, slot_step, lvl.n_activities).mean(axis=2)
71
+ return matrix.reshape(n, -1)
72
+
73
+
74
+ def reduce_components(matrix: np.ndarray, n_components: int | None) -> np.ndarray:
75
+ """Project onto the leading principal components (centred SVD)."""
76
+ if n_components is None or n_components >= min(matrix.shape):
77
+ return matrix
78
+ centred = matrix - matrix.mean(axis=0, keepdims=True)
79
+ # Economy SVD on the smaller Gram side keeps memory at O(d^2).
80
+ cov = centred.T @ centred
81
+ values, vectors = np.linalg.eigh(cov)
82
+ order = np.argsort(values)[::-1][:n_components]
83
+ return (centred @ vectors[:, order]).astype(np.float32)
84
+
85
+
86
+ def ward_linkage(matrix: np.ndarray) -> np.ndarray:
87
+ """Ward linkage matrix, memory-saving when fastcluster is available."""
88
+ data = np.ascontiguousarray(matrix, dtype=np.float64)
89
+ if _fast_linkage is not None:
90
+ return _fast_linkage(data, method="ward")
91
+ return _scipy_linkage(data, method="ward")
92
+
93
+
94
+ def cut(linkage: np.ndarray, n_clusters: int) -> np.ndarray:
95
+ """Cut a linkage matrix into ``n_clusters`` labels ``0..n_clusters-1``."""
96
+ return fcluster(linkage, t=n_clusters, criterion="maxclust").astype(np.int32) - 1
97
+
98
+
99
+ def distance_growth(linkage: np.ndarray, max_k: int = 60) -> np.ndarray:
100
+ """Merge height reached when going from ``k`` to ``k-1`` clusters, for k=2..max_k.
101
+
102
+ Returns an array of shape ``(max_k - 1, 2)`` with columns ``k`` and
103
+ ``height``. A knee in this curve is the usual elbow criterion.
104
+ """
105
+ heights = linkage[:, 2][::-1] # last merge first
106
+ ks = np.arange(2, min(max_k, len(heights) + 1) + 1)
107
+ return np.column_stack([ks, heights[ks - 2]])
108
+
109
+
110
+ def weighted_kmeans(
111
+ matrix: np.ndarray,
112
+ n_clusters: int,
113
+ weights: np.ndarray | None,
114
+ rng: np.random.Generator,
115
+ n_init: int = 4,
116
+ max_iter: int = 100,
117
+ ) -> np.ndarray:
118
+ """Weighted Lloyd's algorithm with k-means++ seeding. Returns labels."""
119
+ n = matrix.shape[0]
120
+ w = np.ones(n) if weights is None else np.asarray(weights, dtype=float)
121
+ best_labels, best_inertia = None, np.inf
122
+ for _ in range(n_init):
123
+ centres = _kmeanspp(matrix, n_clusters, w, rng)
124
+ labels = None
125
+ for _ in range(max_iter):
126
+ distances = _sq_distances(matrix, centres)
127
+ new_labels = distances.argmin(axis=1)
128
+ if labels is not None and np.array_equal(new_labels, labels):
129
+ break
130
+ labels = new_labels
131
+ for k in range(n_clusters):
132
+ mask = labels == k
133
+ if mask.any():
134
+ centres[k] = np.average(matrix[mask], axis=0, weights=w[mask])
135
+ else: # re-seed an empty cluster on the farthest point
136
+ centres[k] = matrix[distances.min(axis=1).argmax()]
137
+ inertia = float((w * distances[np.arange(n), labels]).sum())
138
+ if inertia < best_inertia:
139
+ best_inertia, best_labels = inertia, labels.copy()
140
+ return _relabel_by_size(best_labels, n_clusters)
141
+
142
+
143
+ def _sq_distances(matrix: np.ndarray, centres: np.ndarray) -> np.ndarray:
144
+ # Clamped at zero: the expanded form can go slightly negative in float32.
145
+ return np.maximum(
146
+ (matrix**2).sum(axis=1)[:, None]
147
+ - 2 * matrix @ centres.T
148
+ + (centres**2).sum(axis=1)[None, :],
149
+ 0.0,
150
+ )
151
+
152
+
153
+ def _kmeanspp(matrix, n_clusters, weights, rng):
154
+ n = matrix.shape[0]
155
+ centres = np.empty((n_clusters, matrix.shape[1]), dtype=matrix.dtype)
156
+ centres[0] = matrix[rng.choice(n, p=weights / weights.sum())]
157
+ closest = _sq_distances(matrix, centres[:1]).ravel()
158
+ for k in range(1, n_clusters):
159
+ p = weights * closest
160
+ p = p / p.sum() if p.sum() > 0 else weights / weights.sum()
161
+ centres[k] = matrix[rng.choice(n, p=p)]
162
+ closest = np.minimum(closest, _sq_distances(matrix, centres[k : k + 1]).ravel())
163
+ return centres
164
+
165
+
166
+ def _relabel_by_size(labels: np.ndarray, n_clusters: int) -> np.ndarray:
167
+ """Renumber clusters by decreasing size so labels are stable to read."""
168
+ counts = np.bincount(labels, minlength=n_clusters)
169
+ order = np.argsort(-counts, kind="stable")
170
+ mapping = np.empty(n_clusters, dtype=np.int32)
171
+ mapping[order] = np.arange(n_clusters, dtype=np.int32)
172
+ return mapping[labels]
173
+
174
+
175
+ def cluster_codes(
176
+ codes: np.ndarray,
177
+ n_clusters: int,
178
+ options: ClusteringOptions,
179
+ weights: np.ndarray | None = None,
180
+ rng: np.random.Generator | None = None,
181
+ ) -> np.ndarray:
182
+ """Cluster one pool of diaries (one day type). Returns labels ``0..n_clusters-1``."""
183
+ if n_clusters < 1:
184
+ raise ValueError("n_clusters must be at least 1")
185
+ if codes.shape[0] <= n_clusters:
186
+ raise ValueError(f"cannot form {n_clusters} clusters from {codes.shape[0]} diaries")
187
+ matrix = reduce_components(
188
+ one_hot(codes, options.level, options.slot_step), options.n_components
189
+ )
190
+ if options.method == "ward":
191
+ return _relabel_by_size(cut(ward_linkage(matrix), n_clusters), n_clusters)
192
+ if options.method == "kmeans":
193
+ rng = np.random.default_rng(options.seed) if rng is None else rng
194
+ return weighted_kmeans(matrix, n_clusters, weights, rng)
195
+ raise ValueError(f"unknown clustering method {options.method!r}")
196
+
197
+
198
+ def silhouette_by_k(
199
+ matrix: np.ndarray, linkage: np.ndarray, ks, rng, sample: int = 3000
200
+ ) -> np.ndarray:
201
+ """Mean silhouette on a random sample for each ``k`` (columns ``k``, ``silhouette``)."""
202
+ n = matrix.shape[0]
203
+ idx = rng.choice(n, size=min(sample, n), replace=False)
204
+ sub = matrix[idx]
205
+ d = np.sqrt(np.maximum(_sq_distances(sub, sub), 0))
206
+ out = []
207
+ for k in ks:
208
+ labels = cut(linkage, k)[idx]
209
+ s = np.zeros(len(idx))
210
+ for i in range(len(idx)):
211
+ own = labels == labels[i]
212
+ own[i] = False
213
+ a = d[i, own].mean() if own.any() else 0.0
214
+ b = min(
215
+ (d[i, labels == other].mean() for other in np.unique(labels) if other != labels[i]),
216
+ default=0.0,
217
+ )
218
+ s[i] = 0.0 if max(a, b) == 0 else (b - a) / max(a, b)
219
+ out.append((k, float(s.mean())))
220
+ return np.array(out)
@@ -0,0 +1,14 @@
1
+ """Checks that the library and the matching behave: profiles, distances, effect scores."""
2
+
3
+ from .association import association_scores, weighted_cramers_v
4
+ from .distance import matrix_distance
5
+ from .profiles import activity_probability_matrix, calendar_profile, profile_frame
6
+
7
+ __all__ = [
8
+ "activity_probability_matrix",
9
+ "association_scores",
10
+ "calendar_profile",
11
+ "matrix_distance",
12
+ "profile_frame",
13
+ "weighted_cramers_v",
14
+ ]
@@ -0,0 +1,93 @@
1
+ """How much each socio-demographic variable explains the cluster a diary falls in.
2
+
3
+ Used to order the matching ladder: the variables that carry the least signal
4
+ are the first to be dropped when a cell is empty.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+ import polars as pl
11
+
12
+
13
+ def weighted_cramers_v(x: np.ndarray, y: np.ndarray, weights: np.ndarray) -> float:
14
+ """Weighted Cramer's V between two integer-coded categorical arrays."""
15
+ n_x, n_y = int(x.max()) + 1, int(y.max()) + 1
16
+ table = np.zeros((n_x, n_y))
17
+ np.add.at(table, (x, y), weights)
18
+ total = table.sum()
19
+ if total <= 0 or n_x < 2 or n_y < 2:
20
+ return 0.0
21
+ expected = np.outer(table.sum(axis=1), table.sum(axis=0)) / total
22
+ valid = expected > 0
23
+ chi2 = (((table - expected) ** 2)[valid] / expected[valid]).sum()
24
+ return float(np.sqrt(max(chi2 / (total * min(n_x - 1, n_y - 1)), 0.0)))
25
+
26
+
27
+ def association_scores(
28
+ frame: pl.DataFrame,
29
+ target: str,
30
+ variables: list[str],
31
+ weight: str | None = None,
32
+ n_permutations: int = 199,
33
+ seed: int = 0,
34
+ group: str | None = None,
35
+ ) -> pl.DataFrame:
36
+ """Cramer's V of each variable with ``target`` plus a permutation p-value.
37
+
38
+ Args:
39
+ frame: one row per diary with the target (cluster label), the
40
+ variables and optionally a weight and a grouping column (the
41
+ person, so permutations respect the two-diaries-per-person design).
42
+ target: cluster label column.
43
+ variables: categorical columns to score.
44
+ weight: weight column (``None`` = unweighted).
45
+ n_permutations: permutations for the p-value.
46
+ seed: RNG seed.
47
+ group: column defining the permutation unit.
48
+
49
+ Returns:
50
+ Frame ``variable``, ``n_categories``, ``cramers_v``, ``p_value``, sorted
51
+ by decreasing association.
52
+ """
53
+ rng = np.random.default_rng(seed)
54
+ y = frame.get_column(target).cast(pl.String).rank("dense").cast(pl.Int64).to_numpy() - 1
55
+ w = (
56
+ np.ones(frame.height)
57
+ if weight is None
58
+ else frame.get_column(weight).cast(pl.Float64).fill_null(0).to_numpy()
59
+ )
60
+ if group is not None:
61
+ groups = frame.get_column(group).cast(pl.String).rank("dense").cast(pl.Int64).to_numpy() - 1
62
+ rows = []
63
+ for variable in variables:
64
+ x = (
65
+ frame.get_column(variable)
66
+ .cast(pl.String)
67
+ .fill_null("<null>")
68
+ .rank("dense")
69
+ .cast(pl.Int64)
70
+ .to_numpy()
71
+ - 1
72
+ )
73
+ observed = weighted_cramers_v(x, y, w)
74
+ at_least = 0
75
+ for _ in range(n_permutations):
76
+ if group is None:
77
+ shuffled = rng.permutation(x)
78
+ else:
79
+ # permute the variable at the group level: every diary of a
80
+ # person receives the value of another person.
81
+ first = np.unique(groups, return_index=True)[1]
82
+ values = x[first]
83
+ shuffled = rng.permutation(values)[np.searchsorted(groups[first], groups)]
84
+ at_least += weighted_cramers_v(shuffled, y, w) >= observed
85
+ rows.append(
86
+ {
87
+ "variable": variable,
88
+ "n_categories": int(x.max()) + 1,
89
+ "cramers_v": observed,
90
+ "p_value": (at_least + 1) / (n_permutations + 1),
91
+ }
92
+ )
93
+ return pl.DataFrame(rows).sort("cramers_v", descending=True)
@@ -0,0 +1,33 @@
1
+ """Distance between two probability matrices with the same row/column layout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+
8
+ def matrix_distance(a, b, method: str = "tv", normalize: bool = True) -> float:
9
+ """Mean per-row distance in ``[0, 1]`` between two probability matrices.
10
+
11
+ ``tv`` is the mean total-variation distance, ``hellinger`` the mean
12
+ Hellinger distance. 0 means identical rows, 1 means disjoint support on
13
+ every row.
14
+ """
15
+ a = np.asarray(a, dtype=float)
16
+ b = np.asarray(b, dtype=float)
17
+ if a.shape != b.shape:
18
+ raise ValueError(f"shape mismatch: {a.shape} vs {b.shape}")
19
+ if normalize:
20
+ a, b = _normalise_rows(a), _normalise_rows(b)
21
+ if method == "tv":
22
+ per_row = 0.5 * np.abs(a - b).sum(axis=1)
23
+ elif method == "hellinger":
24
+ per_row = np.sqrt(0.5 * ((np.sqrt(a) - np.sqrt(b)) ** 2).sum(axis=1))
25
+ else:
26
+ raise ValueError("method must be 'tv' or 'hellinger'")
27
+ return float(per_row.mean())
28
+
29
+
30
+ def _normalise_rows(m: np.ndarray) -> np.ndarray:
31
+ totals = m.sum(axis=1, keepdims=True)
32
+ totals[totals == 0] = 1.0
33
+ return m / totals