ltc-code 0.1.95__tar.gz → 0.1.97__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.95
3
+ Version: 0.1.97
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.95"
3
+ version = "0.1.97"
4
4
  description = "Add your description here"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -0,0 +1,330 @@
1
+ from typing import Callable, Optional, Sequence, Union
2
+
3
+ import polars as pl
4
+
5
+ from typing import Callable, Optional, Sequence, Union
6
+
7
+ import polars as pl
8
+
9
+ Frame =
10
+ Rule = Union[pl.Expr, Callable[[Frame], pl.Expr]]
11
+
12
+
13
+ def impute_offers(
14
+ frame: Union[pl.DataFrame, pl.LazyFrame],
15
+ offer_col: str,
16
+ lottery_number_col: Optional[str] = None,
17
+ waitlist_number_col: Optional[str] = None,
18
+ enrollment_col: Optional[str] = None,
19
+ lottery_imputation: Optional[Union[pl.Expr, Callable[[Frame], pl.Expr]]] = None,
20
+ waitlist_imputation: Optional[Union[pl.Expr, Callable[[Frame], pl.Expr]]] = None,
21
+ enrollment_imputation: Optional[Union[pl.Expr, Callable[[Frame], pl.Expr]]] = None,
22
+ sibling_priority_cols: Optional[Sequence[str]] = None,
23
+ within_cols: Optional[Sequence[str]] = None,
24
+ ) -> Union[pl.DataFrame, pl.LazyFrame]:
25
+ """
26
+ Impute offer indicators in a Polars DataFrame or LazyFrame.
27
+
28
+ The function treats null offers as 0. It sets ``offer_col`` to 1 for rows
29
+ that satisfy any supplied or default imputation rule, and creates:
30
+
31
+ - ``offer_imputed_ln``: newly imputed from lottery number information
32
+ - ``offer_imputed_wn``: newly imputed from waitlist number information
33
+ - ``offer_imputed_enr``: newly imputed from enrollment information
34
+
35
+ Flags are only set for rows whose original offer was 0 or null. Rows that
36
+ already had an observed offer are never flagged. Multiple flags may be 1
37
+ for the same row if multiple rules independently imply an offer.
38
+
39
+ Parameters
40
+ ----------
41
+ frame:
42
+ Polars DataFrame or LazyFrame.
43
+ offer_col:
44
+ Name of the observed offer column. Null is treated as 0.
45
+ lottery_number_col:
46
+ Optional lottery number column.
47
+ waitlist_number_col:
48
+ Optional waitlist number column.
49
+ enrollment_col:
50
+ Optional enrollment indicator column.
51
+ lottery_imputation, waitlist_imputation, enrollment_imputation:
52
+ Optional custom imputation rules. Each may be a Polars expression or a
53
+ callable that accepts ``frame`` and returns a Polars expression.
54
+ sibling_priority_cols:
55
+ Optional columns indicating sibling priority. If supplied, observed
56
+ sibling-priority offers are excluded when computing the default lottery
57
+ cutoff.
58
+ within_cols:
59
+ Optional columns defining the risk set within which lottery and waitlist
60
+ cutoffs are computed. For example:
61
+
62
+ ["school_name", "school_year", "entry_grade_clean", "priority_group"]
63
+
64
+ If omitted, cutoffs are computed globally.
65
+
66
+ Default Rules
67
+ -------------
68
+ Lottery:
69
+ Within each ``within_cols`` group, find the highest lottery number among
70
+ observed offers, excluding sibling-priority offers if
71
+ ``sibling_priority_cols`` is supplied. Impute offers for originally
72
+ non-offered rows with lottery numbers at or below that cutoff.
73
+
74
+ Enrollment:
75
+ Impute offers for originally non-offered enrolled rows.
76
+
77
+ Waitlist:
78
+ If enrollment information is supplied, first infer offers from
79
+ enrollment, then within each group use the highest waitlist number among
80
+ those enrollment-implied offers as the waitlist cutoff.
81
+
82
+ If enrollment information is not supplied, within each group use the
83
+ highest waitlist number among observed offers as the waitlist cutoff.
84
+
85
+ Then impute offers for originally non-offered rows with waitlist numbers
86
+ at or below that cutoff.
87
+
88
+ Example
89
+ -------
90
+ >>> result = impute_offers(
91
+ ... df,
92
+ ... offer_col="offer",
93
+ ... lottery_number_col="lottery_number",
94
+ ... waitlist_number_col="waitlist_number",
95
+ ... enrollment_col="enroll_school_year",
96
+ ... sibling_priority_cols=["sibling", "sibling_concur"],
97
+ ... within_cols=["school_name", "school_year", "entry_grade_clean", "priority_group"],
98
+ ... )
99
+ """
100
+ if not isinstance(frame, (pl.DataFrame, pl.LazyFrame)):
101
+ raise TypeError("frame must be a Polars DataFrame or LazyFrame.")
102
+
103
+ within_cols = list(within_cols or [])
104
+ sibling_priority_cols = list(sibling_priority_cols or [])
105
+
106
+ original_offer = "__original_offer_for_imputation__"
107
+
108
+ def as_rule_expr(rule: Union[pl.Expr, Callable[[Frame], pl.Expr]]) -> pl.Expr:
109
+ expr = rule(frame) if callable(rule) else rule
110
+ if not isinstance(expr, pl.Expr):
111
+ raise TypeError("Imputation rules must be Polars expressions.")
112
+ return expr.fill_null(False)
113
+
114
+ def any_sibling_priority(cols: Sequence[str]) -> pl.Expr:
115
+ priority_expr = pl.lit(False)
116
+ for col in cols:
117
+ priority_expr = priority_expr | (pl.col(col).fill_null(0) == 1)
118
+ return priority_expr
119
+
120
+ def maybe_over(expr: pl.Expr) -> pl.Expr:
121
+ if within_cols:
122
+ return expr.over(within_cols)
123
+ return expr
124
+
125
+ work = frame.with_columns(
126
+ pl.col(offer_col).fill_null(0).cast(pl.Int8, strict=False).alias(original_offer)
127
+ )
128
+
129
+ originally_no_offer = pl.col(original_offer) == 0
130
+
131
+ lottery_rule = None
132
+ if lottery_imputation is not None:
133
+ lottery_rule = as_rule_expr(lottery_imputation)
134
+ elif lottery_number_col is not None:
135
+ observed_regular_offer = (
136
+ (pl.col(original_offer) == 1)
137
+ & ~any_sibling_priority(sibling_priority_cols)
138
+ )
139
+
140
+ lottery_cutoff = maybe_over(
141
+ pl.col(lottery_number_col)
142
+ .filter(observed_regular_offer)
143
+ .max()
144
+ )
145
+
146
+ lottery_rule = (
147
+ pl.col(lottery_number_col).is_not_null()
148
+ & (pl.col(lottery_number_col) <= lottery_cutoff)
149
+ ).fill_null(False)
150
+
151
+ enrollment_rule = None
152
+ if enrollment_imputation is not None:
153
+ enrollment_rule = as_rule_expr(enrollment_imputation)
154
+ elif enrollment_col is not None:
155
+ enrollment_rule = (pl.col(enrollment_col).fill_null(0) == 1).fill_null(False)
156
+
157
+ waitlist_rule = None
158
+ if waitlist_imputation is not None:
159
+ waitlist_rule = as_rule_expr(waitlist_imputation)
160
+ elif waitlist_number_col is not None:
161
+ if enrollment_rule is not None:
162
+ waitlist_cutoff_source = originally_no_offer & enrollment_rule
163
+ else:
164
+ waitlist_cutoff_source = pl.col(original_offer) == 1
165
+
166
+ waitlist_cutoff = maybe_over(
167
+ pl.col(waitlist_number_col)
168
+ .filter(waitlist_cutoff_source)
169
+ .max()
170
+ )
171
+
172
+ waitlist_rule = (
173
+ pl.col(waitlist_number_col).is_not_null()
174
+ & (pl.col(waitlist_number_col) <= waitlist_cutoff)
175
+ ).fill_null(False)
176
+
177
+ false_expr = pl.lit(False)
178
+ lottery_rule = lottery_rule if lottery_rule is not None else false_expr
179
+ waitlist_rule = waitlist_rule if waitlist_rule is not None else false_expr
180
+ enrollment_rule = enrollment_rule if enrollment_rule is not None else false_expr
181
+
182
+ ln_flag = originally_no_offer & lottery_rule
183
+ enr_flag = originally_no_offer & enrollment_rule
184
+ wn_flag = originally_no_offer & waitlist_rule
185
+
186
+ any_imputation = ln_flag | wn_flag | enr_flag
187
+
188
+ return (
189
+ work.with_columns(
190
+ [
191
+ pl.when(ln_flag)
192
+ .then(1)
193
+ .otherwise(0)
194
+ .cast(pl.Int8)
195
+ .alias("offer_imputed_ln"),
196
+ pl.when(wn_flag)
197
+ .then(1)
198
+ .otherwise(0)
199
+ .cast(pl.Int8)
200
+ .alias("offer_imputed_wn"),
201
+ pl.when(enr_flag)
202
+ .then(1)
203
+ .otherwise(0)
204
+ .cast(pl.Int8)
205
+ .alias("offer_imputed_enr"),
206
+ pl.when((pl.col(original_offer) == 1) | any_imputation)
207
+ .then(1)
208
+ .otherwise(0)
209
+ .cast(pl.Int8)
210
+ .alias(offer_col),
211
+ ]
212
+ )
213
+ .drop(original_offer)
214
+ )
215
+
216
+
217
+ # mappings.py
218
+
219
+ import polars as pl
220
+
221
+ FINAL_SCHEMA = {
222
+ "cmo_name": {"dtype": pl.String, "default": None},
223
+ "cmo_region": {"dtype": pl.String, "default": None},
224
+ "ren_num": {"dtype": pl.String, "default": None},
225
+ "sid_cepr": {"dtype": pl.String, "default": None},
226
+
227
+ "school_name": {"dtype": pl.String, "default": None},
228
+ "school_year": {"dtype": pl.Int64, "default": None},
229
+ "entry_grade_clean": {"dtype": pl.String, "default": None},
230
+ "priority_group": {"dtype": pl.Int64, "default": None},
231
+ "risk_id_name": {"dtype": pl.String, "default": None},
232
+ "risk_id": {"dtype": pl.Int64, "default": None},
233
+
234
+ "offer": {"dtype": pl.Int8, "default": 0},
235
+ "waitlist": {"dtype": pl.Int8, "default": 0},
236
+ "initial_offer": {"dtype": pl.Int8, "default": 0},
237
+ "waitlist_offer": {"dtype": pl.Int8, "default": 0},
238
+ "offer_accepted": {"dtype": pl.Int8, "default": None},
239
+ "accept_school": {"dtype": pl.String, "default": None},
240
+ "offer_imputed_ln": {"dtype": pl.Int8, "default": 0},
241
+ "offer_imputed_wn": {"dtype": pl.Int8, "default": 0},
242
+ "offer_imputed_enr": {"dtype": pl.Int8, "default": 0},
243
+
244
+ "priority_group_name": {"dtype": pl.String, "default": None},
245
+ "priority_group_clean": {"dtype": pl.String, "default": None},
246
+ "sibling": {"dtype": pl.Int8, "default": 0},
247
+ "sibling_concur": {"dtype": pl.Int8, "default": 0},
248
+ "staff": {"dtype": pl.Int8, "default": 0},
249
+ "zoned": {"dtype": pl.Int8, "default": 0},
250
+ "transfer": {"dtype": pl.Int8, "default": 0},
251
+
252
+ "application_cancel": {"dtype": pl.Int8, "default": 0},
253
+ "late_app": {"dtype": pl.Int8, "default": 0},
254
+ "former_dupe": {"dtype": pl.Int8, "default": 0},
255
+
256
+ "enroll": {"dtype": pl.Int8, "default": 0},
257
+ "enroll_school": {"dtype": pl.Int8, "default": 0},
258
+ "enroll_year": {"dtype": pl.Int8, "default": 0},
259
+ "enroll_school_year": {"dtype": pl.Int8, "default": 0},
260
+ "enroll_missing": {"dtype": pl.Int8, "default": 1},
261
+
262
+ "enrollment_years": {"dtype": pl.Int64, "default": None},
263
+ "year_enrolled": {"dtype": pl.Int64, "default": None},
264
+ "withdraw": {"dtype": pl.Int8, "default": 0},
265
+ "waitlist_number": {"dtype": pl.Int64, "default": None},
266
+ "lottery_number": {"dtype": pl.Int64, "default": None},
267
+ "offer_date": {"dtype": pl.Date, "default": None},
268
+ "application_date": {"dtype": pl.Date, "default": None},
269
+ "birth_cohort": {"dtype": pl.Int64, "default": None},
270
+ "age": {"dtype": pl.Int64, "default": None},
271
+ }
272
+
273
+
274
+
275
+ from typing import Mapping, Union
276
+
277
+ import polars as pl
278
+
279
+ import mappings
280
+
281
+
282
+ def apply_final_schema(
283
+ frame: Union[pl.DataFrame, pl.LazyFrame],
284
+ schema: Mapping[str, Mapping[str, object]] = mappings.FINAL_SCHEMA,
285
+ ) -> Union[pl.DataFrame, pl.LazyFrame]:
286
+ """
287
+ Restrict a Polars DataFrame/LazyFrame to the final LTC application schema.
288
+
289
+ Existing columns are kept, cast to their declared dtype, and ordered
290
+ according to ``schema``. Columns not listed in ``schema`` are dropped.
291
+ Missing schema columns are created from their declared defaults.
292
+
293
+ The schema should be a mapping like:
294
+
295
+ {
296
+ "offer": {"dtype": pl.Int8, "default": 0},
297
+ "school_name": {"dtype": pl.String, "default": None},
298
+ }
299
+
300
+ Defaults may be either scalar values or Polars expressions. Expression
301
+ defaults are useful when a missing final column should be derived from
302
+ other columns.
303
+ """
304
+ if not isinstance(frame, (pl.DataFrame, pl.LazyFrame)):
305
+ raise TypeError("frame must be a Polars DataFrame or LazyFrame.")
306
+
307
+ existing_columns = (
308
+ frame.collect_schema().names()
309
+ if isinstance(frame, pl.LazyFrame)
310
+ else frame.columns
311
+ )
312
+
313
+ final_columns = []
314
+
315
+ for column, spec in schema.items():
316
+ dtype = spec["dtype"]
317
+ default = spec.get("default")
318
+
319
+ if column in existing_columns:
320
+ expr = pl.col(column)
321
+ elif isinstance(default, pl.Expr):
322
+ expr = default
323
+ else:
324
+ expr = pl.lit(default)
325
+
326
+ final_columns.append(
327
+ expr.cast(dtype, strict=False).alias(column)
328
+ )
329
+
330
+ return frame.select(final_columns)
File without changes