Top of mind

It doesn’t auger well

We can never know about the days to come

But we think about them anyway

— Carly Simon, Anticipation

I run across posts now and again that find portents of where the market is heading. I don’t know. I’m not a quant, not a head of global economic research for a big wire house, and if I’m so smart how come I’m not rich? But honestly? I have trouble sometimes telling the difference between what these two characters are doing. Portrait photograph But, what the hay. Maybe there’s a market for this. Portrait photograph

Part of the mystique is flooding the zone with noise. Grab ‘em, with the headline: Ice Cream Says Home Prices are About to Melt Up Show two time series that appear to move in sympathy, call one the leading indicator and draw a fat arrow pointing up to show how the other will follow. Then begin the incantations.

The Street doesn’t want you to see this chart. One overlooked indicator has called every turn in home prices since 2008. What the insiders know: a single proven pattern that tells you where home prices are headed, 21 months before everyone else. Just how good is this gem of a leading indicator? Over a 10-year period there was a 92% correlation. When households cut back on discretionary treats, they’re quietly building down-payment savings. Two years later, that pent-up demand hits the housing market. Expect year-over-year home price growth to climb from about 2% now to about 5% through 2028.

— Iridium Copulae, LLC

Before getting carried away, although the data is real, and the calculations are numerically correct, and the fine print accurate, the analysis is completely bogus.

What the claim amounts to is simply this: Factories made less ice cream in 2024, so home price growth will more than double. Really?

  1. Which series?. The chart presents ice cream production as if it had been the hypothesis from the start. It was actually the best of four candidates, and the four were themselves chosen from the Federal Reserve Bank of St. Louis FRED’s catalog of thousands of economic time series. The reader never learns that eggs, potatoes and confectionery were tried and lost. Nothing on the chart reveals how many candidates were examined, and that number matters more than any other.

  2. Transformation. Each candidate was tried both as a level and as a year-over-year change. Either can be defended: levels show the trend, changes show momentum. Having both available doubles the chances of finding a match, and the reader sees only whichever version won.

  3. Smoothing. The winner uses a 12-month average. The stated reason would be removing seasonality and noise. The real effect is that smoothing turns any series into slow, rolling waves, and two sets of slow waves can almost always be lined up somewhere. Put the jumpiness back by using 12-month changes and the correlation falls from 0.79 to 0.50.

  4. Lead. The 21-month advance is presented as a transmission lag, the time a signal takes to work through the economy. It was actually the lag that scored best on the full sample. Every lead from 12 to 24 months scores between 0.70 and 0.79, a flat plateau rather than a peak. A real mechanism would point to one specific lag; here almost any lag in the range lines up. A forecaster in 2018, without the later data, would have picked 15 months.

  5. Start date. “Since 2008” sounds like a natural boundary: post-crisis, a new regime. It is the start year that scored best. Starting in 2006 drops the same specification to about 0.69, and earlier starts do worse. The chart says nothing about the years left out, so the reader cannot tell that they were left out because they fit badly.

  6. Sign. Inverting the axis is the cleverest trick, because it is disclosed in the legend and so looks honest. But it doubles the search: any strong negative correlation becomes a strong positive one by flipping the axis. That is why the histogram of 3,024 specifications is symmetric around zero. The search was free to take whichever sign scored better, and then present it as a “leading” relationship.

  7. Axis scaling. This parameter wasn’t part of the search but was tuned afterward. The two y-axes were scaled by regression so the lines lie on top of each other. With two independent axes, any two series that move together at all can be made to look identical. The overlap the eye takes as evidence was put there by the choice of axis ranges.

The combined effect. Multiplying the options gives 4 series × 2 transformations × 3 smoothings × 9 leads × 7 start years × 2 signs, or 3,024 specifications. Under the null hypothesis, where ice cream has nothing to do with home prices, those correlations spread from about −0.8 to +0.8. The published 0.79 is the top of that spread: the largest of 3,024 draws. Reporting it as if it were a single test is what statisticians call the garden of forking paths. Each choice is a fork, and the published route is whichever one ends in the best-looking view.

Disclosure doesn’t help. The footnote style these kinds of charts use, “shown inverted and advanced 21 months”, sounds like full transparency. It discloses the parameters but not how they were chosen. The honest disclosure is the number of specifications searched, and that is the one number a published chart never contains.

Every lead in that 12- to 24-month range fits before 2019 at 0.88 or better, yet their results afterward run from 0.57 to −0.28, and the 2018 forecaster’s pick fell from 0.92 to 0.36. When a relationship is real, the parameters are pinned down by the mechanism and the fit holds on data nobody looked at. When the parameters are pinned down by the search, the fit lasts only as long as the data the search was run on.

The rhetorical trap that this kind of chart exploits is availability bias or what you see is all there is in the words of Daniel Kahneman, the only behavioral economist to win the Nobel Prize in Economics. Detecting what is missing from an argument is among the hardest of the Defenses Against the Dark Arts in the Hogwort’s curriculum.

#torture.jl
# Dual-axis torture demo: grid-search tangential FRED series for the best-looking
# "leading indicator" of Case-Shiller home price YoY%, then plot it the dishonest way.
# Packages: CSV DataFrames Dates Statistics CairoMakie  (reads local FRED CSVs only)
using CSV, DataFrames, Dates, Statistics, Printf, CairoMakie

const DATADIR = expanduser("~/Downloads")    # where the FRED CSVs live

fred(id) = begin
    path = joinpath(DATADIR, "$id.csv")
    isfile(path) || error("Missing $path. Download it from https://fred.stlouisfed.org/series/$id")
    df = CSV.read(path, DataFrame; missingstring = ["", "."])
    rename!(df, [:date, :v]); dropmissing!(df)
    df.date = firstdayofmonth.(df.date)
    combine(groupby(df, :date), :v => mean => :v)            # monthly mean
end

asdict(df) = Dict(zip(df.date, df.v))
yoy(d) = Dict(k => 100 * (v / d[k - Month(12)] - 1) for (k, v) in d if haskey(d, k - Month(12)))
roll(d, n) = n == 1 ? d : Dict(k => mean(d[k - Month(i)] for i in 0:n-1) for k in keys(d)
                                if all(haskey(d, k - Month(i)) for i in 0:n-1))
shift(d, m) = Dict(k + Month(m) => v for (k, v) in d)

function paired(x, y, start, stop = Date(9999))      # stop is exclusive
    ks = sort([k for k in keys(x) if haskey(y, k) && Date(start) <= k < stop])
    [x[k] for k in ks], [y[k] for k in ks], ks
end

y = yoy(asdict(fred("CSUSHPINSA")))
cands = ["APU0000708111" => "eggs \$/doz", "APU0000712112" => "potatoes \$/lb",
         "IPG3113S" => "IP sugar & confectionery", "IPN31152N" => "IP ice cream & frozen dessert"]
filter!(c -> isfile(joinpath(DATADIR, "$(first(c)).csv")), cands)   # only series you've downloaded
isempty(cands) && error("No candidate CSVs found in $DATADIR")
for id in ["CSUSHPINSA"; first.(cands)]
    d = fred(id); println(rpad(id, 16), first(d.date), " – ", last(d.date), "  (", nrow(d), " months)")
end
println("Searching over: ", join(last.(cands), ", "))

buildx(id, tr, sm, lead) = shift(roll(tr == :yoy ? yoy(asdict(fred(id))) : asdict(fred(id)), sm), lead)

function search(starts; stop = Date(9999), minn = 120)
    rows = NamedTuple[]
    for (id, name) in cands
        raw = asdict(fred(id))
        for tr in (:yoy, :level), sm in (1, 6, 12), lead in 0:3:24, start in starts, inv in (1, -1)
            x = shift(roll(tr == :yoy ? yoy(raw) : raw, sm), lead)
            xs, ys, _ = paired(x, y, start, stop)
            length(xs) < minn && continue
            push!(rows, (; r = inv * cor(xs, ys), id, name, tr, sm, lead, start, inv, n = length(xs)))
        end
    end
    sort(DataFrame(rows), :r, rev = true)
end

# The tortured search: all data, best-looking wins.
grid = search(2000:2:2012)
println(nrow(grid), " specifications tried"); show(first(grid, 10), allcols = true); println()

# The honest test: search only on data before 2019, freeze the winner, score it on 2019 onward.
train = search(2000:2:2008; stop = Date(2019), minn = 96)
hb = first(train)
xt, yt, _ = paired(buildx(hb.id, hb.tr, hb.sm, hb.lead), y, 2019)
r_test = hb.inv * cor(xt, yt)
println("Honest pick ($(hb.start)–2018): ", hb.name, ", lead ", hb.lead, "m, r = ", round(hb.r; digits = 2),
        "; same spec 2019 onward: r = ", round(r_test; digits = 2))

# ---- plot the winner (expected: ice cream, level, 12m avg, lead 21, from 2008, inverted)
yf(d) = year(d) + (month(d) - 1) / 12      # decimal-year x axis
best = first(grid)
x = shift(roll(best.tr == :yoy ? yoy(asdict(fred(best.id))) : asdict(fred(best.id)), best.sm), best.lead)
xs, ys, ks = paired(x, y, best.start)
b1, b0 = [xs ones(length(xs))] \ ys                      # pick axis ranges so lines overlap
lo, hi = -16.0, 24.0

# ---- the honest numbers for the footnote
base = roll(best.tr == :yoy ? yoy(asdict(fred(best.id))) : asdict(fred(best.id)), best.sm)
xr, yr, _ = paired(base, y, 1900)
r_raw = cor(xr, yr)                                        # same month, full sample, no inversion
d12(v) = v[13:end] .- v[1:end-12]
r_dd = best.inv * cor(d12(xs), d12(ys))                    # 12-month changes
fmt(r) = @sprintf("%.2f", r)
println("raw r = ", fmt(r_raw), "; tortured r = ", fmt(best.r), "; 12m-diff r = ", fmt(r_dd))

fig = Figure(size = (1400, 750))
hdr = fig[0, 1:2] = GridLayout()
Label(hdr[1, 1], "Ice Cream Says Home Prices Are About to Melt Up";
      fontsize = 26, font = :bold, halign = :left, tellwidth = false)
Label(hdr[2, 1], "Frozen dessert output has led US home prices since $(best.start) (r = $(fmt(best.r)) inverted), and it signals a broad rebound through 2028";
      fontsize = 15, color = :gray40, halign = :left, tellwidth = false)
Label(hdr[3, 1], "The little-known signal only a handful of insiders track. Now you can forecast home prices months in advance with proven patterns.";
      fontsize = 15, font = :italic, color = "#9b1c2e", halign = :left, tellwidth = false)
rowgap!(hdr, 4)
Label(fig[2, 1:2],
      "Source: FRED ($(best.id), CSUSHPINSA). Parody. Axis ranges, inversion, lead, smoothing and start date all chosen after looking.\n" *
      "No tricks, same month since 1987: r = $(fmt(r_raw)). In 12-month changes: r = $(fmt(r_dd)). " *
      "Spec chosen on $(hb.start)–2018 data only: r = $(fmt(hb.r)) then; same spec 2019–26: r = $(fmt(r_test)).";
      fontsize = 14, justification = :left, color = :gray40, halign = :left, tellwidth = false)
ax  = Axis(fig[1, 1]; ylabel = best.name * " (inverted, advanced $(best.lead)m)")
ax2 = Axis(fig[1, 1]; yaxisposition = :right, ylabel = "Case-Shiller YoY %")
hidespines!(ax2); hidexdecorations!(ax2)
xk = sort(collect(keys(x))); xk = filter(>=(Date(best.start)), xk)
pl_red  = lines!(ax, yf.(xk), [x[k] for k in xk]; color = "#9b1c2e", linewidth = 2.5)
pl_blue = lines!(ax2, yf.(ks), ys; color = "#2846e0", linewidth = 2.5)
ylims!(ax2, lo, hi)
# Unsorted on purpose: with a negative slope, high > low and ylims! reverses the axis itself.
toleft(v) = (v - b0) / b1
ylims!(ax, toleft(lo), toleft(hi))
linkxaxes!(ax, ax2)
axislegend(ax2, [pl_blue, pl_red],
           ["Case-Shiller US home prices, YoY % (right)",
            best.name * ", 12m avg (left, inverted, advanced $(best.lead)m)"];
           position = :lt, framevisible = false)
# the editorial arrow: starts near the latest actual reading, ends far above where the indicator goes (~5%)
# (curve + triangle head, so it works on any Makie version)
t0 = yf(maximum(xk)) - 3.0
θ = range(0, π / 2, length = 40)
ax_x = t0 .+ 3.4 .* sin.(θ); ax_y = 3.0 .+ 13.0 .* (1 .- cos.(θ))
lines!(ax2, ax_x, ax_y; color = :black, linewidth = 2)
scatter!(ax2, [last(ax_x)], [last(ax_y)]; marker = :utriangle, markersize = 14, color = :black)

axh = Axis(fig[1, 2]; title = "How it was made: $(nrow(grid)) specifications", xlabel = "r")
hist!(axh, grid.r; bins = 40, color = :gray70)
vlines!(axh, [best.r]; color = "#9b1c2e", linewidth = 2, label = "published spec (r = $(fmt(best.r)))")
vlines!(axh, [r_raw]; color = "#2846e0", linewidth = 2, linestyle = :dash,
        label = "same month, 1987–, no tricks (r = $(fmt(r_raw)))")
ylims!(axh, 0, nothing); axh.yautolimitmargin = (0.0, 0.35)   # headroom for the legend
axislegend(axh; position = :lt, framevisible = false, labelsize = 11)
colsize!(fig.layout, 2, Relative(0.32))
save("ice_cream_torture_jl.png", fig)

Catalog of ships

SitePurposeStatus
Diary of a Mad Datascientist™Videos for doing data science in JuliaAwaiting time to enrich
ai-lawtech.comLegal engineeringIn planning
brief-insight.aiAI-assisted briefsIn planning
WmPlayfair.comPublishing armIn planning