ltc-code 0.2.18__tar.gz → 0.2.20__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.
Files changed (77) hide show
  1. {ltc_code-0.2.18 → ltc_code-0.2.20}/PKG-INFO +1 -1
  2. {ltc_code-0.2.18 → ltc_code-0.2.20}/pyproject.toml +2 -1
  3. ltc_code-0.2.20/src/ltc_code/.DS_Store +0 -0
  4. ltc_code-0.2.20/src/ltc_code/make_summary_stats_table.py +235 -0
  5. ltc_code-0.2.20/src/ltc_code/nsc/.DS_Store +0 -0
  6. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/nsc/build_nsc_outcomes.py +125 -13
  7. ltc_code-0.2.20/src/ltc_code/nsc/raw/.DS_Store +0 -0
  8. ltc_code-0.2.20/src/ltc_code/school_name_xwalk/__init__.py +3 -0
  9. ltc_code-0.2.20/src/ltc_code/school_name_xwalk/all_schools_with_ccd.csv +1069 -0
  10. ltc_code-0.2.20/src/ltc_code/school_name_xwalk/merge_school_ccd.py +43 -0
  11. {ltc_code-0.2.18 → ltc_code-0.2.20}/README.md +0 -0
  12. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260614_new_build_scripts_update/aspire.py +0 -0
  13. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260614_new_build_scripts_update/check_cmo_apps.do +0 -0
  14. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260614_new_build_scripts_update/christel_house.py +0 -0
  15. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260614_new_build_scripts_update/democracy_prep.py +0 -0
  16. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260614_new_build_scripts_update/green_dot.py +0 -0
  17. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260614_new_build_scripts_update/helpers.py +0 -0
  18. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260614_new_build_scripts_update/ilt.py +0 -0
  19. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260614_new_build_scripts_update/kipp_nj.py +0 -0
  20. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260614_new_build_scripts_update/main.py +0 -0
  21. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260614_new_build_scripts_update/mappings.py +0 -0
  22. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260614_new_build_scripts_update/rocketship.py +0 -0
  23. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260614_new_build_scripts_update/yes_prep.py +0 -0
  24. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260630_census_disclosure/aspire.py +0 -0
  25. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260630_census_disclosure/christel_house.py +0 -0
  26. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260630_census_disclosure/democracy_prep.py +0 -0
  27. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260630_census_disclosure/green_dot.py +0 -0
  28. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260630_census_disclosure/ilt.py +0 -0
  29. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260630_census_disclosure/kipp.py +0 -0
  30. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260630_census_disclosure/kipp_nj.py +0 -0
  31. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260630_census_disclosure/rocketship.py +0 -0
  32. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260630_census_disclosure/yes_prep.py +0 -0
  33. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260706_ceprscripts/BALANCE.do +0 -0
  34. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260706_ceprscripts/FS.do +0 -0
  35. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260706_ceprscripts/ITT.do +0 -0
  36. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260706_ceprscripts/TOT.do +0 -0
  37. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260706_ceprscripts/apps_helpers.py +0 -0
  38. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260706_ceprscripts/harmony.py +0 -0
  39. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260706_ceprscripts/main.py +0 -0
  40. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260712_uncommon_scripts/main.py +0 -0
  41. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260712_uncommon_scripts/mappings.py +0 -0
  42. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/20260712_uncommon_scripts/uncommon.py +0 -0
  43. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/__init__.py +0 -0
  44. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/aspire.py +0 -0
  45. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/check_cmo_apps.do +0 -0
  46. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/christel_house.py +0 -0
  47. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/green_dot.py +0 -0
  48. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/helpers.py +0 -0
  49. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/june13.py +0 -0
  50. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/june2.py +0 -0
  51. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/june30.py +0 -0
  52. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/june5.py +0 -0
  53. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/june7.py +0 -0
  54. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/kipp_nj.py +0 -0
  55. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/kipp_tx.py +0 -0
  56. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/main.py +0 -0
  57. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/mappings.py +0 -0
  58. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/may27.py +0 -0
  59. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/nsc/__init__.py +0 -0
  60. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/nsc/input/.gitkeep +0 -0
  61. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/nsc/naics.csv +0 -0
  62. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/nsc/naics.py +0 -0
  63. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/nsc/naics_raw.xlsx +0 -0
  64. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/nsc/output/.gitkeep +0 -0
  65. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/nsc/raw/CREDENTIAL_LEVEL_LOOKUP_TABLE.xlsx +0 -0
  66. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/nsc/raw/IPEDS_IC_2013.csv +0 -0
  67. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/nsc/raw/IPEDS_IC_manual.xlsx +0 -0
  68. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/nsc/raw/chetty/mrc_table11.dta +0 -0
  69. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/nsc/raw/chetty/mrc_table2.dta +0 -0
  70. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/nsc/raw/college_crosswalk.xls +0 -0
  71. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/nsc/raw/directory.dta +0 -0
  72. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/nsc/raw/ipeds_data.dta +0 -0
  73. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/nsc/run_nsc_outcomes.py +0 -0
  74. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/polars_dates.py +0 -0
  75. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/rocketship.py +0 -0
  76. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/schema_mapping.py +0 -0
  77. {ltc_code-0.2.18 → ltc_code-0.2.20}/src/ltc_code/yes_prep.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: ltc-code
3
- Version: 0.2.18
3
+ Version: 0.2.20
4
4
  Summary: Add your description here
5
5
  Requires-Dist: fastexcel>=0.16,<0.20
6
6
  Requires-Dist: polars>=1.36.1,<1.42
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ltc-code"
3
- version = "0.2.18"
3
+ version = "0.2.20"
4
4
  description = "Add your description here"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -12,6 +12,7 @@ dependencies = [
12
12
 
13
13
  [project.scripts]
14
14
  ltc-code = "ltc_code:main"
15
+ ltc-summary-stats = "ltc_code.make_summary_stats_table:main"
15
16
 
16
17
  [build-system]
17
18
  requires = ["uv_build>=0.11.16,<0.12.0"]
Binary file
@@ -0,0 +1,235 @@
1
+ """Create a paper-ready two-panel summary-statistics table from a CSV."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import shutil
7
+ import subprocess
8
+ from pathlib import Path
9
+ from typing import Optional, Union
10
+
11
+ import polars as pl
12
+
13
+
14
+ REQUIRED_COLUMNS = ["sample", "Variable", "Mean", "P50", "SD"]
15
+ COUNT_COLUMNS = ["N", "N_in_group"]
16
+
17
+
18
+ def clean_latex(value: object) -> str:
19
+ text = "" if value is None else str(value)
20
+ for old, new in {
21
+ "\\": r"\textbackslash{}",
22
+ "&": r"\&",
23
+ "%": r"\%",
24
+ "$": r"\$",
25
+ "#": r"\#",
26
+ "_": r"\_",
27
+ "{": r"\{",
28
+ "}": r"\}",
29
+ "~": r"\textasciitilde{}",
30
+ "^": r"\textasciicircum{}",
31
+ }.items():
32
+ text = text.replace(old, new)
33
+ return text
34
+
35
+
36
+ def format_cell(column: str, value: object) -> str:
37
+ if value is None:
38
+ return ""
39
+ if column in COUNT_COLUMNS:
40
+ try:
41
+ return f"{float(value):,.0f}"
42
+ except (TypeError, ValueError):
43
+ return clean_latex(value)
44
+ try:
45
+ return f"{float(value):,.2f}"
46
+ except (TypeError, ValueError):
47
+ return clean_latex(value)
48
+
49
+
50
+ def standardize_columns(stats: pl.DataFrame) -> pl.DataFrame:
51
+ rename = {}
52
+ aliases = {
53
+ "sample": "sample",
54
+ "samples": "sample",
55
+ "variable": "Variable",
56
+ "var": "Variable",
57
+ "name": "Variable",
58
+ "mean": "Mean",
59
+ "avg": "Mean",
60
+ "average": "Mean",
61
+ "p50": "P50",
62
+ "median": "P50",
63
+ "med": "P50",
64
+ "sd": "SD",
65
+ "std": "SD",
66
+ "stddev": "SD",
67
+ "standard deviation": "SD",
68
+ "n": "N",
69
+ "obs": "N",
70
+ "observations": "N",
71
+ "n_in_group": "N_in_group",
72
+ "n group": "N_in_group",
73
+ "group n": "N_in_group",
74
+ "groups": "N_in_group",
75
+ "unique count": "N_in_group",
76
+ "unique counts": "N_in_group",
77
+ }
78
+ for column in stats.columns:
79
+ target = aliases.get(column.strip().lower())
80
+ if target is not None:
81
+ rename[column] = target
82
+ return stats.rename(rename)
83
+
84
+
85
+ def panel_for(sample: str) -> str:
86
+ if "Age 20+" in sample:
87
+ return "A"
88
+ if "Age 25+" in sample:
89
+ return "B"
90
+ return "Other"
91
+
92
+
93
+ def sample_label(sample: str) -> str:
94
+ return sample.replace(" Age 20+", "").replace(" Age 25+", "")
95
+
96
+
97
+ def make_tex(
98
+ stats: pl.DataFrame,
99
+ *,
100
+ caption: str = "Summary Statistics by Sample",
101
+ label: str = "tab:summary_stats_by_sample",
102
+ note: str = "Notes: Means, medians, and standard deviations are reported by analysis sample.",
103
+ ) -> str:
104
+ numeric_columns = [
105
+ column for column in ["Mean", "P50", "SD", "N", "N_in_group"] if column in stats.columns
106
+ ]
107
+ header_labels = {"Mean": "Mean", "P50": "Median", "SD": "SD", "N": "N", "N_in_group": "Group N"}
108
+ panel_width = len(numeric_columns)
109
+ num_columns = 1 + 2 * panel_width
110
+ headers = ["Variable"] + [header_labels[column] for column in numeric_columns] * 2
111
+ column_spec = (
112
+ r"@{}p{1.35in}"
113
+ + "r" * panel_width
114
+ + r"@{\hspace{0.22in}}"
115
+ + "r" * panel_width
116
+ + "@{}"
117
+ )
118
+
119
+ lines = [
120
+ r"\documentclass[11pt]{article}",
121
+ r"\usepackage[margin=0.25in]{geometry}",
122
+ r"\usepackage{booktabs}",
123
+ r"\usepackage{caption}",
124
+ r"\usepackage{longtable}",
125
+ r"\usepackage{pdflscape}",
126
+ r"\usepackage[table]{xcolor}",
127
+ r"\begin{document}",
128
+ r"\begin{landscape}",
129
+ r"\scriptsize",
130
+ r"\setlength{\tabcolsep}{3.5pt}",
131
+ r"\renewcommand{\arraystretch}{1.08}",
132
+ rf"\begin{{longtable}}{{{column_spec}}}",
133
+ rf"\caption{{{clean_latex(caption)}}}\label{{{label}}}\\",
134
+ r"\toprule",
135
+ rf" & \multicolumn{{{panel_width}}}{{c}}{{\textbf{{Panel A: Age 20+}}}} & \multicolumn{{{panel_width}}}{{c}}{{\textbf{{Panel B: Age 25+}}}} \\",
136
+ rf"\cmidrule(lr){{2-{1 + panel_width}}}\cmidrule(l){{{2 + panel_width}-{num_columns}}}",
137
+ " & ".join(clean_latex(column) for column in headers) + r" \\",
138
+ r"\midrule",
139
+ r"\endfirsthead",
140
+ rf"\caption*{{{clean_latex(caption)} (continued)}}\\",
141
+ r"\toprule",
142
+ rf" & \multicolumn{{{panel_width}}}{{c}}{{\textbf{{Panel A: Age 20+}}}} & \multicolumn{{{panel_width}}}{{c}}{{\textbf{{Panel B: Age 25+}}}} \\",
143
+ rf"\cmidrule(lr){{2-{1 + panel_width}}}\cmidrule(l){{{2 + panel_width}-{num_columns}}}",
144
+ " & ".join(clean_latex(column) for column in headers) + r" \\",
145
+ r"\midrule",
146
+ r"\endhead",
147
+ r"\midrule",
148
+ rf"\multicolumn{{{num_columns}}}{{r}}{{Continued on next page}} \\",
149
+ r"\endfoot",
150
+ r"\bottomrule",
151
+ r"\endlastfoot",
152
+ ]
153
+
154
+ groups: list[str] = []
155
+ variables: dict[str, list[str]] = {}
156
+ values: dict[tuple[str, str, str], dict[str, object]] = {}
157
+ for row in stats.iter_rows(named=True):
158
+ group = sample_label(str(row["sample"]))
159
+ panel = panel_for(str(row["sample"]))
160
+ variable = str(row["Variable"])
161
+ if group not in groups:
162
+ groups.append(group)
163
+ variables.setdefault(group, [])
164
+ if variable not in variables[group]:
165
+ variables[group].append(variable)
166
+ values[(panel, group, variable)] = row
167
+
168
+ for group_index, group in enumerate(groups):
169
+ if group_index:
170
+ lines.append(r"\addlinespace[0.25em]")
171
+ lines.append(
172
+ rf"\rowcolor{{black!8}}\multicolumn{{{num_columns}}}{{@{{}}l@{{}}}}{{\textbf{{{clean_latex(group)}}}}} \\"
173
+ )
174
+ for variable in variables[group]:
175
+ cells = [clean_latex(variable)]
176
+ for panel in ["A", "B"]:
177
+ row = values.get((panel, group, variable))
178
+ cells.extend(format_cell(column, row[column]) if row is not None else "" for column in numeric_columns)
179
+ lines.append(" & ".join(cells) + r" \\")
180
+
181
+ lines.extend([
182
+ r"\end{longtable}",
183
+ rf"\noindent\footnotesize {clean_latex(note)}",
184
+ r"\end{landscape}",
185
+ r"\end{document}",
186
+ ])
187
+ return "\n".join(lines) + "\n"
188
+
189
+
190
+ def build_summary_stats_table(
191
+ input_csv: Union[str, Path],
192
+ tex_out: Union[str, Path],
193
+ pdf_out: Optional[Union[str, Path]] = None,
194
+ *,
195
+ caption: str = "Summary Statistics by Sample",
196
+ label: str = "tab:summary_stats_by_sample",
197
+ note: str = "Notes: Means, medians, and standard deviations are reported by analysis sample.",
198
+ compile_pdf: bool = True,
199
+ ) -> Optional[Path]:
200
+ stats = standardize_columns(pl.read_csv(input_csv))
201
+ missing = [column for column in REQUIRED_COLUMNS if column not in stats.columns]
202
+ if missing:
203
+ raise ValueError(f"Missing required columns: {', '.join(missing)}")
204
+ display_columns = REQUIRED_COLUMNS + [column for column in COUNT_COLUMNS if column in stats.columns]
205
+ stats = stats.select(display_columns)
206
+
207
+ tex_path = Path(tex_out)
208
+ tex_path.parent.mkdir(parents=True, exist_ok=True)
209
+ tex_path.write_text(make_tex(stats, caption=caption, label=label, note=note))
210
+
211
+ if pdf_out is None or not compile_pdf:
212
+ return None
213
+ engine = shutil.which("tectonic") or shutil.which("pdflatex")
214
+ if engine is None:
215
+ return None
216
+ pdf_path = Path(pdf_out)
217
+ pdf_path.parent.mkdir(parents=True, exist_ok=True)
218
+ command = [engine, tex_path.name]
219
+ if Path(engine).name == "pdflatex":
220
+ command.insert(1, "-interaction=nonstopmode")
221
+ subprocess.run(command, cwd=tex_path.parent, check=True)
222
+ return pdf_path
223
+
224
+
225
+ def main() -> None:
226
+ parser = argparse.ArgumentParser(description=__doc__)
227
+ parser.add_argument("input_csv", type=Path)
228
+ parser.add_argument("--tex-out", type=Path, required=True)
229
+ parser.add_argument("--pdf-out", type=Path)
230
+ args = parser.parse_args()
231
+ build_summary_stats_table(args.input_csv, args.tex_out, args.pdf_out)
232
+
233
+
234
+ if __name__ == "__main__":
235
+ main()
@@ -247,7 +247,7 @@ chetty_college_outcomes = (
247
247
  )
248
248
 
249
249
  k_mean_neverattend = chetty_college_outcomes.filter(
250
- pl.col("super_opeid") == -99
250
+ pl.col("super_opeid") == -9
251
251
  ).item(0, "k_mean")
252
252
  k_mean_insuffdata = chetty_college_outcomes.filter(
253
253
  pl.col("super_opeid") == -1
@@ -365,20 +365,27 @@ college_ref = (
365
365
  # the year the student turns 18 and anchors Sarah's Y1/Y2/... outcome windows.
366
366
 
367
367
 
368
+ apps_scan = pl.scan_csv(APPS)
369
+ app_columns = apps_scan.collect_schema().names()
370
+ app_select = ["sid_cepr", "cohort_lottery"]
371
+ if "offer" in app_columns:
372
+ app_select.append("offer")
373
+
368
374
  apps = (
369
- scan_readstat(
370
- APPS, missing_string_as_null=True, value_labels_as_strings=True
371
- )
372
- .select("sid_cepr", "cohort_lottery")
375
+ apps_scan
376
+ .select(app_select)
373
377
  .drop_nulls(["sid_cepr", "cohort_lottery"])
374
378
  .with_columns(
375
379
  pl.col("sid_cepr").cast(pl.Int64),
376
380
  pl.col("cohort_lottery").cast(pl.Int64),
381
+ pl.col("offer").cast(pl.Int8, strict=False)
382
+ if "offer" in app_columns
383
+ else pl.lit(None).cast(pl.Int8).alias("offer"),
377
384
  )
378
385
  .filter(
379
386
  pl.col("cohort_lottery").is_between(1990, 2005)
380
387
  )
381
- .sort("cohort_lottery")
388
+ .sort(["cohort_lottery", "offer"], nulls_last=True)
382
389
  .unique(["sid_cepr"])
383
390
  .with_columns((pl.col("cohort_lottery") + 18).alias("cohort_18"))
384
391
  .collect()
@@ -919,11 +926,12 @@ for column in enrollment_outcomes.columns:
919
926
  .alias(column)
920
927
  )
921
928
 
922
- # First post-high-school institution is useful for auditing the match.
929
+ # First post-high-school institution is useful for auditing the match. Sarah's
930
+ # final Stata sort keeps the highest ID_FSC when enrollment start dates tie.
923
931
  first_institution = (
924
932
  enroll.with_columns(pl.date(pl.col("cohort_18"), 7, 1).alias("_hs_grad_start"))
925
933
  .filter(pl.col("term_start_date") >= pl.col("_hs_grad_start"))
926
- .sort(["sid_cepr", "term_start_date", "ID_FSC"])
934
+ .sort(["sid_cepr", "term_start_date", "ID_FSC"], descending=[False, False, True])
927
935
  .group_by("sid_cepr", maintain_order=True)
928
936
  .agg(
929
937
  pl.col("ID_FSC").first().alias("ID_FSC_firstinst"),
@@ -936,13 +944,29 @@ first_institution = (
936
944
  .first()
937
945
  .alias("completion_rate_150pct_firstinst"),
938
946
  )
947
+ .with_columns(
948
+ pl.col("ID_FSC_firstinst")
949
+ .str.slice(0, 6)
950
+ .cast(pl.Int64, strict=False)
951
+ .alias("opeid_firstinst")
952
+ )
953
+ .join(
954
+ chetty_by_opeid.select("opeid", "k_mean", "tier"),
955
+ left_on="opeid_firstinst",
956
+ right_on="opeid",
957
+ how="left",
958
+ validate="m:1",
959
+ )
960
+ .rename({"k_mean": "k_mean_firstinst", "tier": "tier_firstinst"})
939
961
  )
940
962
 
963
+ # Carry Sarah's raw IPEDS completion-rate variable from the first institution.
964
+ # Nonattenders remain missing here; Sarah handles the adjusted version later.
941
965
  first_institution = first_institution.with_columns(
942
966
  pl.col("k_mean_firstinst")
943
967
  .fill_null(k_mean_insuffdata)
944
968
  .alias("k_mean"),
945
- pl.col("completion_rate_150pct_firstinst").alias("predicted_completion"),
969
+ pl.col("completion_rate_150pct_firstinst").alias("completion_rate_150pct_ip"),
946
970
  )
947
971
 
948
972
  enrollment_outcomes = enrollment_outcomes.join(
@@ -1346,10 +1370,41 @@ nsc_outcomes = (
1346
1370
  .then(k_mean_neverattend)
1347
1371
  .otherwise(pl.col("k_mean"))
1348
1372
  .alias("k_mean"),
1349
- pl.when(pl.col("ID_FSC_firstinst").is_null())
1350
- .then(0.0)
1351
- .otherwise(pl.col("predicted_completion"))
1352
- .alias("predicted_completion"),
1373
+ )
1374
+ )
1375
+
1376
+ # Sarah's later cleanup takes medians over the student-level analysis file by
1377
+ # tier. Apply the same residual fill only when an attendee is still missing.
1378
+ tier_medians = (
1379
+ nsc_outcomes.group_by("tier_firstinst")
1380
+ .agg(
1381
+ pl.col("k_mean").median().alias("k_mean_tier_median"),
1382
+ pl.col("completion_rate_150pct_ip")
1383
+ .median()
1384
+ .alias("completion_rate_150pct_tier_median"),
1385
+ )
1386
+ .drop_nulls("tier_firstinst")
1387
+ )
1388
+
1389
+ nsc_outcomes = (
1390
+ nsc_outcomes.join(
1391
+ tier_medians,
1392
+ on="tier_firstinst",
1393
+ how="left",
1394
+ validate="m:1",
1395
+ )
1396
+ .with_columns(
1397
+ pl.when(pl.col("k_mean").is_null() & (pl.col("att_any_byY2") == 1))
1398
+ .then(pl.col("k_mean_tier_median"))
1399
+ .otherwise(pl.col("k_mean"))
1400
+ .alias("k_mean"),
1401
+ pl.when(
1402
+ pl.col("completion_rate_150pct_ip").is_null()
1403
+ & (pl.col("att_any_byY2") == 1)
1404
+ )
1405
+ .then(pl.col("completion_rate_150pct_tier_median"))
1406
+ .otherwise(pl.col("completion_rate_150pct_ip"))
1407
+ .alias("completion_rate_150pct_ip"),
1353
1408
  )
1354
1409
  )
1355
1410
 
@@ -1412,6 +1467,59 @@ for year in range(1, N_YEARS_OUT + 1):
1412
1467
  )
1413
1468
 
1414
1469
 
1470
+ # Sarah removes the raw school-specific rate for students who were not observed
1471
+ # attending by Y2 before creating cmp_rate.
1472
+ nsc_outcomes = nsc_outcomes.with_columns(
1473
+ pl.when(pl.col("att_any_byY2") == 0)
1474
+ .then(None)
1475
+ .otherwise(pl.col("completion_rate_150pct_ip"))
1476
+ .alias("completion_rate_150pct_ip")
1477
+ )
1478
+
1479
+ # Sarah creates cmp_rate as a copy of the restricted raw completion rate, then
1480
+ # fills missing cmp_rate values with the late-enrollee/control-group mean.
1481
+ nsc_outcomes = nsc_outcomes.with_columns(
1482
+ pl.col("completion_rate_150pct_ip").alias("cmp_rate")
1483
+ )
1484
+
1485
+ late_enrollee_mean = (
1486
+ nsc_outcomes
1487
+ .filter(
1488
+ (pl.col("att_4yr_inY2") == 0)
1489
+ & (pl.col("att_4yr_inY3") == 1)
1490
+ & (
1491
+ (pl.col("cmp_BA_byY6") == 1)
1492
+ | (pl.col("cmp_BA_byY7") == 1)
1493
+ | (pl.col("cmp_BA_byY8") == 1)
1494
+ )
1495
+ & (pl.col("offer") == 0)
1496
+ )
1497
+ .select(pl.col("cmp_rate").mean())
1498
+ .item()
1499
+ )
1500
+
1501
+ nsc_outcomes = nsc_outcomes.with_columns(
1502
+ pl.when(pl.col("cmp_rate").is_null())
1503
+ .then(pl.lit(late_enrollee_mean, dtype=pl.Float64))
1504
+ .otherwise(pl.col("cmp_rate"))
1505
+ .alias("adj_cmp_rate")
1506
+ ).with_columns(
1507
+ pl.when(pl.col("att_4yr_byY2").is_null())
1508
+ .then(None)
1509
+ .otherwise(pl.col("cmp_rate"))
1510
+ .alias("cmp_rate"),
1511
+ pl.when(pl.col("att_4yr_byY2").is_null())
1512
+ .then(None)
1513
+ .otherwise(pl.col("adj_cmp_rate"))
1514
+ .alias("adj_cmp_rate"),
1515
+ # Preserve the package's existing name as an alias for Sarah's adjusted rate.
1516
+ pl.when(pl.col("att_4yr_byY2").is_null())
1517
+ .then(None)
1518
+ .otherwise(pl.col("adj_cmp_rate"))
1519
+ .alias("predicted_completion"),
1520
+ )
1521
+
1522
+
1415
1523
  ###########################################################
1416
1524
  # Write final outcomes
1417
1525
  ###########################################################
@@ -1443,9 +1551,13 @@ nsc_outcomes = nsc_outcomes.with_columns(
1443
1551
  keep_columns = [
1444
1552
  "sid_cepr",
1445
1553
  "k_mean",
1554
+ "completion_rate_150pct_ip",
1555
+ "cmp_rate",
1556
+ "adj_cmp_rate",
1446
1557
  "predicted_completion",
1447
1558
  "ID_FSC_firstinst",
1448
1559
  "college_name_firstinst",
1560
+ "tier_firstinst",
1449
1561
  "completion_rate_150pct_firstinst",
1450
1562
  ] + outcome_columns
1451
1563
 
@@ -0,0 +1,3 @@
1
+ from ltc_code.school_name_xwalk.merge_school_ccd import add_school_ccd
2
+
3
+ __all__ = ["add_school_ccd"]