Computational methods · Full specification · For review
What the Numbers Actually Do
Every computation behind these housing estimates, written out in full — including the places where the system computes one thing and documents another.
Every constant printed here is read out of the file that defines it by
scripts/paper/extract-model-parameters.mjs, not transcribed. If a weight
changes in the code and this page is not rebuilt, continuous integration fails. That is the
only reason to trust a methods document: not that its author was careful, but that it
cannot disagree with the program and still ship.
Reviewers are asked to attack §05 first. It is where the system is weakest, and the section exists because writing this page found the problem.
01 Notation and conventions
Throughout, P is a place (municipality or census-designated place),
T a census tract, b an AMI band, and t a tenure in
{renter, owner}. CHAS bands are the five the source publishes:
≤30, 31–50, 51–80,
81–100, >100 percent of area median income.
One convention governs everything else and is stated first because it changes what the
other formulas mean: a quantity that cannot be measured is null, and
null is never coerced to zero. In the formulas below, any sum or
quotient with a null operand is null, not a value computed as
though the operand were zero. §07 gives the propagation rules
precisely, because they are where most of the system's defects have lived.
02 Apportionment: tract to place
HUD's CHAS tables are the only public source crossing cost burden with AMI band and tenure. They publish at tract level. Municipal boundaries do not follow tracts, so every place-level figure in this system is an apportionment, and the weight is the whole argument.
M(P, b, t) = Σ_T w(T, P) · M(T, b, t)
- M(T,b,t)
- the CHAS count for tract T, band b, tenure t
- w(T,P)
- the share of tract T attributed to place P, below
w(T, P) = max( area_share(T, P), pop_share(T, P) )
- area_share
- fraction of T's land area lying inside P
- pop_share
- fraction of T's population attributed to P
max(area_share, population_share)
build_place_chas.py opened by describing
“area-weighted apportionment (share_of_tract_area)” long after F28
replaced that rule, and its output-schema example carried the same superseded string.
The executing code takes the maximum of area share and population share, clamped
to 1.0, falling back to area share alone where population is unavailable —
and flagging those places. Both the header and the example are now corrected, and
test/place-chas-method-honesty.test.js fails if they drift again.
A correction to an earlier version of this section. It said the
published metadata was wrong today. That overstated it. The
method string actually emitted into
data/hna/place-chas.json was never the flatly-wrong area-weighted claim
— that string lived in a docstring example. The emitted string said
“Population-share apportionment: weight = min(1, population_share),” which is
incomplete rather than wrong: it omits the max(area_share, …),
so for a tract lying mostly inside a place it names the wrong term as the winner. The
emitter now states the full rule. The published file still carries the older string until
the CHAS build is re-run, which is a data-pipeline operation with its own ordering hazard
and is deliberately not bundled with this documentation fix.
This matters beyond tidiness. max is not a partition: summed over the tracts
overlapping a place it does not equal 1, and summed over the places overlapping a tract it
can exceed 1. Households can therefore be counted into more than one place. §03
is what keeps that from reaching the published levels, which makes the anchor
load-bearing rather than cosmetic.
The assumption underneath both shares is uniform distribution of households within a
tract. It is poorest for slivers — tracts contributing under roughly a tenth of
their area — which are flagged rather than silently included, and it is exactly
where max does the most work.
03 The ACS occupied-household anchor
Apportionment is geometrically defensible and demographically optimistic. Summed across
overlapping tracts it overcounts households in roughly 31% of Colorado places, and under
the max rule it must. The correction anchors each place's tenure totals to an
independent source — the American Community Survey's occupied-unit counts —
and scales the bands to fit.
λ_t = A_t / Σ_b M(P, b, t)
M*(P, b, t) = λ_t · M(P, b, t)
- A_renter
- ACS DP04_0047E, renter-occupied units in P
- A_owner
- ACS DP04_0046E, owner-occupied units in P
- λ_t
- the scale factor applied to every band of tenure t
Because λ_t is constant across bands within a tenure, every rate is
invariant under the anchor and only levels change. A cost-burden share —
the quantity a policy is actually written against — survives the correction
unchanged, while the household count becomes checkable against a source that did not
produce it. Formally, for any bands b, b' of the same tenure,
M*(b)/M*(b') = M(b)/M(b').
The ordering is load-bearing and is a live operational hazard: the ACS summary caches must
regenerate before the CHAS build, or λ_t is computed against stale
denominators and the anchor silently no-ops — producing a file that looks normal and
carries uncorrected levels.
04 Cost burden and published shares
Cost burden follows HUD's definition: a household is burdened above 30% of gross income for housing, severely burdened above 50%. Both are counted directly in CHAS rather than derived, so the apportionment above carries them without further assumption.
C_t = round( Σ_b M*(P, b, t), 1 )
B30_t = round( Σ_b M*30(P, b, t), 1 )
share30_t = round( B30_t / C_t , 4 ) ← from the PUBLISHED counts
scripts/hna/build_place_chas.py — recompute_summary()
Two reasons, and the second is the interesting one. Consistency: a reader dividing the published burdened count by the published total now obtains the published share. Before this change they did not — one place shipped counts of 6.9 and 16 alongside a share of 0.4312, where 6.9/16 is 0.4313.
Reproducibility: the raw accumulators are sums of floats that land on
values like 16.000000000000004. Several shares sit on an exact
.00005 rounding tie — 9.3/48, 104.7/240, 177.5/400, 6.9/16 —
where a one-ulp difference in the denominator flips the fourth decimal. The file then
differed between machines, and a freshness checker reported places as stale whose inputs
had not changed in months. Rounding the denominator first makes the division consume
identical IEEE doubles on every run.
Counts are rounded to one decimal before summing and to whole households for display. Column totals are therefore computed from rounded values, so a displayed subtotal can differ by a household or two from the sum of unrounded bands. The published tables are internally consistent — the columns add up — at the cost of being a rounded view rather than an exact one. An earlier draft of the working paper reported a renter subtotal of 227 where its own column summed to 228; the generated version now takes the subtotal from the rounded bands, so it agrees with what a reader can add.
05 Ownership affordability — seven models, not one
There is no single “affordability model” here. The registry defines
7 models
with back and front
ratio conventions between them, and they answer different questions. The default is
conservative_screening.
The working paper's methodology section previously described this as a single model
solving at “43% back-end DTI, 20% down, 6.5%, 0.65% tax, 0.85% insurance.”
That is a recognisable description of conventional_dti — one of the
seven, and not the default. The default screens at a 30% front-end ratio, which
is a materially smaller number. That section has been corrected; it is recorded here
because a reader who cited the old description would have cited a model the system does
not run by default.
I = AMI₄ · p · f(size)
budget = I · r_front / 12 (front-end models)
= I · r_back / 12 − D (back-end models)
capped at I · r_cap / 12 where a cap exists
budget ← budget − (HOA + ground rent) fixed costs don't scale
k = L · m(i, n) + (τ + ι)/12 + L · π/12 + L · μ/12
price = budget / k (0 if budget ≤ 0)
- AMI₄
- four-person area median income for the county
- p
- the AMI percentage being tested (e.g. 0.80)
- f(size)
- HUD household-size adjustment factor
- r_front
- front-end (housing-only) ratio
- r_back
- back-end (total debt) ratio
- D
- borrower's other monthly debt
- L
- financed share of price = 1 − down payment rate
- m(i,n)
- monthly amortisation factor at rate i over n years
- τ, ι
- annual property-tax and insurance rates
- π, μ
- PMI and MIP annual rates
The structure is a closed-form solve rather than a search: every cost that scales with
price is collected into k, the cost per dollar of price per month, and the
budget is divided by it. Fixed monthly costs are subtracted from the budget first,
precisely because they do not scale. The inverse — income required for a given home
value — is a binary search, because f(size) and the PMI gate
make the forward function piecewise.
| Model | Ratio type | Ratio | Front cap | Tax | Insurance | PMI | PMI gated |
|---|---|---|---|---|---|---|---|
| conservative_screening (default) | front-end | 30% | — | 0.65% | 0.35% | 0.5% | no |
| first_time_buyer | front-end | 30% | — | 0.65% | 0.85% | 0.5% | yes |
| conventional_dti | back-end | 43% | — | 0.65% | 0.85% | 0% | yes |
| fha_insured | back-end | 45% | — | 0.65% | 0.85% | n/a | no |
| usda_rd | back-end | 41% | 29% | 0.65% | 0.85% | n/a | n/a |
| prop123_dpa_eligibility | front-end | 38% | — | 0.65% | 0.35% | 0.5% | no |
| custom | front-end | n/a | — | n/a | n/a | n/a | n/a |
js/config/financial-constants.js carries its own affordability constants,
used by surfaces that do not go through the registry. Where the two disagree, both values
are published here rather than reconciled, because a reader needs to know which number
reached which screen.
| Parameter | Registry default | Constants file |
|---|---|---|
| property tax rate | 0.0065 | 0.006 |
The distinction is substantive and is the reason the default is not
conventional_dti. HUD calls a household cost burdened above 30% of income for
housing. A model solving at a 43% back-end ratio answers “what will an underwriter
approve,” which is a strictly larger number than “what can this household
carry without being cost burdened.” Both are legitimate questions. A subsidy sized
to the first delivers a buyer who is cost burdened on the day they move in — and a
jurisdiction that reported need using the 30% screen and then sized assistance using the
43% one would be internally inconsistent in a way no single published figure would reveal.
06 The scoring chain
The ranking index reduces every jurisdiction to one number. This section writes out the whole chain, because a composite score is the easiest place in a system like this to hide an arbitrary choice, and the only defence is publishing every weight.
Two properties are worth stating before the formulas. First, almost every input enters as
a percentile rank within its own geography type, not as a raw value —
counties are ranked against counties, places against places. Second, the percentile
convention is rank / (n − 1) · 100, so the lowest-ranked geography in each
pool scores exactly 0 and the highest exactly 100 by construction. Scores are
therefore positional, not absolute: a place cannot improve its score by improving, only by
improving relative to its peers.
gap_pressure = w_c·pct(gap_count) + w_r·pct(gap_rate)
cost_pressure = w_a·pct(cb_all) + w_s·pct(cb_severe) + w_d·pct(cb_deep)
afford_intens = w_h·pct(value/income) + w_n·pct(rent/income)
future_press = w_u·pct(future_units) + w_g·pct(senior_growth)
overcrowding = pct(overcrowding_rate) or null if unmeasured
scripts/hna/build_ranking_index.py
| Group | Component | Weight |
|---|---|---|
| Axis | community_need | 0.55 |
| Axis | opportunity | 0.45 |
| Community need | gap_pressure_score | 0.35 |
| Community need | cost_burden_pressure_score | 0.25 |
| Community need | affordability_intensity_score | 0.15 |
| Community need | future_pressure_score | 0.15 |
| Community need | overcrowding_score | 0.1 |
| Opportunity | opportunity_mobility_score | 0.35 |
| Opportunity | walkability_score | 0.25 |
| Opportunity | amenity_access_score | 0.25 |
| Opportunity | qct_dda_score | 0.15 |
| Sub-score: gap | count | 0.4 |
| Sub-score: gap | rate | 0.6 |
| Sub-score: cost_burden | all_renter | 0.4 |
| Sub-score: cost_burden | severe | 0.3 |
| Sub-score: cost_burden | deep_tier | 0.3 |
| Sub-score: affordability | homebuyer | 0.5 |
| Sub-score: affordability | renter | 0.5 |
| Sub-score: future | units | 0.7 |
| Sub-score: future | senior | 0.3 |
| Sub-score: commuter | count | 0.5 |
| Sub-score: commuter | ratio | 0.5 |
| Sub-score: commuter | augment_alpha | 0.2 |
need_core = Σ_f W_f · f over the five factors above
(weights renormalised over present factors)
commuter = 0.5·pct(in_commuters) + 0.5·pct(commute_ratio)
need_aug = min( 100, need_core · (1 + α · commuter/100) )
- α
- 0.2 — the augment coefficient
commuter is a percentile in [0, 100], so the multiplier lies in
[1, 1 + α] and the term is strictly non-negative. A jurisdiction with no
in-commuting is not penalised; one with heavy in-commuting is credited by at most
20%.
This is a deliberate asymmetry: in-commuting is evidence that a place's workforce cannot
live there, which is additional need — but its absence is not evidence of the
reverse, because a place with no jobs also has no in-commuters.
The min(100, ·) is a genuine saturation: a jurisdiction already at the top of
its pool receives no augment at all, so the term compresses precisely where it would
matter most for ranking.
overall_raw = W_need · pct(need_aug) + W_opp · opportunity
c = max( c_min, 1 − Σ penalties )
overall = round( clamp(overall_raw, 0, 100) · c , 1 )
- W_need
- 0.55 — aligned to CHFA's QAP category weighting
- W_opp
- 0.45
- c_min
- 0.85 — the floor on the confidence multiplier
The confidence multiplier deducts 0.03 per imputed score factor and 0.01 per approximated field, with further deductions for low-confidence home values and for opportunity data resolved only at county level. It is floored at 0.85, so the maximum total penalty is 15%.
Need is double-percentiled. The five factors enter
need_core as percentiles; need_aug is then itself converted to a
percentile before the axis blend. Ranking a set of ranks compresses the distribution twice
and makes the final score's spacing arbitrary — differences of a point or two carry
no interpretation.
Opportunity is not percentiled at that stage. It enters the axis blend as a raw 0–100 composite while need enters as a percentile. The two axes are therefore on different scales despite being weighted as though comparable.
The confidence multiplier scales, it does not flag. A jurisdiction whose inputs are largely imputed loses at most 15% of its score and still ranks. The multiplier is published alongside the score, and a user comparing two jurisdictions should read it, because a high score at a low multiplier and a slightly lower score at full confidence are not the same claim.
07 Absence propagation
The rules below are the ones that make the rest of this document mean what it says. They
exist because Number(null) is 0 and 0 is finite, so
a coercion upstream of any formula here produces a value that passes every downstream
validity check.
Σ_f∈F* W_f · f
weighted_avg(F) = ─────────────── F* = { f ∈ F : f is a number }
Σ_f∈F* W_f
= null if F* is empty
scripts/hna/build_ranking_index.py — _weighted_average()
A missing factor is dropped and the remaining weights are renormalised, rather than the factor entering as zero. The difference is not subtle: as a zero, a missing overcrowding rate would push a jurisdiction's need score down by a tenth of its value and look like measured absence of overcrowding. Renormalised, it means “this score was computed from the other four factors” — which is a weaker claim, correctly stated.
The other rules, in the order they bite:
- Rate denominators have a floor. A rate computed on fewer than 50 households is not published. Small denominators produce rates of 0% and 100% that are arithmetically correct and substantively meaningless.
- Sentinels are rejected before arithmetic. The Census API returns
-666666666for unavailable variables. It is finite, so every naive check passes it through; it is caught at parse. - Guard
≤ 0, not< 0. Wherever zero is not a meaningful value — a price, a rent, an income — exactly zero means unknown rather than free. - Negative inputs yield
null, not0. The affordability solve returnsnullfor missing or negative income, and0only for the distinct case where fixed monthly costs consume the entire budget — a real finding, not a missing one. - Reasons travel with values. Every metric carries source, vintage, geography level and confidence, so a consumer can distinguish “not published by the source” from “below the reporting threshold” from “measured.”
08 Reproducibility and known defects
8.1 · What makes a run reproducible
Share denominators are rounded before division (§04), which removes the float-tie instability. Generated files are rebuilt in continuous integration and a pull request whose outputs drift from what its inputs would produce fails. The constants in this document are extracted from source rather than transcribed, so this page cannot drift from the program without failing the build.
8.2 · Defects this document found
Writing out the specification located four disagreements between code and documentation:
- The apportionment weight is
min(1.0, max(area, population))while the file header and its output-schema example both said area-weighted (§02). Both corrected and now guarded. The string actually emitted into the data file was incomplete rather than wrong — it omitted themax— and is also corrected, though the published file carries the older text until the build is re-run. - The working paper described one affordability model where 7 exist, and described the non-default one (§05).
- Affordability defaults are declared in six places, not two. Down
payment takes three values — one of them a whole-number percentage rather than a
fraction — and insurance is declared in two different units:
flat annual dollars in one file, a rate of home value in another. On a $250,000 home
those differ by roughly 2.7×, crossing near $686,000.
test/affordability-defaults-inventory.test.jsnow pins all of them and fails when a new competing default appears (§05). - A tenure label in the working paper's tractability table inverted its two zero-cases, publishing a jurisdiction with 266 burdened renters and no burdened owners as “owner only.” Fixed, and now guarded against the counts rather than against the function that produced them.
8.3 · Open methodological weaknesses
- The
maxweight is not a partition. It can attribute a household to more than one place. The ACS anchor bounds the consequence at the place level but nothing bounds it statewide, so summing place-level counts across Colorado is not a valid operation and the interface should say so more loudly than it does. - Double percentiling compresses the need axis and puts it on a different scale from opportunity in the same weighted blend (§06).
- Uniform within-tract density is assumed by both shares and is
weakest exactly where
maxis doing the most work. - Cost burden and unit gap use different income bases — burden against area medians, gap against household-size-adjusted limits. Both are correct against their own definitions and they are not comparable band-for-band, which is a trap for any reader who assumes a shared denominator.
- The 30%/43% divide is unresolved by design and should be resolved by the user, not by the system picking one.
Each claim here is checkable against the repository. The weights are in
scripts/hna/build_ranking_index.py; the apportionment and anchor in
scripts/hna/build_place_chas.py; the affordability solve in
js/hna/ownership-finance.js against
data/policy/affordability-models.json. A reviewer who finds this page
disagreeing with any of them has found a real defect, and the extraction script named at
the top is where to look first.