ltc-code 0.1.87__tar.gz → 0.1.89__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: ltc-code
3
- Version: 0.1.87
3
+ Version: 0.1.89
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.9
6
6
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ltc-code"
3
- version = "0.1.87"
3
+ version = "0.1.89"
4
4
  description = "Add your description here"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -0,0 +1,205 @@
1
+ DEDUP_KEYS = [
2
+ "sid_cepr",
3
+ "school_year",
4
+ "entry_grade_clean",
5
+ "school_name",
6
+ ]
7
+
8
+ EVER_COLS = [
9
+ "offer",
10
+ "waitlist",
11
+ "withdraw",
12
+ "offer_accepted",
13
+ "enroll",
14
+ "sibling",
15
+ "staff",
16
+ "zoned",
17
+ ]
18
+
19
+ INFO_COLS = [
20
+ "lottery_number",
21
+ "waitlist_number",
22
+ "priority_group",
23
+ "priority_group_name",
24
+ "school_accept",
25
+ ]
26
+
27
+
28
+
29
+
30
+ from typing import List, Union
31
+
32
+ import polars as pl
33
+
34
+
35
+ def resolve_duplicate_applications(
36
+ df: Union[pl.DataFrame, pl.LazyFrame],
37
+ dedup_keys: List[str],
38
+ ever_cols: List[str],
39
+ info_cols: List[str],
40
+ ) -> Union[pl.DataFrame, pl.LazyFrame]:
41
+ columns = df.collect_schema().names() if isinstance(df, pl.LazyFrame) else df.columns
42
+
43
+ missing = [
44
+ c for c in dedup_keys + ever_cols + info_cols
45
+ if c not in columns
46
+ ]
47
+ if missing:
48
+ raise ValueError("Missing specified columns: %s" % missing)
49
+
50
+ has_late_app = "late_app" in columns
51
+ has_application_cancel = "application_cancel" in columns
52
+
53
+ # These are optional tie-breakers only. They are not required and they do
54
+ # not affect the collapsed event flags.
55
+ date_tiebreakers = [
56
+ c for c in ["application_date", "offer_date"]
57
+ if c in columns
58
+ ]
59
+
60
+ late_rank = (
61
+ pl.when(pl.col("late_app") == 1)
62
+ .then(2)
63
+ .when(pl.col("late_app").is_null())
64
+ .then(1)
65
+ .otherwise(0)
66
+ if has_late_app
67
+ else pl.lit(0)
68
+ )
69
+
70
+ cancel_rank = (
71
+ pl.when(pl.col("application_cancel") == 1)
72
+ .then(2)
73
+ .when(pl.col("application_cancel").is_null())
74
+ .then(1)
75
+ .otherwise(0)
76
+ if has_application_cancel
77
+ else pl.lit(0)
78
+ )
79
+
80
+ # Outcome ranking follows the order of ever_cols:
81
+ # earlier columns are treated as stronger representative-row evidence.
82
+ outcome_rank = pl.lit(len(ever_cols))
83
+ for i, col in reversed(list(enumerate(ever_cols))):
84
+ outcome_rank = (
85
+ pl.when(pl.col(col) == 1)
86
+ .then(i)
87
+ .otherwise(outcome_rank)
88
+ )
89
+
90
+ sort_cols = (
91
+ dedup_keys
92
+ + ["_late_rank", "_cancel_rank", "_outcome_rank", "_info_count"]
93
+ + date_tiebreakers
94
+ )
95
+
96
+ descending = (
97
+ [False] * len(dedup_keys)
98
+ + [
99
+ False, # prefer not late, if late_app exists
100
+ False, # prefer not cancelled, if application_cancel exists
101
+ False, # prefer earlier/stronger event columns from ever_cols
102
+ True, # prefer rows with more non-null info columns
103
+ ]
104
+ + [False] * len(date_tiebreakers)
105
+ )
106
+
107
+ ranked = (
108
+ df
109
+ .with_columns(pl.len().over(dedup_keys).alias("_dup_n"))
110
+ .with_columns(
111
+ _late_rank=late_rank,
112
+ _cancel_rank=cancel_rank,
113
+ _outcome_rank=outcome_rank,
114
+ _info_count=pl.sum_horizontal(
115
+ [pl.col(c).is_not_null().cast(pl.Int8) for c in info_cols]
116
+ ),
117
+ )
118
+ )
119
+
120
+ temp_cols = [
121
+ "_dup_n",
122
+ "_late_rank",
123
+ "_cancel_rank",
124
+ "_outcome_rank",
125
+ "_info_count",
126
+ ]
127
+
128
+ non_dupes = (
129
+ ranked
130
+ .filter(pl.col("_dup_n") == 1)
131
+ .drop(temp_cols, strict=False)
132
+ )
133
+
134
+ dupes = (
135
+ ranked
136
+ .filter(pl.col("_dup_n") > 1)
137
+ .sort(sort_cols, descending=descending, nulls_last=True)
138
+ )
139
+
140
+ rep_rows = dupes.group_by(dedup_keys).first()
141
+
142
+ valid_filter = pl.lit(True)
143
+
144
+ if has_late_app:
145
+ valid_filter = valid_filter & (pl.col("late_app") != 1).fill_null(True)
146
+
147
+ if has_application_cancel:
148
+ valid_filter = (
149
+ valid_filter
150
+ & (pl.col("application_cancel") != 1).fill_null(True)
151
+ )
152
+
153
+ valid_dupes = dupes.filter(valid_filter)
154
+
155
+ collapsed_flags = (
156
+ valid_dupes
157
+ .group_by(dedup_keys)
158
+ .agg([pl.col(c).max().alias("_%s_collapsed" % c) for c in ever_cols])
159
+ )
160
+
161
+ dupes_resolved = (
162
+ rep_rows
163
+ .join(collapsed_flags, on=dedup_keys, how="left")
164
+ .with_columns(
165
+ [
166
+ pl.coalesce(pl.col("_%s_collapsed" % c), pl.col(c)).alias(c)
167
+ for c in ever_cols
168
+ ] + [
169
+ pl.lit(1).alias("former_dupe")
170
+ ]
171
+ )
172
+ .drop(
173
+ temp_cols + ["_%s_collapsed" % c for c in ever_cols],
174
+ strict=False,
175
+ )
176
+ )
177
+
178
+ final = pl.concat(
179
+ [
180
+ non_dupes.select(columns + ["former_dupe"]),
181
+ dupes_resolved.select(columns + ["former_dupe"])
182
+ ],
183
+ how="vertical_relaxed",
184
+ )
185
+
186
+ if isinstance(final, pl.DataFrame):
187
+ duplicate_count = (
188
+ final.select(dedup_keys).height
189
+ - final.select(dedup_keys).unique().height
190
+ )
191
+ assert duplicate_count == 0, (
192
+ "Found %s unresolved duplicate rows." % duplicate_count
193
+ )
194
+
195
+ n_rows = (
196
+ final.height
197
+ if isinstance(final, pl.DataFrame)
198
+ else final.select(pl.len()).collect().item()
199
+ )
200
+ print(f"Number of observations after de-duplicating: {n_rows}")
201
+
202
+ return final
203
+
204
+
205
+
@@ -0,0 +1,412 @@
1
+ def resolve_rank_choice_duplicate_applications(
2
+ df: Union[pl.DataFrame, pl.LazyFrame],
3
+ base_keys: Sequence[str],
4
+ school_choice_cols: Sequence[str],
5
+ ever_cols: Sequence[str],
6
+ info_cols: Sequence[str],
7
+ outcome_school_cols: Optional[Mapping[str, str]] = None,
8
+ late_col: str = "late_app",
9
+ cancel_col: str = "application_cancel",
10
+ date_tiebreakers: Sequence[str] = ("application_date", "offer_date"),
11
+ ) -> Union[pl.DataFrame, pl.LazyFrame]:
12
+ """Resolve duplicate rank-choice application rows.
13
+
14
+ Rank-choice files can put multiple school choices on one row. This helper
15
+ compares the set of school choices within each applicant context
16
+ (``base_keys``), collapses rows that look like the same underlying
17
+ application, and leaves distinct applications separate.
18
+
19
+ Rows are collapsed when they have the exact same school set, or when one
20
+ row's school set is a strict subset of another row's set and the outcomes
21
+ for overlapping schools agree. Collapsed rows keep one representative row
22
+ for record-specific fields and take maxes across ``ever_cols`` among valid
23
+ rows. Rows left separate are flagged as current duplicates if more than
24
+ one application remains for the same ``base_keys``.
25
+
26
+ If ``df`` is a LazyFrame, this helper collects it to perform pairwise
27
+ set/subset comparisons, then returns a LazyFrame built from the result.
28
+ """
29
+ if not isinstance(df, (pl.DataFrame, pl.LazyFrame)):
30
+ raise TypeError("df must be a polars DataFrame or LazyFrame.")
31
+
32
+ is_lazy = isinstance(df, pl.LazyFrame)
33
+ frame = df.collect() if is_lazy else df
34
+ columns = list(frame.columns)
35
+
36
+ base_keys = list(base_keys)
37
+ school_choice_cols = list(school_choice_cols)
38
+ ever_cols = list(ever_cols)
39
+ info_cols = list(info_cols)
40
+ outcome_school_cols = dict(outcome_school_cols or {})
41
+
42
+ requested = base_keys + school_choice_cols + ever_cols + info_cols
43
+ requested.extend(outcome_school_cols.values())
44
+ if late_col in columns:
45
+ requested.append(late_col)
46
+ if cancel_col in columns:
47
+ requested.append(cancel_col)
48
+
49
+ missing = [column for column in requested if column not in columns]
50
+ if missing:
51
+ raise ValueError("Missing specified columns: %s" % sorted(set(missing)))
52
+ if not base_keys:
53
+ raise ValueError("base_keys must contain at least one column.")
54
+ if not school_choice_cols:
55
+ raise ValueError("school_choice_cols must contain at least one column.")
56
+ if not ever_cols:
57
+ raise ValueError("ever_cols must contain at least one column.")
58
+
59
+ invalid_outcome_keys = [column for column in outcome_school_cols if column not in ever_cols]
60
+ if invalid_outcome_keys:
61
+ raise ValueError(
62
+ "outcome_school_cols keys must also appear in ever_cols: %s"
63
+ % sorted(invalid_outcome_keys)
64
+ )
65
+
66
+ def _norm_school(value: Any) -> Optional[str]:
67
+ if value is None:
68
+ return None
69
+ text = str(value).strip()
70
+ return text or None
71
+
72
+ def _is_one(value: Any) -> bool:
73
+ if value is None:
74
+ return False
75
+ return value == 1 or value is True
76
+
77
+ def _school_values(row: Mapping[str, Any]) -> Tuple[List[str], Tuple[str, ...]]:
78
+ ordered = []
79
+ seen = set()
80
+ for column in school_choice_cols:
81
+ school = _norm_school(row[column])
82
+ if school is None or school in seen:
83
+ continue
84
+ ordered.append(school)
85
+ seen.add(school)
86
+ return ordered, tuple(sorted(seen))
87
+
88
+ def _outcome_signature(row: Mapping[str, Any], schools: Iterable[str]) -> Tuple[Any, ...]:
89
+ overlap = sorted(set(schools))
90
+ mapped_outcomes = [
91
+ column for column in ever_cols if column in outcome_school_cols
92
+ ]
93
+
94
+ # Best case: an outcome flag has a paired school column, e.g.
95
+ # offer + offer_school. Then compare outcomes only for overlap schools.
96
+ if mapped_outcomes:
97
+ values = []
98
+ for school in overlap:
99
+ for outcome_col in mapped_outcomes:
100
+ school_col = outcome_school_cols[outcome_col]
101
+ outcome_school = _norm_school(row[school_col])
102
+ values.append(
103
+ (
104
+ outcome_col,
105
+ school,
106
+ int(outcome_school == school and _is_one(row[outcome_col])),
107
+ )
108
+ )
109
+ return tuple(values)
110
+
111
+ # Fallback: without school-specific outcome columns, the helper can only
112
+ # compare row-level outcome flags. This is enough for exact school-set
113
+ # duplicates, but it is a weaker assumption for subset decisions.
114
+ return tuple((column, int(_is_one(row[column]))) for column in ever_cols)
115
+
116
+ def _same_overlap_outcomes(
117
+ left: Mapping[str, Any],
118
+ right: Mapping[str, Any],
119
+ overlap: Iterable[str],
120
+ ) -> bool:
121
+ return _outcome_signature(left, overlap) == _outcome_signature(right, overlap)
122
+
123
+ def _info_count(row: Mapping[str, Any]) -> int:
124
+ return sum(1 for column in info_cols if row[column] is not None)
125
+
126
+ def _component_name(base_values: Tuple[Any, ...], school_set: Tuple[str, ...]) -> str:
127
+ return "%r|schools=%r" % (base_values, school_set)
128
+
129
+ work = frame.with_row_index("_rc_row_id")
130
+ rows = work.to_dicts()
131
+
132
+ metadata = {}
133
+ for row in rows:
134
+ ordered_schools, school_set = _school_values(row)
135
+ row_id = row["_rc_row_id"]
136
+ metadata[row_id] = {
137
+ "base": tuple(row[column] for column in base_keys),
138
+ "ordered": ordered_schools,
139
+ "set": school_set,
140
+ "component": "%r|row=%s" % (tuple(row[column] for column in base_keys), row_id),
141
+ "case": 0,
142
+ "school_set_readable": " | ".join(school_set) or None,
143
+ "school_ordered_readable": " | ".join(ordered_schools) or None,
144
+ }
145
+
146
+ rows_by_base = {}
147
+ for row in rows:
148
+ rows_by_base.setdefault(metadata[row["_rc_row_id"]]["base"], []).append(row)
149
+
150
+ for base_values, group_rows in rows_by_base.items():
151
+ rows_by_set = {}
152
+ for row in group_rows:
153
+ rows_by_set.setdefault(metadata[row["_rc_row_id"]]["set"], []).append(row)
154
+
155
+ # Cases 1-2: exact same application school set. These collapse even
156
+ # when row-level outcomes differ, because max(ever_cols) keeps evidence.
157
+ for school_set, set_rows in rows_by_set.items():
158
+ if len(set_rows) <= 1:
159
+ continue
160
+ signatures = {
161
+ _outcome_signature(row, school_set)
162
+ for row in set_rows
163
+ }
164
+ case = 1 if len(signatures) == 1 else 2
165
+ component = _component_name(base_values, school_set)
166
+ for row in set_rows:
167
+ row_id = row["_rc_row_id"]
168
+ metadata[row_id]["component"] = component
169
+ metadata[row_id]["case"] = case
170
+
171
+ # Case 3: a strict subset row can collapse into a richer superset row
172
+ # when outcomes agree for the overlapping schools. If more than one
173
+ # superset qualifies, choose the largest/richest one deterministically.
174
+ for row in group_rows:
175
+ row_id = row["_rc_row_id"]
176
+ school_set = set(metadata[row_id]["set"])
177
+ if not school_set:
178
+ continue
179
+
180
+ candidates = []
181
+ for other in group_rows:
182
+ other_id = other["_rc_row_id"]
183
+ if row_id == other_id:
184
+ continue
185
+ other_set = set(metadata[other_id]["set"])
186
+ if not school_set < other_set:
187
+ continue
188
+ if not _same_overlap_outcomes(row, other, school_set):
189
+ continue
190
+ candidates.append(other)
191
+
192
+ if not candidates:
193
+ continue
194
+
195
+ candidates.sort(
196
+ key=lambda candidate: (
197
+ len(metadata[candidate["_rc_row_id"]]["set"]),
198
+ _info_count(candidate),
199
+ -candidate["_rc_row_id"],
200
+ ),
201
+ reverse=True,
202
+ )
203
+ target_set = metadata[candidates[0]["_rc_row_id"]]["set"]
204
+ component = _component_name(base_values, target_set)
205
+ metadata[row_id]["component"] = component
206
+ metadata[row_id]["case"] = 3
207
+ for target_row in rows_by_set[target_set]:
208
+ target_id = target_row["_rc_row_id"]
209
+ metadata[target_id]["component"] = component
210
+
211
+ assignment_rows = []
212
+ for row_id, values in metadata.items():
213
+ assignment_rows.append(
214
+ {
215
+ "_rc_row_id": row_id,
216
+ "_rc_component": values["component"],
217
+ "rank_choice_duplicate_case": values["case"],
218
+ "rank_choice_school_set": values["school_set_readable"],
219
+ "rank_choice_school_ordered": values["school_ordered_readable"],
220
+ }
221
+ )
222
+
223
+ assignments = pl.DataFrame(assignment_rows)
224
+ work = work.join(assignments, on="_rc_row_id", how="left")
225
+
226
+ component_sizes = (
227
+ work.group_by("_rc_component")
228
+ .agg(
229
+ [
230
+ pl.len().alias("_rc_component_n"),
231
+ pl.col("rank_choice_duplicate_case")
232
+ .max()
233
+ .alias("_rc_component_case"),
234
+ ]
235
+ )
236
+ )
237
+ work = (
238
+ work
239
+ .join(component_sizes, on="_rc_component", how="left")
240
+ .with_columns(
241
+ pl.col("_rc_component_case").alias("rank_choice_duplicate_case")
242
+ )
243
+ )
244
+
245
+ outcome_rank = pl.lit(len(ever_cols))
246
+ for index, column in reversed(list(enumerate(ever_cols))):
247
+ outcome_rank = pl.when(pl.col(column) == 1).then(index).otherwise(outcome_rank)
248
+
249
+ late_rank = (
250
+ pl.when(pl.col(late_col) == 1)
251
+ .then(2)
252
+ .when(pl.col(late_col).is_null())
253
+ .then(1)
254
+ .otherwise(0)
255
+ if late_col in columns
256
+ else pl.lit(0)
257
+ )
258
+ cancel_rank = (
259
+ pl.when(pl.col(cancel_col) == 1)
260
+ .then(2)
261
+ .when(pl.col(cancel_col).is_null())
262
+ .then(1)
263
+ .otherwise(0)
264
+ if cancel_col in columns
265
+ else pl.lit(0)
266
+ )
267
+ info_count = (
268
+ pl.sum_horizontal([pl.col(c).is_not_null().cast(pl.Int8) for c in info_cols])
269
+ if info_cols
270
+ else pl.lit(0)
271
+ )
272
+
273
+ existing_date_tiebreakers = [column for column in date_tiebreakers if column in columns]
274
+ sort_cols = [
275
+ "_rc_component",
276
+ "_late_rank",
277
+ "_cancel_rank",
278
+ "_outcome_rank",
279
+ "_info_count",
280
+ ] + existing_date_tiebreakers
281
+ descending = [False, False, False, False, True] + [
282
+ False for _ in existing_date_tiebreakers
283
+ ]
284
+
285
+ ranked = work.with_columns(
286
+ _late_rank=late_rank,
287
+ _cancel_rank=cancel_rank,
288
+ _outcome_rank=outcome_rank,
289
+ _info_count=info_count,
290
+ )
291
+
292
+ untouched = ranked.filter(pl.col("_rc_component_n") == 1)
293
+ collapsed = (
294
+ ranked
295
+ .filter(pl.col("_rc_component_n") > 1)
296
+ .sort(sort_cols, descending=descending, nulls_last=True)
297
+ )
298
+
299
+ rep_rows = collapsed.group_by("_rc_component").first()
300
+
301
+ valid_filter = pl.lit(True)
302
+ if late_col in columns:
303
+ valid_filter = valid_filter & (pl.col(late_col) != 1).fill_null(True)
304
+ if cancel_col in columns:
305
+ valid_filter = valid_filter & (pl.col(cancel_col) != 1).fill_null(True)
306
+
307
+ collapsed_flags = (
308
+ collapsed
309
+ .filter(valid_filter)
310
+ .group_by("_rc_component")
311
+ .agg([pl.col(c).max().alias("_%s_collapsed" % c) for c in ever_cols])
312
+ )
313
+
314
+ temp_cols = [
315
+ "_rc_row_id",
316
+ "_rc_component",
317
+ "_rc_component_n",
318
+ "_rc_component_case",
319
+ "_late_rank",
320
+ "_cancel_rank",
321
+ "_outcome_rank",
322
+ "_info_count",
323
+ ]
324
+ collapsed_flag_cols = ["_%s_collapsed" % column for column in ever_cols]
325
+
326
+ collapsed_resolved = (
327
+ rep_rows
328
+ .join(collapsed_flags, on="_rc_component", how="left")
329
+ .with_columns(
330
+ [
331
+ pl.coalesce(pl.col("_%s_collapsed" % column), pl.col(column)).alias(
332
+ column
333
+ )
334
+ for column in ever_cols
335
+ ]
336
+ + [pl.lit(1, dtype=pl.Int8).alias("former_dupe")]
337
+ )
338
+ .drop(collapsed_flag_cols, strict=False)
339
+ )
340
+
341
+ untouched = untouched.with_columns(pl.lit(0, dtype=pl.Int8).alias("former_dupe"))
342
+ output_columns = [
343
+ column for column in columns
344
+ if column not in {"former_dupe", "current_dupe"}
345
+ ] + [
346
+ "rank_choice_school_set",
347
+ "rank_choice_school_ordered",
348
+ "rank_choice_duplicate_case",
349
+ "former_dupe",
350
+ ]
351
+
352
+ result = pl.concat(
353
+ [
354
+ untouched.select(output_columns),
355
+ collapsed_resolved.select(output_columns),
356
+ ],
357
+ how="vertical_relaxed",
358
+ )
359
+
360
+ current_dupe_flags = (
361
+ result
362
+ .group_by(base_keys)
363
+ .agg((pl.len() > 1).cast(pl.Int8).alias("current_dupe"))
364
+ )
365
+ result = result.join(current_dupe_flags, on=base_keys, how="left")
366
+
367
+ final_columns = output_columns + ["current_dupe"]
368
+ result = result.select(final_columns)
369
+
370
+ unresolved_collapsible = result.filter(
371
+ (pl.col("rank_choice_duplicate_case").is_in([1, 2, 3]))
372
+ & (pl.col("former_dupe") != 1)
373
+ ).height
374
+ assert unresolved_collapsible == 0, (
375
+ "Found %s rows marked as collapsible cases but not collapsed."
376
+ % unresolved_collapsible
377
+ )
378
+
379
+ current_dupe_errors = result.filter(
380
+ (pl.len().over(base_keys) > 1)
381
+ & (pl.col("current_dupe") != 1)
382
+ ).height
383
+ assert current_dupe_errors == 0, (
384
+ "Found %s rows remaining duplicated at base_keys without current_dupe=1."
385
+ % current_dupe_errors
386
+ )
387
+
388
+ return result.lazy() if is_lazy else result
389
+
390
+
391
+ # Example call without school specific outcomes
392
+ final = df.pipe(
393
+ resolve_rank_choice_duplicate_applications,
394
+ base_keys=["sid_cepr", "school_year", "entry_grade_clean"],
395
+ school_choice_cols=["first_choice", "second_choice", "third_choice"],
396
+ ever_cols=["offer", "waitlist", "withdraw", "offer_accepted", "enroll"],
397
+ info_cols=["lottery_number", "waitlist_number", "priority_group"],
398
+ )
399
+
400
+ # Example call with school specific outcomes
401
+ final = df.pipe(
402
+ resolve_rank_choice_duplicate_applications,
403
+ base_keys=["sid_cepr", "school_year", "entry_grade_clean"],
404
+ school_choice_cols=["first_choice", "second_choice", "third_choice"],
405
+ ever_cols=["offer", "waitlist", "withdraw", "offer_accepted", "enroll"],
406
+ info_cols=["lottery_number", "waitlist_number", "priority_group"],
407
+ outcome_school_cols={
408
+ "offer": "offer_school",
409
+ "waitlist": "waitlist_school",
410
+ "withdraw": "withdraw_school",
411
+ },
412
+ )
File without changes