This site is a public-data reference. Analyses use public datasets and are presented for reference, not as financial, tax, legal, or underwriting advice.

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.

How to read this, and how to break it

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.

Apportionment of a CHAS count to a place 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
scripts/hna/build_place_chas.py
The weight actually in use 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
detected in source: max(area_share, population_share)
The file's own header documents a different rule

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.

Tenure-wise level anchor λ_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
scripts/hna/build_place_chas.py — load_acs_anchor_map()
The property that makes this defensible

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.

Published counts, then published shares 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()
Why the share divides published counts rather than raw accumulators

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.

Rounding is not distributive, and the page says so

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

Read this before citing any affordability figure from this system

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.

Maximum supportable price 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
js/hna/ownership-finance.js — computeBuyerCapacity()

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.

Table 1 · The registered models, as read from the registry
ModelRatio typeRatio Front capTaxInsurance PMIPMI gated
conservative_screening (default)front-end30%0.65%0.35%0.5%no
first_time_buyerfront-end30%0.65%0.85%0.5%yes
conventional_dtiback-end43%0.65%0.85%0%yes
fha_insuredback-end45%0.65%0.85%n/ano
usda_rdback-end41%29%0.65%0.85%n/an/a
prop123_dpa_eligibilityfront-end38%0.65%0.35%0.5%no
customfront-endn/an/an/an/an/a
A third parameter set exists

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.

Table 2 · Registry default versus the standalone constants file
ParameterRegistry defaultConstants file
property tax rate0.00650.006
43% is a lender's ceiling, not an affordability standard

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.

Sub-scores (each part a within-type percentile) 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
Table 3 · Every weight, read from the source file
GroupComponentWeight
Axiscommunity_need0.55
Axisopportunity0.45
Community needgap_pressure_score0.35
Community needcost_burden_pressure_score0.25
Community needaffordability_intensity_score0.15
Community needfuture_pressure_score0.15
Community needovercrowding_score0.1
Opportunityopportunity_mobility_score0.35
Opportunitywalkability_score0.25
Opportunityamenity_access_score0.25
Opportunityqct_dda_score0.15
Sub-score: gapcount0.4
Sub-score: gaprate0.6
Sub-score: cost_burdenall_renter0.4
Sub-score: cost_burdensevere0.3
Sub-score: cost_burdendeep_tier0.3
Sub-score: affordabilityhomebuyer0.5
Sub-score: affordabilityrenter0.5
Sub-score: futureunits0.7
Sub-score: futuresenior0.3
Sub-score: commutercount0.5
Sub-score: commuterratio0.5
Sub-score: commuteraugment_alpha0.2
Community need, then the commuter augment 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
The augment can only ever raise a score

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 score 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%.

Three properties a user of this score should know

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.

Renormalisation over present factors Σ_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 -666666666 for 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, not 0. The affordability solve returns null for missing or negative income, and 0 only 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:

  1. 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 the max — and is also corrected, though the published file carries the older text until the build is re-run.
  2. The working paper described one affordability model where 7 exist, and described the non-default one (§05).
  3. 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.js now pins all of them and fails when a new competing default appears (§05).
  4. 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 max weight 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 max is 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.
What would falsify any of this

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.