| Time | Block |
|---|---|
| 0:00 – 0:40 | 75.1 What a counterfactual chart must show |
| 0:40 – 1:30 | 75.2 Reading app/charts.py |
| 1:30 – 1:45 | Break |
| 1:45 – 2:30 | 75.3 Charts outside the app |
| 2:30 – 3:10 | 75.4 Decomposing a change |
| 3:10 – 3:25 | Break |
| 3:25 – 4:40 | 75.5 Day 75 mini-project: the misleading chart |
| 4:40 – 5:00 | 75.6 Save your work with Git |
75.1 What a counterfactual chart must show (40 min)
A causal effect is a difference between something we will see and something we never will. Charts of effects fail in three predictable ways:
- The counterfactual is missing. "Profit next year: $15.8M" with nothing to compare it to.
- The uncertainty is missing. One bar, one number, and the reader assumes it's exact.
- The scale exaggerates. An axis that starts at $15M turns a 3% change into a tower.
So every chart in the app has the counterfactual in grey, the scenario in colour, a 90% range on the scenario, and a zero-based axis (or, for changes, a marked zero line).
75.2 Reading app/charts.py (50 min)
Reopen app/charts.py from Day 74 and go through the three functions:
| Function | Answers | Design choices |
|---|---|---|
projected_vs_counterfactual | how big is the change? | horizontal bars from zero; grey = no change, blue = scenario; a black rule spans the 5th–95th percentile; tooltips give exact values |
change_by_segment | where does it come from? | one bar per segment plus coupons; green gains, red losses; height grows with the number of rows so no label is hidden |
outcome_distribution | can it lose money? | histogram of the 500 returned draws (binned with numpy, drawn as rectangles from zero); a red line at zero |
Two bugs from building these, both worth knowing:
- Altair's automatic binning drew the histogram as dashes until each bar was given an explicit bottom (
y2=alt.datum(0)) and explicit left/right edges (x,x2fromnp.histogram). - Altair hides overlapping axis labels by default; with five rows it silently dropped two segment names.
labelOverlap=Falseplus a taller chart fixed it.
75.3 Charts outside the app (45 min)
Charts also go into memos and slide decks, where nobody will run Streamlit. Altair saves standalone HTML (interactive, tooltips work) without a server. Create day75/save_charts.py:
# day75/save_charts.py (python -m day75.save_charts)
"""
The app's three counterfactual charts, saved as standalone HTML pages for a scenario, so they can be
reviewed (or dropped into a memo) without running the app. Open the files in a browser.
"""
from app.charts import change_by_segment, outcome_distribution, projected_vs_counterfactual
from engine import load_parameters, simulate
from engine.presets import PRESETS
r = simulate(load_parameters(), PRESETS["'raise' cells + coupons"])
for name, chart in [("projected_vs_counterfactual", projected_vs_counterfactual(r)),
("change_by_segment", change_by_segment(r)),
("outcome_distribution", outcome_distribution(r))]:
chart.properties(width=520).save(f"figures/day75_{name}.html")
print(f"Saved figures/day75_{name}.html")
python -m day75.save_charts
Saved figures/day75_projected_vs_counterfactual.html
Saved figures/day75_change_by_segment.html
Saved figures/day75_outcome_distribution.html
Open them in the browser (right-click → Open with Live Server, or download from the file explorer). They're the same charts as the app, because they're the same functions.
75.4 Decomposing a change (40 min)
"Where does the $6.1M come from?" is the second question every executive asks. The segment chart answers it, and its sum equals the headline because the engine's profit change is a sum over cells plus the coupon programme. Decompositions are only honest when they add up. (A non-additive model would need Shapley-style attributions, with their own assumptions.)
Try it. Save the segment chart for "5% in the 'raise' cells" and count how many cells change price and how many of those gain profit. MODULE6_RAISE has 7 entries: why do more cells change? Create day75/try_segment_chart.py.
Solution
# day75/try_segment_chart.py (python -m day75.try_segment_chart)
from pathlib import Path
from app.charts import change_by_segment
from engine import load_parameters, simulate
from engine.presets import PRESETS
Path("figures").mkdir(exist_ok=True)
r = simulate(load_parameters(), PRESETS["5% in the 'raise' cells"])
change_by_segment(r).save("figures/day75_raise_cells.html")
changed = [c for c in r["cells"] if c["price_change"] != 0]
print(f"Saved figures/day75_raise_cells.html; {len(changed)} of {len(r['cells'])} cells change price, "
f"{sum(c['profit_change_median'] > 0 for c in changed)} of them gain; "
f"cells with 'profit up' counted on the outcome alone: {sum(c['profit_change_median'] > 0 for c in r['cells'])}")
Saved figures/day75_raise_cells.html; 11 of 20 cells change price, 11 of them gain; cells with 'profit up' counted on the outcome alone: 14
One of the 7 entries is a whole segment, "urban premium", which covers its 5 categories: 6 single cells + 5 urban cells = 11 price rises, and all 11 gain. Counting "profit up" on the outcome alone gives 14: three untouched cells have medians that are positive by a floating-point hair (around 1e-10). Two lessons: one scenario key can move many cells (check the app's cell table to see what a scenario actually changed), and count on the decision variable, not on a noisy outcome.
75.5 Day 75 mini-project: the misleading chart (75 min)
Make the same numbers into two charts and decide which belongs in front of a CFO. Create day75/misleading_chart.py:
# day75/misleading_chart.py (python -m day75.misleading_chart)
"""
Mini-project: the same numbers, two charts. A bar chart whose axis starts at $15M makes a 3% gain look
like a doubling; one that starts at zero shows it as it is. And a chart without the range hides that the
'gain' could be smaller. Make both and decide which belongs in front of a CFO.
"""
import altair as alt
import pandas as pd
from engine import load_parameters, simulate
from engine.presets import PRESETS
r = simulate(load_parameters(), PRESETS["5% in the 'raise' cells"])
df = pd.DataFrame({"case": ["No change", "Scenario"],
"profit": [r["counterfactual"]["gross_profit"] / 1e6, r["projected"]["gross_profit"]["median"] / 1e6]})
misleading = alt.Chart(df, title="Profit soars!").mark_bar().encode(
x="case:N", y=alt.Y("profit:Q", scale=alt.Scale(domain=[15.2, 15.9]), title="$M"))
honest = alt.Chart(df, title="Gross profit, $M a year: +3%").mark_bar().encode(
x="case:N", y=alt.Y("profit:Q", scale=alt.Scale(zero=True), title="$M"))
(misleading | honest).save("figures/day75_misleading.html")
print(f"No change ${df.profit[0]:.2f}M, scenario ${df.profit[1]:.2f}M "
f"(+{df.profit[1] / df.profit[0] - 1:.1%}). Saved figures/day75_misleading.html")
python -m day75.misleading_chart
No change $15.33M, scenario $15.85M (+3.4%). Saved figures/day75_misleading.html
On the left, the "Profit soars!" chart's axis runs from $15.2M to $15.9M: the scenario bar is five times the height of the no-change bar. On the right, from zero: two nearly equal bars, titled with the actual change. Neither chart has the range, so both overstate certainty; the app's version has it.
Write three sentences in day75/NOTES.md: which chart you'd show, what's still missing, and how you'd show a 3% change so it doesn't look like nothing (answer: chart the change with its range and a zero line, as outcome_distribution does, rather than the levels).
75.6 Save your work with Git (20 min)
git add day75
git commit -m "Day 75: counterfactual charts, exports, misleading-chart comparison"
git push
Day 75 checklist
- Every chart shows the counterfactual, the scenario and its range
- Axes of levels start at zero; charts of changes mark zero
- My decomposition adds up to the headline number
- I can export the charts for a memo without running the app