ltc-code 0.2.19__tar.gz → 0.2.21__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 (78) hide show
  1. {ltc_code-0.2.19 → ltc_code-0.2.21}/PKG-INFO +1 -1
  2. {ltc_code-0.2.19 → ltc_code-0.2.21}/pyproject.toml +2 -1
  3. ltc_code-0.2.21/src/ltc_code/.DS_Store +0 -0
  4. ltc_code-0.2.21/src/ltc_code/make_summary_stats_table.py +235 -0
  5. ltc_code-0.2.21/src/ltc_code/nsc/.DS_Store +0 -0
  6. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/nsc/build_nsc_outcomes.py +313 -17
  7. ltc_code-0.2.21/src/ltc_code/nsc/raw/.DS_Store +0 -0
  8. ltc_code-0.2.21/src/ltc_code/signal_var_calcs.py +41 -0
  9. {ltc_code-0.2.19 → ltc_code-0.2.21}/README.md +0 -0
  10. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260614_new_build_scripts_update/aspire.py +0 -0
  11. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260614_new_build_scripts_update/check_cmo_apps.do +0 -0
  12. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260614_new_build_scripts_update/christel_house.py +0 -0
  13. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260614_new_build_scripts_update/democracy_prep.py +0 -0
  14. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260614_new_build_scripts_update/green_dot.py +0 -0
  15. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260614_new_build_scripts_update/helpers.py +0 -0
  16. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260614_new_build_scripts_update/ilt.py +0 -0
  17. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260614_new_build_scripts_update/kipp_nj.py +0 -0
  18. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260614_new_build_scripts_update/main.py +0 -0
  19. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260614_new_build_scripts_update/mappings.py +0 -0
  20. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260614_new_build_scripts_update/rocketship.py +0 -0
  21. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260614_new_build_scripts_update/yes_prep.py +0 -0
  22. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260630_census_disclosure/aspire.py +0 -0
  23. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260630_census_disclosure/christel_house.py +0 -0
  24. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260630_census_disclosure/democracy_prep.py +0 -0
  25. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260630_census_disclosure/green_dot.py +0 -0
  26. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260630_census_disclosure/ilt.py +0 -0
  27. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260630_census_disclosure/kipp.py +0 -0
  28. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260630_census_disclosure/kipp_nj.py +0 -0
  29. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260630_census_disclosure/rocketship.py +0 -0
  30. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260630_census_disclosure/yes_prep.py +0 -0
  31. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260706_ceprscripts/BALANCE.do +0 -0
  32. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260706_ceprscripts/FS.do +0 -0
  33. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260706_ceprscripts/ITT.do +0 -0
  34. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260706_ceprscripts/TOT.do +0 -0
  35. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260706_ceprscripts/apps_helpers.py +0 -0
  36. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260706_ceprscripts/harmony.py +0 -0
  37. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260706_ceprscripts/main.py +0 -0
  38. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260712_uncommon_scripts/main.py +0 -0
  39. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260712_uncommon_scripts/mappings.py +0 -0
  40. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/20260712_uncommon_scripts/uncommon.py +0 -0
  41. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/__init__.py +0 -0
  42. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/aspire.py +0 -0
  43. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/check_cmo_apps.do +0 -0
  44. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/christel_house.py +0 -0
  45. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/green_dot.py +0 -0
  46. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/helpers.py +0 -0
  47. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/june13.py +0 -0
  48. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/june2.py +0 -0
  49. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/june30.py +0 -0
  50. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/june5.py +0 -0
  51. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/june7.py +0 -0
  52. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/kipp_nj.py +0 -0
  53. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/kipp_tx.py +0 -0
  54. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/main.py +0 -0
  55. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/mappings.py +0 -0
  56. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/may27.py +0 -0
  57. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/nsc/__init__.py +0 -0
  58. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/nsc/input/.gitkeep +0 -0
  59. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/nsc/naics.csv +0 -0
  60. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/nsc/naics.py +0 -0
  61. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/nsc/naics_raw.xlsx +0 -0
  62. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/nsc/output/.gitkeep +0 -0
  63. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/nsc/raw/CREDENTIAL_LEVEL_LOOKUP_TABLE.xlsx +0 -0
  64. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/nsc/raw/IPEDS_IC_2013.csv +0 -0
  65. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/nsc/raw/IPEDS_IC_manual.xlsx +0 -0
  66. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/nsc/raw/chetty/mrc_table11.dta +0 -0
  67. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/nsc/raw/chetty/mrc_table2.dta +0 -0
  68. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/nsc/raw/college_crosswalk.xls +0 -0
  69. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/nsc/raw/directory.dta +0 -0
  70. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/nsc/raw/ipeds_data.dta +0 -0
  71. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/nsc/run_nsc_outcomes.py +0 -0
  72. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/polars_dates.py +0 -0
  73. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/rocketship.py +0 -0
  74. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/schema_mapping.py +0 -0
  75. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/school_name_xwalk/__init__.py +0 -0
  76. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/school_name_xwalk/all_schools_with_ccd.csv +0 -0
  77. {ltc_code-0.2.19 → ltc_code-0.2.21}/src/ltc_code/school_name_xwalk/merge_school_ccd.py +0 -0
  78. {ltc_code-0.2.19 → ltc_code-0.2.21}/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.19
3
+ Version: 0.2.21
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.19"
3
+ version = "0.2.21"
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()
@@ -235,11 +235,13 @@ chetty_college_outcomes = (
235
235
  missing_string_as_null=True,
236
236
  value_labels_as_strings=False,
237
237
  )
238
- .select("super_opeid", "tier", "tier_name", "k_mean")
238
+ .select("super_opeid", "tier", "tier_name", "iclevel", "count", "k_mean")
239
239
  .collect()
240
240
  .with_columns(
241
241
  pl.col("super_opeid").cast(pl.Int64, strict=False),
242
242
  pl.col("tier").cast(pl.Int8, strict=False),
243
+ pl.col("iclevel").cast(pl.Int8, strict=False),
244
+ pl.col("count").cast(pl.Float64, strict=False),
243
245
  pl.col("k_mean").cast(pl.Float64, strict=False),
244
246
  )
245
247
  .drop_nulls("super_opeid")
@@ -253,6 +255,38 @@ k_mean_insuffdata = chetty_college_outcomes.filter(
253
255
  pl.col("super_opeid") == -1
254
256
  ).item(0, "k_mean")
255
257
 
258
+ # Collapse MRC college-specific earnings to national student-weighted means.
259
+ # MRC count is the mean number of children per cohort represented by each row.
260
+ mrc_sector_means = (
261
+ chetty_college_outcomes
262
+ .filter(
263
+ (pl.col("super_opeid") > 0)
264
+ & pl.col("iclevel").is_in([1, 2, 3])
265
+ & (pl.col("count") > 0)
266
+ & pl.col("k_mean").is_not_null()
267
+ )
268
+ .with_columns(
269
+ pl.when(pl.col("iclevel") == 1)
270
+ .then(pl.lit("4yr"))
271
+ .otherwise(pl.lit("2yr_or_less"))
272
+ .alias("college_sector")
273
+ )
274
+ .group_by("college_sector")
275
+ .agg(
276
+ (
277
+ (pl.col("k_mean") * pl.col("count")).sum()
278
+ / pl.col("count").sum()
279
+ ).alias("k_mean_coarse")
280
+ )
281
+ )
282
+
283
+ k_mean_4yr_coarse = mrc_sector_means.filter(
284
+ pl.col("college_sector") == "4yr"
285
+ ).item(0, "k_mean_coarse")
286
+ k_mean_2yr_coarse = mrc_sector_means.filter(
287
+ pl.col("college_sector") == "2yr_or_less"
288
+ ).item(0, "k_mean_coarse")
289
+
256
290
  chetty_ope_crosswalk = (
257
291
  scan_readstat(
258
292
  CHETTY_MRC_TABLE11,
@@ -275,24 +309,102 @@ chetty_by_opeid = (
275
309
  .unique("opeid", keep="first")
276
310
  )
277
311
 
278
- # IPEDS reports completion within 150% of normal time: six years at four-year
279
- # colleges and three years at two-year colleges.
312
+ # The IPEDS 150%-of-normal-time rate is six-year completion at four-year
313
+ # institutions and three-year completion at two-year institutions.
280
314
  ipeds_completion = (
281
315
  scan_readstat(
282
316
  IPEDS_DATA,
283
317
  missing_string_as_null=True,
284
318
  value_labels_as_strings=False,
285
319
  )
286
- .select("unitid", "completion_rate_150pct_ip")
320
+ .select(
321
+ "unitid",
322
+ "completion_rate_150pct_ip",
323
+ "cohort_adj_150pct_ip",
324
+ "completers_150pct_ip",
325
+ )
287
326
  .collect()
288
327
  .with_columns(
289
328
  pl.col("unitid").cast(pl.Int64, strict=False),
290
329
  pl.col("completion_rate_150pct_ip").cast(pl.Float64, strict=False),
330
+ pl.col("cohort_adj_150pct_ip").cast(pl.Float64, strict=False),
331
+ pl.col("completers_150pct_ip").cast(pl.Float64, strict=False),
291
332
  )
292
333
  .drop_nulls("unitid")
293
334
  .unique("unitid", keep="first")
294
335
  )
295
336
 
337
+ # Use the graduation cohort itself as the student weight. Summing completers and
338
+ # cohorts is equivalent to weighting each college's rate by its IPEDS denominator.
339
+ ipeds_sector_rates = (
340
+ ipeds_completion
341
+ .join(
342
+ ipeds_2013.select("unitid", "years_ipeds_2013"),
343
+ on="unitid",
344
+ how="left",
345
+ validate="1:1",
346
+ )
347
+ .join(
348
+ ipeds_manual.select("unitid", "years_manual"),
349
+ on="unitid",
350
+ how="left",
351
+ validate="1:1",
352
+ )
353
+ .join(
354
+ directory.select("unitid", "years_directory"),
355
+ on="unitid",
356
+ how="left",
357
+ validate="1:1",
358
+ )
359
+ .with_columns(
360
+ pl.coalesce(
361
+ [
362
+ pl.col("years_manual"),
363
+ pl.col("years_ipeds_2013"),
364
+ pl.col("years_directory"),
365
+ ]
366
+ ).alias("college_years")
367
+ )
368
+ .filter(
369
+ pl.col("college_years").is_in([1, 2, 4])
370
+ & (pl.col("cohort_adj_150pct_ip") > 0)
371
+ & pl.col("completers_150pct_ip").is_not_null()
372
+ )
373
+ .with_columns(
374
+ pl.when(pl.col("college_years") == 4)
375
+ .then(pl.lit("4yr"))
376
+ .otherwise(pl.lit("2yr_or_less"))
377
+ .alias("college_sector")
378
+ )
379
+ .group_by("college_sector")
380
+ .agg(
381
+ pl.col("completers_150pct_ip").sum().alias("completers"),
382
+ pl.col("cohort_adj_150pct_ip").sum().alias("cohort"),
383
+ )
384
+ .with_columns((pl.col("completers") / pl.col("cohort")).alias("cmp_rate_coarse"))
385
+ )
386
+
387
+ cmp_rate_4yr_coarse = ipeds_sector_rates.filter(
388
+ pl.col("college_sector") == "4yr"
389
+ ).item(0, "cmp_rate_coarse")
390
+ cmp_rate_2yr_coarse = ipeds_sector_rates.filter(
391
+ pl.col("college_sector") == "2yr_or_less"
392
+ ).item(0, "cmp_rate_coarse")
393
+
394
+ for label, value in {
395
+ "four-year k_mean": k_mean_4yr_coarse,
396
+ "two-year-or-less k_mean": k_mean_2yr_coarse,
397
+ }.items():
398
+ if value is None or value <= 0:
399
+ raise ValueError(f"Invalid national student-weighted {label}: {value}")
400
+
401
+ for label, value in {
402
+ "four-year completion rate": cmp_rate_4yr_coarse,
403
+ "two-year-or-less completion rate": cmp_rate_2yr_coarse,
404
+ }.items():
405
+ if value is None or not 0 <= value <= 1:
406
+ raise ValueError(f"Invalid national student-weighted {label}: {value}")
407
+
296
408
 
297
409
  ###########################################################
298
410
  # Build college reference data
@@ -331,7 +443,7 @@ college_ref = (
331
443
  )
332
444
  .with_columns(
333
445
  (pl.col("college_years") == 4).cast(pl.Int8).alias("college_4yr"),
334
- (pl.col("college_years") == 2).cast(pl.Int8).alias("college_2yr"),
446
+ pl.col("college_years").is_in([1, 2]).cast(pl.Int8).alias("college_2yr"),
335
447
  pl.col("tier").is_in([1, 2]).cast(pl.Int8).alias("college_elite"),
336
448
  )
337
449
  .with_columns(
@@ -365,20 +477,27 @@ college_ref = (
365
477
  # the year the student turns 18 and anchors Sarah's Y1/Y2/... outcome windows.
366
478
 
367
479
 
480
+ apps_scan = pl.scan_csv(APPS)
481
+ app_columns = apps_scan.collect_schema().names()
482
+ app_select = ["sid_cepr", "cohort_lottery"]
483
+ if "offer" in app_columns:
484
+ app_select.append("offer")
485
+
368
486
  apps = (
369
- scan_readstat(
370
- APPS, missing_string_as_null=True, value_labels_as_strings=True
371
- )
372
- .select("sid_cepr", "cohort_lottery")
487
+ apps_scan
488
+ .select(app_select)
373
489
  .drop_nulls(["sid_cepr", "cohort_lottery"])
374
490
  .with_columns(
375
491
  pl.col("sid_cepr").cast(pl.Int64),
376
492
  pl.col("cohort_lottery").cast(pl.Int64),
493
+ pl.col("offer").cast(pl.Int8, strict=False)
494
+ if "offer" in app_columns
495
+ else pl.lit(None).cast(pl.Int8).alias("offer"),
377
496
  )
378
497
  .filter(
379
498
  pl.col("cohort_lottery").is_between(1990, 2005)
380
499
  )
381
- .sort("cohort_lottery")
500
+ .sort(["cohort_lottery", "offer"], nulls_last=True)
382
501
  .unique(["sid_cepr"])
383
502
  .with_columns((pl.col("cohort_lottery") + 18).alias("cohort_18"))
384
503
  .collect()
@@ -919,11 +1038,12 @@ for column in enrollment_outcomes.columns:
919
1038
  .alias(column)
920
1039
  )
921
1040
 
922
- # First post-high-school institution is useful for auditing the match.
1041
+ # First post-high-school institution is useful for auditing the match. Sarah's
1042
+ # final Stata sort keeps the highest ID_FSC when enrollment start dates tie.
923
1043
  first_institution = (
924
1044
  enroll.with_columns(pl.date(pl.col("cohort_18"), 7, 1).alias("_hs_grad_start"))
925
1045
  .filter(pl.col("term_start_date") >= pl.col("_hs_grad_start"))
926
- .sort(["sid_cepr", "term_start_date", "ID_FSC"])
1046
+ .sort(["sid_cepr", "term_start_date", "ID_FSC"], descending=[False, False, True])
927
1047
  .group_by("sid_cepr", maintain_order=True)
928
1048
  .agg(
929
1049
  pl.col("ID_FSC").first().alias("ID_FSC_firstinst"),
@@ -931,18 +1051,33 @@ first_institution = (
931
1051
  .first()
932
1052
  .alias("college_name_firstinst"),
933
1053
  pl.col("college_years").first().alias("college_years_firstinst"),
934
- pl.col("k_mean").first().alias("k_mean_firstinst"),
935
1054
  pl.col("completion_rate_150pct_ip")
936
1055
  .first()
937
1056
  .alias("completion_rate_150pct_firstinst"),
938
1057
  )
1058
+ .with_columns(
1059
+ pl.col("ID_FSC_firstinst")
1060
+ .str.slice(0, 6)
1061
+ .cast(pl.Int64, strict=False)
1062
+ .alias("opeid_firstinst")
1063
+ )
1064
+ .join(
1065
+ chetty_by_opeid.select("opeid", "k_mean", "tier"),
1066
+ left_on="opeid_firstinst",
1067
+ right_on="opeid",
1068
+ how="left",
1069
+ validate="m:1",
1070
+ )
1071
+ .rename({"k_mean": "k_mean_firstinst", "tier": "tier_firstinst"})
939
1072
  )
940
1073
 
1074
+ # Carry Sarah's raw IPEDS completion-rate variable from the first institution.
1075
+ # Nonattenders remain missing here; Sarah handles the adjusted version later.
941
1076
  first_institution = first_institution.with_columns(
942
1077
  pl.col("k_mean_firstinst")
943
1078
  .fill_null(k_mean_insuffdata)
944
1079
  .alias("k_mean"),
945
- pl.col("completion_rate_150pct_firstinst").alias("predicted_completion"),
1080
+ pl.col("completion_rate_150pct_firstinst").alias("completion_rate_150pct_ip"),
946
1081
  )
947
1082
 
948
1083
  enrollment_outcomes = enrollment_outcomes.join(
@@ -1347,9 +1482,48 @@ nsc_outcomes = (
1347
1482
  .otherwise(pl.col("k_mean"))
1348
1483
  .alias("k_mean"),
1349
1484
  pl.when(pl.col("ID_FSC_firstinst").is_null())
1350
- .then(0.0)
1351
- .otherwise(pl.col("predicted_completion"))
1352
- .alias("predicted_completion"),
1485
+ .then(k_mean_neverattend)
1486
+ .when(pl.col("college_years_firstinst") == 4)
1487
+ .then(k_mean_4yr_coarse)
1488
+ .when(pl.col("college_years_firstinst").is_in([1, 2]))
1489
+ .then(k_mean_2yr_coarse)
1490
+ .otherwise(k_mean_insuffdata)
1491
+ .alias("k_mean_coarse"),
1492
+ )
1493
+ )
1494
+
1495
+ # Sarah's later cleanup takes medians over the student-level analysis file by
1496
+ # tier. Apply the same residual fill only when an attendee is still missing.
1497
+ tier_medians = (
1498
+ nsc_outcomes.group_by("tier_firstinst")
1499
+ .agg(
1500
+ pl.col("k_mean").median().alias("k_mean_tier_median"),
1501
+ pl.col("completion_rate_150pct_ip")
1502
+ .median()
1503
+ .alias("completion_rate_150pct_tier_median"),
1504
+ )
1505
+ .drop_nulls("tier_firstinst")
1506
+ )
1507
+
1508
+ nsc_outcomes = (
1509
+ nsc_outcomes.join(
1510
+ tier_medians,
1511
+ on="tier_firstinst",
1512
+ how="left",
1513
+ validate="m:1",
1514
+ )
1515
+ .with_columns(
1516
+ pl.when(pl.col("k_mean").is_null() & (pl.col("att_any_byY2") == 1))
1517
+ .then(pl.col("k_mean_tier_median"))
1518
+ .otherwise(pl.col("k_mean"))
1519
+ .alias("k_mean"),
1520
+ pl.when(
1521
+ pl.col("completion_rate_150pct_ip").is_null()
1522
+ & (pl.col("att_any_byY2") == 1)
1523
+ )
1524
+ .then(pl.col("completion_rate_150pct_tier_median"))
1525
+ .otherwise(pl.col("completion_rate_150pct_ip"))
1526
+ .alias("completion_rate_150pct_ip"),
1353
1527
  )
1354
1528
  )
1355
1529
 
@@ -1412,6 +1586,89 @@ for year in range(1, N_YEARS_OUT + 1):
1412
1586
  )
1413
1587
 
1414
1588
 
1589
+ # Sarah removes the raw school-specific rate for students who were not observed
1590
+ # attending by Y2 before creating cmp_rate.
1591
+ nsc_outcomes = nsc_outcomes.with_columns(
1592
+ pl.when(pl.col("att_any_byY2") == 0)
1593
+ .then(None)
1594
+ .otherwise(pl.col("completion_rate_150pct_ip"))
1595
+ .alias("completion_rate_150pct_ip")
1596
+ )
1597
+
1598
+ # Sarah creates cmp_rate as a copy of the restricted raw completion rate, then
1599
+ # fills missing cmp_rate values with the late-enrollee/control-group mean.
1600
+ nsc_outcomes = nsc_outcomes.with_columns(
1601
+ pl.col("completion_rate_150pct_ip").alias("cmp_rate"),
1602
+ pl.when(pl.col("att_any_byY2") == 0)
1603
+ .then(None)
1604
+ .when(pl.col("college_years_firstinst") == 4)
1605
+ .then(cmp_rate_4yr_coarse)
1606
+ .when(pl.col("college_years_firstinst").is_in([1, 2]))
1607
+ .then(cmp_rate_2yr_coarse)
1608
+ .otherwise(None)
1609
+ .alias("cmp_rate_coarse"),
1610
+ )
1611
+
1612
+ late_enrollee_means = (
1613
+ nsc_outcomes
1614
+ .filter(
1615
+ (pl.col("att_4yr_inY2") == 0)
1616
+ & (pl.col("att_4yr_inY3") == 1)
1617
+ & (
1618
+ (pl.col("cmp_BA_byY6") == 1)
1619
+ | (pl.col("cmp_BA_byY7") == 1)
1620
+ | (pl.col("cmp_BA_byY8") == 1)
1621
+ )
1622
+ & (pl.col("offer") == 0)
1623
+ )
1624
+ .select(
1625
+ pl.col("cmp_rate").mean().alias("cmp_rate"),
1626
+ pl.col("cmp_rate_coarse").mean().alias("cmp_rate_coarse"),
1627
+ )
1628
+ )
1629
+
1630
+ late_enrollee_mean = late_enrollee_means.item(0, "cmp_rate")
1631
+ late_enrollee_coarse_mean = late_enrollee_means.item(0, "cmp_rate_coarse")
1632
+
1633
+ if late_enrollee_mean is None or late_enrollee_coarse_mean is None:
1634
+ raise ValueError(
1635
+ "The late-enrollee control donor group has no usable completion-rate values."
1636
+ )
1637
+
1638
+ nsc_outcomes = nsc_outcomes.with_columns(
1639
+ pl.when(pl.col("cmp_rate").is_null())
1640
+ .then(pl.lit(late_enrollee_mean, dtype=pl.Float64))
1641
+ .otherwise(pl.col("cmp_rate"))
1642
+ .alias("adj_cmp_rate"),
1643
+ pl.when(pl.col("cmp_rate_coarse").is_null())
1644
+ .then(pl.lit(late_enrollee_coarse_mean, dtype=pl.Float64))
1645
+ .otherwise(pl.col("cmp_rate_coarse"))
1646
+ .alias("adj_cmp_rate_coarse"),
1647
+ ).with_columns(
1648
+ pl.when(pl.col("att_4yr_byY2").is_null())
1649
+ .then(None)
1650
+ .otherwise(pl.col("cmp_rate"))
1651
+ .alias("cmp_rate"),
1652
+ pl.when(pl.col("att_4yr_byY2").is_null())
1653
+ .then(None)
1654
+ .otherwise(pl.col("adj_cmp_rate"))
1655
+ .alias("adj_cmp_rate"),
1656
+ pl.when(pl.col("att_4yr_byY2").is_null())
1657
+ .then(None)
1658
+ .otherwise(pl.col("cmp_rate_coarse"))
1659
+ .alias("cmp_rate_coarse"),
1660
+ pl.when(pl.col("att_4yr_byY2").is_null())
1661
+ .then(None)
1662
+ .otherwise(pl.col("adj_cmp_rate_coarse"))
1663
+ .alias("adj_cmp_rate_coarse"),
1664
+ # Preserve the package's existing name as an alias for Sarah's adjusted rate.
1665
+ pl.when(pl.col("att_4yr_byY2").is_null())
1666
+ .then(None)
1667
+ .otherwise(pl.col("adj_cmp_rate"))
1668
+ .alias("predicted_completion"),
1669
+ )
1670
+
1671
+
1415
1672
  ###########################################################
1416
1673
  # Write final outcomes
1417
1674
  ###########################################################
@@ -1443,9 +1700,16 @@ nsc_outcomes = nsc_outcomes.with_columns(
1443
1700
  keep_columns = [
1444
1701
  "sid_cepr",
1445
1702
  "k_mean",
1703
+ "k_mean_coarse",
1704
+ "completion_rate_150pct_ip",
1705
+ "cmp_rate",
1706
+ "adj_cmp_rate",
1707
+ "cmp_rate_coarse",
1708
+ "adj_cmp_rate_coarse",
1446
1709
  "predicted_completion",
1447
1710
  "ID_FSC_firstinst",
1448
1711
  "college_name_firstinst",
1712
+ "tier_firstinst",
1449
1713
  "completion_rate_150pct_firstinst",
1450
1714
  ] + outcome_columns
1451
1715
 
@@ -1479,6 +1743,38 @@ print(
1479
1743
  f"{first_college_coverage['has_completion_rate'].sum()}/"
1480
1744
  f"{first_college_coverage.height} with IPEDS completion rate"
1481
1745
  )
1746
+ print(
1747
+ "National student-weighted coarse values: "
1748
+ f"k_mean 4yr={k_mean_4yr_coarse:.2f}, "
1749
+ f"k_mean 2yr-or-less={k_mean_2yr_coarse:.2f}, "
1750
+ f"completion 4yr={cmp_rate_4yr_coarse:.4f}, "
1751
+ f"completion 2yr-or-less={cmp_rate_2yr_coarse:.4f}, "
1752
+ f"nonattender completion imputation={late_enrollee_mean:.4f}, "
1753
+ f"coarse nonattender imputation={late_enrollee_coarse_mean:.4f}"
1754
+ )
1755
+
1756
+ observable_y2 = nsc_outcomes.filter(pl.col("att_4yr_byY2").is_not_null())
1757
+ coarse_audit = observable_y2.select(
1758
+ pl.len().alias("students"),
1759
+ pl.col("k_mean_coarse").null_count().alias("k_mean_coarse_missing"),
1760
+ pl.col("k_mean_coarse").min().alias("k_mean_coarse_min"),
1761
+ pl.col("k_mean_coarse").max().alias("k_mean_coarse_max"),
1762
+ pl.col("adj_cmp_rate_coarse")
1763
+ .null_count()
1764
+ .alias("adj_cmp_rate_coarse_missing"),
1765
+ pl.col("adj_cmp_rate_coarse").min().alias("adj_cmp_rate_coarse_min"),
1766
+ pl.col("adj_cmp_rate_coarse").max().alias("adj_cmp_rate_coarse_max"),
1767
+ )
1768
+
1769
+ if coarse_audit.item(0, "k_mean_coarse_missing") > 0:
1770
+ raise ValueError("k_mean_coarse is unexpectedly missing for observable students.")
1771
+ if coarse_audit.item(0, "adj_cmp_rate_coarse_missing") > 0:
1772
+ raise ValueError(
1773
+ "adj_cmp_rate_coarse is unexpectedly missing for observable students."
1774
+ )
1775
+
1776
+ print("Coarse outcome audit:")
1777
+ print(coarse_audit)
1482
1778
  print(
1483
1779
  nsc_outcomes.select("sid_cepr", "cohort_lottery", "recovered_nsc_outcome").sort(
1484
1780
  "sid_cepr"
@@ -0,0 +1,41 @@
1
+ # --- Import necessary packages ---
2
+ import polars as pl
3
+
4
+
5
+ SIGNAL_DEP_VARS = ["wages25", "college20"]
6
+
7
+
8
+ def calculate_signal_variance(results: pl.DataFrame) -> pl.DataFrame:
9
+ """Calculate signal variance and its implied 90-10 spread by outcome."""
10
+ return (
11
+ results
12
+ .filter(pl.col("depvar").is_in(SIGNAL_DEP_VARS))
13
+ .drop_nulls(["sample", "depvar", "coef", "se"])
14
+ .group_by("depvar")
15
+ .agg(
16
+ pl.col("sample").n_unique().alias("n_cmos"),
17
+ pl.col("coef").mean().alias("mean_te"),
18
+ pl.col("coef").var().alias("raw_variance"),
19
+ (pl.col("se") ** 2).mean().alias("noise_variance"),
20
+ )
21
+ .with_columns(
22
+ (
23
+ pl.col("raw_variance")
24
+ - pl.col("noise_variance")
25
+ ).alias("signal_variance_raw")
26
+ )
27
+ # Retain the raw estimate, but use a nonnegative value for the spread.
28
+ .with_columns(
29
+ pl.col("signal_variance_raw")
30
+ .clip(lower_bound=0)
31
+ .alias("signal_variance")
32
+ )
33
+ .with_columns(
34
+ pl.col("signal_variance").sqrt().alias("signal_sd")
35
+ )
36
+ # Under normality, P90 - P10 equals 2.563103 standard deviations.
37
+ .with_columns(
38
+ (2.563103 * pl.col("signal_sd")).alias("spread_90_10")
39
+ )
40
+ .sort("depvar")
41
+ )
File without changes