BonQuery
  • Home
  • Left in the Cold
  • Audits
    • INDEPENDENT OVERSIGHT
    • Discrepancy Tracker
    • Emergency Occupancy Audit
    • Central Intake Audit
    • About the Data
  • Shelter Dashboards
    • Inside Toronto’s Shelter System
    • Monthly System Flow Replication
    • Year-to-Date Capacity Match
    • Historical Trend Lines
    • Referral Requests Analysis
    • Daily Occupancy & Capacity
  • About
  • FR

Monthly Snapshot

Toronto Shelter System — current month at a glance

Interactive monthly snapshot of Toronto’s shelter system — actively homeless counts, flow metrics, and population group breakdowns for any month from January 2018 onward.
Author

Miriam Marling

data = FileAttachment("../data/shelter_flow.json").json()
MON = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]
fmtN = d => d.toLocaleString()

// Build month dropdown in descending order (most recent first).
// Display: "March 2026"  Return value: "2026-03-01" (the YYYY-MM-DD from DB)
reportingMonthOptions = data
  .filter(d => d.population_group === "All Population")
  .sort((a, b) => b.flow_date.localeCompare(a.flow_date))
  .map(d => {
    const [y, m] = d.flow_date.split("-").map(Number)
    return {
      label: new Date(y, m - 1, 15).toLocaleString("default", {month: "long", year: "numeric"}),
      value: d.flow_date
    }
  })

Toronto publishes monthly data on its taxed shelter system, and the City’s own dashboards do a good job presenting the headline numbers. This section goes a step further: interactive filters on every chart, a clear note on what the data captures and what it leaves out, and findings that usually stay buried in technical documentation.

The data covers every month from January 2018 to the most recent published month, sourced directly from the City of Toronto’s Shelter System Flow dataset. New data loads automatically each month, usually around the 15th, after the City publishes the prior month’s figures.

viewof reportingDate = Inputs.select(reportingMonthOptions, {
  label: "Reporting Month",
  format: d => d.label,
  value: reportingMonthOptions[0]
})
snapshot  = data.find(d => d.flow_date === reportingDate.value && d.population_group === "All Population")
chronicRow = data.find(d => d.flow_date === reportingDate.value && d.population_group === "Chronic")

// KPI values
activelyHomeless = snapshot ? snapshot.actively_homeless : 0
inflowTotal  = snapshot ? snapshot.newly_identified + snapshot.returned_from_housing + snapshot.returned_to_shelter : 0
outflowTotal = snapshot ? snapshot.moved_to_housing + snapshot.became_inactive : 0
netChange    = inflowTotal - outflowTotal

// For YTD charts
reportingYear  = +reportingDate.value.slice(0, 4)
reportingMonth = +reportingDate.value.slice(5, 7)

html`<div class="kpi-hero">
  <div class="kpi-box">
    <div class="kpi-label">People actively homeless in the last 3 months</div>
    <div class="kpi-value">${activelyHomeless.toLocaleString()}</div>
  </div>
</div>`
html`<div class="kpi-row">
  <div class="kpi-card" style="background:var(--bq-kpi-inflow-bg);">
    <div class="kpi-label">Shelter System Inflow</div>
    <div class="kpi-value" style="color:var(--bq-kpi-inflow);">+${inflowTotal.toLocaleString()}</div>
  </div>
  <div class="kpi-card" style="background:${netChange > 0 ? 'var(--bq-kpi-net-pos-bg)' : netChange < 0 ? 'var(--bq-kpi-net-neg-bg)' : 'var(--bq-kpi-net-zero-bg)'};">
    <div class="kpi-label">Change (Inflow − Outflow)</div>
    <div class="kpi-value" style="color:${netChange > 0 ? 'var(--bq-kpi-net-pos)' : netChange < 0 ? 'var(--bq-kpi-net-neg)' : 'var(--bq-kpi-net-zero)'};">
      ${netChange >= 0 ? '+' : ''}${netChange.toLocaleString()}
    </div>
  </div>
  <div class="kpi-card" style="background:var(--bq-kpi-outflow-bg);">
    <div class="kpi-label">Shelter System Outflow</div>
    <div class="kpi-value" style="color:var(--bq-kpi-outflow);">−${outflowTotal.toLocaleString()}</div>
  </div>
</div>`

Chronic Homelessness

Note: Oracle APEX renders this as a donut chart. Observable Plot does not support pie/donut natively — shown here as a percentage bar with the same data and colors.

chronicPct    = chronicRow ? chronicRow.population_group_pct / 100 : 0
_cs = getComputedStyle(document.body)
_nonChronicFill = _cs.getPropertyValue("--bq-bg-alt").trim() || "#CCCCCC"
_chartFg = _cs.getPropertyValue("--bq-fg").trim() || "#333"
_chartStroke = _cs.getPropertyValue("--bq-chart-stroke").trim() || "#333"

chronicData   = [
  {label: "Chronic",     pct: chronicPct,       fill: "#5856D6"},
  {label: "Non-Chronic", pct: 1 - chronicPct,   fill: _nonChronicFill}
]

Plot.plot({
  width,
  height: 90,
  marginLeft: 10,
  marginRight: 10,
  x: {domain: [0, 1], axis: null},
  y: {axis: null},
  color: {domain: ["Chronic","Non-Chronic"], range: ["#5856D6", _nonChronicFill], legend: true},
  marks: [
    Plot.barX(chronicData, Plot.stackX({x: "pct", y: () => "", fill: "label", inset: 0})),
    Plot.text(chronicData, Plot.stackX({
      x: "pct",
      y: () => "",
      text: d => d.pct >= 0.04 ? `${d.label}\n${(d.pct * 100).toFixed(1)}%` : "",
      fill: d => d.label === "Chronic" ? "white" : _chartFg,
      textAnchor: "middle",
      lineWidth: 8,
      fontSize: 13,
      fontWeight: "600"
    }))
  ]
})

Cumulative YTD comparison: Newly Identified

ytdComputed = {
  const rows = data
    .filter(d => d.population_group === "All Population")
    .filter(d => {
      const y = +d.flow_date.slice(0, 4)
      const m = +d.flow_date.slice(5, 7)
      return (y === reportingYear || y === reportingYear - 1) && m <= reportingMonth
    })
    .sort((a, b) => a.flow_date.localeCompare(b.flow_date))

  const accNI = {}, accMH = {}
  return rows.map(d => {
    const y = +d.flow_date.slice(0, 4)
    const m = +d.flow_date.slice(5, 7)
    accNI[y] = (accNI[y] || 0) + d.newly_identified
    accMH[y] = (accMH[y] || 0) + d.moved_to_housing
    return {
      year: y, month: m,
      monthLabel: MON[m - 1],
      yearLabel: String(y),
      isCurrentYear: y === reportingYear,
      ytd_ni: accNI[y],
      ytd_mh: accMH[y]
    }
  })
}

// Sort: by month, then prior year first (so bars appear: prior ↑ current ↓ within each month)
ytdNI = ytdComputed
  .sort((a, b) => a.month - b.month || a.year - b.year)
  .map(d => ({...d, barLabel: `${d.monthLabel} ${d.yearLabel}`}))

ytdNIDomain = ytdNI.map(d => d.barLabel)

Plot.plot({
  width,
  height: 60 + ytdNI.length * 22,
  marginLeft: 100,
  marginRight: 80,
  x: {label: "Cumulative newly identified (YTD)", grid: true, tickFormat: fmtN},
  y: {label: null, domain: ytdNIDomain},
  color: {
    domain: [String(reportingYear - 1), String(reportingYear)],
    range: ["#FFB3C1", "#FF2D55"],
    legend: true
  },
  marks: [
    Plot.ruleX([0]),
    Plot.barX(ytdNI, {x: "ytd_ni", y: "barLabel", fill: "yearLabel", tip: true,
                      insetTop: 1, insetBottom: 1}),
    Plot.text(ytdNI, {
      x: "ytd_ni", y: "barLabel",
      text: d => d.ytd_ni.toLocaleString(),
      textAnchor: "start", dx: 5, fontSize: 11
    })
  ]
})

Cumulative YTD comparison: Moved to Permanent Housing

ytdMH = ytdComputed
  .sort((a, b) => a.month - b.month || a.year - b.year)
  .map(d => ({...d, barLabel: `${d.monthLabel} ${d.yearLabel}`}))

Plot.plot({
  width,
  height: 60 + ytdMH.length * 22,
  marginLeft: 100,
  marginRight: 80,
  x: {label: "Cumulative moved to permanent housing (YTD)", grid: true, tickFormat: fmtN},
  y: {label: null, domain: ytdMH.map(d => d.barLabel)},
  color: {
    domain: [String(reportingYear - 1), String(reportingYear)],
    range: ["#AED5AE", "#5BA75B"],
    legend: true
  },
  marks: [
    Plot.ruleX([0]),
    Plot.barX(ytdMH, {x: "ytd_mh", y: "barLabel", fill: "yearLabel", tip: true,
                      insetTop: 1, insetBottom: 1}),
    Plot.text(ytdMH, {
      x: "ytd_mh", y: "barLabel",
      text: d => d.ytd_mh.toLocaleString(),
      textAnchor: "start", dx: 5, fontSize: 11
    })
  ]
})

Inflow breakdown

inflowBreakdown = snapshot ? [
  {label: "Newly Identified",              value: snapshot.newly_identified,      fill: "#E63946"},
  {label: "Returned from Permanent Housing", value: snapshot.returned_from_housing, fill: "#FFD600"},
  {label: "Returned to Shelter",           value: snapshot.returned_to_shelter,   fill: "#FB8C00"}
].sort((a, b) => b.value - a.value) : []

inflowMax = d3.max(inflowBreakdown, d => d.value) || 1

{
  const isMobile = width < 650
  const colorOpts = isMobile ? {
    color: {
      domain: inflowBreakdown.map(d => d.label),
      range:  inflowBreakdown.map(d => d.fill),
      legend: true
    }
  } : {}
  return Plot.plot({
    width,
    height: isMobile ? 190 : 130,
    marginLeft: isMobile ? 10 : 240,
    marginRight: 80,
    x: {label: "People", grid: true, tickFormat: fmtN, domain: [0, inflowMax * 1.15]},
    y: {label: null, domain: inflowBreakdown.map(d => d.label), axis: isMobile ? null : "left"},
    ...colorOpts,
    marks: [
      Plot.ruleX([0]),
      Plot.barX(inflowBreakdown, {
        x: "value", y: "label",
        fill: isMobile ? "label" : "fill",
        insetTop: 3, insetBottom: 3
      }),
      Plot.text(inflowBreakdown, {
        x: "value", y: "label",
        text: d => d.value.toLocaleString(),
        textAnchor: "start", dx: 6, fontSize: 12
      })
    ]
  })
}

Outflow breakdown

outflowBreakdown = snapshot ? [
  {label: "Moved to Permanent Housing", value: snapshot.moved_to_housing, fill: "#66CC66"},
  {label: "Became Inactive",            value: snapshot.became_inactive,  fill: "#512DA8"}
].sort((a, b) => b.value - a.value) : []

outflowMax = d3.max(outflowBreakdown, d => d.value) || 1

{
  const isMobile = width < 650
  const colorOpts = isMobile ? {
    color: {
      domain: outflowBreakdown.map(d => d.label),
      range:  outflowBreakdown.map(d => d.fill),
      legend: true
    }
  } : {}
  return Plot.plot({
    width,
    height: isMobile ? 160 : 100,
    marginLeft: isMobile ? 10 : 220,
    marginRight: 80,
    x: {label: "People", grid: true, tickFormat: fmtN, domain: [0, outflowMax * 1.15]},
    y: {label: null, domain: outflowBreakdown.map(d => d.label), axis: isMobile ? null : "left"},
    ...colorOpts,
    marks: [
      Plot.ruleX([0]),
      Plot.barX(outflowBreakdown, {
        x: "value", y: "label",
        fill: isMobile ? "label" : "fill",
        insetTop: 3, insetBottom: 3
      }),
      Plot.text(outflowBreakdown, {
        x: "value", y: "label",
        text: d => d.value.toLocaleString(),
        textAnchor: "start", dx: 6, fontSize: 12
      })
    ]
  })
}

Age of people actively homeless in the last 3 months

ageBandDefs2 = [
  {key: "age_under_16", label: "Under 16"},
  {key: "age_16_24",    label: "16–24"},
  {key: "age_25_34",    label: "25–34"},
  {key: "age_35_44",    label: "35–44"},
  {key: "age_45_54",    label: "45–54"},
  {key: "age_55_64",    label: "55–64"},
  {key: "age_65_over",  label: "65+"}
]

ageSnapshotData = (() => {
  if (!snapshot) return []
  const total = ageBandDefs2.reduce((s, b) => s + snapshot[b.key], 0)
  return ageBandDefs2.map(b => ({
    band: b.label,
    pct: total > 0 ? snapshot[b.key] / total : 0
  }))
})()

ageSnapMax = d3.max(ageSnapshotData, d => d.pct) || 0.4

Plot.plot({
  width,
  height: 340,
  marginLeft: 60,
  marginTop: 30,
  x: {label: null, domain: ageBandDefs2.map(b => b.label)},
  y: {label: null, axis: null, domain: [0, ageSnapMax * 1.4]},
  color: {domain: ageBandDefs2.map(b => b.label), legend: true},
  marks: [
    Plot.barY(ageSnapshotData, {x: "band", y: "pct", fill: "band"}),
    Plot.text(ageSnapshotData, {
      x: "band", y: "pct",
      text: d => (d.pct * 100).toFixed(1) + "%",
      dy: -10, fontSize: 13, fontWeight: "600", textAnchor: "middle"
    }),
    Plot.ruleY([0])
  ]
})

Gender of people actively homeless in the last 3 months

genderDefs2 = [
  {key: "gender_male",                label: "Men"},
  {key: "gender_female",              label: "Women"},
  {key: "gender_trans_nb_two_spirit", label: "Transgender, Non-Binary, or Two-Spirit"}
]

genderSnapshotData = (() => {
  if (!snapshot) return []
  const total = genderDefs2.reduce((s, g) => s + snapshot[g.key], 0)
  return genderDefs2
    .map(g => ({gender: g.label, pct: total > 0 ? snapshot[g.key] / total : 0}))
    .sort((a, b) => b.pct - a.pct)
})()

genderSnapMax = d3.max(genderSnapshotData, d => d.pct) || 1

{
  const isMobile = width < 650
  return Plot.plot({
    width,
    height: isMobile ? 160 : 130,
    marginLeft: isMobile ? 10 : 260,
    marginRight: 80,
    x: {label: "Share of gender total", grid: true,
        tickFormat: d => (d * 100).toFixed(0) + "%",
        domain: [0, genderSnapMax * 1.15]},
    y: {label: null, domain: genderSnapshotData.map(d => d.gender), axis: isMobile ? null : "left"},
    color: {legend: true},
    marks: [
      Plot.ruleX([0]),
      Plot.barX(genderSnapshotData, {x: "pct", y: "gender", fill: "gender",
                                     insetTop: 3, insetBottom: 3}),
      Plot.text(genderSnapshotData, {
        x: "pct", y: "gender",
        text: d => (d.pct * 100).toFixed(1) + "%",
        textAnchor: "start", dx: 6, fontSize: 12
      })
    ]
  })
}

Explore other dashboards:

  • Historical Trends
  • YTD Comparison

This dashboard is also available as an Oracle APEX dashboard built on the same database — useful if you want to see how the same data renders in Oracle’s native low-code platform, or if you’re curious about the SQL and APEX configuration behind it.

How to read this data

These numbers reflect the shelter system, not all homelessness. The data captures people who used a City-funded overnight service at least once in the past three months. It doesn’t include people sleeping outside, in shelters that aren’t City-funded, or staying temporarily with friends or family. The City estimates that roughly 18% of people experiencing absolute homelessness in Toronto aren’t reflected in these numbers. Trends here reflect changes in the shelter system specifically — not in total homelessness in the city.

Key terms

The dashboards use a few terms that have specific meanings worth knowing before you read the charts.

Actively homeless. A person who has used City-funded shelter services at least once in the past three months and hasn’t been recorded as moving to permanent housing. This is the headline number on every dashboard. Because it looks back three months, the count includes people who may not be in shelter on any given night.

Chronic homelessness. The federal definition: someone who has spent at least 180 nights in shelter over the past year, or at least 546 nights over the past three years. A person can meet this definition while still actively in the shelter system — they don’t need to have left and returned.

Inflow categories (people entering the shelter system this month):

  • Newly Identified. People entering the shelter system for the first time. One exception for the “Chronic” group: in that row, this column counts people who became chronically homeless during the reporting month, regardless of how long they’d already been using the shelter system.
  • Returned from Permanent Housing. People who previously moved to permanent housing and have come back to the shelter system.
  • Returned to Shelter. People who were in the system, didn’t use it for at least three months, and have now returned.

Outflow categories (people leaving the shelter system this month):

  • Moved to Permanent Housing. People who left the shelter system for permanent housing.
  • Became Inactive. People who haven’t used shelter services in the past three months, including the reporting month.

Definitions are based on the City of Toronto’s Shelter System Flow Data page.

Read all data findings →

Data source

All data comes from the City of Toronto’s Shelter System Flow dataset, published monthly on the City’s open data portal.

Contains information licensed under the Open Government Licence – Toronto.


bayesian_gender = FileAttachment("../data/bayesian_gender.json").json()
shelter_flow    = data
KEY DATA FINDING

The most affected group — and the one we hear least about

_allFlow = shelter_flow
  .filter(d => d.population_group === "All Population"
            && d.gender_male != null && d.gender_female != null)

// Most recent month — for the hero ratio and sub-line
_latestFlow = _allFlow.sort((a, b) => b.flow_date.localeCompare(a.flow_date))[0]

_menCount   = _latestFlow ? _latestFlow.gender_male : 0
_womenCount = _latestFlow ? _latestFlow.gender_female : 0
_transCount = _latestFlow ? _latestFlow.gender_trans_nb_two_spirit : 0
_ratio      = _womenCount > 0 ? (_menCount / _womenCount).toFixed(1) : "—"
_flowMonth  = _latestFlow
  ? new Date(_latestFlow.flow_date + "T00:00:00")
      .toLocaleString("default", {month: "long", year: "numeric"})
  : ""

function _mean(arr) { return arr.reduce((a, b) => a + b, 0) / arr.length }
function _sd(arr) {
  const m = _mean(arr)
  return Math.sqrt(arr.reduce((s, x) => s + (x - m) ** 2, 0) / (arr.length - 1))
}
function _se(arr) { return _sd(arr) / Math.sqrt(arr.length) }

function _Phi(z) {
  const c = [0.319381530, -0.356563782, 1.781477937, -1.821255978, 1.330274429]
  const t = 1 / (1 + 0.2316419 * Math.abs(z))
  const tail = t*(c[0]+t*(c[1]+t*(c[2]+t*(c[3]+t*c[4])))) * Math.exp(-z*z/2) / Math.sqrt(2*Math.PI)
  return z >= 0 ? 1 - tail : tail
}

_menSeries   = _allFlow.map(d => d.gender_male)
_womenSeries = _allFlow.map(d => d.gender_female)
_transSeries = _allFlow.map(d => d.gender_trans_nb_two_spirit).filter(v => v != null)

_menMean   = _mean(_menSeries);   _menSE   = _se(_menSeries)
_womenMean = _mean(_womenSeries); _womenSE = _se(_womenSeries)
_transMean = _mean(_transSeries); _transSE = _se(_transSeries)

_n1 = _menSeries.length;   _s1 = _sd(_menSeries)
_n2 = _womenSeries.length; _s2 = _sd(_womenSeries)
_n3 = _transSeries.length; _s3 = _sd(_transSeries)

function _wt(m1, s1, n1, m2, s2, n2) {
  const v1 = s1**2/n1, v2 = s2**2/n2
  const t  = (m1 - m2) / Math.sqrt(v1 + v2)
  const df = (v1 + v2)**2 / (v1**2/(n1-1) + v2**2/(n2-1))
  return {t, df}
}

_pw_mw = _wt(_menMean,   _s1, _n1, _womenMean, _s2, _n2)
_pw_mt = _wt(_menMean,   _s1, _n1, _transMean, _s3, _n3)
_pw_wt = _wt(_womenMean, _s2, _n2, _transMean, _s3, _n3)

function _pAdj(t) { return Math.min(1, 3 * 2 * _Phi(-Math.abs(t))) }
function _pLabel(t) {
  const p = _pAdj(t)
  return p < 0.0001 ? "< 0.0001 ***" : p < 0.001 ? "< 0.001 **" : p < 0.05 ? p.toFixed(3) + " *" : p.toFixed(3)
}

_yearRange = _allFlow.length > 0
  ? _allFlow[_allFlow.length - 1].flow_date.slice(0, 4) + "–" + _latestFlow.flow_date.slice(0, 4)
  : ""

_menShareSeries = _allFlow.map(d => d.gender_male / d.actively_homeless)
_menShareMinPct = Math.floor(Math.min(..._menShareSeries) * 100)

_gapAvg    = Math.round(_menMean - (_womenMean + _transMean))
_gapAvgStr = _gapAvg.toLocaleString()

_recordStartYear = _allFlow.map(d => d.flow_date.slice(0, 4)).sort()[0]
_recordEndYear   = _latestFlow.flow_date.slice(0, 4)
html`<div class="crisis-hero">
  <div class="hero-number">${_ratio}×</div>
  <div class="hero-text">
    <div class="hero-label">more <strong>men</strong> than women are actively homeless in Toronto's shelter system</div>
    <div class="hero-sub"><strong>${_menCount.toLocaleString()} men</strong> · ${_womenCount.toLocaleString()} women · ${_transCount.toLocaleString()} transgender, non-binary, or two-spirit · ${_flowMonth}</div>
  </div>
</div>`
_genderStats = [
  {label: "Men",          mean: _menMean,   ci_lo: _menMean   - 1.96 * _menSE,   ci_hi: _menMean   + 1.96 * _menSE,   isFocus: true},
  {label: "Women",        mean: _womenMean, ci_lo: _womenMean - 1.96 * _womenSE, ci_hi: _womenMean + 1.96 * _womenSE, isFocus: false},
  {label: "Trans/NB/2S",  mean: _transMean, ci_lo: _transMean - 1.96 * _transSE, ci_hi: _transMean + 1.96 * _transSE, isFocus: false}
].sort((a, b) => b.mean - a.mean)

Plot.plot({
  width, height: 300, marginBottom: 36, marginLeft: 70, marginRight: 20,
  title: `Mean monthly actively homeless count by gender — ${_yearRange} (${_n1} months)`,
  caption: "Error bars show 95% confidence intervals of the monthly mean. Source: City of Toronto Shelter System Flow dataset (open.toronto.ca).",
  x: {label: null, domain: _genderStats.map(d => d.label)},
  y: {label: "Mean individuals actively homeless / month", grid: true,
      tickFormat: d => d.toLocaleString(),
      domain: [0, Math.max(..._genderStats.map(d => d.ci_hi)) * 1.12]},
  marks: [
    Plot.barY(_genderStats, {
      x: "label", y: "mean",
      fill: d => d.isFocus ? "#ff3b30" : "#4a6fa5",
      fillOpacity: d => d.isFocus ? 1 : 0.55
    }),
    Plot.ruleY(_genderStats, {
      x: "label", y1: "ci_lo", y2: "ci_hi",
      stroke: _chartStroke, strokeWidth: 1.8
    }),
    Plot.tickY(_genderStats, {
      x: "label", y: "ci_hi",
      stroke: _chartStroke, strokeWidth: 1.8,
      insetLeft: 18, insetRight: 18
    }),
    Plot.tickY(_genderStats, {
      x: "label", y: "ci_lo",
      stroke: _chartStroke, strokeWidth: 1.8,
      insetLeft: 18, insetRight: 18
    }),
    Plot.text(_genderStats, {
      x: "label", y: "ci_hi",
      text: d => Math.round(d.mean).toLocaleString(),
      dy: -10, textAnchor: "middle", fontSize: 13,
      fill: d => d.isFocus ? "#cc1f15" : _chartFg,
      fontWeight: "600"
    }),
    Plot.ruleY([0])
  ]
})
{
  const bg   = bayesian_gender
  const rr   = bg.rate_ratios
  const pd   = bg.pairwise_diffs
  const diag = bg.diagnostics
  const fmt1 = x => Number(x).toFixed(1)
  const fmtN = x => Math.round(x).toLocaleString()

  const bayesPairs = [
    {comp: "Men vs Women",         rr: rr.men_vs_women,       diff: pd.men_vs_women,       note: false},
    {comp: "Men vs Trans/NB/2S",   rr: rr.men_vs_transnb2s,   diff: pd.men_vs_transnb2s,   note: true},
    {comp: "Women vs Trans/NB/2S", rr: rr.women_vs_transnb2s, diff: pd.women_vs_transnb2s, note: true}
  ]
  const bayesRows = bayesPairs.map(p => html`<tr>
    <td style="padding:3px 10px 3px 0">${p.comp}${p.note ? html`<sup> †</sup>` : ""}</td>
    <td style="padding:3px 10px;text-align:right;font-variant-numeric:tabular-nums">
      ${fmt1(p.rr.median)}&times; (${fmt1(p.rr.hdi[0])}&ndash;${fmt1(p.rr.hdi[1])})
    </td>
    <td style="padding:3px 0;text-align:right;font-variant-numeric:tabular-nums;font-weight:${p.note?"400":"700"}">
      ${fmtN(p.diff.median)} (${fmtN(p.diff.hdi[0])}&ndash;${fmtN(p.diff.hdi[1])})
    </td>
  </tr>`)

  return html`<div class="bq-bayes">
    <p style="margin:0 0 0.3rem">
      <strong>Bayesian analysis (negative-binomial regression, fit with
      <code>brms</code> ${bg.brms_version} on ${bg.n_months} months of data):</strong>
      Weakly informative priors were used throughout.
      Sampling diagnostics: R&#x0302;&nbsp;${diag.max_rhat},
      ESS&nbsp;${diag.min_ess_bulk.toLocaleString()},
      ${diag.n_divergent} divergent transitions.
    </p>
    <table>
      <thead><tr>
        <th style="text-align:left;padding:3px 10px 3px 0;font-weight:600">Comparison</th>
        <th style="text-align:right;padding:3px 10px;font-weight:600">Rate ratio (95% HDI)</th>
        <th style="text-align:right;padding:3px 0;font-weight:600">Difference in count (95% HDI)</th>
      </tr></thead>
      <tbody>${bayesRows}</tbody>
    </table>
    <p class="bq-note" style="margin:0 0 0.5rem">
      † The Trans/NB/2S group averages ~${Math.round(_transMean)} individuals per month,
      a small count that is also likely under-reported in administrative shelter records.
      Tests involving this group should be interpreted with caution.
    </p>
    <p style="margin:0 0 0.3rem">
      <strong>Men vs Women:</strong>
      Men's expected monthly count is roughly
      <strong>${fmt1(rr.men_vs_women.median)}&times; that of women</strong>
      (95% HDI ${fmt1(rr.men_vs_women.hdi[0])}&ndash;${fmt1(rr.men_vs_women.hdi[1])}&times;),
      an average gap of about
      <strong>${fmtN(pd.men_vs_women.median)} people per month</strong>
      (95% HDI ${fmtN(pd.men_vs_women.hdi[0])}&ndash;${fmtN(pd.men_vs_women.hdi[1])}).
      Across a wide range of reasonable priors, the posterior probability that the true mean
      monthly count of men exceeds women is indistinguishable from 1.
    </p>
    <div class="plain-eng">
      <div class="pe-label">Plain English</div>
      A posterior probability indistinguishable from 1 means the estimated probability is
      close to 100% that men's true monthly count exceeds women's. The 95% HDI also indicates
      the gap is consistently large — men's running about ${fmt1(rr.men_vs_women.median)}&times;
      women's, or roughly ${fmtN(pd.men_vs_women.median)} more people per month.
      <strong>Men are the largest and most consistently over-represented group in the city's shelter system.</strong>
    </div>
  </div>`
}
md`Men have accounted for at least **${_menShareMinPct}%** of Toronto's actively homeless population every single month on record (${_recordStartYear} to ${_recordEndYear}). That figure is the **lowest single-month share of men observed across the entire data record**; in most months the share is higher. The average monthly gap between men and everyone else (women plus transgender, non-binary, and two-spirit individuals combined) is roughly **${_gapAvgStr} people**, and it has barely moved as the total has risen and fallen. The pattern is not new, not subtle, and not hidden in the data. Yet it rarely makes headlines, and few of the city's public conversations about homelessness lead with it.`
 

An independent data audit for Toronto residents. / Un audit de données indépendant pour les résidents de Toronto.
© 2026 Miriam Marling · BonQuery