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.
- activitymodel/__init__.py +45 -0
- activitymodel/calendar.py +286 -0
- activitymodel/clustering.py +220 -0
- activitymodel/diagnostics/__init__.py +14 -0
- activitymodel/diagnostics/association.py +93 -0
- activitymodel/diagnostics/distance.py +33 -0
- activitymodel/diagnostics/profiles.py +59 -0
- activitymodel/exceptions.py +21 -0
- activitymodel/harmonise.py +269 -0
- activitymodel/library.py +325 -0
- activitymodel/matching.py +189 -0
- activitymodel/pipeline.py +239 -0
- activitymodel/survey/__init__.py +18 -0
- activitymodel/survey/diaries.py +285 -0
- activitymodel/survey/io.py +98 -0
- activitymodel/taxonomy.py +397 -0
- activitymodel/vocabulary.py +196 -0
- activitymodel-0.1.0.dist-info/METADATA +153 -0
- activitymodel-0.1.0.dist-info/RECORD +22 -0
- activitymodel-0.1.0.dist-info/WHEEL +5 -0
- activitymodel-0.1.0.dist-info/licenses/LICENCE.md +21 -0
- activitymodel-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|