How the Calculations Work
This page is the mathematical journey of an HCPI price: from one number written down in a shop to the single national inflation figure on the news. It's detailed enough for a statistician to verify the methodology, and explained plainly enough for a project sponsor or new developer to follow without a stats background.
If you want the workflow version (who clicks what, in which order), see Data Flow Walkthrough. For the gentlest possible introduction to what a CPI even is, see CPI Explained. For the literal click-by-click guide in the Odoo UI — which menu, which button, in what order — see Using the System (UI Walkthrough).
About the function names you'll see in this page
Throughout this page, you'll see references like (in compute_pr()) or (run_turkey() in hcpi_data_validation.py) attached to each calculation step. These are the actual Python function and model names in the codebase so a developer or statistician can grep for them and read the source to verify the maths. File paths are relative to the HCPI module root.
In one breath
Fieldworkers record prices. Each price is turned into a ratio showing how it changed. For the smallest product group, those ratios are blended with a geometric mean (which treats a doubling and a halving symmetrically). Those baby indices are then added up by how much households spend on each thing — bread counts more than shoelaces — all the way up to one national inflation number. Along the way, the system throws out crazy values, fills small gaps, and refuses to compute if it sees an impossible zero price.
That sentence is the whole page. Everything below is the same idea, with the maths, the safeguards, and a worked example.
The two key statistical moves
The HCPI is built bottom-up in two distinct mathematical steps. Spotting which step you are in tells you which formula applies.
| Step | Where it happens | Method | Why |
|---|---|---|---|
| Blending individual price quotes into the smallest published index (the elementary aggregate) | Bottom of the ladder | Geometric mean of price relatives (a Jevons index) | International standard when sub-EA expenditure weights don't exist |
| Combining those baby indices upward into class, group, division, national | Every level above | Weighted arithmetic mean with expenditure shares (Laspeyres-type) | Reflects how much households actually spend on each category |
Keep this distinction in mind. Gold = geometric mean at the bottom. Green = weighted mean above. Two methods, two purposes.
The vocabulary you need first
| Term | Plain meaning |
|---|---|
| Observation | One price quote: one item, in one outlet, on one date. The atom of the whole system. |
| Outlet | A shop, market, or vendor where prices are collected. |
| Item | A precisely specified product, e.g. "white bread loaf, 500 g". |
| Outlet Item | A specific item as sold in a specific outlet — what gets a price each month. Carries the base_price. |
| Elementary Aggregate (EA) | The lowest level at which an index is published (e.g. "Bread"). The foundation. |
| COICOP hierarchy | The international classification ladder above the EA: micro-class → sub-class → class → group → division. |
| Consumption Segment (a "basket") | A population stratum, typically region × household type (e.g. "Kampala Urban"). Each basket has its own weights. |
| Weight | The expenditure share of an EA within a basket. Drives all aggregation above the EA. |
| Price relative (PR) | A ratio of two prices — the currency of price-change measurement. |
| Base price | The reference-period price each long-term relative is measured against. |
The journey of a price — overview
flowchart TD
A["Price quote<br/>(one item, one shop, one month)"] --> B["Price relative<br/>(this price ÷ a reference price)"]
B --> C["Elementary Aggregate index<br/><b>GEOMETRIC mean</b> of relatives × 100<br/>(a Jevons index)"]
C --> D["Micro-class → Sub-class → Class<br/>→ Group → Division"]
D --> E["Basket (all-items) index<br/><b>WEIGHTED arithmetic mean</b><br/>(Laspeyres-type)"]
E --> F["National index<br/>(weighted across all baskets)"]
C -.-> G["Special indices<br/>(custom EA groups, e.g. Energy, Core)"]
D -.-> H["Classification indices<br/>(custom class groups, e.g. Food / Non-food)"]
E -.-> I["Domestic indices<br/>(regional / urban-rural sub-totals)"]
style C fill:#fde68a,stroke:#b45309
style E fill:#bbf7d0,stroke:#15803d
The rest of this page walks each of those boxes.
Stage 1 — Collecting a price (the input layer)
A single price record means: "On this date, this item, in this outlet, cost this much." The record lives in model hcpi.outlet.item.observation (file: hcpi_outlet/models/hcpi_outlet_item_observation.py). The important fields are:
| Field | Meaning |
|---|---|
observed_price |
The price the collector recorded |
observation_date |
The day it was collected |
outlet_item_id |
Links to the item-in-outlet (and through it, to the COICOP classification and base_price) |
base_price |
The reference-period price, pulled from the outlet-item |
consumption_segment_id |
Which basket this belongs to, pulled from the outlet |
elementary_aggregate_id |
Which EA this rolls up into |
month / readable_date |
Calendar-month grouping, e.g. "June_2026" / "June 2026" (computed by _compute_month and _compute_readable_date) |
Critically, the collector only enters a price against an outlet item. The EA, COICOP codes, and basket attach themselves automatically through this chain of related fields:
flowchart LR
O["Outlet<br/>(where)"] --> OI["Outlet Item<br/>(what, in this shop)"]
I["Item<br/>(what, in general)"] --> OI
OI --> OB["Observation<br/>(the price)"]
I --> EA["Elementary Aggregate"]
EA --> MC["Micro-class"] --> SC["Sub-class"] --> CL["Class"] --> GR["Group"] --> DV["Division"]
O --> CS["Consumption Segment<br/>(the basket)"]
The field workflow is: a data collection (hcpi.data.collection — a questionnaire for one outlet on one date) generates collection lines (hcpi.data.collection.line, one per item, each producing a standard_price normalised to the standard unit), which are then grouped into a data validation (hcpi.data.validation, one batch per basket × month) — that's where cleaning happens before observations become permanent.
Stage 2 — Price relatives: the two kinds, and why
A price relative is just one price divided by another. The system never averages raw prices (you can't sensibly average UGX/kg of rice with UGX/litre of fuel). It averages ratios of change, which are unit-free.
The helper — compute_pr() on the shared hcpi.computation base model (hcpi_computation/models/hcpi_computation.py) — is one line of arithmetic:
def compute_pr(self, current_price, previous_price):
if current_price and previous_price:
return current_price / previous_price
return 0
For statisticians: zero relatives are intentional
If either price is missing or zero, the relative is set to 0, not 1. Zero relatives are deliberately excluded from the geometric mean later (see Stage 4) so they don't corrupt the index — they signal "no usable data", not "no price change."
Each observation produces two price relatives, both computed on hcpi.outlet.item.observation:
Long-term price relative — field long_term_price_relative
Computed by _compute_long_term_pr (in hcpi_outlet_item_observation.py):
What it measures: the cumulative change since the fixed base period. Feeds the long-term index — the main published level (the "117.3" headline).
Short-term price relative — field short_term_pr
Computed by _compute_short_term_pr (same file). It uses a single efficient SQL LATERAL join to find the most recent observation of the same outlet-item in the prior calendar month:
What it measures: the month-on-month change. If no previous-month price exists for that exact outlet-item, the system falls back to the base price. Feeds the short-term index, which is then chained (Stage 4) so monthly movements link into a continuous series.
| Long-term | Short-term | |
|---|---|---|
| Compared against | Fixed base period | Previous month |
| Feeds | Long-term index (level) | Chained short-term index |
| Captures | Total change since base | Latest monthly movement |
Stage 3 — Cleaning the data
Before any index is built, suspicious prices are flagged and gaps are filled.
The Tukey outlier algorithm — run_turkey()
This is the same outlier method recommended by Eurostat for the HICP. It runs once per basket × month, executed by run_turkey() on hcpi.data.validation (file: hcpi_data_collection/models/hcpi_data_validation.py).
Naming note: the method is spelled
run_turkeyin the code (a typo), but statistically this is the Tukey algorithm. Same maths, just a misspelt method name.
flowchart TD
Z["Zero prices → automatically removed"] --> PR["Compute PR = standard_price ÷ previous price, for each item"]
PR --> SORT["Sort items by PR"]
SORT --> DROP1["Drop PRs exactly = 1 (no change)"]
DROP1 --> TRIM["Trim top 5% & bottom 5%<br/>(marked is_removed)"]
TRIM --> SPLIT["Split remainder:<br/>upper = PR>1, lower = PR<1"]
SPLIT --> AVG["upper_avg, lower_avg, general_avg"]
AVG --> LIM["upper_limit = general_avg + 2.5·(upper_avg − general_avg)<br/>lower_limit = general_avg − 2.5·(general_avg − lower_avg)"]
LIM --> FLAG["Any PR outside [lower_limit, upper_limit]<br/>→ flagged is_outlier"]
The bounds:
where \(\bar u\) is the mean of relatives above 1, \(\bar\ell\) the mean below 1, and \(\bar g\) the overall mean.
Plain language: find the typical upward move and the typical downward move, then flag anything that moved much more than typical (2.5× beyond the centre). The 5% trim first removes the wildest values so they don't distort the averages used to set the bounds.
Methodological parameters
The multiplier (2.5) and the trim fraction (5%) are tunable parameters of this implementation. They are hard-coded; changing them is a deliberate methodological decision, not a config tweak.
Inliers — validate_inliers()
A price is flagged as an inlier if it has been identical for the last 12 months. Persistent non-movement can be just as much a data-quality flag (a forgotten or copy-pasted price) as a wild jump. The check is validate_inliers() on hcpi.data.validation.
Imputation — filling gaps
When a price is missing, the system estimates it rather than dropping the item — climbing the classification ladder until it finds usable data. Each rung has its own method on hcpi.data.validation:
flowchart TD
A["Same product in another outlet?"] -->|yes| A1["impute_with_prices()<br/>(geometric-mean price relative)"]
A -->|no| B["Other items in same EA?"]
B -->|yes| B1["impute_with_ea()"]
B -->|no| C["Micro-class → Sub-class → Class → …"]
C --> C1["impute_with_micro_class() / sub_class() / …<br/>(weighted relative carried onto previous price)"]
The principle at every level: take the price relative observed for the surrounding group (computed with the geometric mean helper compute_geo_mean() on hcpi.computation) and apply it to the item's own previous price. The imputed observation is marked is_imputed with the imputation method recorded for audit. The whole imputation pass is kicked off by action_impute() on the data-validation form.
Stage 4 — The elementary aggregate index (the foundation)
This is the single most important calculation in the system. It lives in hcpi_index/models/basket_coicop_indices/hcpi_basket_ea_index.py.
For every EA × basket × month, the system computes the geometric mean of the price relatives of all the individual items in that EA, then multiplies by 100. The geometric mean of price ratios is the Jevons index — the international standard for elementary aggregates without sub-weights.
The maths:
It is computed directly in SQL by _get_geo_mean_lookup() on hcpi.basket.ea.index, using the identity EXP(AVG(LN(x))):
SELECT
outlet_item.elementary_aggregate_id AS ea_id,
outlet_item.consumption_segment_id AS segment_id,
date_trunc('month', observation.observation_date)::date AS month_start,
EXP(AVG(LN(observation.long_term_price_relative))) AS geo_mean
FROM hcpi_outlet_item_observation observation
JOIN hcpi_outlet_item outlet_item ON outlet_item.id = observation.outlet_item_id
WHERE observation.long_term_price_relative > 0 -- only positive relatives
GROUP BY ea_id, segment_id, month_start
The same SQL runs twice — once for the long-term relative, once for the short-term — giving one geometric mean per direction per EA per basket per month.
Why only > 0?
Zero or negative relatives (missing data, see Stage 2) would make LN(x) undefined or pull the product to zero. Excluding them means the EA index reflects only items that actually have a usable price.
From geometric mean to index level
This happens inside _job_compute_indices_for_segment() — a background queue-job method on hcpi.basket.ea.index that runs once per consumption segment.
Long-term index (a level, anchored at base = 100):
Short-term index (chained month-on-month):
with the first month anchored at \(\text{GeoMean} \times 100\).
Chaining means each month's movement is multiplied onto the previous month's level, producing a continuous series — that is how short-term movements accumulate into a long-run trend.
The same function also records rich diagnostics (why an index came out zero — no observations? non-positive relatives? missing geo-mean? a zero cascading from a previous month?), which is invaluable for auditing a suspicious result.
Why a geometric mean, not an arithmetic one?
A geometric mean treats a doubling and a halving as equal-magnitude moves. An arithmetic mean does not.
Consider one item that doubles in price (PR = 2.0) and another that halves (PR = 0.5). On average, prices have not changed.
- Geometric mean: \(\sqrt{2.0 \times 0.5} = 1.0\) ✓
- Arithmetic mean: \((2.0 + 0.5)/2 = 1.25\) ✗ (falsely claims 25% inflation)
This symmetry is why the geometric mean is the international standard at the elementary level.
Stage 5 — Climbing the COICOP ladder (weighted aggregation)
From the EA upward, the system switches method. It takes a weighted arithmetic mean of the child indices, where the weights are expenditure shares — how much households actually spend on each category. This is the Laspeyres-type part of the construction.
Where weights live
Weights are stored only at the EA level — one weight per (elementary aggregate × consumption segment), in model hcpi.ea.weight (hcpi_outlet/models/hcpi_ea_weight.py). Every higher-level weight is simply the sum of the EA weights beneath it.
flowchart BT
EA["Elementary Aggregate<br/>(has a stored weight)"] --> MC["Micro-class<br/>weight = Σ child EA weights"]
MC --> SC["Sub-class"]
SC --> CL["Class"]
CL --> GR["Group"]
GR --> DV["Division"]
DV --> BK["Basket (all-items)"]
The universal aggregation formula
Every rung above the EA uses the same pattern. It lives in hcpi_index/models/computations/basket_coicop_index_update.py on model hcpi.basket.coicop.index.update:
where \(I_c\) is each child's index and \(w_c\) its weight.
In code, this looks like:
indices = []
total_weight = 0
for child_index, child_id in children:
weight = weight_lookup.get((child_id, basket.id), 0)
if weight:
indices.append(child_index * weight)
total_weight += weight
computed_index = sum(indices) / total_weight if total_weight else 0
This exact formula is applied at every rung, each by its own method:
| Level computed | From | Method on hcpi.basket.coicop.index.update |
|---|---|---|
| Micro-class | EA indices | _process_micro_class_indices |
| Sub-class | micro-class indices | _process_sub_class_indices |
| Class | sub-class indices | _process_class_indices |
| Group | class indices | _process_group_indices |
| Division | group indices | _process_division_indices |
Performance note for developers: each
_process_*function pre-fetches all child indices and all weights once into in-memory dictionaries (weight_lookup,*_indices_lookup), then does the arithmetic in pure Python with O(1) lookups — avoiding per-record database queries.
Stage 6 — Basket, national and the side-cuts
Once the COICOP ladder is built for each basket, several "headline" numbers come out. They all reuse the same weighted-mean formula from Stage 5 — they differ only in what they sum over.
flowchart TD
EA["Basket EA indices"] --> BI["BASKET all-items index<br/>(weighted mean of ALL EAs in one basket)"]
BI --> NI["NATIONAL index<br/>(weighted across ALL baskets)"]
BI --> DI["DOMESTIC index<br/>(weighted across a SUBSET of baskets:<br/>urban/rural, region…)"]
EA --> SI["SPECIAL index<br/>(custom set of EAs:<br/>Energy, Core, Food-away…)"]
CL["Class indices"] --> CI["CLASSIFICATION index<br/>(custom set of classes:<br/>Food / Non-food, Goods / Services)"]
| Index | Update wizard (model) | Source file | What it aggregates over |
|---|---|---|---|
| Basket (all-items) | hcpi.basket.index.update |
hcpi_index/models/computations/basket_index_update.py |
Every EA in one consumption segment |
| National | hcpi.national.index.update |
hcpi_index/models/computations/national_index_update.py |
Every basket combined, at each COICOP level |
| Domestic | hcpi.domestic.index.update |
hcpi_index/models/computations/domestic_index_update.py |
A chosen subset of baskets (urban vs rural, region…) |
| Special | hcpi.special.index.update |
hcpi_index/models/computations/special_index_update.py |
A custom set of EAs (e.g. Energy, Core, Food-away) |
| Classification | hcpi.classification.index.update |
hcpi_index/models/computations/classification_index_update.py |
A custom set of COICOP classes (e.g. Food / Non-food) |
The key distinction: a basket index describes price change for one stratum (e.g. "Kampala Urban"). The national index re-aggregates across all baskets to produce the country-wide figure — and does so at every COICOP level, so national inflation is available for divisions, groups, classes, etc., not just the top.
A fully worked example — bread in Kampala
Let's run a single number through the whole pipeline. The elementary aggregate is Bread, in the Kampala Urban basket.
Step 1 — Three price quotes this month
| Shop | Price now (UGX) | Base price | Long-term relative |
|---|---|---|---|
| Shop A | 2,500 | 2,000 | 1.25 |
| Shop B | 2,200 | 2,000 | 1.10 |
| Shop C | 2,700 | 2,250 | 1.20 |
Step 2 — The Tukey check
Suppose a fourth shop reports bread at 9,000 UGX (relative 4.5) — clearly a typo. With the other three ratios sitting near 1.1–1.3, the upper Tukey limit lands well below 4.5, so the 4.5 is flagged and excluded before any index is built.
Step 3 — The Bread EA index (Jevons)
The geometric mean of the three surviving relatives:
Multiplied by 100:
Plain language: bread costs about 18.3% more than it did in the base period.
Step 4 — Climbing one rung: the "Bread and Cereals" class
The class sits one level above the EA. Suppose it has three EAs with these indices and weights (expenditure shares within the class):
| EA | Index | Weight | Index × Weight |
|---|---|---|---|
| Bread | 118.3 | 0.50 | 59.15 |
| Rice | 110.0 | 0.30 | 33.00 |
| Maize flour | 130.0 | 0.20 | 26.00 |
| Total | 1.00 | 118.15 |
Plain language: bread carries half the weight, so it pulls the class index closest to itself. Maize flour rose more (130), but matters less because households spend less on it.
Step 5 — Up to the headline
The same Σ(index × weight) / Σ(weight) is applied at every higher rung — group, division, basket all-items. Then the national index is the weighted mean across all baskets. The single headline figure on the news comes out the top.
The zero-price safety gate
A zero observed price is almost always a data error (a forgotten entry), and it would silently corrupt every index above it. The abstract base model hcpi.computation (file: hcpi_computation/models/hcpi_computation.py) therefore enforces a hard rule:
If any active observation has
observed_price = 0, computation stops from that month onward until the price is corrected. Earlier "safe" months are still computed.
Four helpers on hcpi.computation enforce this; every index-update wizard inherits them and calls them before computing:
| Function | Job |
|---|---|
get_zero_price_validation_status() |
Finds the first month containing a zero price and builds the human-readable warning listing affected months and sample items |
filter_date_observations_for_zero_prices() |
Drops the unsafe observations from the in-memory dataset before any index runs |
filter_month_keys_for_zero_prices() |
Drops the unsafe month buckets from the queue |
validate_no_zero_price_months() |
Raises a blocking error if the very first queued month is contaminated |
A single impossible zero stops the whole calculation, on purpose.
How the computations are triggered
Each index level has an update wizard with a "Start Update" button (label string="Start Update" on the form view) that calls a method named action_update_*_indices(). Because each level reads the level below, computation must run bottom-up:
flowchart TD
P1["1 ▸ EA Index Update<br/>action_update_ea_indices()"] --> P2["2 ▸ Basket COICOP Index Update<br/>action_update_basket_coicop_indices()"]
P2 --> P3["3 ▸ Basket all-items Index Update<br/>action_update_basket_indices()"]
P3 --> P4["4 ▸ National Index Update<br/>action_update_national_indices()"]
P1 -.-> S1["Special Index Update<br/>action_update_special_indices()"]
P2 -.-> S2["Classification Index Update<br/>action_update_classification_indices()"]
P3 -.-> S3["Domestic Index Update<br/>action_update_domestic_indices()"]
EA computation is offloaded to a background queue job (one job per consumption segment, scheduled in _job_compute_indices_for_segment()), and a PostgreSQL advisory lock — pg_try_advisory_xact_lock(31818, segment_id) — guarantees two workers never compute the same segment at once. Each wizard also checks that no other computation is already running before starting, keeping the layers consistent.
For the literal click-by-click guide — which menu, which form, which button label — see Using the System (UI Walkthrough).
One method, many countries
The repository is one codebase with per-country git branches: ug_18 (Uganda), ke_18 (Kenya), tz_18 (Tanzania), zar_18 (South Africa), plus rw, bi, ss. The _18 suffix denotes Odoo 18.0.
The headline finding for statisticians: the calculation logic is byte-for-byte identical across every country. A git diff of the computation modules (hcpi_computation, hcpi_index, hcpi_data_collection, hcpi_coicop, hcpi_outlet, hcpi_item) between ug_18, ke_18, tz_18, and zar_18 returns no differences. The geometric-mean EA index, the Tukey algorithm, the weighting, and the chaining are harmonised — which is exactly the point of a Harmonised CPI across the East African Community.
What does differ between countries is purely the geographic dimension and data-entry layer in the {country}_* modules:
| Country | Geographic modules | Geographic levels |
|---|---|---|
Uganda (ug_18) |
ug_location, ug_outlet |
Region → District → County → Sub-county → Parish |
Kenya (ke_18) |
ke_location, ke_outlet |
Region → Zone |
Tanzania (tz_18) |
tz_location, tz_outlet |
Tanzania's regional structure |
South Africa (zar_18) |
zar_location, zar_outlet |
RSA's regional structure |
For a developer onboarding a new country: you do not touch the index maths. You create {country}_location (your administrative hierarchy), {country}_outlet (link outlets to that hierarchy and parse your outlet-code format), {country}_data_collection (label validation by your geography), and {country}_base_import (your spreadsheet importer). The shared hcpi_* modules then produce harmonised indices automatically.
Quick formula reference card
| Quantity | Formula | Where in the code |
|---|---|---|
| Long-term price relative | observed_price / base_price |
_compute_long_term_pr in hcpi_outlet_item_observation.py |
| Short-term price relative | observed_price / previous-month price (fallback: base) |
_compute_short_term_pr, same file |
| Price-relative helper | current / previous (returns 0 if either missing) |
compute_pr() on hcpi.computation |
| EA geometric mean (Jevons) | EXP(AVG(LN(price_relative))), positive relatives only |
_get_geo_mean_lookup() on hcpi.basket.ea.index |
| EA long-term index | geo_mean × 100 |
_job_compute_indices_for_segment(), same file |
| EA short-term index (chained) | geo_mean × previous_short_term_index |
same function |
| Higher-level index (Laspeyres-type) | Σ(child_index × weight) / Σ(weight) |
_process_*_indices on hcpi.basket.coicop.index.update |
| Higher-level weight | Σ(child EA weights) |
derived in each _process_* |
| Tukey outlier bounds | gen_avg ± 2.5·(side_avg − gen_avg), after 5% trim |
run_turkey() on hcpi.data.validation |
| Inlier check | identical price for 12 months | validate_inliers(), same model |
| Imputation (per rung) | apply geo-mean PR of surrounding group | impute_with_prices() / _ea() / _micro_class() / … |
| Zero-price gate | block months with observed_price = 0 |
get_zero_price_validation_status() etc. on hcpi.computation |
Where to go next
- Data Flow Walkthrough — the workflow version: who clicks what, in which order, to make these computations happen.
- CPI Concepts — the building blocks (basket, weights, base period) explained with the system's vocabulary.
- CPI Explained — the gentlest introduction, no maths, no code.
- Glossary — every term, defined once.