Source document

scripts/loaders/philly/derive_peer_groups.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.

// Derived peer groups for Philly schools (K-nearest neighbors on demographics).
//
// Mirrors scripts/loaders/nycenet-comparison-groups.ts (NYC) but adapted:
//   - HARD filter: school_level (Elementary / Middle / High / K-8 / etc.)
//   - HARD filter: admission bucket (collapsed from PhillyAdmissionCategory)
//   - HARD filter: school_type sub-buckets (district/charter — different
//     governance is its own peer universe; the analysis pipeline only
//     compares like-with-like)
//   - WEIGHTED distance: 7 demographic features × Euclidean
//   - K = 40 default; we ALSO derive k=20 and k=30 + emit a flip-diff
//     showing which schools' peer set changes substantively. The 3
//     derivations write to philly_comparison_groups with separate `k` rows;
//     only k=40 is linked into philly_schools.comparison_group_id by default.
//
// Run:  npx tsx scripts/loaders/philly/derive_peer_groups.ts

import crypto from "node:crypto";
import { prisma } from "./_lib";
import type { PhillyAdmissionCategory, PhillySchoolType } from "@prisma/client";

const KS_TO_DERIVE = [20, 30, 40] as const;
const DEFAULT_K = 40;
const YEAR = "2024-25";

const DEMOG_FEATURES = [
  "pct_black",
  "pct_hispanic",
  "pct_asian",
  "pct_white",
  "pct_english_learner",
  "pct_special_ed",
  "pct_econ_disadv",
] as const;
type Feature = (typeof DEMOG_FEATURES)[number];

// Collapse admission categories into broader peer buckets so small universes
// (e.g. 8 special-admission HS) have enough peers.
const ADMISSION_BUCKETS: Record<string, PhillyAdmissionCategory[]> = {
  catchment: ["CATCHMENT"],
  selective: ["SPECIAL_ADMISSION", "CRITERIA_BASED"],
  lottery: ["CITYWIDE_LOTTERY", "CHARTER_LOTTERY"],
  cte: ["CTE"],
  alternative: ["ALTERNATIVE"],
  other: ["OTHER"],
};

function admissionBucket(cat: PhillyAdmissionCategory | null): string {
  if (!cat) return "other";
  for (const [b, cats] of Object.entries(ADMISSION_BUCKETS)) {
    if (cats.includes(cat)) return b;
  }
  return "other";
}

// School-type sub-buckets — district vs charter is its own analytical universe.
// (Per O.2 they're "one universe, tagged" for storage; for peer matching
// they get different buckets so a district school doesn't peer with a charter.)
function typeBucket(t: PhillySchoolType): string {
  switch (t) {
    case "REGULAR":
    case "CONTRACTED":
      return "district";
    case "CHARTER":
      return "charter";
    case "CYBER":
      return "cyber";
    case "COMPCTC":
      return "ctc";
    case "SPECIALED":
      return "specialed";
    default:
      return "other";
  }
}

type SchoolForMatch = {
  ulcsCode: string;
  schoolLevel: string;             // Elementary / Middle / High / K-8 / etc.
  admissionBucket: string;
  typeBucket: string;
  includeInDefaultComparisons: boolean;
  demographics: Partial<Record<Feature, number>>;
};

function demographicDistance(a: SchoolForMatch, b: SchoolForMatch): number {
  let sum = 0;
  let n = 0;
  for (const f of DEMOG_FEATURES) {
    const va = a.demographics[f];
    const vb = b.demographics[f];
    if (va === undefined || vb === undefined) continue;
    const d = va - vb;
    sum += d * d;
    n++;
  }
  if (n === 0) return Infinity;
  return Math.sqrt(sum / n);
}

async function main() {
  console.log(`[derive] loading Philly schools + demographics`);
  const schools = await prisma.phillySchool.findMany({
    select: {
      ulcsCode: true,
      schoolLevel: true,
      schoolType: true,
      admissionType: true,
      includeInDefaultComparisons: true,
      closedAt: true,
    },
  });
  console.log(`[derive]   ${schools.length} total schools`);

  // Demographics are stored as long-format facts in philly_school_year_metrics
  // (subgroup=ALL, metric_key in DEMOG_FEATURES). Pull the latest year per
  // (school, metric).
  const demRows = await prisma.phillySchoolYearMetric.findMany({
    where: {
      metricKey: { in: [...DEMOG_FEATURES] },
      subgroup: "ALL",
      value: { not: null },
    },
    select: { schoolUlcs: true, year: true, metricKey: true, value: true },
    orderBy: [{ schoolUlcs: "asc" }, { year: "desc" }],
  });
  // Map school → metric → latest value
  const demByUlcs = new Map<string, Partial<Record<Feature, number>>>();
  for (const r of demRows) {
    const m = demByUlcs.get(r.schoolUlcs) ?? {};
    const f = r.metricKey as Feature;
    // First-seen wins; orderBy(year desc) → that's the latest
    if (m[f] === undefined) m[f] = r.value!;
    demByUlcs.set(r.schoolUlcs, m);
  }

  const working: SchoolForMatch[] = schools
    .filter((s) => s.closedAt === null && demByUlcs.has(s.ulcsCode))
    .map((s) => ({
      ulcsCode: s.ulcsCode,
      schoolLevel: s.schoolLevel ?? "Unknown",
      admissionBucket: admissionBucket(s.admissionType),
      typeBucket: typeBucket(s.schoolType),
      includeInDefaultComparisons: s.includeInDefaultComparisons,
      demographics: demByUlcs.get(s.ulcsCode)!,
    }));
  console.log(`[derive]   ${working.length} schools with demographics + open`);

  // Group by (school_level, admission_bucket, type_bucket) — the matching universe.
  const byKey = new Map<string, SchoolForMatch[]>();
  for (const s of working) {
    const key = `${s.schoolLevel}|${s.admissionBucket}|${s.typeBucket}`;
    const arr = byKey.get(key) ?? [];
    arr.push(s);
    byKey.set(key, arr);
  }

  console.log(`\n[derive] Matching universes (school_level|admission|type):`);
  const keys = [...byKey.keys()].sort();
  for (const k of keys) {
    console.log(`  ${k.padEnd(50)} ${byKey.get(k)!.length} schools`);
  }

  // Compute ranked peer list per school once (sorted by distance).
  // Then take top-K for each k value.
  type RankedPeer = { ulcsCode: string; d: number };
  const rankedPerSchool = new Map<string, RankedPeer[]>();
  for (const [_key, peers] of byKey) {
    for (const me of peers) {
      const ranked = peers
        .filter((p) => p.ulcsCode !== me.ulcsCode && p.includeInDefaultComparisons)
        .map((p) => ({ ulcsCode: p.ulcsCode, d: demographicDistance(me, p) }))
        .filter((p) => Number.isFinite(p.d))
        .sort((a, b) => a.d - b.d);
      rankedPerSchool.set(me.ulcsCode, ranked);
    }
  }

  // Wipe + recreate comparison groups (idempotent).
  console.log(`\n[derive] resetting philly_comparison_groups...`);
  await prisma.phillySchool.updateMany({ data: { comparisonGroupId: null } });
  await prisma.phillyComparisonGroup.deleteMany({});

  // For each k, derive groups and write rows. Keep mapping (k → ulcs → groupId)
  // so we can flip-diff and link the default k.
  type GroupRow = {
    id: string;
    k: number;
    ulcs: string;
    methodologyNotes: { k: number; year: string; school_level: string; admission_bucket: string; type_bucket: string; peers: string[] };
  };
  const groupsByK: Record<number, Map<string, GroupRow>> = {};
  for (const k of KS_TO_DERIVE) {
    groupsByK[k] = new Map();
    let dropped = 0;
    for (const me of working) {
      const ranked = rankedPerSchool.get(me.ulcsCode) ?? [];
      const top = ranked.slice(0, k);
      if (top.length < 5) { dropped++; continue; }
      const id = crypto.randomUUID();
      groupsByK[k].set(me.ulcsCode, {
        id,
        k,
        ulcs: me.ulcsCode,
        methodologyNotes: {
          k,
          year: YEAR,
          school_level: me.schoolLevel,
          admission_bucket: me.admissionBucket,
          type_bucket: me.typeBucket,
          peers: top.map((t) => t.ulcsCode),
        },
      });
    }
    console.log(`[derive]   k=${k}: ${groupsByK[k].size} groups (${dropped} schools dropped with <5 peers)`);
  }

  // Bulk-insert all groups for all k.
  console.log(`\n[derive] inserting comparison_groups rows...`);
  const allGroups = Object.values(groupsByK).flatMap((m) => [...m.values()]);
  for (let i = 0; i < allGroups.length; i += 500) {
    const batch = allGroups.slice(i, i + 500);
    await prisma.phillyComparisonGroup.createMany({
      data: batch.map((g) => ({
        id: g.id,
        year: YEAR,
        k: g.k,
        description: `KNN-${g.k} on demographics; hard filters school_level + admission + type bucket`,
        methodologyNotes: g.methodologyNotes as unknown as object,
      })),
    });
  }

  // Link only the DEFAULT_K group on each PhillySchool.
  console.log(`[derive] linking default k=${DEFAULT_K} into philly_schools.comparison_group_id...`);
  const defaultGroups = groupsByK[DEFAULT_K];
  for (const [ulcs, g] of defaultGroups) {
    await prisma.phillySchool.update({
      where: { ulcsCode: ulcs },
      data: { comparisonGroupId: g.id },
    });
  }
  console.log(`[derive]   linked ${defaultGroups.size} schools`);

  // FLIP DIFF: schools whose top-20 peers under k=20 vs k=40 differ substantively.
  console.log(`\n[derive] FLIP DIFF (k=20 vs k=40):`);
  let nDifferentTop10 = 0;
  for (const [ulcs, g20] of groupsByK[20]) {
    const g40 = groupsByK[40].get(ulcs);
    if (!g40) continue;
    const top10_20 = new Set(g20.methodologyNotes.peers.slice(0, 10));
    const top10_40 = new Set(g40.methodologyNotes.peers.slice(0, 10));
    let common = 0;
    for (const p of top10_20) if (top10_40.has(p)) common++;
    if (common < 8) nDifferentTop10++;
  }
  console.log(`[derive]   ${nDifferentTop10} schools where the top-10 peer set differs by ≥3 members between k=20 and k=40`);
  console.log(`[derive]   (≤2-member difference is typical; this counts the substantive flips)`);
  console.log(`\n[derive] done — ${allGroups.length} groups across k=${KS_TO_DERIVE.join(",")}`);
}

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