ltc-code 0.1.88__tar.gz → 0.1.91__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.88
3
+ Version: 0.1.91
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.88"
3
+ version = "0.1.91"
4
4
  description = "Add your description here"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -165,6 +165,8 @@ def resolve_duplicate_applications(
165
165
  [
166
166
  pl.coalesce(pl.col("_%s_collapsed" % c), pl.col(c)).alias(c)
167
167
  for c in ever_cols
168
+ ] + [
169
+ pl.lit(1).alias("former_dupe")
168
170
  ]
169
171
  )
170
172
  .drop(
@@ -173,7 +175,13 @@ def resolve_duplicate_applications(
173
175
  )
174
176
  )
175
177
 
176
- final = pl.concat([non_dupes, dupes_resolved], how="vertical_relaxed")
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
+ )
177
185
 
178
186
  if isinstance(final, pl.DataFrame):
179
187
  duplicate_count = (
@@ -183,5 +191,15 @@ def resolve_duplicate_applications(
183
191
  assert duplicate_count == 0, (
184
192
  "Found %s unresolved duplicate rows." % duplicate_count
185
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
+
186
205
 
187
- return final
@@ -0,0 +1,406 @@
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
+ If df is a LazyFrame, this helper collects it to do pairwise set/subset
15
+ comparisons, then returns a LazyFrame built from the result.
16
+ """
17
+ if not isinstance(df, (pl.DataFrame, pl.LazyFrame)):
18
+ raise TypeError("df must be a polars DataFrame or LazyFrame.")
19
+
20
+ is_lazy = isinstance(df, pl.LazyFrame)
21
+ frame = df.collect() if is_lazy else df
22
+ columns = list(frame.columns)
23
+
24
+ base_keys = list(base_keys)
25
+ school_choice_cols = list(school_choice_cols)
26
+ ever_cols = list(ever_cols)
27
+ info_cols = list(info_cols)
28
+ outcome_school_cols = dict(outcome_school_cols or {})
29
+
30
+ requested = base_keys + school_choice_cols + ever_cols + info_cols
31
+ requested.extend(outcome_school_cols.values())
32
+
33
+ missing = [column for column in requested if column not in columns]
34
+ if missing:
35
+ raise ValueError("Missing specified columns: %s" % sorted(set(missing)))
36
+ if not base_keys:
37
+ raise ValueError("base_keys must contain at least one column.")
38
+ if not school_choice_cols:
39
+ raise ValueError("school_choice_cols must contain at least one column.")
40
+ if not ever_cols:
41
+ raise ValueError("ever_cols must contain at least one column.")
42
+
43
+ invalid_outcome_keys = [column for column in outcome_school_cols if column not in ever_cols]
44
+ if invalid_outcome_keys:
45
+ raise ValueError(
46
+ "outcome_school_cols keys must also appear in ever_cols: %s"
47
+ % sorted(invalid_outcome_keys)
48
+ )
49
+
50
+ def _norm_school(value: Any) -> Optional[str]:
51
+ if value is None:
52
+ return None
53
+ text = str(value).strip()
54
+ return text or None
55
+
56
+ def _is_one(value: Any) -> bool:
57
+ if value is None:
58
+ return False
59
+ return value == 1 or value is True
60
+
61
+ def _school_values(row: Mapping[str, Any]) -> Tuple[List[str], Tuple[str, ...]]:
62
+ ordered = []
63
+ seen = set()
64
+ for column in school_choice_cols:
65
+ school = _norm_school(row[column])
66
+ if school is None or school in seen:
67
+ continue
68
+ ordered.append(school)
69
+ seen.add(school)
70
+ return ordered, tuple(sorted(seen))
71
+
72
+ def _outcome_signature(row: Mapping[str, Any], schools: Iterable[str]) -> Tuple[Any, ...]:
73
+ overlap = sorted(set(schools))
74
+ mapped_outcomes = [column for column in ever_cols if column in outcome_school_cols]
75
+
76
+ if mapped_outcomes:
77
+ values = []
78
+ for school in overlap:
79
+ for outcome_col in mapped_outcomes:
80
+ school_col = outcome_school_cols[outcome_col]
81
+ outcome_school = _norm_school(row[school_col])
82
+ values.append(
83
+ (
84
+ outcome_col,
85
+ school,
86
+ int(outcome_school == school and _is_one(row[outcome_col])),
87
+ )
88
+ )
89
+ return tuple(values)
90
+
91
+ return tuple((column, int(_is_one(row[column]))) for column in ever_cols)
92
+
93
+ def _same_overlap_outcomes(
94
+ left: Mapping[str, Any],
95
+ right: Mapping[str, Any],
96
+ overlap: Iterable[str],
97
+ ) -> bool:
98
+ return _outcome_signature(left, overlap) == _outcome_signature(right, overlap)
99
+
100
+ def _info_count(row: Mapping[str, Any]) -> int:
101
+ return sum(1 for column in info_cols if row[column] is not None)
102
+
103
+ def _component_name(base_values: Tuple[Any, ...], school_set: Tuple[str, ...]) -> str:
104
+ return "%r|schools=%r" % (base_values, school_set)
105
+
106
+ def _find(parent: Dict[int, int], row_id: int) -> int:
107
+ while parent[row_id] != row_id:
108
+ parent[row_id] = parent[parent[row_id]]
109
+ row_id = parent[row_id]
110
+ return row_id
111
+
112
+ def _union(parent: Dict[int, int], left_id: int, right_id: int) -> None:
113
+ left_root = _find(parent, left_id)
114
+ right_root = _find(parent, right_id)
115
+ if left_root == right_root:
116
+ return
117
+ parent[max(left_root, right_root)] = min(left_root, right_root)
118
+
119
+ work = frame.with_row_index("_rc_row_id")
120
+ rows = work.to_dicts()
121
+
122
+ metadata = {}
123
+ parent = {}
124
+ for row in rows:
125
+ ordered_schools, school_set = _school_values(row)
126
+ row_id = row["_rc_row_id"]
127
+ parent[row_id] = row_id
128
+ metadata[row_id] = {
129
+ "base": tuple(row[column] for column in base_keys),
130
+ "ordered": ordered_schools,
131
+ "set": school_set,
132
+ "case": 0,
133
+ "school_set_readable": " | ".join(school_set) or None,
134
+ "school_ordered_readable": " | ".join(ordered_schools) or None,
135
+ }
136
+
137
+ rows_by_base = {}
138
+ for row in rows:
139
+ rows_by_base.setdefault(metadata[row["_rc_row_id"]]["base"], []).append(row)
140
+
141
+ for base_values, group_rows in rows_by_base.items():
142
+ rows_by_set = {}
143
+ for row in group_rows:
144
+ rows_by_set.setdefault(metadata[row["_rc_row_id"]]["set"], []).append(row)
145
+
146
+ for school_set, set_rows in rows_by_set.items():
147
+ if len(set_rows) <= 1:
148
+ continue
149
+ signatures = {_outcome_signature(row, school_set) for row in set_rows}
150
+ case = 1 if len(signatures) == 1 else 2
151
+ anchor_id = set_rows[0]["_rc_row_id"]
152
+ for row in set_rows:
153
+ row_id = row["_rc_row_id"]
154
+ _union(parent, anchor_id, row_id)
155
+ metadata[row_id]["case"] = case
156
+
157
+ for row in group_rows:
158
+ row_id = row["_rc_row_id"]
159
+ school_set = set(metadata[row_id]["set"])
160
+ if not school_set:
161
+ continue
162
+
163
+ for other in group_rows:
164
+ other_id = other["_rc_row_id"]
165
+ if row_id == other_id:
166
+ continue
167
+ other_set = set(metadata[other_id]["set"])
168
+ if not school_set < other_set:
169
+ continue
170
+ if not _same_overlap_outcomes(row, other, school_set):
171
+ continue
172
+ _union(parent, row_id, other_id)
173
+ if metadata[row_id]["case"] == 0:
174
+ metadata[row_id]["case"] = 3
175
+ if metadata[other_id]["case"] == 0:
176
+ metadata[other_id]["case"] = 3
177
+
178
+ component_rows = {}
179
+ for row in rows:
180
+ row_id = row["_rc_row_id"]
181
+ component_rows.setdefault(_find(parent, row_id), []).append(row_id)
182
+
183
+ component_labels = {}
184
+ for component_row_ids in component_rows.values():
185
+ representative_id = max(
186
+ component_row_ids,
187
+ key=lambda row_id: (
188
+ len(metadata[row_id]["set"]),
189
+ _info_count(rows[row_id]),
190
+ -row_id,
191
+ ),
192
+ )
193
+ component_school_set = metadata[representative_id]["set"]
194
+ component_base = metadata[representative_id]["base"]
195
+ if len(component_row_ids) == 1:
196
+ component = "%r|row=%s" % (component_base, representative_id)
197
+ else:
198
+ component = _component_name(component_base, component_school_set)
199
+
200
+ component_case = max(metadata[row_id]["case"] for row_id in component_row_ids)
201
+ for row_id in component_row_ids:
202
+ component_labels[row_id] = component
203
+ metadata[row_id]["case"] = component_case
204
+
205
+ assignment_rows = []
206
+ for row_id, values in metadata.items():
207
+ assignment_rows.append(
208
+ {
209
+ "_rc_row_id": row_id,
210
+ "_rc_component": component_labels[row_id],
211
+ "rank_choice_duplicate_case": values["case"],
212
+ "rank_choice_school_set": values["school_set_readable"],
213
+ "rank_choice_school_ordered": values["school_ordered_readable"],
214
+ "_rc_school_count": len(values["set"]),
215
+ }
216
+ )
217
+
218
+ assignments = pl.DataFrame(assignment_rows)
219
+ work = work.join(assignments, on="_rc_row_id", how="left")
220
+
221
+ component_sizes = (
222
+ work.group_by("_rc_component")
223
+ .agg(
224
+ [
225
+ pl.len().alias("_rc_component_n"),
226
+ pl.col("rank_choice_duplicate_case")
227
+ .max()
228
+ .alias("_rc_component_case"),
229
+ ]
230
+ )
231
+ )
232
+
233
+ work = (
234
+ work
235
+ .join(component_sizes, on="_rc_component", how="left")
236
+ .with_columns(pl.col("_rc_component_case").alias("rank_choice_duplicate_case"))
237
+ )
238
+
239
+ outcome_rank = pl.lit(len(ever_cols))
240
+ for index, column in reversed(list(enumerate(ever_cols))):
241
+ outcome_rank = pl.when(pl.col(column) == 1).then(index).otherwise(outcome_rank)
242
+
243
+ late_rank = (
244
+ pl.when(pl.col(late_col) == 1)
245
+ .then(2)
246
+ .when(pl.col(late_col).is_null())
247
+ .then(1)
248
+ .otherwise(0)
249
+ if late_col in columns
250
+ else pl.lit(0)
251
+ )
252
+
253
+ cancel_rank = (
254
+ pl.when(pl.col(cancel_col) == 1)
255
+ .then(2)
256
+ .when(pl.col(cancel_col).is_null())
257
+ .then(1)
258
+ .otherwise(0)
259
+ if cancel_col in columns
260
+ else pl.lit(0)
261
+ )
262
+
263
+ info_count = (
264
+ pl.sum_horizontal([pl.col(c).is_not_null().cast(pl.Int8) for c in info_cols])
265
+ if info_cols
266
+ else pl.lit(0)
267
+ )
268
+
269
+ existing_date_tiebreakers = [column for column in date_tiebreakers if column in columns]
270
+
271
+ sort_cols = [
272
+ "_rc_component",
273
+ "_late_rank",
274
+ "_cancel_rank",
275
+ "_outcome_rank",
276
+ "_rc_school_count",
277
+ "_info_count",
278
+ ] + existing_date_tiebreakers
279
+
280
+ descending = [False, False, False, False, True, True] + [
281
+ False for _ in existing_date_tiebreakers
282
+ ]
283
+
284
+ ranked = work.with_columns(
285
+ _late_rank=late_rank,
286
+ _cancel_rank=cancel_rank,
287
+ _outcome_rank=outcome_rank,
288
+ _info_count=info_count,
289
+ )
290
+
291
+ untouched = ranked.filter(pl.col("_rc_component_n") == 1)
292
+
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
+ collapsed_flag_cols = ["_%s_collapsed" % column for column in ever_cols]
315
+
316
+ collapsed_resolved = (
317
+ rep_rows
318
+ .join(collapsed_flags, on="_rc_component", how="left")
319
+ .with_columns(
320
+ [
321
+ pl.coalesce(pl.col("_%s_collapsed" % column), pl.col(column)).alias(column)
322
+ for column in ever_cols
323
+ ]
324
+ + [pl.lit(1, dtype=pl.Int8).alias("former_dupe")]
325
+ )
326
+ .drop(collapsed_flag_cols, strict=False)
327
+ )
328
+
329
+ untouched = untouched.with_columns(pl.lit(0, dtype=pl.Int8).alias("former_dupe"))
330
+
331
+ output_columns = [
332
+ column for column in columns
333
+ if column not in {"former_dupe", "current_dupe"}
334
+ ] + [
335
+ "rank_choice_school_set",
336
+ "rank_choice_school_ordered",
337
+ "rank_choice_duplicate_case",
338
+ "former_dupe",
339
+ ]
340
+
341
+ result = pl.concat(
342
+ [
343
+ untouched.select(output_columns),
344
+ collapsed_resolved.select(output_columns),
345
+ ],
346
+ how="vertical_relaxed",
347
+ )
348
+
349
+ current_dupe_flags = (
350
+ result
351
+ .group_by(base_keys)
352
+ .agg((pl.len() > 1).cast(pl.Int8).alias("current_dupe"))
353
+ )
354
+
355
+ result = result.join(current_dupe_flags, on=base_keys, how="left")
356
+
357
+ final_columns = output_columns + ["current_dupe"]
358
+ result = result.select(final_columns)
359
+
360
+ unresolved_collapsible = result.filter(
361
+ (pl.col("rank_choice_duplicate_case").is_in([1, 2, 3]))
362
+ & (pl.col("former_dupe") != 1)
363
+ ).height
364
+
365
+ assert unresolved_collapsible == 0, (
366
+ "Found %s rows marked as collapsible cases but not collapsed."
367
+ % unresolved_collapsible
368
+ )
369
+
370
+ current_dupe_errors = result.filter(
371
+ (pl.len().over(base_keys) > 1)
372
+ & (pl.col("current_dupe") != 1)
373
+ ).height
374
+
375
+ assert current_dupe_errors == 0, (
376
+ "Found %s rows remaining duplicated at base_keys without current_dupe=1."
377
+ % current_dupe_errors
378
+ )
379
+
380
+ return result.lazy() if is_lazy else result
381
+
382
+
383
+
384
+
385
+ # Example call without school specific outcomes
386
+ final = df.pipe(
387
+ resolve_rank_choice_duplicate_applications,
388
+ base_keys=["sid_cepr", "school_year", "entry_grade_clean"],
389
+ school_choice_cols=["first_choice", "second_choice", "third_choice"],
390
+ ever_cols=["offer", "waitlist", "withdraw", "offer_accepted", "enroll"],
391
+ info_cols=["lottery_number", "waitlist_number", "priority_group"],
392
+ )
393
+
394
+ # Example call with school specific outcomes
395
+ final = df.pipe(
396
+ resolve_rank_choice_duplicate_applications,
397
+ base_keys=["sid_cepr", "school_year", "entry_grade_clean"],
398
+ school_choice_cols=["first_choice", "second_choice", "third_choice"],
399
+ ever_cols=["offer", "waitlist", "withdraw", "offer_accepted", "enroll"],
400
+ info_cols=["lottery_number", "waitlist_number", "priority_group"],
401
+ outcome_school_cols={
402
+ "offer": "offer_school",
403
+ "waitlist": "waitlist_school",
404
+ "withdraw": "withdraw_school",
405
+ },
406
+ )
File without changes