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,189 @@
1
+ """Assign each occupant a cluster per day type from their socio-demographics.
2
+
3
+ The previous implementation drew a *survey diary* per occupant through
4
+ ``buildingmodel.join_random_by_columns`` (repeating every diary row
5
+ ``pond_carnet`` times, i.e. tens of millions of rows) and matched weekday and
6
+ weekend independently, losing the fact that the same respondent filled both.
7
+
8
+ Here the draw is over the library's aggregate ``patterns`` table:
9
+
10
+ 1. Occupants are grouped by the values of the keys of the current ladder
11
+ level; the pattern rows are summed to the same keys.
12
+ 2. Inside each cell one pattern row (a triple ``cluster_weekday``,
13
+ ``cluster_saturday``, ``cluster_sunday``, some possibly null) is drawn per
14
+ occupant with probability proportional to the summed person weight. Drawing
15
+ a whole row keeps the weekday/weekend coupling observed on real people.
16
+ 3. Occupants whose cell has no pattern fall through to the next, coarser level.
17
+ 4. Day types left null by the drawn pattern are filled from the marginal
18
+ distribution of that day type, again walking the ladder from the finest
19
+ level with support.
20
+
21
+ Everything is driven by one ``numpy.random.Generator``; cells are visited in
22
+ sorted order, so a seed gives the same assignment on every machine.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from collections.abc import Sequence
28
+
29
+ import numpy as np
30
+ import polars as pl
31
+
32
+ from .exceptions import MatchingError, VocabularyError
33
+ from .library import DiaryLibrary, cluster_column
34
+ from .vocabulary import DEFAULT_MATCHING_LADDER
35
+
36
+ MATCH_LEVEL_COLUMN = "match_level"
37
+ _CELL = "_cell"
38
+ _NULL_TOKEN = "\x00"
39
+
40
+
41
+ def _cell_expr(keys: Sequence[str]) -> pl.Expr:
42
+ if not keys:
43
+ return pl.lit("")
44
+ return pl.concat_str(
45
+ [pl.col(k).cast(pl.String).fill_null(_NULL_TOKEN) for k in keys], separator="|"
46
+ )
47
+
48
+
49
+ def _augment(frame: pl.DataFrame) -> pl.DataFrame:
50
+ """Derive the coarser ``activity_status`` when only ``employment_status`` exists."""
51
+ if "activity_status" not in frame.columns and "employment_status" in frame.columns:
52
+ frame = frame.with_columns(
53
+ activity_status=pl.when(pl.col("employment_status").str.starts_with("employed"))
54
+ .then(pl.lit("employed"))
55
+ .otherwise(pl.col("employment_status"))
56
+ )
57
+ return frame
58
+
59
+
60
+ def _draw_rows(
61
+ occupants: pl.DataFrame,
62
+ table: pl.DataFrame,
63
+ keys: Sequence[str],
64
+ value_columns: Sequence[str],
65
+ rng: np.random.Generator,
66
+ ) -> pl.DataFrame:
67
+ """Draw one ``table`` row per occupant inside its cell; unmatched occupants are dropped.
68
+
69
+ Returns ``occupants`` columns ``_occ`` plus ``value_columns``.
70
+ """
71
+ grouped = (
72
+ table.group_by(list(keys) + list(value_columns), maintain_order=False)
73
+ .agg(pl.col("weight").sum())
74
+ .with_columns(_cell_expr(keys).alias(_CELL))
75
+ .sort([_CELL] + list(value_columns), nulls_last=True)
76
+ )
77
+ occ = occupants.with_columns(_cell_expr(keys).alias(_CELL))
78
+ pools = grouped.partition_by(_CELL, as_dict=True, maintain_order=True)
79
+ pieces = []
80
+ for (cell,), members in sorted(occ.partition_by(_CELL, as_dict=True).items()):
81
+ pool = pools.get((cell,))
82
+ if pool is None:
83
+ continue
84
+ weights = pool.get_column("weight").to_numpy()
85
+ picks = rng.choice(pool.height, size=members.height, p=weights / weights.sum())
86
+ pieces.append(
87
+ members.select("_occ").with_columns(
88
+ pool.select(list(value_columns))[picks].get_columns()
89
+ )
90
+ )
91
+ if not pieces:
92
+ return (
93
+ occupants.select("_occ")
94
+ .head(0)
95
+ .with_columns([pl.lit(None, dtype=table.schema[c]).alias(c) for c in value_columns])
96
+ )
97
+ return pl.concat(pieces)
98
+
99
+
100
+ def assign_clusters(
101
+ library: DiaryLibrary,
102
+ occupants: pl.DataFrame,
103
+ seed: int | np.random.Generator | None = 0,
104
+ ladder: Sequence[Sequence[str]] = DEFAULT_MATCHING_LADDER,
105
+ occupant_id: str = "occupant_id",
106
+ ) -> pl.DataFrame:
107
+ """Assign one cluster per day type to every occupant.
108
+
109
+ Args:
110
+ library: the diary library.
111
+ occupants: one row per occupant with ``occupant_id`` and the harmonised
112
+ key columns used by ``ladder``.
113
+ seed: integer seed or a ``numpy.random.Generator``.
114
+ ladder: key subsets tried from finest to coarsest; every key must be a
115
+ library matching key or ``activity_status``. The last level should
116
+ be empty so nobody is left unmatched.
117
+ occupant_id: name of the occupant identifier column.
118
+
119
+ Returns:
120
+ Frame with ``occupant_id``, ``cluster_<day_type>`` (``Int16``) for each
121
+ library day type and ``match_level`` (``Int8``, index of the ladder
122
+ level that matched, 0 = finest).
123
+
124
+ Raises:
125
+ VocabularyError: an occupant column required by the ladder is missing.
126
+ MatchingError: occupants remain unmatched after the last level.
127
+ """
128
+ rng = seed if isinstance(seed, np.random.Generator) else np.random.default_rng(seed)
129
+ if occupant_id not in occupants.columns:
130
+ raise VocabularyError(f"occupants lack identifier column {occupant_id!r}")
131
+ if occupants.get_column(occupant_id).n_unique() != occupants.height:
132
+ raise VocabularyError(f"{occupant_id!r} is not unique")
133
+
134
+ patterns = _augment(library.patterns)
135
+ allowed = set(patterns.columns)
136
+ needed = {k for level in ladder for k in level}
137
+ unknown = needed - allowed
138
+ if unknown:
139
+ raise VocabularyError(
140
+ f"ladder uses keys absent from the library patterns: {sorted(unknown)}"
141
+ )
142
+ occ = _augment(occupants)
143
+ missing = needed - set(occ.columns)
144
+ if missing:
145
+ raise VocabularyError(f"occupants lack columns required by the ladder: {sorted(missing)}")
146
+ value_columns = library.cluster_columns
147
+ occ = occ.with_row_index("_occ")
148
+
149
+ # Pass 1: joint draw of a pattern row, walking the ladder.
150
+ remaining = occ
151
+ assigned = []
152
+ for level, keys in enumerate(ladder):
153
+ if remaining.height == 0:
154
+ break
155
+ drawn = _draw_rows(remaining, patterns, keys, value_columns, rng)
156
+ if drawn.height:
157
+ assigned.append(
158
+ drawn.with_columns(pl.lit(level, dtype=pl.Int8).alias(MATCH_LEVEL_COLUMN))
159
+ )
160
+ remaining = remaining.join(drawn.select("_occ"), on="_occ", how="anti")
161
+ if remaining.height:
162
+ raise MatchingError(
163
+ f"{remaining.height} occupants matched no pattern even at the coarsest level; "
164
+ "end the ladder with an empty key set"
165
+ )
166
+ result = pl.concat(assigned).join(occ, on="_occ", how="left")
167
+
168
+ # Pass 2: fill day types the drawn pattern left null from the marginals.
169
+ for day_type in library.day_types:
170
+ column = cluster_column(day_type)
171
+ marginal = patterns.filter(pl.col(column).is_not_null())
172
+ for keys in ladder:
173
+ holes = result.filter(pl.col(column).is_null())
174
+ if holes.height == 0:
175
+ break
176
+ filled = _draw_rows(holes, marginal, keys, [column], rng).rename({column: "_fill"})
177
+ result = (
178
+ result.join(filled, on="_occ", how="left")
179
+ .with_columns(pl.coalesce([pl.col(column), pl.col("_fill")]).alias(column))
180
+ .drop("_fill")
181
+ )
182
+ if result.get_column(column).null_count():
183
+ raise MatchingError(f"no pattern provides a {day_type} cluster at any ladder level")
184
+
185
+ return (
186
+ result.sort("_occ")
187
+ .select([occupant_id] + value_columns + [MATCH_LEVEL_COLUMN])
188
+ .with_columns([pl.col(c).cast(pl.Int16) for c in value_columns])
189
+ )
@@ -0,0 +1,239 @@
1
+ """Build a :class:`~activitymodel.library.DiaryLibrary` from the survey.
2
+
3
+ This is the entry point the buildingdata Snakemake rule calls::
4
+
5
+ from activitymodel.pipeline import LibraryBuildOptions, build_library
6
+ library = build_library(survey_directory, LibraryBuildOptions())
7
+ library.write(output_directory)
8
+
9
+ It can also be run by hand::
10
+
11
+ python -m activitymodel.pipeline /path/to/edt2010 /path/to/output --weekday 16 --saturday 10 --sunday 10
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import datetime as dt
18
+ from dataclasses import asdict, dataclass, field
19
+ from pathlib import Path
20
+
21
+ import numpy as np
22
+ import polars as pl
23
+
24
+ from . import __version__
25
+ from .clustering import ClusteringOptions, cluster_codes
26
+ from .library import (
27
+ CLOCK,
28
+ SLOT_COLUMNS,
29
+ SLOT_MINUTES,
30
+ SLOTS_PER_DAY,
31
+ DiaryLibrary,
32
+ cluster_column,
33
+ )
34
+ from .survey.diaries import DAY_TYPES, DiaryOptions, DiaryTable, build_diaries
35
+ from .survey.io import SurveyFiles, load_survey_tables
36
+ from .taxonomy import DETAILED_ACTIVITIES
37
+ from .vocabulary import DEFAULT_MATCHING_KEYS
38
+
39
+ DEFAULT_SOURCE = {
40
+ "name": "INSEE Enquete Emploi du temps 2009-2010",
41
+ "distributor": "PROGEDO-ADISP",
42
+ "doi": "10.13144/lil-0695",
43
+ "url": "https://data.progedo.fr/studies/doi/10.13144/lil-0695",
44
+ "access": "on request, not redistributable; the library holds no microdata row",
45
+ }
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class LibraryBuildOptions:
50
+ """Everything that shapes a library build, recorded in its manifest."""
51
+
52
+ diaries: DiaryOptions = field(default_factory=DiaryOptions)
53
+ clustering: ClusteringOptions = field(default_factory=ClusteringOptions)
54
+ matching_keys: tuple[str, ...] = DEFAULT_MATCHING_KEYS
55
+ source: dict = field(default_factory=lambda: dict(DEFAULT_SOURCE))
56
+
57
+
58
+ def build_library(
59
+ survey: str | Path | SurveyFiles | tuple[pl.DataFrame, pl.DataFrame, pl.DataFrame],
60
+ options: LibraryBuildOptions | None = None,
61
+ ) -> DiaryLibrary:
62
+ """Read the survey, code and cluster the diaries, assemble the library.
63
+
64
+ Args:
65
+ survey: a directory holding the ADISP files, a :class:`SurveyFiles`, or
66
+ the three raw frames ``(diaries, individuals, households)``.
67
+ options: build options (defaults to :class:`LibraryBuildOptions`).
68
+ """
69
+ options = LibraryBuildOptions() if options is None else options
70
+ if isinstance(survey, tuple):
71
+ diaries, individuals, households = survey
72
+ else:
73
+ files = survey if isinstance(survey, SurveyFiles) else SurveyFiles.from_directory(survey)
74
+ diaries, individuals, households = load_survey_tables(files)
75
+ table = build_diaries(diaries, individuals, households, options.diaries)
76
+ labels = cluster_diaries(table, options.clustering)
77
+ return assemble_library(table, labels, options)
78
+
79
+
80
+ def cluster_diaries(table: DiaryTable, options: ClusteringOptions) -> dict[str, np.ndarray]:
81
+ """Cluster each day type's pool. Returns ``day_type -> labels`` (aligned with the table)."""
82
+ rng = np.random.default_rng(options.seed)
83
+ day_types = table.frame.get_column("day_type").to_numpy().astype(object)
84
+ weights = table.frame.get_column("weight").to_numpy()
85
+ labels = {}
86
+ for day_type in DAY_TYPES:
87
+ rows = np.flatnonzero(day_types == day_type)
88
+ if rows.size == 0:
89
+ continue
90
+ k = options.n_clusters.get(day_type)
91
+ if k is None:
92
+ raise ValueError(f"no cluster count configured for day type {day_type!r}")
93
+ out = np.full(table.n_diaries, -1, dtype=np.int32)
94
+ out[rows] = cluster_codes(table.codes[rows], k, options, weights=weights[rows], rng=rng)
95
+ labels[day_type] = out
96
+ return labels
97
+
98
+
99
+ def assemble_library(
100
+ table: DiaryTable, labels: dict[str, np.ndarray], options: LibraryBuildOptions
101
+ ) -> DiaryLibrary:
102
+ """Turn a coded diary table and its cluster labels into the published tables."""
103
+ day_types = tuple(d for d in DAY_TYPES if d in labels)
104
+ frame = table.frame.with_row_index("_row")
105
+ keys = list(options.matching_keys)
106
+
107
+ # Global cluster ids: contiguous blocks per day type, in DAY_TYPES order.
108
+ cluster_index = np.full(table.n_diaries, -1, dtype=np.int32)
109
+ cluster_id = np.full(table.n_diaries, -1, dtype=np.int32)
110
+ offset = 0
111
+ cluster_rows = []
112
+ for day_type in day_types:
113
+ mask = labels[day_type] >= 0
114
+ k = int(labels[day_type][mask].max()) + 1
115
+ cluster_index[mask] = labels[day_type][mask]
116
+ cluster_id[mask] = offset + labels[day_type][mask]
117
+ for i in range(k):
118
+ cluster_rows.append(
119
+ {"cluster_id": offset + i, "day_type": day_type, "cluster_index": i}
120
+ )
121
+ offset += k
122
+ frame = frame.with_columns(
123
+ pl.Series("cluster_id", cluster_id, dtype=pl.Int16),
124
+ )
125
+
126
+ slots = pl.DataFrame(table.codes, schema=list(SLOT_COLUMNS)).cast(pl.UInt8)
127
+ days = frame.select(
128
+ pl.col("_row").cast(pl.UInt32).alias("day_id"),
129
+ pl.col("day_type").cast(pl.String),
130
+ pl.col("cluster_id"),
131
+ pl.col("weight").cast(pl.Float64),
132
+ ).with_columns(slots.get_columns())
133
+
134
+ clusters = (
135
+ pl.DataFrame(cluster_rows)
136
+ .cast({"cluster_id": pl.Int16, "cluster_index": pl.Int16})
137
+ .join(
138
+ days.group_by("cluster_id").agg(
139
+ pl.len().cast(pl.UInt32).alias("n_days"), pl.col("weight").sum().alias("weight_sum")
140
+ ),
141
+ on="cluster_id",
142
+ how="left",
143
+ )
144
+ .sort("cluster_id")
145
+ .select("cluster_id", "day_type", "cluster_index", "n_days", "weight_sum")
146
+ )
147
+
148
+ # One row per person: cluster per day type (first diary of that type),
149
+ # person weight (questionnaire weight, else mean diary weight), keys.
150
+ per_person = (
151
+ frame.sort("diary_id")
152
+ .group_by("person_id", maintain_order=True)
153
+ .agg(
154
+ [pl.col(k).first() for k in keys]
155
+ + [
156
+ pl.coalesce([pl.col("person_weight").first(), pl.col("weight").mean()]).alias(
157
+ "weight"
158
+ ),
159
+ ]
160
+ + [
161
+ pl.col("cluster_id")
162
+ .filter(pl.col("day_type") == d)
163
+ .first()
164
+ .alias(cluster_column(d))
165
+ for d in day_types
166
+ ]
167
+ )
168
+ )
169
+ patterns = (
170
+ per_person.group_by(keys + [cluster_column(d) for d in day_types])
171
+ .agg(pl.col("weight").sum(), pl.len().cast(pl.UInt32).alias("n_persons"))
172
+ .with_columns([pl.col(k).cast(pl.String) for k in keys])
173
+ .sort(keys + [cluster_column(d) for d in day_types], nulls_last=True)
174
+ )
175
+
176
+ manifest = {
177
+ "activitymodel_version": __version__,
178
+ "created_at": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"),
179
+ "source": dict(options.source),
180
+ "taxonomy_level": "detailed",
181
+ "activities": list(DETAILED_ACTIVITIES),
182
+ "slot_minutes": SLOT_MINUTES,
183
+ "slots_per_day": SLOTS_PER_DAY,
184
+ "clock": CLOCK,
185
+ "day_types": list(day_types),
186
+ "matching_keys": keys,
187
+ "age_class_bounds": list(options.diaries.age_classes.bounds),
188
+ "diary_options": _jsonable(asdict(options.diaries)),
189
+ "clustering": _jsonable(asdict(options.clustering)),
190
+ "n_days": int(days.height),
191
+ "n_persons": int(per_person.height),
192
+ }
193
+ return DiaryLibrary.from_frames(days, clusters, patterns, manifest)
194
+
195
+
196
+ def _jsonable(value):
197
+ if isinstance(value, dict):
198
+ return {str(k): _jsonable(v) for k, v in value.items()}
199
+ if isinstance(value, (list, tuple)):
200
+ return [_jsonable(v) for v in value]
201
+ if isinstance(value, (str, int, float, bool)) or value is None:
202
+ return value
203
+ if hasattr(value, "bounds"): # AgeClasses
204
+ return list(value.bounds)
205
+ return str(value)
206
+
207
+
208
+ def main(argv=None) -> None:
209
+ parser = argparse.ArgumentParser(
210
+ description="Build an activitymodel diary library from the EDT 2010 files."
211
+ )
212
+ parser.add_argument(
213
+ "survey_dir", type=Path, help="directory holding carnet/individu/menage (.parquet or .dta)"
214
+ )
215
+ parser.add_argument("output_dir", type=Path, help="directory receiving the library tables")
216
+ parser.add_argument("--weekday", type=int, default=16)
217
+ parser.add_argument("--saturday", type=int, default=10)
218
+ parser.add_argument("--sunday", type=int, default=10)
219
+ parser.add_argument("--method", choices=("ward", "kmeans"), default="ward")
220
+ parser.add_argument("--level", default="detailed", help="taxonomy level used for clustering")
221
+ parser.add_argument("--seed", type=int, default=0)
222
+ args = parser.parse_args(argv)
223
+ options = LibraryBuildOptions(
224
+ clustering=ClusteringOptions(
225
+ n_clusters={"weekday": args.weekday, "saturday": args.saturday, "sunday": args.sunday},
226
+ method=args.method,
227
+ level=args.level,
228
+ seed=args.seed,
229
+ )
230
+ )
231
+ library = build_library(args.survey_dir, options)
232
+ paths = library.write(args.output_dir)
233
+ print(library.summary())
234
+ for name, path in paths.items():
235
+ print(f" wrote {name:9s} -> {path}")
236
+
237
+
238
+ if __name__ == "__main__":
239
+ main()
@@ -0,0 +1,18 @@
1
+ """Reading and preparing the Enquete Emploi du temps 2009-2010 (pipeline side).
2
+
3
+ Nothing in this sub-package is imported by the matching or calendar modules:
4
+ building_eload never needs it, and it is the only part of activitymodel that
5
+ touches restricted survey microdata.
6
+ """
7
+
8
+ from .diaries import DiaryOptions, DiaryTable, build_diaries
9
+ from .io import SurveyFiles, load_survey_tables, read_table
10
+
11
+ __all__ = [
12
+ "DiaryOptions",
13
+ "DiaryTable",
14
+ "SurveyFiles",
15
+ "build_diaries",
16
+ "load_survey_tables",
17
+ "read_table",
18
+ ]