// Peer analysis: which CoCs resembled King County (WA-500) in 2015-2019, and how did they change by 2024? // Usage: node peers.js (writes /peers/peers.csv and prints tables) const fs = require('fs'), path = require('path'); const dataDir = process.argv[2] || path.join(__dirname, '..', '..'); function parseCSV(t) { const rows = []; let row = [], f = '', q = false; for (let i = 0; i < t.length; i++) { const c = t[i]; if (q) { if (c === '"') { if (t[i + 1] === '"') { f += '"'; i++; } else q = false; } else f += c; } else if (c === '"') q = true; else if (c === ',') { row.push(f); f = ''; } else if (c === '\n' || c === '\r') { if (c === '\r' && t[i + 1] === '\n') i++; row.push(f); rows.push(row); row = []; f = ''; } else f += c; } if (f.length || row.length) { row.push(f); rows.push(row); } const h = rows.shift(); return rows.filter(r => r.length === h.length).map(r => Object.fromEntries(h.map((k, j) => [k, r[j]]))); } const raw = parseCSV(fs.readFileSync(path.join(dataDir, 'coc_homelessness_rent_panel.csv'), 'utf8')); const num = v => (v === '' || v == null ? null : +v); const P = {}; // P[coc][year] = row for (const r of raw) { (P[r.coc_code] ||= { code: r.coc_code, name: r.short_name, cat: r.hud_category_2025, y: {} }).y[+r.year] = { full: r.sheltered_only_count === '0', total: num(r.total), unsh: num(r.unsheltered), shel: num(r.sheltered), pop: num(r.population), rate: num(r.rate_per_10k), urate: num(r.unsheltered_rate), rent: num(r.median_rent), acs: num(r.acs_year) }; } const median = a => { const s = a.filter(x => x != null).sort((x, y) => x - y); const n = s.length; return n ? (n % 2 ? s[(n - 1) / 2] : (s[n / 2 - 1] + s[n / 2]) / 2) : null; }; const mean = a => { const s = a.filter(x => x != null); return s.length ? s.reduce((x, y) => x + y, 0) / s.length : null; }; const BASE = [2015, 2016, 2017, 2018, 2019]; const cocs = Object.values(P); // national (sample) median rent per year, for relative rent const medRent = {}; for (const y of [...BASE, 2021, 2022, 2023, 2024, 2025]) medRent[y] = median(cocs.map(c => c.y[y] && c.y[y].rent)); for (const c of cocs) { const fy = BASE.filter(y => c.y[y] && c.y[y].full && c.y[y].rate != null); c.nFull = fy.length; c.bRate = mean(fy.map(y => c.y[y].rate)); c.bU = mean(fy.map(y => c.y[y].urate)); c.bRelRent = mean(BASE.map(y => c.y[y] && c.y[y].rent ? c.y[y].rent / medRent[y] : null)); c.rentG1519 = c.y[2015].rent && c.y[2019].rent ? c.y[2019].rent / c.y[2015].rent - 1 : null; c.logPop = Math.log(c.y[2019].pop); c.pop2019 = c.y[2019].pop; c.rent2019 = c.y[2019].rent; c.rate2019 = c.y[2019].rate; c.u2019 = c.y[2019].urate; c.full2019 = c.y[2019].full; // endpoint: 2024 if full count, else 2025 if full count c.endY = c.y[2024] && c.y[2024].full ? 2024 : (c.y[2025] && c.y[2025].full ? 2025 : null); if (c.endY) { const e = c.y[c.endY]; c.endRate = e.rate; c.endU = e.urate; c.endRent = e.rent; c.endAcs = e.acs; c.dRate = e.rate - c.rate2019; c.dU = e.urate - c.u2019; c.pRate = e.rate / c.rate2019 - 1; c.pU = c.u2019 > 0 ? e.urate / c.u2019 - 1 : null; c.dTotalN = e.total - c.y[2019].total; } c.rent2024 = c.y[2024] && c.y[2024].rent; c.rentG1924 = c.rent2024 && c.rent2019 ? c.rent2024 / c.rent2019 - 1 : null; c.relRent2019 = c.rent2019 / medRent[2019]; c.relRent2024 = c.rent2024 / medRent[2024]; c.relRentChg = c.relRent2024 - c.relRent2019; } const sd = a => { const m = mean(a); return Math.sqrt(mean(a.map(x => (x - m) ** 2))); }; function distances(vars, tag) { const st = {}; for (const v of vars) { const a = cocs.map(c => c[v]).filter(x => x != null); st[v] = { m: mean(a), s: sd(a) }; } const K = P['WA-500']; for (const c of cocs) { let d = 0, ok = true; for (const v of vars) { if (c[v] == null) { ok = false; break; } d += ((c[v] - K[v]) / st[v].s) ** 2; } c[tag] = ok ? Math.sqrt(d) : null; } const ranked = cocs.filter(c => c.code !== 'WA-500' && c[tag] != null).sort((a, b) => a[tag] - b[tag]); ranked.forEach((c, i) => c[tag + '_rank'] = i + 1); return ranked; } const main = distances(['bRate', 'bU', 'bRelRent', 'rentG1519'], 'dist'); const withPop = distances(['bRate', 'bU', 'bRelRent', 'rentG1519', 'logPop'], 'distPop'); const K = P['WA-500']; // simple screen for (const c of cocs) c.simple = c.code !== 'WA-500' && Math.abs(c.rent2019 / K.rent2019 - 1) <= 0.15 && Math.abs(c.rate2019 / K.rate2019 - 1) <= 0.40; // King method adjustment (count-method.md): subtract 1,100 (range 0-3,600) from 2024 unsheltered const kPop24 = K.y[2024].pop; K.adj = {}; for (const [lab, x] of [['central_1100', 1100], ['low_0', 0], ['high_3600', 3600]]) { const t = K.y[2024].total - x, u = K.y[2024].unsh - x; K.adj[lab] = { rate: t / kPop24 * 1e4, urate: u / kPop24 * 1e4, dRate: t / kPop24 * 1e4 - K.rate2019, dU: u / kPop24 * 1e4 - K.u2019, pRate: (t / kPop24 * 1e4) / K.rate2019 - 1, pU: (u / kPop24 * 1e4) / K.u2019 - 1 }; } // output CSV const f = (x, d = 2) => x == null || Number.isNaN(x) ? '' : (+x).toFixed(d); const cols = ['coc_code', 'short_name', 'hud_category_2025', 'dist_rank', 'distance', 'dist_rank_with_pop', 'distance_with_pop', 'simple_screen', 'n_full_2015_19', 'base_rate_per_10k', 'base_unsheltered_rate', 'base_rel_rent', 'rent_growth_2015_19_pct', 'pop_2019', 'rent_2019', 'rate_2019', 'unsheltered_rate_2019', 'end_year', 'end_rate', 'end_unsheltered_rate', 'chg_rate', 'chg_rate_pct', 'chg_unsheltered_rate', 'chg_unsheltered_pct', 'rent_2024', 'rent_growth_2019_24_pct', 'rel_rent_2019', 'rel_rent_2024']; const lines = [cols.join(',')]; const order = [K, ...main, ...cocs.filter(c => c.code !== 'WA-500' && c.dist == null)]; for (const c of order) { lines.push([c.code, '"' + c.name + '"', '"' + c.cat + '"', c.code === 'WA-500' ? 0 : c.dist_rank ?? '', f(c.dist), c.code === 'WA-500' ? 0 : c.distPop_rank ?? '', f(c.distPop), c.simple ? 1 : 0, c.nFull, f(c.bRate), f(c.bU), f(c.bRelRent, 3), f(c.rentG1519 * 100, 1), c.pop2019, c.rent2019, f(c.rate2019), f(c.u2019), c.endY ?? '', f(c.endRate), f(c.endU), f(c.dRate), f(c.pRate * 100, 1), f(c.dU), f(c.pU * 100, 1), c.rent2024 ?? '', f(c.rentG1924 * 100, 1), f(c.relRent2019, 3), f(c.relRent2024, 3)].join(',')); } fs.writeFileSync(path.join(dataDir, 'peers', 'peers.csv'), lines.join('\n') + '\n'); fs.writeFileSync(path.join(dataDir, 'peers', 'king_adjusted.json'), JSON.stringify({ king: { rate2019: K.rate2019, u2019: K.u2019, rate2024: K.endRate, u2024: K.endU, dRate: K.dRate, dU: K.dU, pRate: K.pRate, pU: K.pU, rentG1924: K.rentG1924 }, adj: K.adj, medRent }, null, 2)); // console report const row = c => [c.code, c.name.padEnd(28), f(c.dist).padStart(5), f(c.distPop).padStart(5), String(c.distPop_rank ?? '').padStart(3), c.simple ? 'S' : ' ', f(c.bRate, 1).padStart(6), f(c.bU, 1).padStart(5), f(c.bRelRent, 2).padStart(5), f(c.rentG1519 * 100, 0).padStart(4), '|', c.endY || 'none', f(c.rate2019, 1).padStart(6), '->', f(c.endRate, 1).padStart(6), f(c.pRate * 100, 0).padStart(5) + '%', f(c.u2019, 1).padStart(5), '->', f(c.endU, 1).padStart(5), f(c.pU * 100, 0).padStart(5) + '%', 'rentG19-24', f(c.rentG1924 * 100, 0) + '%', 'relRentChg', f(c.relRentChg, 3)].join(' '); console.log('KING', row(K)); console.log('medRent', JSON.stringify(medRent)); console.log('King adj', JSON.stringify(K.adj)); console.log('--- top 20 by main distance'); main.slice(0, 20).forEach(c => console.log(String(c.dist_rank).padStart(2), row(c))); console.log('--- top 10 with pop'); withPop.slice(0, 10).forEach(c => console.log(String(c.distPop_rank).padStart(2), row(c))); console.log('--- simple screen'); cocs.filter(c => c.simple).sort((a, b) => a.dist - b.dist).forEach(c => console.log(row(c))); console.log('--- no endpoint:', cocs.filter(c => !c.endY).map(c => c.code + ' ' + c.name).join('; ')); const all = cocs.filter(c => c.endY && c.code !== 'WA-500'); console.log('sample n', all.length, 'median pRate', f(median(all.map(c => c.pRate)) * 100, 1), 'median pU', f(median(all.map(c => c.pU).filter(x => x != null)) * 100, 1), 'share rate up', f(all.filter(c => c.pRate > 0).length / all.length * 100, 0), 'share unsh up', f(all.filter(c => c.pU > 0).length / all.length * 100, 0)); const rk = (arr, key) => [...arr, K].sort((a, b) => b[key] - a[key]).indexOf(K) + 1; console.log('King rank (1=largest increase) among', all.length + 1, ': pRate', rk(all, 'pRate'), 'dRate', rk(all, 'dRate'), 'pU', rk(all, 'pU'), 'dU', rk(all, 'dU')); const corr = (a, b) => { const ma = mean(a), mb = mean(b); let s = 0, sa = 0, sb = 0; a.forEach((x, i) => { s += (x - ma) * (b[i] - mb); sa += (x - ma) ** 2; sb += (b[i] - mb) ** 2; }); return s / Math.sqrt(sa * sb); }; const cg = all.filter(c => c.rentG1924 != null && c.pU != null); console.log('corr rentG1924 vs pRate (all, n=' + cg.length + ')', f(corr(cg.map(c => c.rentG1924), cg.map(c => c.pRate)), 3), ' vs pU', f(corr(cg.map(c => c.rentG1924), cg.map(c => c.pU)), 3), ' vs dRate', f(corr(cg.map(c => c.rentG1924), cg.map(c => c.dRate)), 3)); const top10 = main.slice(0, 10).filter(c => c.endY); console.log('corr top10 (n=' + top10.length + ') rentG1924 vs pRate', f(corr(top10.map(c => c.rentG1924), top10.map(c => c.pRate)), 3), 'vs pU', f(corr(top10.map(c => c.rentG1924), top10.map(c => c.pU)), 3)); console.log('median rentG1924 all', f(median(cg.map(c => c.rentG1924)) * 100, 1), 'improved(pRate<0) median rentG', f(median(cg.filter(c => c.pRate < 0).map(c => c.rentG1924)) * 100, 1), 'n', cg.filter(c => c.pRate < 0).length, 'worsened median rentG', f(median(cg.filter(c => c.pRate >= 0).map(c => c.rentG1924)) * 100, 1)); console.log('King rentG1924 rank (1=fastest) among', cg.length + 1, ':', [...cg, K].sort((a, b) => b.rentG1924 - a.rentG1924).indexOf(K) + 1); console.log('--- change vs 2015-19 full-count average (sensitivity to low 2019 baseline)'); for (const c of [K, ...main.slice(0, 10), ...cocs.filter(c => c.simple && c.dist_rank > 10)]) console.log(c.code.padEnd(7), c.name.slice(0, 24).padEnd(24), 'rate vs base', f((c.endRate / c.bRate - 1) * 100, 0) + '%', 'unsh vs base', f((c.endU / c.bU - 1) * 100, 0) + '%', 'n2019full', c.full2019); const t10 = main.slice(0, 10); console.log('top10 median pRate', f(median(t10.map(c => c.pRate)) * 100, 1), 'median pU', f(median(t10.map(c => c.pU)) * 100, 1), 'rate up', t10.filter(c => c.pRate > 0).length, 'unsh up', t10.filter(c => c.pU > 0).length);