Source document

scripts/analysis/export-dataset.ts

Served verbatim from the project repository. Internal working document conventions apply: documents may reference file paths, branch names, and findings-ledger anchors from the repo.

// Export a reproducible analysis dataset for the clustering/decile bake-off.
// Read-only against the warehouse. Writes CSVs to scripts/analysis/data/.
//
//   school_year_panel.csv  — one row per (dbn, year): demographic feature vector
//                            + same-year outcomes (ELA/Math proficiency, grad
//                            rate, chronic absenteeism, college readiness).
//   school_current.csv     — current proclivity decile/score + comparison-group
//                            id per school (the production methods, for head-to-head).
//   comparison_groups.csv  — current DERIVED/NYCENET group id -> peer dbns.
//
// Long-format metrics are pivoted to wide here so the Python modeling is simple.
import { prisma } from "../loaders/_lib";
import fs from "node:fs";
import path from "node:path";

const OUT_DIR = path.join("scripts", "analysis", "data");

const OUTCOME_KEYS = [
  "ela_all_proficiency",
  "math_all_proficiency",
  "graduation_rate_4yr",
  "chronic_absenteeism_rate",
  "college_career_readiness",
] as const;

const DEMOG_COLS = [
  "totalEnrollment",
  "pctBlack",
  "pctHispanic",
  "pctWhite",
  "pctAsian",
  "pctMultiRacial",
  "pctOther",
  "pctEll",
  "pctSwd",
  "pctEconDis",
  "pctTempHousing",
  "pctFemale",
] as const;

function csvEscape(v: unknown): string {
  if (v === null || v === undefined) return "";
  const s = String(v);
  return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}

function writeCsv(file: string, header: string[], rows: unknown[][]) {
  const lines = [header.join(",")];
  for (const r of rows) lines.push(r.map(csvEscape).join(","));
  fs.writeFileSync(file, lines.join("\n"));
  console.log(`  wrote ${file}  (${rows.length} rows)`);
}

async function main() {
  fs.mkdirSync(OUT_DIR, { recursive: true });

  // ---- Schools (static attributes) ----
  const schools = await prisma.school.findMany({
    select: {
      dbn: true,
      name: true,
      borough: true,
      district: true,
      schoolType: true,
      gradeBand: true,
      admissionCategory: true,
      includeInDefaultComparisons: true,
      proclivityDecile: true,
      proclivityScore: true,
      comparisonGroupId: true,
    },
  });
  const schoolMeta = new Map(schools.map((s) => [s.dbn, s]));
  console.log(`Schools: ${schools.length}`);

  // ---- Demographics (all years) ----
  const dem = await prisma.schoolYearDemographics.findMany({
    select: {
      schoolDbn: true,
      year: true,
      totalEnrollment: true,
      pctBlack: true,
      pctHispanic: true,
      pctWhite: true,
      pctAsian: true,
      pctMultiRacial: true,
      pctOther: true,
      pctEll: true,
      pctSwd: true,
      pctEconDis: true,
      pctTempHousing: true,
      pctFemale: true,
    },
  });
  console.log(`Demographics rows: ${dem.length}`);

  // ---- Outcomes (ALL subgroup, all years), pivoted ----
  const metricRows = await prisma.schoolYearMetric.findMany({
    where: { metricKey: { in: OUTCOME_KEYS as unknown as string[] }, subgroup: "ALL" },
    select: { schoolDbn: true, year: true, metricKey: true, value: true, suppressed: true },
  });
  console.log(`Outcome metric rows: ${metricRows.length}`);
  // (dbn|year) -> { metricKey: value }
  const outBySY = new Map<string, Record<string, number | null>>();
  for (const m of metricRows) {
    const k = `${m.schoolDbn}|${m.year}`;
    const o = outBySY.get(k) ?? {};
    o[m.metricKey] = m.suppressed ? null : m.value;
    outBySY.set(k, o);
  }

  // ---- Panel ----
  const panelHeader = [
    "dbn",
    "year",
    "name",
    "borough",
    "district",
    "school_type",
    "grade_band",
    "admission_category",
    "include_in_default_comparisons",
    ...DEMOG_COLS.map((c) => c.replace(/[A-Z]/g, (m) => "_" + m.toLowerCase())),
    ...OUTCOME_KEYS,
  ];
  const panelRows: unknown[][] = [];
  for (const d of dem) {
    const meta = schoolMeta.get(d.schoolDbn);
    if (!meta) continue;
    const out = outBySY.get(`${d.schoolDbn}|${d.year}`) ?? {};
    panelRows.push([
      d.schoolDbn,
      d.year,
      meta.name,
      meta.borough,
      meta.district,
      meta.schoolType,
      meta.gradeBand,
      meta.admissionCategory,
      meta.includeInDefaultComparisons,
      ...DEMOG_COLS.map((c) => (d as Record<string, unknown>)[c]),
      ...OUTCOME_KEYS.map((k) => out[k] ?? ""),
    ]);
  }
  writeCsv(path.join(OUT_DIR, "school_year_panel.csv"), panelHeader, panelRows);

  // ---- Current production snapshot ----
  writeCsv(
    path.join(OUT_DIR, "school_current.csv"),
    ["dbn", "grade_band", "admission_category", "include_in_default_comparisons", "proclivity_decile", "proclivity_score", "comparison_group_id"],
    schools.map((s) => [
      s.dbn,
      s.gradeBand,
      s.admissionCategory,
      s.includeInDefaultComparisons,
      s.proclivityDecile ?? "",
      s.proclivityScore ?? "",
      s.comparisonGroupId ?? "",
    ])
  );

  // ---- Current comparison groups (peer lists from methodologyNotes) ----
  const groups = await prisma.comparisonGroup.findMany({
    select: { id: true, year: true, source: true, methodologyNotes: true },
  });
  const groupRows: unknown[][] = [];
  for (const g of groups) {
    let peers: string[] = [];
    try {
      const parsed = g.methodologyNotes ? JSON.parse(g.methodologyNotes) : null;
      if (parsed && Array.isArray(parsed.peers)) peers = parsed.peers;
    } catch {
      /* ignore */
    }
    groupRows.push([g.id, g.year, g.source, peers.join("|")]);
  }
  writeCsv(path.join(OUT_DIR, "comparison_groups.csv"), ["id", "year", "source", "peer_dbns"], groupRows);

  console.log("Export complete.");
}

main()
  .catch((e) => {
    console.error("EXPORT ERROR:", e);
    process.exit(1);
  })
  .finally(() => prisma.$disconnect());