{"spec_id":"logistic-regression","library":"muix","language":"javascript","code":"// anyplot.ai\n// logistic-regression: Logistic Regression Curve Plot\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-09-02\n\nimport { ChartContainer } from \"@mui/x-charts/ChartContainer\";\nimport { LinePlot } from \"@mui/x-charts/LineChart\";\nimport { ScatterPlot } from \"@mui/x-charts/ScatterChart\";\nimport { ChartsXAxis } from \"@mui/x-charts/ChartsXAxis\";\nimport { ChartsYAxis } from \"@mui/x-charts/ChartsYAxis\";\nimport { ChartsGrid } from \"@mui/x-charts/ChartsGrid\";\nimport { ChartsReferenceLine } from \"@mui/x-charts/ChartsReferenceLine\";\nimport { useXScale, useYScale } from \"@mui/x-charts/hooks\";\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// Reproducible LCG (seed 42) — no Math.random() in the browser harness context\nlet seed = 42;\nfunction rng() {\n  seed = (1664525 * seed + 1013904223) >>> 0;\n  return seed / 4294967296;\n}\n\nfunction hexToRgba(hex, alpha) {\n  const r = parseInt(hex.slice(1, 3), 16);\n  const g = parseInt(hex.slice(3, 5), 16);\n  const b = parseInt(hex.slice(5, 7), 16);\n  return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n}\n\n// --- Data: marketing conversion vs. customer engagement score --------------\n// True generating relationship — a customer with a higher engagement score\n// (0-100) is more likely to convert, with a decision midpoint around 55.\nfunction trueProbability(score) {\n  return 1 / (1 + Math.exp(-(0.09 * (score - 55))));\n}\n\nconst N_POINTS = 180;\nconst engagementScores = Array.from(\n  { length: N_POINTS },\n  () => Math.round(rng() * 1000) / 10,\n);\nconst converted = engagementScores.map((score) => (rng() < trueProbability(score) ? 1 : 0));\n\n// Jitter around 0 / 1 so overlapping points stay legible — wide enough to\n// ease the dense x=40-60 overlap band without touching the -0.08/1.08 axis padding.\nconst scatterClass0 = [];\nconst scatterClass1 = [];\nengagementScores.forEach((score, i) => {\n  const jitter = (rng() - 0.5) * 0.14;\n  if (converted[i] === 1) {\n    scatterClass1.push({ x: score, y: 1 + jitter, id: `converted-${i}` });\n  } else {\n    scatterClass0.push({ x: score, y: 0 + jitter, id: `not-converted-${i}` });\n  }\n});\n\n// --- Fit a logistic regression by batch gradient descent (standardized x) --\nfunction fitLogisticRegression(xs, ys, iterations, learningRate) {\n  const n = xs.length;\n  const mean = xs.reduce((a, b) => a + b, 0) / n;\n  const std = Math.sqrt(xs.reduce((a, b) => a + (b - mean) ** 2, 0) / n);\n  const xn = xs.map((v) => (v - mean) / std);\n\n  let b0 = 0;\n  let b1 = 0;\n  for (let iter = 0; iter < iterations; iter++) {\n    let grad0 = 0;\n    let grad1 = 0;\n    for (let i = 0; i < n; i++) {\n      const p = 1 / (1 + Math.exp(-(b0 + b1 * xn[i])));\n      const error = p - ys[i];\n      grad0 += error;\n      grad1 += error * xn[i];\n    }\n    b0 -= (learningRate * grad0) / n;\n    b1 -= (learningRate * grad1) / n;\n  }\n\n  // Wald standard errors from the observed Fisher information (X'WX)^-1\n  let info00 = 0;\n  let info01 = 0;\n  let info11 = 0;\n  for (let i = 0; i < n; i++) {\n    const p = 1 / (1 + Math.exp(-(b0 + b1 * xn[i])));\n    const w = p * (1 - p);\n    info00 += w;\n    info01 += w * xn[i];\n    info11 += w * xn[i] * xn[i];\n  }\n  const det = info00 * info11 - info01 * info01;\n  return {\n    b0,\n    b1,\n    mean,\n    std,\n    varB0: info11 / det,\n    varB1: info00 / det,\n    covB01: -info01 / det,\n  };\n}\n\nconst model = fitLogisticRegression(engagementScores, converted, 600, 0.5);\n\n// Inflection point (p = 0.5) and in-sample accuracy at that threshold, for\n// the model-summary annotation drawn near the curve's midpoint.\nconst midpointX = model.mean + (-model.b0 / model.b1) * model.std;\nconst accuracy =\n  engagementScores.reduce((correct, score, i) => {\n    const xn = (score - model.mean) / model.std;\n    const p = 1 / (1 + Math.exp(-(model.b0 + model.b1 * xn)));\n    const predictedClass = p >= 0.5 ? 1 : 0;\n    return correct + (predictedClass === converted[i] ? 1 : 0);\n  }, 0) / N_POINTS;\n\n// 95% Wald confidence interval on the predicted probability, via the delta\n// method on the linear predictor (standard logistic-regression CI approach).\nconst Z95 = 1.96;\nfunction predictWithCI(x) {\n  const xn = (x - model.mean) / model.std;\n  const eta = model.b0 + model.b1 * xn;\n  const se = Math.sqrt(model.varB0 + 2 * xn * model.covB01 + xn * xn * model.varB1);\n  const sigmoid = (v) => 1 / (1 + Math.exp(-v));\n  return {\n    probability: sigmoid(eta),\n    lower: sigmoid(eta - Z95 * se),\n    upper: sigmoid(eta + Z95 * se),\n  };\n}\n\nconst CURVE_POINTS = 200;\nconst curveXs = Array.from({ length: CURVE_POINTS }, (_, i) => (i / (CURVE_POINTS - 1)) * 100);\nconst curvePredictions = curveXs.map(predictWithCI);\nconst curveY = curvePredictions.map((p) => p.probability);\nconst ciLower = curvePredictions.map((p) => p.lower);\nconst ciUpper = curvePredictions.map((p) => p.upper);\n\n// 95% confidence band, drawn as a filled path from the live axis scales —\n// the two Imprint stops the band would need don't apply here (this is an\n// uncertainty band around a single fit, not sequential/diverging data), so\n// it reuses the neutral ink token at low opacity, matching the fitted line.\nfunction ConfidenceBand() {\n  const xScale = useXScale();\n  const yScale = useYScale();\n  if (!xScale || !yScale) return null;\n\n  const upper = curveXs.map((x, i) => [xScale(x), yScale(ciUpper[i])]);\n  const lower = curveXs.map((x, i) => [xScale(x), yScale(ciLower[i])]);\n  const points = [...upper, ...lower.slice().reverse()];\n  const d =\n    points.map((p, i) => `${i === 0 ? \"M\" : \"L\"}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(\" \") +\n    \" Z\";\n\n  return <path d={d} fill={t.ink} fillOpacity={0.14} stroke=\"none\" />;\n}\n\n// Small model-summary callout anchored on the curve's inflection point\n// (p = 0.5). Drawn in the empty mid-band between the two jittered scatter\n// clusters, so it never collides with data points, the legend, or the\n// dashed threshold-line label (which sits at the axis' left edge).\nfunction InflectionAnnotation() {\n  const xScale = useXScale();\n  const yScale = useYScale();\n  if (!xScale || !yScale) return null;\n\n  const markerX = xScale(midpointX);\n  const markerY = yScale(0.5);\n  const labelY = yScale(0.8);\n\n  return (\n    <g>\n      <circle cx={markerX} cy={markerY} r={5} fill={t.pageBg} stroke={t.ink} strokeWidth={2} />\n      <text x={markerX} y={labelY} fontSize={13} fill={t.inkSoft} textAnchor=\"middle\">\n        <tspan x={markerX} dy={0}>{`Midpoint ≈ ${midpointX.toFixed(1)}`}</tspan>\n        <tspan x={markerX} dy={16}>{`Accuracy at p=0.5: ${(accuracy * 100).toFixed(0)}%`}</tspan>\n      </text>\n    </g>\n  );\n}\n\nconst TITLE = \"Customer Conversion · logistic-regression · javascript · muix · anyplot.ai\";\nconst TITLE_FONT_DEFAULT = 22;\nconst titleFontSize =\n  TITLE.length > 67 ? Math.round(TITLE_FONT_DEFAULT * (67 / TITLE.length)) : TITLE_FONT_DEFAULT;\nconst TITLE_HEIGHT = 60;\n\n// Legend built by hand so each swatch matches its series' real mark shape —\n// circular dots for the two scatter series, a short stroke for the fitted\n// line — instead of ChartsLegend's uniform bar swatches.\nconst LEGEND_ITEMS = [\n  { type: \"circle\", color: t.palette[4], label: \"Not converted (y = 0)\" },\n  { type: \"circle\", color: t.palette[0], label: \"Converted (y = 1)\" },\n  { type: \"line\", color: t.ink, label: \"Fitted probability\" },\n];\n\nexport default function Chart() {\n  return (\n    <div style={{ width, height, backgroundColor: t.pageBg }}>\n      <div\n        style={{\n          height: TITLE_HEIGHT,\n          position: \"relative\",\n          display: \"flex\",\n          alignItems: \"center\",\n          justifyContent: \"center\",\n          fontSize: titleFontSize,\n          fontWeight: 600,\n          color: t.ink,\n        }}\n      >\n        {TITLE}\n        <div\n          style={{\n            position: \"absolute\",\n            top: \"50%\",\n            right: 70,\n            transform: \"translateY(-50%)\",\n            display: \"flex\",\n            flexDirection: \"column\",\n            gap: 5,\n            alignItems: \"flex-start\",\n          }}\n        >\n          {LEGEND_ITEMS.map((item) => (\n            <div key={item.label} style={{ display: \"flex\", alignItems: \"center\", gap: 8 }}>\n              {item.type === \"circle\" ? (\n                <span\n                  style={{\n                    width: 9,\n                    height: 9,\n                    borderRadius: \"50%\",\n                    backgroundColor: item.color,\n                    flexShrink: 0,\n                  }}\n                />\n              ) : (\n                <span style={{ width: 18, height: 3, backgroundColor: item.color, flexShrink: 0 }} />\n              )}\n              <span style={{ fontSize: 13, color: t.ink }}>{item.label}</span>\n            </div>\n          ))}\n        </div>\n      </div>\n      <ChartContainer\n        width={width}\n        height={height - TITLE_HEIGHT}\n        margin={{ top: 24, right: 64, bottom: 84, left: 92 }}\n        sx={{ \"& .MuiLineElement-series-fitted-curve\": { strokeWidth: 3 } }}\n        series={[\n          {\n            type: \"line\",\n            id: \"fitted-curve\",\n            data: curveY,\n            label: \"Fitted probability\",\n            color: t.ink,\n            showMark: false,\n            curve: \"monotoneX\",\n            xAxisId: \"engagement\",\n          },\n          {\n            type: \"scatter\",\n            id: \"class-0\",\n            data: scatterClass0,\n            label: \"Not converted (y = 0)\",\n            color: hexToRgba(t.palette[4], 0.6),\n            markerSize: 8,\n            xAxisId: \"engagement\",\n          },\n          {\n            type: \"scatter\",\n            id: \"class-1\",\n            data: scatterClass1,\n            label: \"Converted (y = 1)\",\n            color: hexToRgba(t.palette[0], 0.6),\n            markerSize: 8,\n            xAxisId: \"engagement\",\n          },\n        ]}\n        xAxis={[\n          {\n            id: \"engagement\",\n            scaleType: \"linear\",\n            data: curveXs,\n            min: 0,\n            max: 100,\n            label: \"Customer Engagement Score\",\n            tickInterval: [0, 20, 40, 60, 80, 100],\n            valueFormatter: (v) => `${v}`,\n            tickLabelStyle: { fontSize: 14, fill: t.inkSoft },\n            labelStyle: { fontSize: 16, fill: t.ink },\n          },\n        ]}\n        yAxis={[\n          {\n            id: \"probability\",\n            min: -0.08,\n            max: 1.08,\n            label: \"Probability\",\n            tickInterval: [0, 0.2, 0.4, 0.6, 0.8, 1],\n            valueFormatter: (v) => v.toFixed(1),\n            tickLabelStyle: { fontSize: 14, fill: t.inkSoft },\n            labelStyle: { fontSize: 16, fill: t.ink },\n          },\n        ]}\n      >\n        <ChartsGrid horizontal />\n        <ConfidenceBand />\n        <ScatterPlot skipAnimation />\n        <LinePlot skipAnimation />\n        <InflectionAnnotation />\n        <ChartsXAxis\n          axisId=\"engagement\"\n          tickLabelStyle={{ fontSize: 14, fill: t.inkSoft }}\n          labelStyle={{ fontSize: 16, fill: t.ink }}\n        />\n        <ChartsYAxis\n          axisId=\"probability\"\n          tickLabelStyle={{ fontSize: 14, fill: t.inkSoft }}\n          labelStyle={{ fontSize: 16, fill: t.ink }}\n        />\n        <ChartsReferenceLine\n          y={0.5}\n          axisId=\"probability\"\n          label=\"Decision threshold (p = 0.5)\"\n          labelAlign=\"start\"\n          labelStyle={{ fill: t.inkSoft, fontSize: 13 }}\n          lineStyle={{ stroke: t.inkSoft, strokeDasharray: \"8 5\", strokeWidth: 1.5 }}\n        />\n      </ChartContainer>\n    </div>\n  );\n}\n"}