|
| 1 | +// anyplot.ai |
| 2 | +// pdp-basic: Partial Dependence Plot |
| 3 | +// Library: muix 7.29.1 | JavaScript 22.23.2 |
| 4 | +// Quality: 97/100 | Created: 2026-09-05 |
| 5 | +import { LineChart } from "@mui/x-charts/LineChart"; |
| 6 | +import { ChartsReferenceLine } from "@mui/x-charts/ChartsReferenceLine"; |
| 7 | +import Box from "@mui/material/Box"; |
| 8 | +import Typography from "@mui/material/Typography"; |
| 9 | + |
| 10 | +const t = window.ANYPLOT_TOKENS; |
| 11 | +const muted = window.ANYPLOT_THEME === "dark" ? "#A8A79F" : "#6B6A63"; |
| 12 | + |
| 13 | +// --- Data (in-memory, deterministic) --------------------------------------- |
| 14 | +// Tiny fixed-seed LCG — the browser has no seeded RNG. |
| 15 | +function makeLcg(seed: number) { |
| 16 | + let state = seed >>> 0; |
| 17 | + return function next() { |
| 18 | + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; |
| 19 | + return state / 4294967296; |
| 20 | + }; |
| 21 | +} |
| 22 | + |
| 23 | +const rng = makeLcg(20260905); |
| 24 | +const GRID_POINTS = 61; |
| 25 | +const SPEND_MIN = 5; |
| 26 | +const SPEND_MAX = 65; |
| 27 | + |
| 28 | +// Simulated PartialDependenceDisplay output for a GradientBoostingRegressor |
| 29 | +// predicting weekly units sold from weekly marketing spend, averaging over |
| 30 | +// every other feature in the model. |
| 31 | +const spend = Array.from( |
| 32 | + { length: GRID_POINTS }, |
| 33 | + (_, i) => SPEND_MIN + (i * (SPEND_MAX - SPEND_MIN)) / (GRID_POINTS - 1), |
| 34 | +); |
| 35 | + |
| 36 | +const rawPrediction = spend.map((x) => { |
| 37 | + const saturating = 620 / (1 + Math.exp(-(x - 32) / 7)); |
| 38 | + const modelWiggle = (rng() - 0.5) * 16; |
| 39 | + return saturating + modelWiggle; |
| 40 | +}); |
| 41 | + |
| 42 | +// Center at zero so the curve reads as "effect relative to the average |
| 43 | +// prediction" rather than an absolute (and arbitrary-looking) sales count. |
| 44 | +const meanPrediction = |
| 45 | + rawPrediction.reduce((sum, v) => sum + v, 0) / rawPrediction.length; |
| 46 | +const partialDependence = rawPrediction.map((v) => v - meanPrediction); |
| 47 | + |
| 48 | +// Confidence band widens toward both ends of the spend range, where training |
| 49 | +// samples are sparser and the model's average prediction is less certain. |
| 50 | +const ciHalfWidth = spend.map((x) => 9 + 0.5 * Math.abs(x - 32)); |
| 51 | +const ciLowerBound = partialDependence.map((v, i) => v - ciHalfWidth[i]); |
| 52 | +const ciBandWidth = ciHalfWidth.map((halfWidth) => 2 * halfWidth); |
| 53 | + |
| 54 | +// A handful of individual conditional expectation (ICE) curves — the |
| 55 | +// per-instance predictions the PDP curve is the average of. Each sample |
| 56 | +// varies the saturation midpoint/amplitude and carries its own model noise, |
| 57 | +// then is shifted by the same meanPrediction constant as the PDP so it reads |
| 58 | +// in the same "effect relative to average" units. |
| 59 | +const ICE_SAMPLE_COUNT = 8; |
| 60 | +const iceCurves = Array.from({ length: ICE_SAMPLE_COUNT }, () => { |
| 61 | + const midpointShift = (rng() - 0.5) * 16; |
| 62 | + const amplitudeScale = 0.82 + rng() * 0.36; |
| 63 | + return spend.map((x) => { |
| 64 | + const saturating = |
| 65 | + (620 * amplitudeScale) / |
| 66 | + (1 + Math.exp(-(x - (32 + midpointShift)) / 7)); |
| 67 | + const modelWiggle = (rng() - 0.5) * 12; |
| 68 | + return saturating + modelWiggle - meanPrediction; |
| 69 | + }); |
| 70 | +}); |
| 71 | + |
| 72 | +// --- Chart (default-exported component — the harness mounts it) ------------- |
| 73 | +export default function Chart() { |
| 74 | + const W = window.ANYPLOT_SIZE.width; |
| 75 | + const H = window.ANYPLOT_SIZE.height; |
| 76 | + const CHART_TOP = 64; |
| 77 | + |
| 78 | + const title = "pdp-basic · javascript · muix · anyplot.ai"; |
| 79 | + const titleSize = |
| 80 | + title.length > 67 ? Math.round((22 * 67) / title.length) : 22; |
| 81 | + |
| 82 | + // MUI X's built-in y-axis title sits at a fixed, small offset from the |
| 83 | + // axis line — too small to clear wide 4-digit tick numbers, so it renders |
| 84 | + // the axis label as its own rotated element in a reserved strip instead. |
| 85 | + const Y_LABEL_W = 44; |
| 86 | + const yAxisLabel = "Partial dependence (Δ units sold/week)"; |
| 87 | + |
| 88 | + return ( |
| 89 | + <Box sx={{ position: "relative", width: W, height: H, bgcolor: t.pageBg }}> |
| 90 | + <Box sx={{ position: "absolute", top: 20, left: 56, right: 56 }}> |
| 91 | + <Typography sx={{ color: t.ink, fontSize: titleSize, fontWeight: 500 }}> |
| 92 | + {title} |
| 93 | + </Typography> |
| 94 | + </Box> |
| 95 | + <Box |
| 96 | + sx={{ |
| 97 | + position: "absolute", |
| 98 | + top: CHART_TOP, |
| 99 | + left: 0, |
| 100 | + width: Y_LABEL_W, |
| 101 | + bottom: 0, |
| 102 | + display: "flex", |
| 103 | + alignItems: "center", |
| 104 | + justifyContent: "center", |
| 105 | + }} |
| 106 | + > |
| 107 | + <Typography |
| 108 | + sx={{ |
| 109 | + color: t.ink, |
| 110 | + fontSize: 16, |
| 111 | + whiteSpace: "nowrap", |
| 112 | + transform: "rotate(-90deg)", |
| 113 | + }} |
| 114 | + > |
| 115 | + {yAxisLabel} |
| 116 | + </Typography> |
| 117 | + </Box> |
| 118 | + <Box |
| 119 | + sx={{ |
| 120 | + position: "absolute", |
| 121 | + top: CHART_TOP, |
| 122 | + left: Y_LABEL_W, |
| 123 | + right: 0, |
| 124 | + bottom: 0, |
| 125 | + }} |
| 126 | + > |
| 127 | + <LineChart |
| 128 | + width={W - Y_LABEL_W} |
| 129 | + height={H - CHART_TOP} |
| 130 | + skipAnimation |
| 131 | + series={[ |
| 132 | + ...iceCurves.map((curve, i) => ({ |
| 133 | + id: `ice-${i}`, |
| 134 | + data: curve, |
| 135 | + color: t.palette[0], |
| 136 | + curve: "monotoneX" as const, |
| 137 | + area: false, |
| 138 | + showMark: false, |
| 139 | + valueFormatter: () => null, |
| 140 | + })), |
| 141 | + { |
| 142 | + id: "pdp", |
| 143 | + data: partialDependence, |
| 144 | + label: "Partial dependence", |
| 145 | + color: t.palette[0], |
| 146 | + curve: "monotoneX", |
| 147 | + area: false, |
| 148 | + showMark: ({ index }: { index: number }) => index % 6 === 0, |
| 149 | + valueFormatter: (value: number | null) => |
| 150 | + value == null |
| 151 | + ? null |
| 152 | + : `${value >= 0 ? "+" : ""}${value.toFixed(0)} units/week`, |
| 153 | + }, |
| 154 | + { |
| 155 | + id: "ci-lower", |
| 156 | + data: ciLowerBound, |
| 157 | + color: muted, |
| 158 | + curve: "monotoneX", |
| 159 | + area: true, |
| 160 | + stack: "ci", |
| 161 | + showMark: false, |
| 162 | + valueFormatter: () => null, |
| 163 | + }, |
| 164 | + { |
| 165 | + id: "ci-band", |
| 166 | + data: ciBandWidth, |
| 167 | + label: "95% confidence interval", |
| 168 | + color: muted, |
| 169 | + curve: "monotoneX", |
| 170 | + area: true, |
| 171 | + stack: "ci", |
| 172 | + showMark: false, |
| 173 | + valueFormatter: (value: number | null) => |
| 174 | + value == null ? null : `±${(value / 2).toFixed(0)} units/week`, |
| 175 | + }, |
| 176 | + ]} |
| 177 | + xAxis={[ |
| 178 | + { |
| 179 | + data: spend, |
| 180 | + scaleType: "linear", |
| 181 | + label: "Weekly marketing spend ($1,000s)", |
| 182 | + labelStyle: { fontSize: 16 }, |
| 183 | + tickLabelStyle: { fontSize: 14 }, |
| 184 | + valueFormatter: (value: number) => `$${value.toFixed(0)}k`, |
| 185 | + }, |
| 186 | + ]} |
| 187 | + yAxis={[ |
| 188 | + { |
| 189 | + tickLabelStyle: { fontSize: 14 }, |
| 190 | + }, |
| 191 | + ]} |
| 192 | + grid={{ horizontal: true }} |
| 193 | + slotProps={{ legend: { labelStyle: { fontSize: 14 } } }} |
| 194 | + sx={{ |
| 195 | + "& .MuiLineElement-series-pdp": { strokeWidth: 3.5 }, |
| 196 | + "& .MuiLineElement-series-ci-band": { strokeWidth: 0 }, |
| 197 | + "& .MuiLineElement-series-ci-lower": { strokeWidth: 0 }, |
| 198 | + "& .MuiAreaElement-series-ci-lower": { fill: "none" }, |
| 199 | + "& .MuiAreaElement-series-ci-band": { fillOpacity: 0.22 }, |
| 200 | + ...Object.fromEntries( |
| 201 | + iceCurves.map((_, i) => [ |
| 202 | + `& .MuiLineElement-series-ice-${i}`, |
| 203 | + { strokeWidth: 1.1, strokeOpacity: 0.22 }, |
| 204 | + ]), |
| 205 | + ), |
| 206 | + }} |
| 207 | + > |
| 208 | + <ChartsReferenceLine |
| 209 | + y={0} |
| 210 | + label="average prediction" |
| 211 | + labelAlign="end" |
| 212 | + labelStyle={{ fontSize: 13, fill: muted }} |
| 213 | + lineStyle={{ stroke: muted, strokeDasharray: "6 4" }} |
| 214 | + /> |
| 215 | + </LineChart> |
| 216 | + </Box> |
| 217 | + </Box> |
| 218 | + ); |
| 219 | +} |
0 commit comments