cassionData Analysis

Lesson 5 of 8

Unit · The market is an outcome too

The calendar is bigger than the programme

Median maize is 75 gourdes at the December harvest and 130 at the July peak. Compare two survey rounds taken at different points in that cycle and you have measured the calendar — and one district's panel changed composition exactly when it mattered.

PythonR135 minIntegrated Food Security Phase Classification (IPC)Sphere Standards

An independent line of evidence

Everything in the first two units is what households said about themselves. A price series is not: nobody is recalling anything, and a market that has doubled its maize price has done so whether or not a survey was in the field.

import pandas as pd

prices = pd.read_csv("market-prices-2024.v1.csv")
print(f"{len(prices):,} observations")
print(f"{prices['market_id'].nunique()} markets, "
      f"{prices['commodity'].nunique()} series, "
      f"{prices['period'].nunique()} months")
library(dplyr)

prices |> summarise(n = n(), markets = n_distinct(market_id),
                    series = n_distinct(commodity), months = n_distinct(period))

2,273 observations, twelve markets, eight series, twenty-four months. Two years is the minimum useful length, because a single year cannot distinguish a seasonal peak from a deterioration.

Clean three things before plotting anything

prices["price_htg"] = pd.to_numeric(prices["price_htg"])

dollars = prices["price_htg"] < 10
marmite = (prices["commodity"] == "maize") & (prices["market_id"] == "MK005")
thin = prices["trader_quotes"] == 1

print(f"dollar entries: {dollars.sum()}")
print(f"marmite market maize rows: {marmite.sum()}")
print(f"single-quote observations: {thin.sum()}")
prices |> summarise(
  dollars = sum(price_htg < 10),
  thin = sum(trader_quotes == 1)
)

Eight prices are in dollars with the currency column still saying HTG. They read as values under 10 and an outlier filter would delete them, when multiplying by about 132 recovers them.

One market records maize by the marmite, a volume measure of roughly 2.7 kg, and files it in a column labelled per kilogram. Its median maize price is 263 gourdes against about 100 everywhere else.

by_market = prices[prices["commodity"] == "maize"].groupby("market_id")["price_htg"].median()
print(by_market.round(1).sort_values())
prices |> filter(commodity == "maize") |>
  summarise(median = median(price_htg), .by = market_id) |> arrange(median)

The unit error is invisible in one market and obvious across twelve. That is the general rule for a unit slip: it never looks wrong on its own row, and it always looks wrong beside its peers.

Thirteen observations rest on a single trader quote. They are not errors. A median of one is a quote rather than a price, and the decision is whether to exclude them or weight them down — either is defensible, silence is not.

The seasonal cycle

clean = prices[~dollars & ~marmite]
maize = clean[clean["commodity"] == "maize"]
series = maize.groupby("period")["price_htg"].median()
print(series.round(0))
prices |>
  filter(commodity == "maize", price_htg >= 10, market_id != "MK005") |>
  summarise(median = median(price_htg), .by = period) |> arrange(period)
Month 2023 2024
January 66 70
April 97 110
July (lean peak) 130 159
October 92 104
December (harvest) 75 81

Maize costs 74% more at the July peak than at the December harvest in 2023, and 96% more in 2024. That is the size of the seasonal effect, and it is larger than almost any programme effect anyone will ask you to detect.

The consequence for survey design is direct. A baseline in December and an endline in July would show a catastrophic deterioration produced entirely by the calendar. Compare like with like: July against July.

peaks = series.loc[["2023-07", "2024-07"]]
print(f"lean peak 2023: {peaks['2023-07']:.1f}")
print(f"lean peak 2024: {peaks['2024-07']:.1f}")
print(f"year on year: {peaks['2024-07'] / peaks['2023-07'] - 1:+.1%}")
# Same point in the season, one year apart. Everything else is the calendar.

130.1 against 159.3 — a 22% rise at the same point in the season. That is the deterioration, and it is a quarter of the size of the seasonal swing that would have been mistaken for it.

The panel that changed composition

coverage = maize.pivot_table(index="period", columns="market_id",
                             values="price_htg", aggfunc="size")
print(coverage.isna().sum(axis=1)[lambda s: s > 0])
prices |> filter(commodity == "maize") |> count(period, market_id) |>
  count(period) |> filter(n < 11)

MK001 stops reporting from June to September 2024, when its road is cut. The rows are absent rather than zero, so nothing in the file announces it.

It is also the most expensive market in Nord-Ouest, and Nord-Ouest has only three markets — so losing one moves the district mean by a third of its deviation rather than a twelfth.

nord = maize[maize["district"] == "Nord-Ouest"]
reported = nord.groupby("period")["price_htg"].mean()

balanced = nord[nord["market_id"] != "MK001"].groupby("period")["price_htg"].mean()
print(pd.DataFrame({"reported": reported, "balanced": balanced}).round(1)
      .loc["2024-04":"2024-10"])
prices |>
  filter(commodity == "maize", district == "Nord-Ouest") |>
  summarise(reported = mean(price_htg), .by = period)
Month Whoever reported The two that reported every month
May 2024 147.0 123.2
June 2024 172.1 172.1
May to June +17% +40%

The reported series understates the lean-season rise by more than half, and every number in it is a correct mean of the markets that reported. The composition changed underneath the series and the series did not say so.

Use a balanced panel — only the markets present in every month you are comparing — or chain month-on-month changes computed within markets. Both are more work than a groupby and both are the difference between a 17% rise and a 40% one.

Report the series with its panel

Maize price, gourdes per kg, median across markets

  2023 harvest (Dec)        75      2024 harvest (Dec)       81
  2023 lean peak (Jul)     130      2024 lean peak (Jul)    159
  Seasonal swing          +74%      Seasonal swing         +96%
  Year on year at the lean peak: +22%

  Balanced panel of 10 markets throughout. MK001 (Nord-Ouest) did not
  report June to September 2024 and is excluded from all periods; MK005
  records maize by the marmite and is excluded from maize.
  8 dollar-denominated entries converted at 132 HTG; 13 single-quote
  observations retained and flagged.

The panel stated, the exclusions counted, and the year-on-year comparison made at the same point in the season. A price table without its panel is the same defect as a coverage figure without its reporting rate, and this course has now met it three times.

What comes next

A price says what food cost. It does not say whether anybody could afford it, and the next lesson is the ratio that does — where a goat whose price fell 18% lost 62% of its purchasing power.

Teach this lesson

The lesson as a slide deck, with the prose kept in the speaker notes rather than on the slide. Generated from this page, so it cannot fall out of step with it.

Start the slideshowRead the slides

The PDF needs no software and projects from any machine. The PowerPoint file is there to be edited — add your organisation's branding, cut a section for a shorter session, or merge two lessons into a workshop.