Lecture 10: Logistic Regression with Metric-Space Covariates

Binary classification when predictors live in a nonlinear metric space

1 Learning Goals

By the end of this lecture, learners should be able to:

  • Explain how the roles of predictor and response are reversed in metric-space logistic regression compared to Fréchet regression.
  • Derive the Alexandrov inner-product representation of the usual Euclidean logistic regression and explain why it suggests a metric-space generalization.
  • State the metric-space logistic model using the Fréchet mean anchor and the coefficient object.
  • Formulate the two-stage estimation procedure (anchor via Fréchet mean, coefficient via MLE).
  • Describe the generalized LIPO algorithm for global optimization in metric spaces and explain how the Hölder constant \(K\) controls the exploration–exploitation tradeoff.
  • Interpret the fitted geodesic as a discriminating direction and relate it to log-odds.
  • Visualize and interpret the decision boundary on \(\mathbb{S}^2\) as a great circle orthogonal to the discriminating direction.
  • Apply metric-space logistic regression to classify market regimes from portfolio covariance matrices (SPD-valued covariates).
  • Summarize the convergence rates and classification risk bounds.

2 Motivation: Reversing the Roles

So far in this course, the non-Euclidean structure has been placed on the response side — \(Y\) lives in a metric space, \(X\) is Euclidean. A complementary problem reverses the roles: the response is binary, while the covariate itself lives in a metric space. This setting is developed by Lin and Lin (2025), who propose a logistic-type regression model and classifier for metric-space-valued covariates.

TipWhy this matters

Many modern datasets produce covariates that are not vectors: brain connectivity matrices (SPD), phylogenetic trees (BHV space), or distribution-valued features (Wasserstein space). When the outcome is binary (disease/no disease, task A/task B), we need a classifier that respects the geometry of the covariate space.

3 From Euclidean to Metric-Space Logistic Regression

Recall the Euclidean model with a binary response \(Y \in \{0, 1\}\) and a covariate \(X \in \mathbb{R}^D\). Writing \(P_X = \mathbb{P}(Y = 1 \mid X)\) and suppressing the intercept, the usual logistic regression model is

\[ \log\frac{P_X}{1 - P_X} = X^\top \beta^*. \]

Geometrically, if one anchors the vectors at the origin, then

\[ X^\top \beta^* = \|X\|_2 \|\beta^*\|_2 \cos\angle_0(X, \beta^*). \]

This representation depends only on a distance from the anchor, a distance to the coefficient, and an angle between the corresponding directions. That observation suggests a route to generalization in a uniquely geodesic metric space.

4 The Metric-Space Logistic Model

Let \(X\) now take values in a uniquely geodesic metric space \((\mathcal{M}, d)\), and let \(Y \in \{0, 1\}\). Because a general metric space has no distinguished origin, one first chooses an anchor point. A natural and statistically meaningful choice is the Fréchet mean of the covariate distribution,

\[ \mu^* \in \arg\min_{\omega \in \mathcal{M}} \mathbb{E}\bigl[d^2(X, \omega)\bigr], \]

which is unique in Hadamard spaces under a finite second-moment condition.

For three points \(p, q, r\) in a metric space \((\mathcal{M}, d)\), form a Euclidean comparison triangle \(\triangle(\tilde{p}, \tilde{q}, \tilde{r})\) in \(\mathbb{R}^2\) whose side lengths match: \(\|\tilde{q} - \tilde{r}\| = d(q, r)\), \(\|\tilde{p} - \tilde{q}\| = d(p, q)\), and \(\|\tilde{p} - \tilde{r}\| = d(p, r)\). The Euclidean comparison angle \(\overline{\angle}_p(q, r)\) is the angle at \(\tilde{p}\) in this triangle. By the law of cosines in \(\mathbb{R}^2\),

\[ \overline{\angle}_p(q, r) = \arccos\!\left(\frac{d(p,q)^2 + d(p,r)^2 - d(q,r)^2}{2\,d(p,q)\,d(p,r)}\right). \]

The comparison angle is well defined whenever \(d(p,q) > 0\) and \(d(p,r) > 0\). It measures what the angle would be if the three points were embedded isometrically in a flat Euclidean plane.

Definition 1 Let \((\mathcal{M}, d)\) be a uniquely geodesic space. For \(p, q, r \in \mathcal{M}\), let \(\gamma_q, \gamma_r : [0, \infty) \to \mathcal{M}\) be the unit-speed geodesics from \(p\) to \(q\) and from \(p\) to \(r\), respectively. The Alexandrov angle at \(p\) between \(q\) and \(r\) is

\[ \angle_p(q, r) = \limsup_{s, t \downarrow 0}\; \overline{\angle}_p\bigl(\gamma_q(s), \gamma_r(t)\bigr) \]

where \(\overline{\angle}_p\) is the Euclidean comparison angle defined above. Intuitively, one follows the two geodesics a tiny distance \(s\) and \(t\) from \(p\), computes the comparison angle of the resulting three points (\(p\), \(\gamma_q(s)\), \(\gamma_r(t)\)), and takes the limiting value as \(s, t \to 0\). In a smooth Riemannian manifold, the Alexandrov angle coincides with the usual Riemannian angle between tangent vectors.

The Alexandrov inner product of \(q\) and \(r\) with anchor \(p\) is

\[ \langle q, r \rangle_p = d(p, q)\, d(p, r) \cos\angle_p(q, r) \]

In Euclidean space \((\mathbb{R}^D, \|\cdot\|_2)\) with \(p = 0\), this reduces to the ordinary inner product \(q^\top r\).

Code
html`
<div style="display: flex; gap: 40px; flex-wrap: wrap; justify-content: center; align-items: flex-start; max-width: 780px; font-family: system-ui, sans-serif;">

  <!-- Comparison Angle Panel -->
  <div style="text-align: center;">
    <svg width="280" height="260" style="border: 1px solid #dee2e6; border-radius: 6px; background: #fafafa;">
      ${(() => {
        // Euclidean comparison triangle
        var pts = { px: 70, py: 210, qx: 210, qy: 210, rx: 170, ry: 60 };
        var margin = 30;

        function line(x1,y1,x2,y2,c,w,dash) {
          return '<line x1="' + x1 + '" y1="' + y1 +
            '" x2="' + x2 + '" y2="' + y2 +
            '" stroke="' + c + '" stroke-width="' + (w||1.8) + '"' +
            (dash ? ' stroke-dasharray="' + dash + '"' : '') + '/>';
        }

        var parts = [];

        // Side labels
        var midPqX = (pts.px + pts.qx)/2;
        var midPqY = (pts.py + pts.qy)/2 + 18;
        parts.push('<text x="' + midPqX + '" y="' + midPqY + '" text-anchor="middle" font-size="11" fill="#495057">d(p,q)</text>');

        var midPrX = (pts.px + pts.rx)/2 - 28;
        var midPrY = (pts.py + pts.ry)/2;
        parts.push('<text x="' + midPrX + '" y="' + midPrY + '" text-anchor="middle" font-size="11" fill="#495057">d(p,r)</text>');

        var midQrX = (pts.qx + pts.rx)/2 + 20;
        var midQrY = (pts.qy + pts.ry)/2;
        parts.push('<text x="' + midQrX + '" y="' + midQrY + '" text-anchor="middle" font-size="11" fill="#495057">d(q,r)</text>');

        // Triangle edges
        parts.push(line(pts.px, pts.py, pts.qx, pts.qy, '#212529', 1.8));
        parts.push(line(pts.px, pts.py, pts.rx, pts.ry, '#212529', 1.8));
        parts.push(line(pts.qx, pts.qy, pts.rx, pts.ry, '#212529', 1.8));

        // Angle arc at p
        var arcR = 25;
        var vx1 = pts.qx - pts.px, vy1 = pts.qy - pts.py;
        var vx2 = pts.rx - pts.px, vy2 = pts.ry - pts.py;
        var a1 = Math.atan2(vy1, vx1);
        var a2 = Math.atan2(vy2, vx2);
        parts.push('<path d="M ' + (pts.px+arcR*Math.cos(a1)).toFixed(1) + ' ' + (pts.py+arcR*Math.sin(a1)).toFixed(1) +
          ' A ' + arcR + ' ' + arcR + ' 0 0 1 ' + (pts.px+arcR*Math.cos(a2)).toFixed(1) + ' ' + (pts.py+arcR*Math.sin(a2)).toFixed(1) +
          '" fill="none" stroke="#e03131" stroke-width="2.2"/>');

        var angleMid = (a1 + a2) / 2;
        var labelR = arcR + 18;
        parts.push('<text x="' + (pts.px + labelR*Math.cos(angleMid)).toFixed(0) +
          '" y="' + (pts.py + labelR*Math.sin(angleMid)).toFixed(0) +
          '" text-anchor="middle" font-size="13" dy="4" fill="#e03131" font-weight="bold">' +
          '<tspan text-decoration="overline">&#x2220;</tspan></text>');

        // Vertex labels
        parts.push('<circle cx="' + pts.px + '" cy="' + pts.py + '" r="4" fill="#1971c2"/>');
        parts.push('<text x="' + (pts.px-12) + '" y="' + (pts.py+18) + '" font-size="13" fill="#1971c2" font-weight="bold">p̃</text>');

        parts.push('<circle cx="' + pts.qx + '" cy="' + pts.qy + '" r="4" fill="#2b8a3e"/>');
        parts.push('<text x="' + (pts.qx+8) + '" y="' + (pts.qy+18) + '" font-size="13" fill="#2b8a3e" font-weight="bold">q̃</text>');

        parts.push('<circle cx="' + pts.rx + '" cy="' + pts.ry + '" r="4" fill="#f08c00"/>');
        parts.push('<text x="' + (pts.rx+8) + '" y="' + (pts.ry-8) + '" font-size="13" fill="#f08c00" font-weight="bold">r̃</text>');

        // Subtitle
        parts.push('<text x="140" y="20" text-anchor="middle" font-size="13" fill="#495057" font-weight="600">Euclidean Comparison Triangle</text>');
        parts.push('<text x="140" y="36" text-anchor="middle" font-size="11" fill="#868e96">' +
          '<tspan text-decoration="overline">&#x2220;</tspan>' +
          '<tspan baseline-shift="sub" font-size="8">p</tspan>' +
          '<tspan baseline-shift="baseline" font-size="11">(q,r) = arccos((d²+d²−d²)/(2·d·d))</tspan></text>');

        return parts.join('\n');
      })()}
    </svg>
  </div>

  <!-- Alexandrov Angle Panel -->
  <div style="text-align: center;">
    <svg width="280" height="260" style="border: 1px solid #dee2e6; border-radius: 6px; background: #fafafa;">
      ${(() => {
        var parts = [];
        var cx = 100, cy = 195, R = 160;

        // Curved background (metric space)
        parts.push('<ellipse cx="' + cx + '" cy="' + cy + '" rx="' + R + '" ry="' + (R*0.6) +
          '" fill="none" stroke="#dee2e6" stroke-width="1" opacity="0.7"/>');
        parts.push('<ellipse cx="' + cx + '" cy="' + cy + '" rx="' + (R*0.6) + '" ry="' + (R*0.35) +
          '" fill="none" stroke="#dee2e6" stroke-width="0.7" opacity="0.5"/>');

        // Point p (anchor)
        var px = 125, py = 190;
        // Geodesic to q — quadratic bezier curve
        var qx = 210, qy = 65;
        var ctrlQx = 200, ctrlQy = 125;
        parts.push('<path d="M ' + px + ' ' + py + ' Q ' + ctrlQx + ' ' + ctrlQy + ' ' + qx + ' ' + qy +
          '" fill="none" stroke="#2b8a3e" stroke-width="2.2"/>');

        // Geodesic to r — quadratic bezier curve
        var rx = 55, ry = 80;
        var ctrlRx = 80, ctrlRy = 135;
        parts.push('<path d="M ' + px + ' ' + py + ' Q ' + ctrlRx + ' ' + ctrlRy + ' ' + rx + ' ' + ry +
          '" fill="none" stroke="#f08c00" stroke-width="2.2"/>');

        // Angle arc at p
        var tanQx = ctrlQx - px, tanQy = ctrlQy - py;
        var tanRx = ctrlRx - px, tanRy = ctrlRy - py;
        var aQ = Math.atan2(tanQy, tanQx);
        var aR = Math.atan2(tanRy, tanRx);
        var arcR = 22;
        parts.push('<path d="M ' + (px+arcR*Math.cos(aQ)).toFixed(1) + ' ' + (py+arcR*Math.sin(aQ)).toFixed(1) +
          ' A ' + arcR + ' ' + arcR + ' 0 0 0 ' + (px+arcR*Math.cos(aR)).toFixed(1) + ' ' + (py+arcR*Math.sin(aR)).toFixed(1) +
          '" fill="none" stroke="#e03131" stroke-width="2.2"/>');

        var angleMid = (aQ + aR) / 2;
        var labelR = arcR + 18;
        parts.push('<text x="' + (px + labelR*Math.cos(angleMid)).toFixed(0) +
          '" y="' + (py + labelR*Math.sin(angleMid)).toFixed(0) +
          '" text-anchor="middle" font-size="14" dy="4" fill="#e03131" font-weight="bold">∠</text>');

        // Points
        parts.push('<circle cx="' + px + '" cy="' + py + '" r="4.5" fill="#1971c2" stroke="#fff" stroke-width="1.2"/>');
        parts.push('<text x="' + (px-16) + '" y="' + (py+20) + '" font-size="13" fill="#1971c2" font-weight="bold">p</text>');

        parts.push('<circle cx="' + qx + '" cy="' + qy + '" r="4" fill="#2b8a3e" stroke="#fff" stroke-width="1"/>');
        parts.push('<text x="' + (qx+8) + '" y="' + (qy-2) + '" font-size="13" fill="#2b8a3e" font-weight="bold">q</text>');

        parts.push('<circle cx="' + rx + '" cy="' + ry + '" r="4" fill="#f08c00" stroke="#fff" stroke-width="1"/>');
        parts.push('<text x="' + (rx-16) + '" y="' + (ry-4) + '" font-size="13" fill="#f08c00" font-weight="bold">r</text>');

        // Dashed tangent directions
        var sLen = 35;
        var qsx = px + sLen * Math.cos(aQ);
        var qsy = py + sLen * Math.sin(aQ);
        var rsx = px + sLen * Math.cos(aR);
        var rsy = py + sLen * Math.sin(aR);
        parts.push('<line x1="' + px + '" y1="' + py +
          '" x2="' + qsx.toFixed(0) + '" y2="' + qsy.toFixed(0) +
          '" stroke="#adb5bd" stroke-width="0.8" stroke-dasharray="4,3"/>');
        parts.push('<line x1="' + px + '" y1="' + py +
          '" x2="' + rsx.toFixed(0) + '" y2="' + rsy.toFixed(0) +
          '" stroke="#adb5bd" stroke-width="0.8" stroke-dasharray="4,3"/>');

        // Subtitle
        parts.push('<text x="140" y="20" text-anchor="middle" font-size="13" fill="#495057" font-weight="600">Alexandrov Angle in Metric Space</text>');
        parts.push('<text x="140" y="36" text-anchor="middle" font-size="11" fill="#868e96">' +
          '<tspan>&#x2220;</tspan><tspan baseline-shift="sub" font-size="8">p</tspan>' +
          '<tspan baseline-shift="baseline" font-size="11">(q,r) = limsup </tspan>' +
          '<tspan text-decoration="overline">&#x2220;</tspan>' +
          '<tspan baseline-shift="sub" font-size="8">p</tspan>' +
          '<tspan baseline-shift="baseline" font-size="11">(γ</tspan>' +
          '<tspan baseline-shift="sub" font-size="8">q</tspan>' +
          '<tspan baseline-shift="baseline" font-size="11">(s), γ</tspan>' +
          '<tspan baseline-shift="sub" font-size="8">r</tspan>' +
          '<tspan baseline-shift="baseline" font-size="11">(t))</tspan></text>');

        return parts.join('\n');
      })()}
    </svg>
  </div>

</div>
`
Figure 1: Comparison angle (left) and Alexandrov angle (right). Left: a Euclidean comparison triangle with the same three side lengths as the metric-space triple. Right: two geodesics emanating from p along directions toward q and r in a curved metric space.

With these definitions in hand, the metric-space logistic model is

\[ \log\frac{P_X}{1 - P_X} = h(\beta^*; X, \mu^*) = d(\mu^*, X)\, d(\mu^*, \beta^*) \cos\angle_{\mu^*}(X, \beta^*). \]

The right-hand side is the Alexandrov inner product \(\langle X, \beta^* \rangle_{\mu^*}\) defined above. In Euclidean space it reduces to

\[ h(\beta^*; X, \mu^*) = (X - \mu^*)^\top(\beta^* - \mu^*), \]

so the model is a genuine extension of logistic regression.

NoteGeometric interpretation

The geodesic from \(\mu^*\) to \(\beta^*\) plays the role of a discriminating direction: points \(X\) whose geodesic from \(\mu^*\) has a large positive projection onto this direction increase the log odds of \(Y = 1\), while points pointing in the opposite direction decrease it. The scalar \(d(\mu^*, \beta^*)\) controls the magnitude of this effect.

5 Estimation

Given i.i.d. observations \((X_1, Y_1), \ldots, (X_n, Y_n)\), estimation proceeds in two stages:

Stage 1 — Anchor estimation. Estimate the anchor by the sample Fréchet mean:

\[ \hat{\mu} \in \arg\min_{\omega \in \mathcal{M}} \frac{1}{n}\sum_{i=1}^n d^2(X_i, \omega). \]

Stage 2 — Coefficient estimation. Define the population log-likelihood

\[ L(\beta) = \mathbb{E}\Bigl[Y\, h(\beta; X, \mu^*) - \log\bigl(1 + e^{h(\beta; X, \mu^*)}\bigr)\Bigr] \]

and its empirical counterpart

\[ L_n(\beta) = \frac{1}{n}\sum_{i=1}^n \Bigl[Y_i\, h(\beta; X_i, \hat{\mu}) - \log\bigl(1 + e^{h(\beta; X_i, \hat{\mu})}\bigr)\Bigr]. \]

The estimator of the coefficient object is the metric-space maximum likelihood estimator:

\[ \hat{\beta} \in \arg\max_{\beta \in \mathcal{M}} L_n(\beta). \]

Under correct specification, the true parameter maximizes the population log-likelihood.

Proposition 1 If \((X, Y)\) follows the metric-space logistic model with coefficient \(\beta^*\) and anchor \(\mu^*\), then

\[ \beta^* \in \arg\max_{\beta \in \mathcal{M}} L(\beta). \]

If the maximizer of \(L\) is unique, then \(\beta^*\) is exactly that unique maximizer.

6 Computation: Generalized LIPO Algorithm

The new computational challenge is that \(L_n\) is usually neither convex nor differentiable in any linear sense on a general metric space. To address this, Lin and Lin (2025) extend the LIPO (Lipschitz optimization) idea to metric spaces.

Assume \(L_n\) satisfies a Hölder continuity bound:

\[ |L_n(\beta_1) - L_n(\beta_2)| \le K\, d(\beta_1, \beta_2)^{\alpha_U}, \qquad \alpha_U \in (0, 1], \]

for some known \(K > 0\).

Algorithm 1 Fix an iteration budget \(T \in \mathbb{N}\), a Hölder constant \(K > 0\), and an exponent \(\alpha_U \in (0, 1]\).

  1. Sample \(\beta_{(0)}\) uniformly from \(\mathcal{M}\).
  2. Set \(t \leftarrow 0\).
  3. While \(t < T\), repeat:
    • Sample a proposal \(\beta\) uniformly from \(\mathcal{M}\).
    • If \(\min_{j=0,\ldots,t}\bigl(L_n(\beta_{(j)}) + K\, d(\beta, \beta_{(j)})^{\alpha_U}\bigr) \ge \max_{j=0,\ldots,t} L_n(\beta_{(j)})\), then accept: \(\beta_{(t+1)} = \beta\), \(t \leftarrow t + 1\).
  4. Return \(\beta_{(\tau)}\), where \(\tau \in \arg\max_{0 \le t \le T} L_n(\beta_{(t)})\).

The acceptance rule keeps only those candidates whose optimistic Hölder upper envelope can still match or beat the best objective value seen so far. In Hadamard spaces one may take \(C_U = 5\) and \(\alpha_U = 1\), with

\[ K = \frac{2 C_U}{n}\sum_{i=1}^n d(X_i, \hat{\mu}). \]

Tip

When additional Riemannian structure is available, one may also exploit manifold optimization methods, but the appeal of the LIPO-type strategy is that it only uses the metric.

6.1 Interactive Demo: LIPO Algorithm in Action

The following demo visualizes the generalized LIPO algorithm on the circle \(S^1\), where the parameter \(\beta\) is an angle. The log-likelihood \(L_n(\beta)\) is shown as a black curve. As the algorithm runs, it evaluates proposals and builds a Hölder upper envelope (shaded region). A proposal is accepted only if its upper envelope can still beat the current best value — this prunes the search space adaptively.

Code
lipo_n_control = Inputs.range([30, 300], {step: 10, value: 150, label: "Sample size n"})
lipo_K_factor_control = Inputs.range([0.2, 3.0], {step: 0.1, value: 1.0, label: "Hölder constant factor (× true K)"})
lipo_budget_control = Inputs.range([5, 80], {step: 1, value: 25, label: "LIPO budget T"})
lipo_seed_control = Inputs.range([1, 50], {step: 1, value: 7, label: "Random seed"})
lipo_n = Generators.input(lipo_n_control)
lipo_K_factor = Generators.input(lipo_K_factor_control)
lipo_budget = Generators.input(lipo_budget_control)
lipo_seed = Generators.input(lipo_seed_control)

lipo_controls_view = html`
<style>
  .lipo-controls-shell {
    width:100%;
    margin:0 0 14px;
    font-family:system-ui,sans-serif;
    container-type:inline-size;
  }
  .lipo-controls-grid {
    display:grid;
    grid-template-columns:repeat(2,minmax(0,1fr));
    gap:8px 24px;
    width:100%;
  }
  .lipo-controls-grid > * {
    min-width:0;
    margin:0;
  }
  .lipo-controls-grid input[type="number"] {
    width:7.5rem !important;
  }
  @container (max-width:560px) {
    .lipo-controls-grid {
      grid-template-columns:1fr;
    }
  }
</style>
<div class="lipo-controls-shell">
  <div class="lipo-controls-grid">
    <div>${lipo_n_control}</div>
    <div>${lipo_K_factor_control}</div>
    <div>${lipo_budget_control}</div>
    <div>${lipo_seed_control}</div>
  </div>
</div>
`

function runLIPODemo(n, Kfactor, T, seed) {
  // ---- PRNG ----
  function mulberry32(a) {
    return function() {
      a |= 0; a = a + 0x6D2B79F5 | 0;
      var t = Math.imul(a ^ a >>> 15, 1 | a);
      t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
      return ((t ^ t >>> 14) >>> 0) / 4294967296;
    };
  }
  var rng = mulberry32(seed);

  // ---- Generate data on S¹ ----
  // True parameters
  var muStar = 0;
  var betaTrue = 1.2; // ~69 degrees

  // Circular utilities
  function circDist(a, b) {
    var d = Math.abs(a - b) % (2 * Math.PI);
    return Math.min(d, 2 * Math.PI - d);
  }
  function alexIP(beta, x, anchor) {
    var dx = circDist(anchor, x);
    var db = circDist(anchor, beta);
    // Alexandrov angle on S¹
    var dirX = x - anchor;
    if (dirX > Math.PI) dirX -= 2 * Math.PI;
    if (dirX < -Math.PI) dirX += 2 * Math.PI;
    var dirBeta = beta - anchor;
    if (dirBeta > Math.PI) dirBeta -= 2 * Math.PI;
    if (dirBeta < -Math.PI) dirBeta += 2 * Math.PI;
    var cosAngle = Math.cos(dirX) * Math.cos(dirBeta) + Math.sin(dirX) * Math.sin(dirBeta);
    return dx * db * cosAngle;
  }

  // Generate covariates concentrated around anchor
  var X = [];
  var kappa = 3.0;
  for (var i = 0; i < n; i++) {
    // von Mises sampling (simple rejection)
    var theta;
    var done = false;
    while (!done) {
      theta = rng() * 2 * Math.PI;
      var vmDens = Math.exp(kappa * Math.cos(theta - muStar));
      if (rng() < vmDens / Math.exp(kappa)) done = true;
    }
    X.push(theta);
  }

  // Generate binary responses
  var Y = [];
  for (var i = 0; i < n; i++) {
    var logit = alexIP(betaTrue, X[i], muStar);
    var prob = 1 / (1 + Math.exp(-logit));
    Y.push(rng() < prob ? 1 : 0);
  }

  // Sample Fréchet mean (anchor)
  var muHat = 0; // known for this demo
  for (var iter = 0; iter < 20; iter++) {
    var sumDir = 0;
    for (var i = 0; i < n; i++) {
      var d = X[i] - muHat;
      if (d > Math.PI) d -= 2 * Math.PI;
      if (d < -Math.PI) d += 2 * Math.PI;
      sumDir += d;
    }
    muHat = (muHat + sumDir / n + 2 * Math.PI) % (2 * Math.PI);
  }

  // ---- Log-likelihood on a fine grid ----
  var nFine = 360;
  var llGrid = [];
  var betaGrid = [];
  for (var g = 0; g < nFine; g++) {
    var beta = g * 2 * Math.PI / nFine;
    var ll = 0;
    for (var i = 0; i < n; i++) {
      var h = alexIP(beta, X[i], muHat);
      ll += Y[i] * h - Math.log(1 + Math.exp(h));
    }
    ll /= n;
    betaGrid.push(beta);
    llGrid.push(ll);
  }
  var llMax = Math.max.apply(null, llGrid);
  var llMin = Math.min.apply(null, llGrid);
  var bestIdx = llGrid.indexOf(llMax);
  var betaMLE = betaGrid[bestIdx];

  // ---- True Hölder constant K ----
  // K = (2*C_U / n) * sum_i d(X_i, muHat) with C_U=1 on S¹
  var Ktrue = 0;
  for (var i = 0; i < n; i++) {
    Ktrue += circDist(X[i], muHat);
  }
  Ktrue = 2 * Ktrue / n;
  var Kused = Kfactor * Ktrue;

  // ---- Run LIPO ----
  var evaluated = [];     // {beta, ll}
  var accepted = [];      // indices into evaluated
  var rejected = [];      // {beta, upperBound}

  // Start with random point
  var b0 = rng() * 2 * Math.PI;
  var ll0 = 0;
  for (var i = 0; i < n; i++) {
    var h = alexIP(b0, X[i], muHat);
    ll0 += Y[i] * h - Math.log(1 + Math.exp(h));
  }
  ll0 /= n;
  evaluated.push({beta: b0, ll: ll0});
  accepted.push(0);

  var t = 0;
  var totalProposals = 0;
  while (t < T && totalProposals < 2000) {
    totalProposals++;
    var prop = rng() * 2 * Math.PI;

    // Compute upper envelope: min_j (ll_j + K * d(prop, beta_j)^alpha)
    var bestVal = -Infinity;
    for (var j = 0; j < evaluated.length; j++) {
      if (evaluated[j].ll > bestVal) bestVal = evaluated[j].ll;
    }
    var upperBound = Infinity;
    for (var j = 0; j < evaluated.length; j++) {
      var ub = evaluated[j].ll + Kused * circDist(prop, evaluated[j].beta);
      if (ub < upperBound) upperBound = ub;
    }

    if (upperBound >= bestVal) {
      // Accept
      var llProp = 0;
      for (var i = 0; i < n; i++) {
        var h = alexIP(prop, X[i], muHat);
        llProp += Y[i] * h - Math.log(1 + Math.exp(h));
      }
      llProp /= n;
      evaluated.push({beta: prop, ll: llProp});
      accepted.push(evaluated.length - 1);
      t++;
    } else {
      rejected.push({beta: prop, upperBound: upperBound});
    }
  }

  // LIPO best
  var lipoBest = -Infinity, lipoBestBeta = 0;
  for (var a = 0; a < accepted.length; a++) {
    var ev = evaluated[accepted[a]];
    if (ev.ll > lipoBest) { lipoBest = ev.ll; lipoBestBeta = ev.beta; }
  }

  // ---- Build upper envelope curve for display ----
  var envelopeCurve = [];
  for (var g = 0; g < nFine; g++) {
    var beta = g * 2 * Math.PI / nFine;
    var ub = Infinity;
    for (var j = 0; j < evaluated.length; j++) {
      var u = evaluated[j].ll + Kused * circDist(beta, evaluated[j].beta);
      if (u < ub) ub = u;
    }
    envelopeCurve.push(ub);
  }

  return {
    X: X, Y: Y, muHat: muHat, betaTrue: betaTrue,
    betaGrid: betaGrid, llGrid: llGrid, llMin: llMin, llMax: llMax,
    betaMLE: betaMLE,
    evaluated: evaluated, accepted: accepted, rejected: rejected,
    envelopeCurve: envelopeCurve,
    lipoBest: lipoBest, lipoBestBeta: lipoBestBeta,
    Ktrue: Ktrue, Kused: Kused, T: T, totalProposals: totalProposals
  };
}

lipoResult = runLIPODemo(lipo_n, lipo_K_factor, lipo_budget, lipo_seed);

html`
<div style="font-family: system-ui, sans-serif; max-width: 880px;">

  <div style="display: flex; gap: 20px; flex-wrap: wrap;">

    <!-- Main LIPO visualization -->
    <div>
      <h4>LIPO Search on S¹</h4>
      <svg width="480" height="310" style="border: 1px solid #dee2e6; border-radius: 4px;">
        ${(() => {
          var margin = {top: 20, right: 20, bottom: 35, left: 50};
          var pw = 480 - margin.left - margin.right;
          var ph = 310 - margin.top - margin.bottom;

          function xS(b) { return margin.left + (b / (2 * Math.PI)) * pw; }
          var res = lipoResult;
          var vMin = res.llMin, vMax = res.llMax;
          var range = vMax - vMin || 0.01;
          function yS(v) { return margin.top + ph - ((v - vMin) / range) * ph; }

          var parts = [];

          // Background
          parts.push('<rect x="' + margin.left + '" y="' + margin.top + '" width="' + pw +
            '" height="' + ph + '" fill="#fafafa" rx="2"/>');

          // Upper envelope (shaded)
          var envPath = '';
          for (var g = 0; g < res.envelopeCurve.length; g++) {
            var bx = xS(res.betaGrid[g]);
            var by = yS(res.envelopeCurve[g]);
            envPath += (g === 0 ? 'M' : 'L') + ' ' + bx.toFixed(1) + ' ' + by.toFixed(1);
          }
          // Fill from envelope down to bottom
          parts.push('<path d="' + envPath + ' L ' + xS(2*Math.PI).toFixed(1) + ' ' + yS(vMin).toFixed(1) +
            ' L ' + xS(0).toFixed(1) + ' ' + yS(vMin).toFixed(1) + ' Z" fill="#ffd43b" opacity="0.18"/>');
          parts.push('<path d="' + envPath + '" fill="none" stroke="#f08c00" stroke-width="1.2" stroke-dasharray="5,3" opacity="0.8"/>');

          // True log-likelihood curve
          var llPath = '';
          for (var g = 0; g < res.betaGrid.length; g++) {
            llPath += (g === 0 ? 'M' : 'L') + ' ' + xS(res.betaGrid[g]).toFixed(1) + ' ' + yS(res.llGrid[g]).toFixed(1);
          }
          parts.push('<path d="' + llPath + '" fill="none" stroke="#212529" stroke-width="2.2"/>');

          // Rejected proposals (gray X marks at their upper bound estimate)
          for (var r = 0; r < res.rejected.length; r++) {
            var rx = xS(res.rejected[r].beta);
            var ry = yS(res.rejected[r].upperBound);
            var s = 3;
            parts.push('<line x1="' + (rx-s) + '" y1="' + (ry-s) + '" x2="' + (rx+s) + '" y2="' + (ry+s) + '" stroke="#adb5bd" stroke-width="1"/>');
            parts.push('<line x1="' + (rx-s) + '" y1="' + (ry+s) + '" x2="' + (rx+s) + '" y2="' + (ry-s) + '" stroke="#adb5bd" stroke-width="1"/>');
          }

          // Evaluated/accepted points
          for (var a = 0; a < res.accepted.length; a++) {
            var ev = res.evaluated[res.accepted[a]];
            var ex = xS(ev.beta);
            var ey = yS(ev.ll);
            var isBest = ev.ll >= res.lipoBest - 1e-10;
            if (a === 0) {
              // First point: initial random
              parts.push('<circle cx="' + ex + '" cy="' + ey + '" r="5" fill="#1971c2" stroke="#0b3d91" stroke-width="1.5"/>');
            } else if (isBest) {
              parts.push('<circle cx="' + ex + '" cy="' + ey + '" r="5.5" fill="#e03131" stroke="#a61e24" stroke-width="1.5"/>');
            } else {
              parts.push('<circle cx="' + ex + '" cy="' + ey + '" r="4" fill="#1971c2" opacity="0.7"/>');
            }
          }

          // True β* marker
          var tx = xS(res.betaTrue);
          parts.push('<line x1="' + tx + '" y1="' + margin.top + '" x2="' + tx + '" y2="' + (margin.top+ph) +
            '" stroke="#2b8a3e" stroke-width="1.2" stroke-dasharray="4,4"/>');
          parts.push('<text x="' + tx + '" y="' + (margin.top-5) + '" text-anchor="middle" font-size="9" fill="#2b8a3e">β*</text>');

          // Current best line
          var bestY = yS(res.lipoBest);
          parts.push('<line x1="' + margin.left + '" y1="' + bestY + '" x2="' + (margin.left+pw) +
            '" y2="' + bestY + '" stroke="#e03131" stroke-width="0.8" stroke-dasharray="3,3"/>');

          // Axes
          parts.push('<line x1="' + margin.left + '" y1="' + (margin.top+ph) + '" x2="' + (margin.left+pw) +
            '" y2="' + (margin.top+ph) + '" stroke="#adb5bd"/>');
          parts.push('<line x1="' + margin.left + '" y1="' + margin.top + '" x2="' + margin.left +
            '" y2="' + (margin.top+ph) + '" stroke="#adb5bd"/>');

          // X-axis labels
          var labels = [{v:0,l:'0'},{v:Math.PI/2,l:'π/2'},{v:Math.PI,l:'π'},{v:3*Math.PI/2,l:'3π/2'},{v:2*Math.PI,l:'2π'}];
          for (var li = 0; li < labels.length; li++) {
            parts.push('<text x="' + xS(labels[li].v) + '" y="' + (margin.top+ph+14) +
              '" text-anchor="middle" font-size="9">' + labels[li].l + '</text>');
          }
          parts.push('<text x="' + (margin.left+pw/2) + '" y="' + (margin.top+ph+28) +
            '" text-anchor="middle" font-size="10">β (radians)</text>');
          parts.push('<text x="' + (margin.left-42) + '" y="' + (margin.top+ph/2) +
            '" text-anchor="middle" font-size="10" transform="rotate(-90,' + (margin.left-42) + ',' + (margin.top+ph/2) + ')">Log-likelihood</text>');

          // Legend
          var lx = margin.left + pw - 195, ly = margin.top + 5;
          parts.push('<circle cx="' + (lx+4) + '" cy="' + (ly) + '" r="3.5" fill="#1971c2" opacity="0.7"/>');
          parts.push('<text x="' + (lx+10) + '" y="' + (ly+4) + '" font-size="8" fill="#616161">Evaluated</text>');
          parts.push('<circle cx="' + (lx+68) + '" cy="' + (ly) + '" r="4" fill="#e03131"/>');
          parts.push('<text x="' + (lx+74) + '" y="' + (ly+4) + '" font-size="8" fill="#616161">Best</text>');
          parts.push('<line x1="' + (lx+106) + '" y1="' + (ly) + '" x2="' + (lx+118) + '" y2="' + (ly) +
            '" stroke="#f08c00" stroke-width="1" stroke-dasharray="4,2"/>');
          parts.push('<text x="' + (lx+121) + '" y="' + (ly+4) + '" font-size="8" fill="#616161">Envelope</text>');

          parts.push('<rect x="' + margin.left + '" y="' + margin.top + '" width="' + pw +
            '" height="' + ph + '" fill="none" stroke="#dee2e6"/>');

          return parts.join('\n');
        })()}
      </svg>
    </div>

    <!-- Right panel: stats -->
    <div style="flex: 1; min-width: 270px;">
      <div style="padding: 12px; background: #f8f9fa; border-radius: 6px;">
        <table style="width: 100%; border-collapse: collapse; font-size: 0.9em;">
          <tr><td style="padding: 3px 8px;">True β*</td>
              <td style="padding: 3px 8px; text-align: right;">${(lipoResult.betaTrue * 180 / Math.PI).toFixed(1)}°</td></tr>
          <tr><td style="padding: 3px 8px;">MLE (grid search)</td>
              <td style="padding: 3px 8px; text-align: right;">${(lipoResult.betaMLE * 180 / Math.PI).toFixed(1)}°</td></tr>
          <tr><td style="padding: 3px 8px; font-weight: bold; color: #e03131;">LIPO best β̂</td>
              <td style="padding: 3px 8px; text-align: right; font-weight: bold; color: #e03131;">${(lipoResult.lipoBestBeta * 180 / Math.PI).toFixed(1)}°</td></tr>
          <tr><td colspan="2"><hr style="margin: 4px 0;"></td></tr>
          <tr><td style="padding: 3px 8px;">Accepted proposals</td>
              <td style="padding: 3px 8px; text-align: right;">${lipoResult.accepted.length}</td></tr>
          <tr><td style="padding: 3px 8px;">Rejected proposals</td>
              <td style="padding: 3px 8px; text-align: right;">${lipoResult.rejected.length}</td></tr>
          <tr><td style="padding: 3px 8px;">Total proposals</td>
              <td style="padding: 3px 8px; text-align: right;">${lipoResult.totalProposals}</td></tr>
          <tr><td style="padding: 3px 8px;">Acceptance rate</td>
              <td style="padding: 3px 8px; text-align: right;">${(lipoResult.accepted.length / lipoResult.totalProposals * 100).toFixed(1)}%</td></tr>
          <tr><td colspan="2"><hr style="margin: 4px 0;"></td></tr>
          <tr><td style="padding: 3px 8px;">True K</td>
              <td style="padding: 3px 8px; text-align: right;">${lipoResult.Ktrue.toFixed(3)}</td></tr>
          <tr><td style="padding: 3px 8px;">Used K (factor × true)</td>
              <td style="padding: 3px 8px; text-align: right;">${lipoResult.Kused.toFixed(3)}</td></tr>
          <tr><td style="padding: 3px 8px;">max L_n (LIPO)</td>
              <td style="padding: 3px 8px; text-align: right; font-family: monospace;">${lipoResult.lipoBest.toFixed(4)}</td></tr>
          <tr><td style="padding: 3px 8px;">max L_n (grid)</td>
              <td style="padding: 3px 8px; text-align: right; font-family: monospace;">${lipoResult.llMax.toFixed(4)}</td></tr>
        </table>
      </div>
    </div>
  </div>

  <p style="margin-top: 12px; font-size: 0.9em; color: #495057;">
    <b>How to read the plot:</b> The black curve is the true log-likelihood $L_n(\beta)$.
    Orange dashed curve = Hölder upper envelope built from evaluated points. Blue dots = accepted
    LIPO evaluations (the first is the initial random point). Red dot = current best. Gray × marks = rejected
    proposals (their upper envelope bound fell below the current best, so they can't improve on it).
    The green dashed line marks the true $\beta^*$.
  </p>
</div>
`
(a)
(b)
(c)
(d)
(e)
(f)
(g)
(h)
(i)
(j)
(k)
(l)
Figure 2: Interactive: Generalized LIPO algorithm searching for the MLE on S¹
TipTry these experiments
  • Set K factor = 0.5: The envelope is too tight — the algorithm rejects proposals that could actually beat the current best, potentially missing the global maximum (premature convergence).
  • Set K factor = 2.5: The envelope is too loose — almost every proposal passes the test, and the algorithm degenerates to uniform random search (acceptance rate near 100%).
  • Increase T: More evaluations refine the envelope and drive the best found value closer to the true MLE.
  • Compare LIPO best vs. grid MLE: With enough budget and a well-chosen K, LIPO approaches the grid-search optimum without evaluating every grid point.

7 Application: Task-Based fMRI Classification

Section 5 of Lin and Lin (2025) illustrates the method using task-based fMRI data from the Human Connectome Project.

7.1 Data

  • After preprocessing BOLD signals from 8 regions of interest, each subject is represented by a functional-connectivity matrix \(X_i \in \mathcal{S}_{++}^8\).
  • \(Y_i = 0\): motor task (\(n_0 = 201\) subjects)
  • \(Y_i = 1\): language-processing task (\(n_1 = 193\) subjects)
  • Total \(n = 394\).

The model is fitted using the Log-Cholesky metric on \(\mathcal{S}_{++}^8\) (Lin 2019); the log-Euclidean and affine-invariant metrics lead to similar conclusions.

7.2 The Fitted Geodesic

Fitted geodesic \(\gamma_{\hat{\mu}}^{\hat{\beta}}(t)\) on \(\mathcal{S}_{++}^8\). The number below each matrix is the fitted odds. Moving forward along the geodesic increases the fitted log odds. Source: Lin and Lin (2025).

Legend for the fitted geodesic on \(\mathcal{S}_{++}^8\).

The fitted geodesic is normal to the empirical decision boundary: the positive direction points toward the language-processing task, while the negative direction points toward the motor task. Replacing the connectivity matrix \(\gamma_{\hat{\mu}}^{\hat{\beta}}(t)\) by \(\gamma_{\hat{\mu}}^{\hat{\beta}}(t + \delta)\) with \(\delta = 0.00375\) increases the fitted log odds by \(1.486\).

7.3 Neuroanatomical Interpretability

The fitted effect is anatomically interpretable:

  • Region 1 (superior temporal gyrus) becomes markedly more active in the positive direction — consistent with its role in auditory and language processing.
  • The correlation between Region 1 and Region 4 (inferior frontal gyrus, associated with speech/language) becomes visibly stronger in the positive direction.
  • Correlations between Region 1 and Regions 5–8 (primary motor and visual cortices) become weaker in the positive direction — suggesting that language processing is accompanied by relative suppression of connectivity between superior temporal gyrus and motor/visual areas.

7.4 Classification Performance

In-sample ROC curve for the fitted metric-space logistic classifier. Source: Lin and Lin (2025).

The in-sample ROC curve above shows that the proposed model is adequate to classify the two tasks based on functional connectivity. For a comprehensive evaluation, the proposed classifier is compared with a diverse set of methods using 10-fold cross-validated point measures (accuracy, sensitivity, specificity) and summary measures (AUC, Brier score, logarithmic score).

Competing methods. The comparison includes:

  • SPDNet (Huang and Van Gool 2017): a neural network designed specifically for SPD-matrix inputs.
  • \(k\)-NN: \(k\)-nearest neighbors classifier on the SPD manifold (Chaudhuri and Dasgupta 2014).
  • kSVM: kernel support vector machine with a Gaussian kernel adapted to SPD matrices (Jayasumana et al. 2013).
  • KDC: kernel density classifier (Hastie et al. 2009).
  • RF: random forest using the vectorized upper triangle of the matrix logarithm.
  • Logistic: Euclidean logistic regression on the vectorized upper triangle of the matrix logarithm.
  • Logistic-BOLD: Euclidean logistic regression using only the diagonal entries (BOLD signals) of each \(X_i\), ignoring inter-regional connectivity.
  • GLMAF-LR / GLMAF-RF: task-related GLM activation features (Barch et al. 2013) fed into logistic regression and random forest.
  • ALFF-LR / ALFF-RF: amplitude of low-frequency fluctuation features (Yu-Feng et al. 2007) fed into logistic regression and random forest.

Results. The full comparison is summarized in Table 1.

Table 1: Comparison of classification performance for task-based fMRI data. Bold = best, underlined = second best. Source: Lin and Lin (2025).
Method Accuracy Sensitivity Specificity AUC Brier LogS
SPDNet 0.8667 0.8613 0.8690 0.9205 −0.1278 −0.4600
\(k\)-NN 0.8333 0.8401 0.8297 0.8349 −0.0062 −0.4382
kSVM 0.8462 0.8667 0.8266 0.8466 −0.0039 −0.3458
KDC 0.7744 0.8313 0.7309 0.7811 −0.0072 −0.8431
RF 0.8179 0.8536 0.7930 0.8913 −0.0067 −0.4487
Logistic 0.8205 0.8617 0.7878 0.8647 −0.0065 −0.8565
Logistic-BOLD 0.7641 0.8490 0.6984 0.8120 −0.0109 −0.8002
GLMAF-LR 0.7241 0.7467 0.7147 0.7902 −0.0063 −0.5843
GLMAF-RF 0.7215 0.7400 0.7088 0.8019 −0.0056 −0.5528
ALFF-LR 0.7266 0.7109 0.7459 0.7881 −0.0047 −0.6455
ALFF-RF 0.7557 0.7284 0.7784 0.8478 −0.0024 −0.4894
Proposed 0.8718 0.9158 0.8376 0.9060 −0.0060 −0.4200

Key observations:

  1. The proposed classifier achieves the best accuracy (0.8718) and sensitivity (0.9158), and ranks second in specificity (0.8376), AUC (0.9060), and logarithmic score (−0.4200). It outperforms all non-neural-network competitors across accuracy, sensitivity, and specificity.

  2. SPDNet achieves the best AUC (0.9205) and specificity (0.8690), but the proposed method is competitive — trailing by only 0.0145 in AUC and 0.0314 in specificity — while offering substantially greater geometric interpretability.

  3. Nonparametric classifiers suffer from the curse of dimensionality. The manifold \(\mathcal{S}_{++}^8\) has intrinsic dimension 36, which degrades the performance of \(k\)-NN, KDC, and even the kernel SVM relative to the parametric proposed model.

  4. Connectivity structure matters decisively. The Logistic-BOLD baseline (accuracy 0.7641, AUC 0.8120) ignores off-diagonal entries and performs substantially worse than the proposed model (accuracy 0.8718, AUC 0.9060). The gap of over 10 percentage points in accuracy demonstrates that inter-regional functional connectivity — encoded in the off-diagonal entries of each \(X_i\) — carries the discriminating information between motor and language tasks.

  5. Alternative feature extraction underperforms. Methods based on GLM activation features (GLMAF-LR, GLMAF-RF) and ALFF features (ALFF-LR, ALFF-RF) all achieve substantially lower accuracy (0.72–0.76) than the proposed functional-connectivity-based classifier, confirming that the SPD-matrix representation is more informative for this task.

ImportantKey finding

Even relative to SPDNet — a purpose-built neural network for SPD inputs — the metric-space logistic classifier is competitive in predictive performance while remaining far more interpretable: the fitted geodesic \(\gamma_{\hat{\mu}}^{\hat{\beta}}\) directly reveals which connectivity patterns drive the classification decision and how. This interpretability is a central advantage of the model-based approach.

8 Application: Classifying Market Regimes from Portfolio Covariance

The portfolio stress-testing framework developed in Lectures 6–9 models how the covariance matrix of asset returns varies with a market stress indicator. But a complementary question arises naturally: given only a covariance matrix (estimated from a window of returns), can we classify whether the market was in a crisis regime or a normal regime? This is a binary classification problem where the covariate \(X\) lives in \(\mathcal{S}_{++}^p\) and the response \(Y\) is the regime label.

8.1 Setup

Consider a portfolio of \(p = 3\) assets (e.g., equity index, bond index, commodity index). During crisis periods, correlations spike and volatilities increase; during calm periods, correlations are moderate and volatilities are lower. We observe a \(3 \times 3\) SPD covariance matrix \(\Sigma_t\) for each time window \(t\), and we want to classify the regime.

Under the log-Euclidean metric, the SPD manifold is isometric to Euclidean space (the space of symmetric matrices), so the Alexandrov inner product simplifies. For \(X = \log\Sigma\) (the matrix logarithm) and anchor \(\mu^*\) (the Fréchet mean of log-covariances):

\[ h(\beta^*; X, \mu^*) = \langle X - \mu^*, \beta^* - \mu^* \rangle_F = \operatorname{tr}\bigl((X - \mu^*)^\top(\beta^* - \mu^*)\bigr), \]

where \(\langle\cdot,\cdot\rangle_F\) is the Frobenius inner product. This is ordinary logistic regression in the log-domain — but the geometry of the SPD manifold shapes the interpretation: the fitted coefficient \(\hat{\beta}\) (exponentiated) is an SPD matrix whose eigendirections reveal which portfolio weights drive the classification.

8.2 Interactive Demo: Regime Classification on Simulated SPD Data

The following demo simulates \(3 \times 3\) SPD covariance matrices from two regimes (normal and crisis), fits the metric-space logistic classifier, and visualizes the discriminating direction in terms of its effect on portfolio variance.

Code
pc_n_control = Inputs.range([40, 300], {step: 10, value: 120, label: "Sample size per regime"})
pc_sep_control = Inputs.range([0.1, 2.0], {step: 0.1, value: 0.7, label: "Regime separation"})
pc_noise_control = Inputs.range([0.05, 0.4], {step: 0.02, value: 0.15, label: "Noise level σ"})
pc_seed_control = Inputs.range([1, 50], {step: 1, value: 3, label: "Random seed"})
pc_n = Generators.input(pc_n_control)
pc_sep = Generators.input(pc_sep_control)
pc_noise = Generators.input(pc_noise_control)
pc_seed = Generators.input(pc_seed_control)

pc_controls_view = html`
<style>
  .pc-slider-grid {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 6px 20px;
    max-width: 620px;
    margin: 0 auto 12px;
    font-family: system-ui, sans-serif;
    font-size: 0.85em;
  }
  .pc-slider-grid > * {
    min-width: 0;
    margin: 0;
  }
  .pc-slider-grid input[type="number"] {
    width: 7.5rem !important;
  }
  @media (max-width: 500px) {
    .pc-slider-grid {
      grid-template-columns: 1fr;
    }
  }
</style>
<div class="pc-slider-grid">
  <div>${pc_n_control}</div>
  <div>${pc_sep_control}</div>
  <div>${pc_noise_control}</div>
  <div>${pc_seed_control}</div>
</div>
`

function runPortfolioClassify(nPerRegime, sep, noise, seed) {
  var rng = (function(a) {
    return function() {
      a |= 0; a = a + 0x6D2B79F5 | 0;
      var t = Math.imul(a ^ a >>> 15, 1 | a);
      t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
      return ((t ^ t >>> 14) >>> 0) / 4294967296;
    };
  })(seed);
  function randn() {
    var u1 = rng(), u2 = rng();
    return Math.sqrt(-2 * Math.log(Math.max(u1, 1e-15))) * Math.cos(2 * Math.PI * u2);
  }

  // 3x3 symmetric matrix stored as [a,b,c, d,e, f] for [[a,b,c],[b,d,e],[c,e,f]]
  function symFrobSq(m) {
    return m[0]*m[0]+m[1]*m[1]+m[2]*m[2]+m[3]*m[3]+m[4]*m[4]+m[5]*m[5] + 2*(m[1]*m[1]+m[2]*m[2]+m[4]*m[4]);
  }
  function symFrob(m) { return Math.sqrt(symFrobSq(m)); }
  function symDot(a, b) {
    return a[0]*b[0]+a[3]*b[3]+a[5]*b[5] + 2*(a[1]*b[1]+a[2]*b[2]+a[4]*b[4]);
  }
  function symScale(m, s) { return [m[0]*s, m[1]*s, m[2]*s, m[3]*s, m[4]*s, m[5]*s]; }
  function symAdd(a, b) { return [a[0]+b[0], a[1]+b[1], a[2]+b[2], a[3]+b[3], a[4]+b[4], a[5]+b[5]]; }
  function symSub(a, b) { return [a[0]-b[0], a[1]-b[1], a[2]-b[2], a[3]-b[3], a[4]-b[4], a[5]-b[5]]; }

  // Regime centers (in log-domain, i.e., symmetric matrices)
  // Normal regime: moderate variances, low correlations
  var regime0 = [0.3, 0.05, 0.05, 0.35, 0.0, 0.4];
  // Crisis regime: high variances, high correlations
  var regime1 = [0.3 + sep, 0.05 + 0.8*sep, 0.05 + 0.7*sep, 0.35 + sep, 0.0 + 0.5*sep, 0.4 + sep];

  // Generate data
  var X = [], Y = [];
  for (var i = 0; i < nPerRegime; i++) {
    // Regime 0
    var x0 = [regime0[0]+noise*randn(), regime0[1]+noise*randn(), regime0[2]+noise*randn(),
              regime0[3]+noise*randn(), regime0[4]+noise*randn(), regime0[5]+noise*randn()];
    X.push(x0); Y.push(0);
    // Regime 1
    var x1 = [regime1[0]+noise*randn(), regime1[1]+noise*randn(), regime1[2]+noise*randn(),
              regime1[3]+noise*randn(), regime1[4]+noise*randn(), regime1[5]+noise*randn()];
    X.push(x1); Y.push(1);
  }
  var n = X.length;

  // Fréchet mean (anchor) = sample mean in log-domain
  var muHat = [0,0,0,0,0,0];
  for (var i = 0; i < n; i++) {
    muHat = symAdd(muHat, X[i]);
  }
  muHat = symScale(muHat, 1/n);

  // Fit logistic regression: max L_n(beta) = (1/n) sum [Y_i * <X_i-mu, beta-mu> - log(1+exp(...))]
  // Gradient ascent in the 6-dimensional log-domain
  var betaHat = symAdd(muHat, [0.01,0,0,0.01,0,0.01]); // initialize near mu
  var lr = 0.05, nIter = 500;
  for (var iter = 0; iter < nIter; iter++) {
    var grad = [0,0,0,0,0,0];
    var ll = 0;
    for (var i = 0; i < n; i++) {
      var h = symDot(symSub(X[i], muHat), symSub(betaHat, muHat));
      var p = 1 / (1 + Math.exp(-h));
      ll += Y[i] * h - Math.log(1 + Math.exp(h));
      var resid = Y[i] - p;
      grad = symAdd(grad, symScale(symSub(X[i], muHat), resid));
    }
    grad = symScale(grad, 1/n);
    betaHat = symAdd(betaHat, symScale(grad, lr));
    // Normalize step size decay
    if (iter % 100 === 99) lr *= 0.5;
  }

  // Compute log-likelihood at betaHat
  var llFinal = 0;
  for (var i = 0; i < n; i++) {
    var h = symDot(symSub(X[i], muHat), symSub(betaHat, muHat));
    llFinal += Y[i] * h - Math.log(1 + Math.exp(h));
  }
  llFinal /= n;

  // Classification accuracy
  var correct = 0;
  var probs = [];
  for (var i = 0; i < n; i++) {
    var h = symDot(symSub(X[i], muHat), symSub(betaHat, muHat));
    var pred = h >= 0 ? 1 : 0;
    if (pred === Y[i]) correct++;
    probs.push(1 / (1 + Math.exp(-h)));
  }
  var accuracy = correct / n;

  // The discriminating direction (in log-domain)
  var discDir = symSub(betaHat, muHat);
  var discNorm = symFrob(discDir);

  // Project data onto the discriminating direction for 1D visualization
  var projections = [];
  for (var i = 0; i < n; i++) {
    var centered = symSub(X[i], muHat);
    if (discNorm > 1e-10) {
      projections.push(symDot(centered, discDir) / discNorm);
    } else {
      projections.push(0);
    }
  }

  // ROC data: sort by predicted prob
  var idx = Array.from({length: n}, function(_, k) { return k; });
  idx.sort(function(a, b) { return probs[a] - probs[b]; });
  var tpr = [], fpr = [], auc = 0;
  var n0 = 0, n1 = 0;
  for (var i = 0; i < n; i++) { if (Y[i] === 0) n0++; else n1++; }
  var tp = 0, fp = 0, prevFpr = 0;
  for (var k = n - 1; k >= 0; k--) {
    var i = idx[k];
    if (Y[i] === 1) {
      tp++;
    } else {
      fp++;
      auc += (tp / n1) * ((fp / n0) - prevFpr);
      prevFpr = fp / n0;
    }
    tpr.push(tp / n1);
    fpr.push(fp / n0);
  }
  auc += (tp / n1) * (1 - prevFpr);

  return {
    X: X, Y: Y, n: n, muHat: muHat, betaHat: betaHat,
    discDir: discDir, discNorm: discNorm,
    projections: projections, probs: probs,
    accuracy: accuracy, auc: auc, llFinal: llFinal,
    regime0: regime0, regime1: regime1
  };
}

pcResult = runPortfolioClassify(pc_n, pc_sep, pc_noise, pc_seed);

html`
<div style="font-family: system-ui, sans-serif; max-width: 920px;">

  <div style="display: flex; gap: 20px; flex-wrap: wrap;">

    <!-- Projection histogram -->
    <div>
      <h4>Data Projected onto Discriminating Direction</h4>
      <svg width="420" height="280" style="border: 1px solid #dee2e6; border-radius: 4px;">
        ${(() => {
          var res = pcResult;
          var margin = {top: 20, right: 15, bottom: 35, left: 50};
          var pw = 420 - margin.left - margin.right;
          var ph = 280 - margin.top - margin.bottom;

          // Separate projections by class
          var proj0 = [], proj1 = [];
          for (var i = 0; i < res.n; i++) {
            if (res.Y[i] === 0) proj0.push(res.projections[i]);
            else proj1.push(res.projections[i]);
          }

          var allP = res.projections;
          var pMin = Math.min.apply(null, allP), pMax = Math.max.apply(null, allP);
          var pRange = pMax - pMin || 0.01;
          var pad = pRange * 0.1;
          pMin -= pad; pMax += pad; pRange = pMax - pMin;

          function xS(v) { return margin.left + ((v - pMin) / pRange) * pw; }

          // Histogram bins
          var nBins = 40;
          var bins0 = new Array(nBins).fill(0);
          var bins1 = new Array(nBins).fill(0);
          for (var i = 0; i < proj0.length; i++) {
            var bi = Math.floor((proj0[i] - pMin) / pRange * nBins);
            if (bi >= 0 && bi < nBins) bins0[bi]++;
          }
          for (var i = 0; i < proj1.length; i++) {
            var bi = Math.floor((proj1[i] - pMin) / pRange * nBins);
            if (bi >= 0 && bi < nBins) bins1[bi]++;
          }
          var maxCount = Math.max(Math.max.apply(null, bins0), Math.max.apply(null, bins1));
          if (maxCount === 0) maxCount = 1;
          function yS(c) { return margin.top + ph - (c / maxCount) * (ph * 0.85); }

          var parts = [];
          parts.push('<rect x="' + margin.left + '" y="' + margin.top + '" width="' + pw +
            '" height="' + ph + '" fill="#fafafa" rx="2"/>');

          // Decision boundary (projection = 0)
          var dbX = xS(0);
          parts.push('<line x1="' + dbX + '" y1="' + margin.top + '" x2="' + dbX +
            '" y2="' + (margin.top+ph) + '" stroke="#212529" stroke-width="1.5" stroke-dasharray="6,3"/>');
          parts.push('<text x="' + dbX + '" y="' + (margin.top-5) + '" text-anchor="middle" font-size="9" fill="#212529">Decision boundary</text>');

          // Bars
          var barW = pw / nBins - 1;
          for (var b = 0; b < nBins; b++) {
            if (bins0[b] > 0) {
              var bx = xS(pMin + (b + 0.5) * (pRange / nBins)) - barW/2;
              var bh = (bins0[b] / maxCount) * ph * 0.85;
              parts.push('<rect x="' + bx + '" y="' + (margin.top+ph-bh) + '" width="' + barW +
                '" height="' + bh + '" fill="#1971c2" opacity="0.7"/>');
            }
            if (bins1[b] > 0) {
              var bx2 = xS(pMin + (b + 0.5) * (pRange / nBins)) - barW/2;
              var bh2 = (bins1[b] / maxCount) * ph * 0.85;
              parts.push('<rect x="' + bx2 + '" y="' + (margin.top+ph-bh2) + '" width="' + barW +
                '" height="' + bh2 + '" fill="#e03131" opacity="0.7"/>');
            }
          }

          // Axes
          parts.push('<line x1="' + margin.left + '" y1="' + (margin.top+ph) + '" x2="' + (margin.left+pw) +
            '" y2="' + (margin.top+ph) + '" stroke="#adb5bd"/>');
          parts.push('<line x1="' + margin.left + '" y1="' + margin.top + '" x2="' + margin.left +
            '" y2="' + (margin.top+ph) + '" stroke="#adb5bd"/>');
          parts.push('<text x="' + (margin.left+pw/2) + '" y="' + (margin.top+ph+28) +
            '" text-anchor="middle" font-size="10">Projection onto ⟨β̂ − μ̂⟩</text>');
          parts.push('<text x="' + (margin.left-44) + '" y="' + (margin.top+ph/2) +
            '" text-anchor="middle" font-size="10" transform="rotate(-90,' + (margin.left-44) + ',' + (margin.top+ph/2) + ')">Count</text>');

          // Legend
          parts.push('<rect x="' + (margin.left+10) + '" y="' + (margin.top+5) + '" width="10" height="10" fill="#1971c2" opacity="0.7"/>');
          parts.push('<text x="' + (margin.left+24) + '" y="' + (margin.top+13) + '" font-size="9" fill="#616161">Regime 0 (Normal)</text>');
          parts.push('<rect x="' + (margin.left+150) + '" y="' + (margin.top+5) + '" width="10" height="10" fill="#e03131" opacity="0.7"/>');
          parts.push('<text x="' + (margin.left+164) + '" y="' + (margin.top+13) + '" font-size="9" fill="#616161">Regime 1 (Crisis)</text>');

          parts.push('<rect x="' + margin.left + '" y="' + margin.top + '" width="' + pw +
            '" height="' + ph + '" fill="none" stroke="#dee2e6"/>');

          return parts.join('\n');
        })()}
      </svg>
    </div>

    <!-- ROC curve -->
    <div>
      <h4>ROC Curve (In-sample)</h4>
      <svg width="260" height="280" style="border: 1px solid #dee2e6; border-radius: 4px;">
        ${(() => {
          var res = pcResult;
          var margin = {top: 20, right: 15, bottom: 35, left: 45};
          var pw = 260 - margin.left - margin.right;
          var ph = 280 - margin.top - margin.bottom;

          // Recompute ROC
          var idx = Array.from({length: res.n}, function(_, k) { return k; });
          idx.sort(function(a, b) { return res.probs[a] - res.probs[b]; });
          var n0 = 0, n1 = 0;
          for (var i = 0; i < res.n; i++) { if (res.Y[i] === 0) n0++; else n1++; }
          var rocX = [0], rocY = [0];
          var tp = 0, fp = 0;
          for (var k = res.n - 1; k >= 0; k--) {
            var i = idx[k];
            if (res.Y[i] === 1) tp++; else fp++;
            rocX.push(fp / n0);
            rocY.push(tp / n1);
          }

          function xS(v) { return margin.left + v * pw; }
          function yS(v) { return margin.top + ph - v * ph; }

          var parts = [];
          parts.push('<rect x="' + margin.left + '" y="' + margin.top + '" width="' + pw +
            '" height="' + ph + '" fill="#fafafa" rx="2"/>');

          // Diagonal
          parts.push('<line x1="' + margin.left + '" y1="' + (margin.top+ph) + '" x2="' + (margin.left+pw) +
            '" y2="' + margin.top + '" stroke="#adb5bd" stroke-width="1" stroke-dasharray="4,4"/>');

          // ROC curve
          var d = '';
          for (var j = 0; j < rocX.length; j++) {
            d += (j===0?'M':'L') + ' ' + xS(rocX[j]).toFixed(1) + ' ' + yS(rocY[j]).toFixed(1);
          }
          parts.push('<path d="' + d + '" fill="none" stroke="#1971c2" stroke-width="2.2"/>');

          // Axes
          parts.push('<line x1="' + margin.left + '" y1="' + (margin.top+ph) + '" x2="' + (margin.left+pw) +
            '" y2="' + (margin.top+ph) + '" stroke="#adb5bd"/>');
          parts.push('<line x1="' + margin.left + '" y1="' + margin.top + '" x2="' + margin.left +
            '" y2="' + (margin.top+ph) + '" stroke="#adb5bd"/>');
          parts.push('<text x="' + (margin.left+pw/2) + '" y="' + (margin.top+ph+28) +
            '" text-anchor="middle" font-size="10">FPR</text>');
          parts.push('<text x="' + (margin.left-38) + '" y="' + (margin.top+ph/2) +
            '" text-anchor="middle" font-size="10" transform="rotate(-90,' + (margin.left-38) + ',' + (margin.top+ph/2) + ')">TPR</text>');

          // AUC label
          parts.push('<text x="' + (margin.left+pw*0.7) + '" y="' + (margin.top+ph*0.8) +
            '" font-size="11" fill="#1971c2" font-weight="bold">AUC = ' + res.auc.toFixed(3) + '</text>');

          parts.push('<rect x="' + margin.left + '" y="' + margin.top + '" width="' + pw +
            '" height="' + ph + '" fill="none" stroke="#dee2e6"/>');

          return parts.join('\n');
        })()}
      </svg>
    </div>
  </div>

  <!-- Summary stats -->
  <div style="display: flex; gap: 15px; flex-wrap: wrap; margin-top: 12px;">
    <div style="padding: 10px 16px; background: #f8f9fa; border-radius: 6px; font-size: 0.9em;">
      <table style="border-collapse: collapse;">
        <tr><td style="padding: 2px 8px;">Classification accuracy</td>
            <td style="padding: 2px 8px; text-align: right; font-weight: bold;">${(pcResult.accuracy * 100).toFixed(1)}%</td></tr>
        <tr><td style="padding: 2px 8px;">AUC</td>
            <td style="padding: 2px 8px; text-align: right; font-weight: bold;">${pcResult.auc.toFixed(3)}</td></tr>
        <tr><td style="padding: 2px 8px;">Log-likelihood</td>
            <td style="padding: 2px 8px; text-align: right; font-family: monospace;">${pcResult.llFinal.toFixed(4)}</td></tr>
        <tr><td style="padding: 2px 8px;">‖β̂ − μ̂‖_F</td>
            <td style="padding: 2px 8px; text-align: right; font-family: monospace;">${pcResult.discNorm.toFixed(3)}</td></tr>
        <tr><td style="padding: 2px 8px;">Sample size</td>
            <td style="padding: 2px 8px; text-align: right;">${pcResult.n}</td></tr>
      </table>
    </div>
    <div style="padding: 10px 16px; background: #f8f9fa; border-radius: 6px; font-size: 0.85em;">
      <b>Discriminating direction</b> (log-domain, upper triangle):<br>
      <span style="font-family: monospace;">
        Δ = [${pcResult.discDir[0].toFixed(3)}, ${pcResult.discDir[1].toFixed(3)}, ${pcResult.discDir[2].toFixed(3)};<br>
        &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;${pcResult.discDir[1].toFixed(3)}, ${pcResult.discDir[3].toFixed(3)}, ${pcResult.discDir[4].toFixed(3)};<br>
        &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;${pcResult.discDir[2].toFixed(3)}, ${pcResult.discDir[4].toFixed(3)}, ${pcResult.discDir[5].toFixed(3)}]
      </span>
    </div>
  </div>
</div>
`
(a)
(b)
(c)
(d)
(e)
(f)
(g)
(h)
(i)
(j)
(k)
(l)
Figure 3: Interactive: Metric-space logistic classification of market regimes from 3×3 SPD covariance matrices
NoteHow to read the plots

The histogram shows data projected onto the estimated discriminating direction \(\langle \hat{\beta} - \hat{\mu} \rangle\). Larger positive values indicate higher predicted probability of crisis (Regime 1). The decision boundary (dashed vertical line) separates the two regimes. The off-diagonal entries of the discriminating direction matrix reveal which asset pairs drive the classification — analogously to how the fMRI application identified which brain-region connections distinguish tasks.

TipTry these experiments
  • Increase regime separation: Larger separation makes the two regimes more distinct — observe how the histogram separates and accuracy rises.
  • Increase noise: Higher noise makes the distributions overlap more; the decision boundary becomes less reliable and AUC drops.
  • Inspect the discriminating direction: The off-diagonal entries (positions 1, 2, 4 in the compact storage) capture which asset pairs change most across regimes. In a real application, this tells the risk manager which correlations are most diagnostic of a crisis.
  • Compare to the fMRI application: Both applications use the same model — an SPD-matrix covariate and a binary response — but the interpretation shifts from “which brain connections” to “which asset correlations” distinguish the classes.

8.3 Connection to the Portfolio Stress-Testing Framework

The metric-space logistic classifier complements the Fréchet regression framework developed in earlier lectures:

Method Goal Predictor Response
Global Fréchet (L6) Estimate \(\mathbb{E}[\Sigma \mid \text{stress}]\) Stress level (scalar) Covariance matrix (SPD)
Kernel/Local-linear (L7–8) Smooth estimate of \(\mathbb{E}[\Sigma \mid \text{stress}]\) Stress level (scalar) Covariance matrix (SPD)
TV-regularized (L9) Piecewise-constant \(\mathbb{E}[\Sigma \mid \text{time}]\) Time (ordered scalar) Covariance matrix (SPD)
Metric logistic (L10) Classify regime from Σ Covariance matrix (SPD) Binary regime label

The last row reverses the roles: instead of predicting the covariance matrix from a scalar, we predict a binary label from the covariance matrix. In practice, a risk manager might use both approaches:

  1. Fit a smooth Fréchet regression model to estimate how covariance evolves with a continuous stress indicator.
  2. Fit the metric-space logistic classifier to identify which features of the covariance matrix are most diagnostic of crisis vs. calm regimes.
  3. Use the discriminating direction from (2) to construct a monitoring index that tracks, in real time, how far the current covariance matrix is from the anchor in the crisis direction.

9 Asymptotic Theory

We now present the formal asymptotic theory of the metric-space logistic estimator, following the framework of Lin and Lin (2025). The analysis proceeds from five assumptions — two geometric, two distributional, and one complexity-theoretic — together with bounds on estimation and classification risk.

NoteNotation

For two non-negative sequences \(\{a_n\}\) and \(\{b_n\}\), we write \(a_n \lesssim b_n\) (resp. \(a_n \gtrsim b_n\)) if there exists a constant \(c > 0\), independent of \(n\), such that \(a_n \le c b_n\) (resp. \(a_n \ge c b_n\)) for all sufficiently large \(n\). We write \(a_n \asymp b_n\) if both \(a_n \lesssim b_n\) and \(a_n \gtrsim b_n\) hold.

9.1 Assumptions

The first two assumptions are geometric; the next two are distributional.

NoteAssumption 1: Geometric regularity

\((\mathcal{M}, d)\) is a complete uniquely geodesic metric space.

This standard condition ensures that any pair of points in \(\mathcal{M}\) can be connected by a unique geodesic.

NoteAssumption 2: Hölder continuity of Alexandrov projections

There exist constants \(C_U > 0\) and \(\alpha_U \in (0, 1]\) such that, for all \(p, q, r, u \in \mathcal{M}\),

\[ \bigl|d(p, q)\cos\angle_p(u, q) - d(p, r)\cos\angle_p(u, r)\bigr| \le C_U\, d(q, r)^{\alpha_U}. \]

Assumption 2 is a curvature regularity condition analogous to the weak quadruple inequality in (Schötz 2019). It is satisfied with \(\alpha_U = 1\) in all Hadamard spaces and in certain subspaces of positively curved Alexandrov spaces. Concrete examples include the SPD manifold with the Log-Cholesky or affine-invariant metric, the positive orthant of the unit sphere, the Billera–Holmes–Vogtmann tree space, and the 2-Wasserstein space of distributions on a compact interval (see Example 1 of Lin and Lin 2025).

NoteAssumption 3: Existence of basic estimators

The distribution of \(X \in \mathcal{M}\) has bounded support. The population Fréchet mean \(\mu^*\) and the sample Fréchet mean \(\hat{\mu}\) exist and are unique, the latter almost surely. Moreover, the maximum likelihood estimator \(\hat{\beta}\) exists almost surely.

NoteAssumption 4: Margin / identification condition

There exist constants \(\lambda_X > 0\), \(C_L > 0\), and \(\alpha_L > 1 + 2/m\) for some \(m \ge 4\) with \(\mathbb{E}[d^m(\mu^*, X)] < \infty\), such that for all \(\beta \in \mathcal{M}\),

\[ \mathbb{E}\bigl[|h(\beta; X, \mu^*) - h(\beta^*; X, \mu^*)|^2\bigr] \ge C_L \lambda_X\, d(\beta, \beta^*)^{\alpha_L}. \]

Assumption 4 controls the behavior of \(L_n - L\) near \(\beta^*\) and is standard in M-estimation theory (Ahidar-Coutrix et al. 2020). In Euclidean space \(\mathcal{M} = \mathbb{R}^D\), it reduces to the familiar requirement that \(\mathbb{E}[(X - \mu^*)(X - \mu^*)^{\top}]\) be positive definite, with \(\lambda_X\) being its smallest eigenvalue. The moment condition \(m \ge 4\) is needed for the expected squared estimation error; for the first-moment error, \(m \ge 2\) suffices. When \(\mathcal{M}\) is bounded, the constraint \(\alpha_L > 1 + 2/m\) can be relaxed to \(\alpha_L > 1\), since \(\mathbb{E}[d^m(\mu^*, X)] < \infty\) holds for any arbitrarily large fixed \(m\).

NoteDiscussion of existence and uniqueness

Some remarks on Assumptions 3–4 (see Web Appendix E of Lin and Lin (2025) for details):

  • Fréchet means: In Hadamard spaces, \(\mu^*\) and \(\hat{\mu}\) exist and are unique whenever \(\mathbb{E}[d^2(X, u)] < \infty\) for some \(u \in \mathcal{M}\), regardless of the distribution of \(X\) (Sturm 2003a). For Riemannian manifolds, sufficient conditions involve data residing in a geodesically convex region (Bhattacharya and Patrangenaru 2003; Afsari 2011; Lin and Müller 2021).
  • Existence of \(\hat{\beta}\): Proposition 1 of Lin and Lin (2025) shows that \(\hat{\beta}\) exists when \(\mathcal{M}\) is complete and totally bounded. For unbounded spaces — even in \(\mathbb{R}^D\) — additional distributional conditions are required, such as the overlap condition (Albert and Anderson 1984). In the SPD case with the Log-Euclidean metric, an analogous overlap condition can be formulated.
  • Uniqueness of \(\beta^*\): Proposition 2 of Lin and Lin (2025) establishes uniqueness of \(\beta^*\) under Assumption 4.
  • Measurability: The theoretical results hold for any measurable choice of \(\hat{\beta}\) maximizing \(L_n\); uniqueness of \(\hat{\beta}\) is not required.

9.2 Local Metric Entropy

The convergence rate of \(\hat{\beta}\) depends on the complexity of the metric space near \(\beta^*\), as measured by covering numbers. Let \(B(\beta^*, \delta) \subset \mathcal{M}\) be the ball of radius \(\delta > 0\) centered at \(\beta^*\), and let \(N(\varepsilon, B(\beta^*, \delta), d)\) be its covering number using balls of radius \(\varepsilon > 0\) (Wainwright 2019, sec. 5.1). The metric entropy is \(\log N(\varepsilon, B(\beta^*, \delta), d)\).

NoteAssumption 5: Local metric entropy

There exists a constant \(D > 0\), such that one of the following holds:

  1. (Log-polynomial metric entropy)

\[ N(\varepsilon, B(\beta^*, \delta), d) \lesssim \Bigl(\frac{\delta}{\varepsilon}\Bigr)^{D}. \]

  1. (Polynomial metric entropy)

\[ \log N(\varepsilon, B(\beta^*, \delta), d) \lesssim \Bigl(\frac{\delta}{\varepsilon}\Bigr)^{D}. \]

Part (a) is typical of finite-dimensional metric spaces: it holds for all finite-dimensional compact Riemannian manifolds, and more generally for spaces admitting an Ahlfors–David regular measure. In this regime the entropy grows as \(D \log(\delta / \varepsilon)\). Part (b) is much weaker and accommodates spaces of much higher intrinsic complexity. A notable example is the 2-Wasserstein space of distributions on a compact interval, which satisfies (b) with \(D = 1\) but not (a) (Yang 1999; Petersen and Müller 2019).

9.3 Estimation Rates

Recall that \(\alpha_U\) (from Assumption 2) is related to curvature, while \(\alpha_L\) (from Assumption 4) controls the local behavior of the loss near \(\beta^*\). The benchmark rate \(v_{L,n}\) is defined piecewise according to the entropy regime:

\[ \begin{aligned} &\text{Under Assumption 5(a) — Log-polynomial entropy:} & v_{L,n} &= \Bigl(\frac{D}{n}\Bigr)^{\frac{1}{2(\alpha_L - \alpha_U)}}. \\[12pt] &\text{Under Assumption 5(b) — Polynomial entropy:} & v_{L,n} &= \begin{cases} \Bigl(\dfrac{1}{n}\Bigr)^{\frac{1}{2(\alpha_L - \alpha_U)}}, & \text{if } D < 2\alpha_U, \\[10pt] \Bigl(\dfrac{\log^2 n}{n}\Bigr)^{\frac{1}{2(\alpha_L - \alpha_U)}}, & \text{if } D = 2\alpha_U, \\[10pt] \Bigl(\dfrac{1}{n}\Bigr)^{\frac{\alpha_U}{D(\alpha_L - \alpha_U)}}, & \text{if } D > 2\alpha_U. \end{cases} \end{aligned} \]

The three-case structure under polynomial entropy reflects a competition between the intrinsic dimension \(D\) and the curvature exponent \(\alpha_U\):

  • \(D < 2\alpha_U\) (low-dimensional regime): The rate is dimension-free — the estimator achieves the same rate as when the space is low-dimensional.
  • \(D = 2\alpha_U\) (critical regime): A logarithmic penalty appears, reflecting the boundary behavior of the entropy integral.
  • \(D > 2\alpha_U\) (high-dimensional regime): The dimension \(D\) enters the exponent, degrading the rate; the estimator pays a genuine curse-of-dimensionality cost relative to the log-polynomial case.
NoteThe common case: \(\alpha_L = 2\alpha_U\)

An important and common setting is \(\alpha_L = 2\alpha_U\). For example, on a Hadamard manifold where the expected Hessian of \(h\) at \(\beta^*\) is positive definite, one has \(\alpha_L = 2\) and \(\alpha_U = 1\). In this case, under log-polynomial entropy, \(v_{L,n} = \sqrt{D/n}\), matching the familiar parametric rate in dimension \(D\).

Theorem 1 Suppose Assumptions 1–5 hold and the metric-space logistic model is well specified. Then there exists a constant \(c > 0\), depending on the constants in the assumptions, such that for any measurable sequence \(\hat{\beta}\) determined by \(\hat{\beta} \in \arg\max_{\beta \in \mathcal{M}} L_n(\beta)\),

\[ \mathbb{E}\bigl[d^2(\beta^*, \hat{\beta})\bigr] \le c\, v_{L,n}^2. \]

Theorem 2 Suppose \(\sigma_X^2 = \mathbb{E}[d^2(\mu^*, X)] < \infty\) and Assumptions 1–2 hold. If \((X_i, Y_i)_{i=1}^n\) are i.i.d. from the metric-space logistic model, then

\[ \inf_{\hat{\beta}} \sup_{\beta \in \mathcal{M}} \mathbb{E}_\beta\bigl[d^2(\beta, \hat{\beta})\bigr] \gtrsim \begin{cases} \Bigl(\dfrac{D}{\sigma_X^2 n}\Bigr)^{\frac{1}{\alpha_U}}, & \text{when } N(\varepsilon, B(\beta^*, \delta), d) \asymp \Bigl(\dfrac{\delta}{\varepsilon}\Bigr)^{D}, \\[10pt] \Bigl(\dfrac{1}{\sigma_X^2 n}\Bigr)^{\frac{1}{\alpha_U}}, & \text{when } \log N(\varepsilon, B(\beta^*, \delta), d) \asymp \Bigl(\dfrac{\delta}{\varepsilon}\Bigr)^{D}. \end{cases} \]

Here the infimum is taken over all estimators \(\hat{\beta}\), and \(\mathbb{E}_\beta\) denotes the expectation under the model with \(\beta^* = \beta\).

Optimality summary. When the common quadratic-margin relation \(\alpha_L = 2\alpha_U\) holds (as is the case on Hadamard manifolds with a positive-definite expected Hessian):

  • Under log-polynomial entropy (Assumption 5a), the upper and lower bounds match, establishing that \(\mathbb{E}[d^2(\beta^*, \hat{\beta})] \asymp D/n\). The estimator is rate-optimal.
  • Under polynomial entropy (Assumption 5b) with \(D \le 2\alpha_U\), the estimator is also rate-optimal, up to a logarithmic factor when \(D = 2\alpha_U\).
  • When \(D > 2\alpha_U\), a gap between the upper and lower bound remains.

9.4 Classification Risk

The regression model induces a plug-in classifier. Defining the estimated success probability

\[ \hat{P}(x) = \bigl[1 + \exp\{-h(\hat{\beta}; x, \hat{\mu})\}\bigr]^{-1}, \]

the classifier is

\[ \hat{s}(x) = \begin{cases} 1, & \hat{P}(x) \ge 1/2, \\ 0, & \hat{P}(x) < 1/2. \end{cases} \]

The Bayes classifier \(s^*\) — minimizing the error probability under the model — is defined analogously with \((\mu^*, \beta^*)\) in place of \((\hat{\mu}, \hat{\beta})\). The excess risk of \(\hat{s}\) is

\[ \mathcal{E}(\hat{s}, s^*) = \mathbb{E}[\ell(\hat{s})] - \mathbb{E}[\ell(s^*)], \qquad \ell(s) = \mathbf{1}\{s(X) \ne Y\}. \]

The excess risk measures how much accuracy is lost relative to the optimal (Bayes) rule.

Theorem 3 Suppose \(\mathcal{M}\) is a complete Alexandrov space with curvature upper bounded by \(\kappa\) and diameter at most \(D_\kappa/2\), where \(D_\kappa = \pi/\sqrt{\kappa}\) if \(\kappa > 0\) and \(D_\kappa = \infty\) otherwise. Assume further that the Alexandrov inner product \(h(\beta^*; x, \mu)\) is regular near \(\mu^*\) (i.e., Lipschitz in both arguments locally). Then, under Assumption 3, there exists a constant \(c > 0\) such that

\[ \mathcal{E}(\hat{s}, s^*) \le c\, \Bigl(\mathbb{E}[d^2(\mu^*, \hat{\mu})] + \mathbb{E}[d^2(\beta^*, \hat{\beta})]\Bigr)^{1/2}. \]

Under standard regularity conditions, the Fréchet mean converges at rate \(\mathbb{E}[d^2(\mu^*, \hat{\mu})] \lesssim n^{-1}\) (Schötz 2019; Ahidar-Coutrix et al. 2020), which is dominated by the estimation error of \(\hat{\beta}\). Hence the excess risk inherits the rates established above for \(\hat{\beta}\).

Theorem 4 For a \(D\)-dimensional Hadamard manifold of bounded sectional curvature, suppose the distribution of \(X\) has bounded support, \(\lambda_X > 0\), and \(\hat{\beta}\) exists almost surely. Then there exists a constant \(c > 0\) such that

\[ \mathcal{E}(\hat{s}, s^*) \le c\,\sqrt{\frac{D}{n}}. \]

The same conclusion holds for a complete, simply connected \(D\)-dimensional Riemannian manifold with non-negative sectional curvature bounded above by \(\kappa > 0\) and diameter at most \(D_\kappa/2\), under the same Hessian non-degeneracy condition as in the estimation corollary.

Moreover, this rate is minimax optimal: for any \(D\)-dimensional Riemannian manifold \(\mathcal{M}\),

\[ \inf_{\widehat{s}} \sup_{s^* \in \mathcal{C}} \mathcal{E}(\hat{s}, s^*) \gtrsim \sqrt{\frac{D}{n}}, \]

where \(\mathcal{C} = \{s(X) = \mathbf{1}\{h(\beta; X, \mu^*) \ge 0\} : \beta \in \mathcal{M}\}\) is the class of half-space-type classifiers induced by the model, and the infimum is taken over all classifiers.

TipInterpretation of the \(\sqrt{D/n}\) rate

The classification risk bound \(\mathcal{E}(\hat{s}, s^*) \lesssim \sqrt{D/n}\) reveals three important features:

  1. Parametric rate. The \(n^{-1/2}\) dependence is optimal even in \(\mathbb{R}^D\), confirming that the metric-space logistic classifier does not pay a nonparametric penalty.

  2. Dimension dependence. The \(\sqrt{D}\) factor reflects the intrinsic dimension of the covariate space — not an ambient or embedding dimension — and matches the classical Euclidean result.

  3. Minimax optimality. The matching lower bound means that no classifier can achieve a faster rate uniformly over the class \(\mathcal{C}\), regardless of whether it exploits metric, Riemannian, or Euclidean structure. The proposed estimator attains the fundamental limit.

10 Interactive Exploration: Logistic Regression on the 2D Sphere

The following demo implements metric-space logistic regression on the 2D sphere \(\mathbb{S}^2\), where the covariate \(X\) lies on the unit sphere in \(\mathbb{R}^3\) and the response is binary. Compared to the circle \(S^1\), the sphere offers a richer geometry: the decision boundary is a great circle orthogonal to the discriminating direction at the anchor, and the estimated parameters \(\hat{\mu}\) and \(\hat{\beta}\) are visualized directly on the sphere.

The class separation slider directly sets the geodesic distance between the two class centers. Larger values move the red and blue point clouds apart across the true great-circle boundary; smaller values move both clouds toward the boundary and create more overlap.

Visual guide:

  • Wireframe sphere = \(\mathbb{S}^2\), with the north pole at top
  • Red circles = \(Y = 1\), Blue triangles = \(Y = 0\)
  • Green sphere = estimated anchor \(\hat{\mu}\) (Fréchet mean)
  • Blue arrow = estimated discriminating direction \(\hat{\beta}\)
  • Dashed red great circle = estimated decision boundary (where \(h(\hat{\beta}; x, \hat{\mu}) = 0\))
  • Dashed green great circle = true decision boundary
Code
s2_n_control = Inputs.range([30, 300], {step: 10, value: 300, label: "Sample size n"})
s2_separation_control = Inputs.range([10, 120], {step: 5, value: 60, label: "Class separation (geodesic °)"})
s2_conc_control = Inputs.range([2, 30], {step: 1, value: 3, label: "Covariate concentration κ"})
s2_rotY_control = Inputs.range([0, 360], {step: 5, value: 210, label: "View rotation (azimuth °)"})
s2_rotX_control = Inputs.range([-60, 60], {step: 5, value: 25, label: "View tilt (elevation °)"})
s2_n = Generators.input(s2_n_control)
s2_separation = Generators.input(s2_separation_control)
s2_conc = Generators.input(s2_conc_control)
s2_rotY = Generators.input(s2_rotY_control)
s2_rotX = Generators.input(s2_rotX_control)

s2_controls_view = html`
<style>
  .s2-slider-grid {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 6px 20px;
    max-width: 620px;
    margin: 0 auto 12px;
    font-family: system-ui, sans-serif;
    font-size: 0.85em;
  }
  .s2-slider-grid > * {
    min-width: 0;
    margin: 0;
  }
  .s2-slider-grid input[type="number"] {
    width: 7.5rem !important;
  }
  @media (max-width: 500px) {
    .s2-slider-grid {
      grid-template-columns: 1fr;
    }
  }
</style>
<div class="s2-slider-grid">
  <div>${s2_n_control}</div>
  <div>${s2_separation_control}</div>
  <div>${s2_conc_control}</div>
  <div>${s2_rotY_control}</div>
  <div>${s2_rotX_control}</div>
</div>
`

function attachS2DragRotation(root, selector, azimuthLabel, elevationLabel, minElevation, maxElevation, data, kind) {
  var svg = root.querySelector(selector);
  if (!svg) return root;
  var scene = svg.querySelector('g[transform^="translate"]');
  if (!scene) return root;

  function findRange(labelText) {
    var labels = Array.from(document.querySelectorAll("label"));
    for (var i = 0; i < labels.length; i++) {
      if (labels[i].textContent.indexOf(labelText) >= 0) {
        return labels[i].querySelector('input[type="range"]') ||
          (labels[i].parentElement && labels[i].parentElement.querySelector('input[type="range"]'));
      }
    }
    return null;
  }

  var az = findRange(azimuthLabel);
  var el = findRange(elevationLabel);
  var state = {
    rotY: az ? Number(az.value) : 210,
    rotX: el ? Number(el.value) : 25
  };

  function clamp(x, lo, hi) {
    return Math.max(lo, Math.min(hi, x));
  }

  function makeProjector(rotY, rotX, R) {
    var ry = rotY * Math.PI / 180;
    var rx = rotX * Math.PI / 180;
    return function(v) {
      var x1 = v[0]*Math.cos(ry) + v[2]*Math.sin(ry);
      var y1 = v[1];
      var z1 = -v[0]*Math.sin(ry) + v[2]*Math.cos(ry);
      var x2 = x1;
      var y2 = y1*Math.cos(rx) - z1*Math.sin(rx);
      var z2 = y1*Math.sin(rx) + z1*Math.cos(rx);
      return {x: x2*R, y: -y2*R, z: z2};
    };
  }

  function renderLogisticS2(res, rotY, rotX) {
    var parts = [];
    var R = 180;
    var proj = makeProjector(rotY, rotX, R);

    function vdot_local(a, b) { return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]; }

    for (var p = 0; p <= 18; p++) {
      var z = 1 - p * 2/18;
      var r = Math.sqrt(Math.max(0, 1 - z*z));
      var d = '';
      for (var j = 0; j <= 120; j++) {
        var phi = j * 2 * Math.PI / 120;
        var pt = proj([r*Math.cos(phi), r*Math.sin(phi), z]);
        d += (j===0 ? 'M' : 'L') + ' ' + pt.x.toFixed(1) + ' ' + pt.y.toFixed(1);
      }
      var alpha = p === 0 || p === 12 ? 0.25 : 0.12;
      parts.push('<path d="' + d + '" fill="none" stroke="#adb5bd" stroke-width="0.6" opacity="' + alpha + '"/>');
    }
    for (var m = 0; m < 24; m++) {
      var mphi = m * Math.PI / 12;
      var md = '';
      for (var mj = 0; mj <= 90; mj++) {
        var theta = mj * Math.PI / 90;
        var mpt = proj([Math.sin(theta)*Math.cos(mphi), Math.sin(theta)*Math.sin(mphi), Math.cos(theta)]);
        md += (mj===0 ? 'M' : 'L') + ' ' + mpt.x.toFixed(1) + ' ' + mpt.y.toFixed(1);
      }
      parts.push('<path d="' + md + '" fill="none" stroke="#adb5bd" stroke-width="0.6" opacity="0.12"/>');
    }

    parts.push('<circle cx="0" cy="0" r="' + R + '" fill="none" stroke="#dee2e6" stroke-width="1.2"/>');

    var dPath = '';
    for (var dj = 0; dj < res.decPoints.length; dj++) {
      var dpt = proj(res.decPoints[dj]);
      dPath += (dj===0 ? 'M' : 'L') + ' ' + dpt.x.toFixed(1) + ' ' + dpt.y.toFixed(1);
    }
    parts.push('<path d="' + dPath + '" fill="none" stroke="#e03131" stroke-width="2" stroke-dasharray="10,5" opacity="0.85"/>');

    var tdPath = '';
    for (var tj = 0; tj < res.trueDecPoints.length; tj++) {
      var tdpt = proj(res.trueDecPoints[tj]);
      tdPath += (tj===0 ? 'M' : 'L') + ' ' + tdpt.x.toFixed(1) + ' ' + tdpt.y.toFixed(1);
    }
    parts.push('<path d="' + tdPath + '" fill="none" stroke="#2b8a3e" stroke-width="1.5" stroke-dasharray="5,4" opacity="0.6"/>');

    var dataPts = [];
    for (var i = 0; i < res.n; i++) {
      var pt = proj(res.X[i]);
      dataPts.push({x: pt.x, y: pt.y, z: pt.z, cls: res.Y[i]});
    }
    dataPts.sort(function(a, b) { return a.z - b.z; });
    for (var di = 0; di < dataPts.length; di++) {
      var dp = dataPts[di];
      var col = dp.cls === 1 ? '#e03131' : '#1971c2';
      var opacity = 0.35 + 0.45 * ((dp.z + 1) / 2);
      if (dp.cls === 1) {
        parts.push('<circle cx="' + dp.x.toFixed(1) + '" cy="' + dp.y.toFixed(1) + '" r="3.5" fill="' + col + '" opacity="' + opacity.toFixed(2) + '"/>');
      } else {
        var s = 5, px = dp.x, py = dp.y;
        parts.push('<polygon points="' + px.toFixed(1) + ',' + (py-s).toFixed(1) + ' ' +
          (px-s*0.866).toFixed(1) + ',' + (py+s*0.5).toFixed(1) + ' ' +
          (px+s*0.866).toFixed(1) + ',' + (py+s*0.5).toFixed(1) +
          '" fill="' + col + '" opacity="' + opacity.toFixed(2) + '"/>');
      }
    }

    var muP = proj(res.muHat);
    var betaP = proj(res.betaHat);
    var tb = [res.betaHat[0]-res.muHat[0]*vdot_local(res.muHat, res.betaHat),
              res.betaHat[1]-res.muHat[1]*vdot_local(res.muHat, res.betaHat),
              res.betaHat[2]-res.muHat[2]*vdot_local(res.muHat, res.betaHat)];
    var ntb = Math.sqrt(tb[0]*tb[0]+tb[1]*tb[1]+tb[2]*tb[2]);
    if (ntb > 1e-10) {
      tb = [tb[0]/ntb, tb[1]/ntb, tb[2]/ntb];
      var arrowLen = 0.55;
      var arcPath = '';
      for (var a = 0; a <= 20; a++) {
        var t = a * arrowLen / 20;
        var interp = [res.muHat[0] + t*tb[0], res.muHat[1] + t*tb[1], res.muHat[2] + t*tb[2]];
        var ni = Math.sqrt(interp[0]*interp[0]+interp[1]*interp[1]+interp[2]*interp[2]);
        interp = [interp[0]/ni, interp[1]/ni, interp[2]/ni];
        var ip = proj(interp);
        arcPath += (a===0 ? 'M' : 'L') + ' ' + ip.x.toFixed(1) + ' ' + ip.y.toFixed(1);
      }
      parts.push('<path d="' + arcPath + '" fill="none" stroke="#1971c2" stroke-width="3" stroke-linecap="round"/>');
      var tip = [res.muHat[0] + arrowLen*tb[0], res.muHat[1] + arrowLen*tb[1], res.muHat[2] + arrowLen*tb[2]];
      var nTip = Math.sqrt(tip[0]*tip[0]+tip[1]*tip[1]+tip[2]*tip[2]);
      tip = [tip[0]/nTip, tip[1]/nTip, tip[2]/nTip];
      var ah = proj(tip);
      parts.push('<circle cx="' + ah.x.toFixed(1) + '" cy="' + ah.y.toFixed(1) + '" r="5.5" fill="#1971c2"/>');
    }

    parts.push('<circle cx="' + muP.x.toFixed(1) + '" cy="' + muP.y.toFixed(1) + '" r="6" fill="#2b8a3e" stroke="#fff" stroke-width="1.5"/>');
    parts.push('<circle cx="' + betaP.x.toFixed(1) + '" cy="' + betaP.y.toFixed(1) + '" r="4.5" fill="#1971c2" stroke="#fff" stroke-width="1"/>');
    var betaTrueP = proj(res.betaStarDir);
    parts.push('<circle cx="' + betaTrueP.x.toFixed(1) + '" cy="' + betaTrueP.y.toFixed(1) + '" r="4" fill="#2b8a3e" stroke="#fff" stroke-width="1" opacity="0.7"/>');

    return parts.join('\n');
  }

  function render() {
    if (kind === "logistic") scene.innerHTML = renderLogisticS2(data, state.rotY, state.rotX);
  }

  var dragging = false;
  var lastX = 0;
  var lastY = 0;

  svg.addEventListener("pointerdown", function(event) {
    dragging = true;
    lastX = event.clientX;
    lastY = event.clientY;
    svg.setPointerCapture(event.pointerId);
    svg.style.cursor = "grabbing";
  });

  svg.addEventListener("pointermove", function(event) {
    if (!dragging) return;
    var dx = event.clientX - lastX;
    var dy = event.clientY - lastY;
    lastX = event.clientX;
    lastY = event.clientY;
    state.rotY = (state.rotY + dx * 0.55 + 360) % 360;
    // Match vertical rotation to the pointer's direction.
    state.rotX = clamp(state.rotX + dy * 0.55, minElevation, maxElevation);
    render();
  });

  function stopDrag(event) {
    if (!dragging) return;
    dragging = false;
    svg.style.cursor = "grab";
    if (event.pointerId !== undefined) svg.releasePointerCapture(event.pointerId);
  }

  svg.addEventListener("pointerup", stopDrag);
  svg.addEventListener("pointercancel", stopDrag);
  svg.addEventListener("pointerleave", stopDrag);
  return root;
}

function runLogisticS2(n, rotYdeg, rotXdeg, kappa, separation, seed) {
  // ---- PRNG ----
  function mulberry32(a) {
    return function() {
      a |= 0; a = a + 0x6D2B79F5 | 0;
      var t = Math.imul(a ^ a >>> 15, 1 | a);
      t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
      return ((t ^ t >>> 14) >>> 0) / 4294967296;
    };
  }
  var rng = mulberry32(seed);

  // ---- 3D vector utilities ----
  function vec3(x, y, z) { return [x, y, z]; }
  function vdot(a, b) { return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]; }
  function vnorm(a) { return Math.sqrt(vdot(a, a)); }
  function vscale(a, s) { return [a[0]*s, a[1]*s, a[2]*s]; }
  function vsub(a, b) { return [a[0]-b[0], a[1]-b[1], a[2]-b[2]]; }
  function vadd(a, b) { return [a[0]+b[0], a[1]+b[1], a[2]+b[2]]; }
  function vnormalize(a) {
    var n = vnorm(a);
    if (n < 1e-14) return [0, 0, 1];
    return [a[0]/n, a[1]/n, a[2]/n];
  }
  function vcross(a, b) {
    return [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]];
  }

  // ---- Spherical coordinates to 3D ----
  function sphTo3D(theta, phi) {
    return [Math.sin(theta)*Math.cos(phi), Math.sin(theta)*Math.sin(phi), Math.cos(theta)];
  }
  function toSph(v) {
    var r = vnorm(v);
    var theta = Math.acos(Math.max(-1, Math.min(1, v[2]/r)));
    var phi = Math.atan2(v[1], v[0]);
    return {theta: theta, phi: phi};
  }

  // Geodesic distance on unit sphere
  function geoDist(a, b) {
    var dot = vdot(a, b);
    return Math.acos(Math.max(-1, Math.min(1, dot)));
  }

  // ---- Generate two spherical class clouds ----
  // Their centers are symmetric about muStar, so the population anchor remains
  // the north pole and the true decision boundary remains the great circle x=0.
  var muStar = [0, 0, 1]; // north pole as anchor

  function rvMFAround(center, kap) {
    // Exact inverse-CDF sampler for the S² von Mises-Fisher distribution
    // followed by a rotation from the north pole to the requested center.
    var u = rng();
    var z;
    if (kap < 1e-8) {
      z = 2*u - 1;
    } else {
      z = 1 + Math.log(u + (1 - u) * Math.exp(-2 * kap)) / kap;
    }
    var r = Math.sqrt(Math.max(0, 1 - z*z));
    var phi = rng() * 2 * Math.PI;
    var reference = Math.abs(center[2]) < 0.9 ? [0, 0, 1] : [0, 1, 0];
    var e1 = vnormalize(vcross(reference, center));
    var e2 = vnormalize(vcross(center, e1));
    return vnormalize(vadd(
      vadd(vscale(e1, r * Math.cos(phi)), vscale(e2, r * Math.sin(phi))),
      vscale(center, z)
    ));
  }

  // Put the two centers on opposite sides of the boundary, separated by the
  // slider's geodesic angle.
  var halfSeparation = separation * Math.PI / 360;
  var center0 = sphTo3D(halfSeparation, Math.PI);
  var center1 = sphTo3D(halfSeparation, 0);
  var X = [], Y = [];
  for (var i = 0; i < n; i++) {
    var cls = i % 2;
    X.push(rvMFAround(cls === 1 ? center1 : center0, kappa));
    Y.push(cls);
  }

  // ---- True coefficient direction ----
  // β* points from the anchor toward the Y=1 class center.
  var betaStarThetaDeg = 90;
  var betaStarDir = sphTo3D(betaStarThetaDeg * Math.PI / 180, 0);
  betaStarDir = vnormalize(betaStarDir);

  // ---- Alexandrov inner product on S² ----
  function alexIPS2(beta, x, anchor) {
    // d(anchor, x)
    var dx = geoDist(anchor, x);
    var db = geoDist(anchor, beta);
    if (dx < 1e-12 || db < 1e-12) return 0;

    // Tangent vector at anchor toward x
    var vx = vsub(x, vscale(anchor, vdot(anchor, x)));
    var nvx = vnorm(vx);
    if (nvx < 1e-12) return 0;
    vx = vscale(vx, 1/nvx);

    // Tangent vector at anchor toward beta
    var vb = vsub(beta, vscale(anchor, vdot(anchor, beta)));
    var nvb = vnorm(vb);
    if (nvb < 1e-12) return 0;
    vb = vscale(vb, 1/nvb);

    var cosAngle = vdot(vx, vb);
    return dx * db * cosAngle;
  }

  // ---- Estimate Fréchet mean (anchor) ----
  // Gradient descent on S²
  var muHat = [0, 0, 1];
  var lrMu = 0.35;
  for (var iter = 0; iter < 120; iter++) {
    var descent = [0, 0, 0];
    for (var i = 0; i < n; i++) {
      var d = geoDist(muHat, X[i]);
      if (d < 1e-12) continue;
      // The negative gradient points from muHat toward each observation.
      var tdir = vsub(X[i], vscale(muHat, vdot(muHat, X[i])));
      var ntd = vnorm(tdir);
      if (ntd < 1e-12) continue;
      tdir = vscale(tdir, 1/ntd);
      descent = vadd(descent, vscale(tdir, d));
    }
    descent = vscale(descent, 1/n);
    var nd = vnorm(descent);
    if (nd < 1e-8) break;
    var step = Math.min(lrMu * nd, 0.25);
    var dir = vscale(descent, 1/nd);
    muHat = vadd(vscale(muHat, Math.cos(step)), vscale(dir, Math.sin(step)));
    muHat = vnormalize(muHat);
  }

  // ---- Fit logistic model: grid search over S² ----
  // Fibonacci sphere for uniform-ish grid
  var nGrid = 300;
  var betaCandidates = [];
  var phi_golden = Math.PI * (3 - Math.sqrt(5));
  for (var g = 0; g < nGrid; g++) {
    var y = 1 - (g / (nGrid - 1)) * 2; // y from 1 to -1
    var radius = Math.sqrt(1 - y * y);
    var theta = phi_golden * g;
    betaCandidates.push([Math.cos(theta)*radius, Math.sin(theta)*radius, y]);
  }

  var bestLL = -Infinity, betaHat = betaCandidates[0];
  var logLikGrid = [];
  for (var g = 0; g < nGrid; g++) {
    var beta = betaCandidates[g];
    var ll = 0;
    for (var i = 0; i < n; i++) {
      var h = alexIPS2(beta, X[i], muHat);
      ll += Y[i] * h - Math.log(1 + Math.exp(h));
    }
    ll /= n;
    logLikGrid.push({beta: beta, ll: ll});
    if (ll > bestLL) { bestLL = ll; betaHat = beta; }
  }

  // ---- Classification accuracy ----
  var correct = 0;
  var probs = [];
  for (var i = 0; i < n; i++) {
    var hVal = alexIPS2(betaHat, X[i], muHat);
    var pred = hVal >= 0 ? 1 : 0;
    if (pred === Y[i]) correct++;
    probs.push(1 / (1 + Math.exp(-hVal)));
  }
  var accuracy = correct / n;

  // ---- Decision boundary great circle ----
  // Points on S² where alexIPS2(betaHat, x, muHat) = 0
  // This is the great circle orthogonal to the tangent direction of betaHat at muHat
  var tb = vsub(betaHat, vscale(muHat, vdot(muHat, betaHat)));
  var ntb = vnorm(tb);
  var decPlaneNormal = [0, 0, 1];
  if (ntb > 1e-10) {
    tb = vscale(tb, 1/ntb);
    // h(betaHat; x, muHat) = 0 iff x is orthogonal to this tangent direction.
    // Thus the decision boundary is the great circle in the plane normal to tb.
    decPlaneNormal = tb;
  }

  // True decision boundary
  var tbTrue = vsub(betaStarDir, vscale(muStar, vdot(muStar, betaStarDir)));
  var ntbTrue = vnorm(tbTrue);
  var trueDecNormal = [0, 1, 0]; // default
  if (ntbTrue > 1e-10) {
    tbTrue = vscale(tbTrue, 1/ntbTrue);
    trueDecNormal = tbTrue;
  }

  // Sample dec boundary points
  var decPoints = [];
  var nDec = 120;
  for (var d = 0; d < nDec; d++) {
    var angle = d * 2 * Math.PI / nDec;
    // Point on great circle: rotate muHat by 90° around decPlaneNormal
    var axis = decPlaneNormal;
    var cosa = Math.cos(angle), sina = Math.sin(angle);
    // Rodrigues rotation around axis
    // Start from a point orthogonal to both muHat and decNormal
    var startPt = vcross(decPlaneNormal, muHat);
    startPt = vnormalize(startPt);
    var pt = vadd(vadd(vscale(startPt, cosa), vscale(vcross(axis, startPt), sina)),
                  vscale(axis, vdot(axis, startPt) * (1 - cosa)));
    decPoints.push(vnormalize(pt));
  }

  var trueDecPoints = [];
  for (var d = 0; d < nDec; d++) {
    var angle = d * 2 * Math.PI / nDec;
    var startPt = vcross(trueDecNormal, muStar);
    startPt = vnormalize(startPt);
    var axis = trueDecNormal;
    var cosa = Math.cos(angle), sina = Math.sin(angle);
    var pt = vadd(vadd(vscale(startPt, cosa), vscale(vcross(axis, startPt), sina)),
                  vscale(axis, vdot(axis, startPt) * (1 - cosa)));
    trueDecPoints.push(vnormalize(pt));
  }

  // ---- Rotation matrices ----
  var ry = rotYdeg * Math.PI / 180;
  var rx = rotXdeg * Math.PI / 180;
  function rotate(v) {
    // Rotate around Y axis
    var x1 = v[0]*Math.cos(ry) + v[2]*Math.sin(ry);
    var y1 = v[1];
    var z1 = -v[0]*Math.sin(ry) + v[2]*Math.cos(ry);
    // Rotate around X axis
    var x2 = x1;
    var y2 = y1*Math.cos(rx) - z1*Math.sin(rx);
    var z2 = y1*Math.sin(rx) + z1*Math.cos(rx);
    return [x2, y2, z2];
  }
  function project(v) {
    // Orthographic projection: (x, y) with z for depth
    return {x: v[0], y: v[1], z: v[2]};
  }

  return {
    X: X, Y: Y, probs: probs,
    muStar: muStar, betaStarDir: betaStarDir,
    muHat: muHat, betaHat: betaHat,
    decPoints: decPoints, trueDecPoints: trueDecPoints,
    decPlaneNormal: decPlaneNormal, trueDecNormal: trueDecNormal,
    betaStarThetaDeg: betaStarThetaDeg,
    accuracy: accuracy, bestLL: bestLL,
    n: n, kappa: kappa, separation: separation,
    center0: center0, center1: center1,
    rotate: rotate, project: project
  };
}

s2result = runLogisticS2(s2_n, s2_rotY, s2_rotX, s2_conc, s2_separation, 42);

(() => {
const view = html`
<div style="font-family: system-ui, sans-serif; max-width: 920px;">

  <div style="display: flex; gap: 20px; flex-wrap: wrap;">

    <!-- Sphere visualization -->
    <div>
      <h4>Classification on 𝕊²</h4>
      <svg class="s2-logistic-globe" width="440" height="440" style="border: 1px solid #dee2e6; border-radius: 4px; cursor: grab; touch-action: none; user-select: none; background: linear-gradient(180deg, #fbfdff 0%, #f5f8fb 100%);">
        <g transform="translate(220, 220)">
          ${(() => {
            var res = s2result;
            var parts = [];
            var R = 180;

            // Helper: dot product (defined inside this scope)
            function vdot_local(a, b) { return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]; }

            // Helper: project + depth sort
            function proj(v) {
              var rv = res.rotate(v);
              return {x: rv[0]*R, y: -rv[1]*R, z: rv[2]};
            }

            // Sphere wireframe: parallels and meridians
            // Parallels (constant z)
            for (var p = 0; p <= 18; p++) {
              var z = 1 - p * 2/18;
              var r = Math.sqrt(Math.max(0, 1 - z*z));
              var d = '';
              var nPts = 120;
              for (var j = 0; j <= nPts; j++) {
                var phi = j * 2 * Math.PI / nPts;
                var pt = proj([r*Math.cos(phi), r*Math.sin(phi), z]);
                d += (j===0 ? 'M' : 'L') + ' ' + pt.x.toFixed(1) + ' ' + pt.y.toFixed(1);
              }
              var alpha = p === 0 || p === 12 ? 0.25 : 0.12;
              parts.push('<path d="' + d + '" fill="none" stroke="#adb5bd" stroke-width="0.6" opacity="' + alpha + '"/>');
            }
            // Meridians (constant phi)
            for (var m = 0; m < 24; m++) {
              var phi = m * Math.PI / 12;
              var d = '';
              var nPts = 90;
              for (var j = 0; j <= nPts; j++) {
                var theta = j * Math.PI / nPts;
                var pt = proj([Math.sin(theta)*Math.cos(phi), Math.sin(theta)*Math.sin(phi), Math.cos(theta)]);
                d += (j===0 ? 'M' : 'L') + ' ' + pt.x.toFixed(1) + ' ' + pt.y.toFixed(1);
              }
              parts.push('<path d="' + d + '" fill="none" stroke="#adb5bd" stroke-width="0.6" opacity="0.12"/>');
            }

            // Sphere outline
            parts.push('<circle cx="0" cy="0" r="' + R + '" fill="none" stroke="#dee2e6" stroke-width="1.2"/>');

            // Decision boundary great circle (estimated)
            var dPath = '';
            var decPts = res.decPoints;
            for (var j = 0; j < decPts.length; j++) {
              var pt = proj(decPts[j]);
              dPath += (j===0 ? 'M' : 'L') + ' ' + pt.x.toFixed(1) + ' ' + pt.y.toFixed(1);
            }
            parts.push('<path d="' + dPath + '" fill="none" stroke="#e03131" stroke-width="2" stroke-dasharray="10,5" opacity="0.85"/>');

            // True decision boundary
            var tdPath = '';
            for (var j = 0; j < res.trueDecPoints.length; j++) {
              var pt = proj(res.trueDecPoints[j]);
              tdPath += (j===0 ? 'M' : 'L') + ' ' + pt.x.toFixed(1) + ' ' + pt.y.toFixed(1);
            }
            parts.push('<path d="' + tdPath + '" fill="none" stroke="#2b8a3e" stroke-width="1.5" stroke-dasharray="5,4" opacity="0.6"/>');

            // Data points (depth sorted)
            var dataPts = [];
            for (var i = 0; i < res.n; i++) {
              var pt = proj(res.X[i]);
              dataPts.push({x: pt.x, y: pt.y, z: pt.z, cls: res.Y[i], idx: i});
            }
            dataPts.sort(function(a, b) { return a.z - b.z; }); // far first
            for (var i = 0; i < dataPts.length; i++) {
              var dp = dataPts[i];
              var col = dp.cls === 1 ? '#e03131' : '#1971c2';
              var opacity = 0.35 + 0.45 * ((dp.z + 1) / 2); // brighter in front
              if (dp.cls === 1) {
                parts.push('<circle cx="' + dp.x.toFixed(1) + '" cy="' + dp.y.toFixed(1) +
                  '" r="3.5" fill="' + col + '" opacity="' + opacity.toFixed(2) + '"/>');
              } else {
                var s = 5;
                var px = dp.x, py = dp.y;
                parts.push('<polygon points="' + px.toFixed(1) + ',' + (py-s).toFixed(1) + ' ' +
                  (px-s*0.866).toFixed(1) + ',' + (py+s*0.5).toFixed(1) + ' ' +
                  (px+s*0.866).toFixed(1) + ',' + (py+s*0.5).toFixed(1) +
                  '" fill="' + col + '" opacity="' + opacity.toFixed(2) + '"/>');
              }
            }

            // β̂ direction (arrow from origin through muHat pointing toward betaHat)
            var muP = proj(res.muHat);
            var betaP = proj(res.betaHat);
            // Draw line from muHat toward betaHat direction
            // The discriminating direction at muHat: tangent toward betaHat
            var tb = [res.betaHat[0]-res.muHat[0]*vdot_local(res.muHat, res.betaHat),
                      res.betaHat[1]-res.muHat[1]*vdot_local(res.muHat, res.betaHat),
                      res.betaHat[2]-res.muHat[2]*vdot_local(res.muHat, res.betaHat)];
            var ntb = Math.sqrt(tb[0]*tb[0]+tb[1]*tb[1]+tb[2]*tb[2]);
            if (ntb > 1e-10) {
              tb = [tb[0]/ntb, tb[1]/ntb, tb[2]/ntb];
              // Extend along tangent: muHat + t * tb (projected back to sphere)
              var arrowLen = 0.55;
              var tip = [res.muHat[0] + arrowLen*tb[0], res.muHat[1] + arrowLen*tb[1], res.muHat[2] + arrowLen*tb[2]];
              var nTip = Math.sqrt(tip[0]*tip[0]+tip[1]*tip[1]+tip[2]*tip[2]);
              tip = [tip[0]/nTip, tip[1]/nTip, tip[2]/nTip];
              var tipP = proj(tip);
              // Draw an arc from muHat to tip
              var arcPts = [];
              var nArc = 20;
              for (var a = 0; a <= nArc; a++) {
                var t = a * arrowLen / nArc;
                var interp = [res.muHat[0] + t*tb[0], res.muHat[1] + t*tb[1], res.muHat[2] + t*tb[2]];
                var ni = Math.sqrt(interp[0]*interp[0]+interp[1]*interp[1]+interp[2]*interp[2]);
                interp = [interp[0]/ni, interp[1]/ni, interp[2]/ni];
                var ip = proj(interp);
                arcPts.push(ip);
              }
              var arcPath = '';
              for (var a = 0; a < arcPts.length; a++) {
                arcPath += (a===0 ? 'M' : 'L') + ' ' + arcPts[a].x.toFixed(1) + ' ' + arcPts[a].y.toFixed(1);
              }
              parts.push('<path d="' + arcPath + '" fill="none" stroke="#1971c2" stroke-width="3" stroke-linecap="round"/>');
              // Arrowhead
              var ah = proj(tip);
              parts.push('<circle cx="' + ah.x.toFixed(1) + '" cy="' + ah.y.toFixed(1) + '" r="5.5" fill="#1971c2"/>');
            }

            // μ̂ marker (anchor)
            parts.push('<circle cx="' + muP.x.toFixed(1) + '" cy="' + muP.y.toFixed(1) +
              '" r="6" fill="#2b8a3e" stroke="#fff" stroke-width="1.5"/>');

            // β̂ marker
            parts.push('<circle cx="' + betaP.x.toFixed(1) + '" cy="' + betaP.y.toFixed(1) +
              '" r="4.5" fill="#1971c2" stroke="#fff" stroke-width="1"/>');

            // True β* marker
            var betaTrueP = proj(res.betaStarDir);
            parts.push('<circle cx="' + betaTrueP.x.toFixed(1) + '" cy="' + betaTrueP.y.toFixed(1) +
              '" r="4" fill="#2b8a3e" stroke="#fff" stroke-width="1" opacity="0.7"/>');

            return parts.join('\n');
          })()}
        </g>

        <!-- Legend -->
        <g transform="translate(15, 400)">
          <circle cx="0" cy="0" r="4" fill="#e03131" opacity="0.7"/><text x="8" y="4" font-size="10" fill="#495057">Y=1</text>
          <polygon points="70,-3.5 67,3 73,3" fill="#1971c2" opacity="0.7"/><text x="78" y="4" font-size="10" fill="#495057">Y=0</text>
          <circle cx="145" cy="0" r="4.5" fill="#2b8a3e" stroke="#fff" stroke-width="1"/><text x="153" y="4" font-size="10" fill="#495057">μ̂</text>
          <circle cx="175" cy="0" r="4" fill="#1971c2" stroke="#fff" stroke-width="1"/><text x="183" y="4" font-size="10" fill="#495057">β̂</text>
          <line x1="208" y1="0" x2="222" y2="0" stroke="#e03131" stroke-width="1.5" stroke-dasharray="6,3"/>
          <text x="226" y="4" font-size="10" fill="#495057">Decision boundary (est.)</text>
        </g>
        <text x="220" y="426" text-anchor="middle" font-size="11" fill="#6c757d">Drag the sphere to rotate and tilt</text>
      </svg>
    </div>

    <!-- Right panel: stats and info -->
    <div style="flex: 1; min-width: 280px;">
      <h4>Model Summary</h4>
      <div style="padding: 12px; background: #f8f9fa; border-radius: 6px;">
        <table style="width: 100%; border-collapse: collapse; font-size: 0.9em;">
          <tr><td style="padding: 3px 8px;">True β*</td>
              <td style="padding: 3px 8px; text-align: right; font-family: monospace;">θ=${s2result.betaStarThetaDeg.toFixed(0)}°, φ=0°</td></tr>
          <tr><td style="padding: 3px 8px;">Estimated β̂</td>
              <td style="padding: 3px 8px; text-align: right; font-weight: bold; color: #1971c2; font-family: monospace;">
                θ=${(() => { var v = s2result.betaHat; return (Math.acos(Math.max(-1,Math.min(1,v[2])))*180/Math.PI).toFixed(0); })()}°,
                φ=${(() => { var v = s2result.betaHat; return (Math.atan2(v[1],v[0])*180/Math.PI).toFixed(0); })()}°</td></tr>
          <tr><td style="padding: 3px 8px;">‖μ̂ − μ*‖ (geodesic)</td>
              <td style="padding: 3px 8px; text-align: right; font-family: monospace;">${(() => { var v = s2result.muHat; return (Math.acos(Math.max(-1,Math.min(1,v[2])))*180/Math.PI).toFixed(2); })()}°</td></tr>
          <tr><td style="padding: 3px 8px;">‖β̂ − β*‖ (geodesic)</td>
              <td style="padding: 3px 8px; text-align: right; font-family: monospace;">${(() => { function gd(a,b){return Math.acos(Math.max(-1,Math.min(1,a[0]*b[0]+a[1]*b[1]+a[2]*b[2])));} return (gd(s2result.betaHat,s2result.betaStarDir)*180/Math.PI).toFixed(2); })()}°</td></tr>
          <tr><td style="padding: 3px 8px;">Classification accuracy</td>
              <td style="padding: 3px 8px; text-align: right; font-weight: bold;">${(s2result.accuracy * 100).toFixed(1)}%</td></tr>
          <tr><td style="padding: 3px 8px;">Max log-likelihood</td>
              <td style="padding: 3px 8px; text-align: right; font-family: monospace;">${s2result.bestLL.toFixed(4)}</td></tr>
          <tr><td style="padding: 3px 8px;">Sample size</td>
              <td style="padding: 3px 8px; text-align: right;">${s2result.n}</td></tr>
          <tr><td style="padding: 3px 8px;">Concentration κ</td>
              <td style="padding: 3px 8px; text-align: right;">${s2result.kappa}</td></tr>
          <tr><td style="padding: 3px 8px;">Class separation</td>
              <td style="padding: 3px 8px; text-align: right;">${s2result.separation.toFixed(0)}°</td></tr>
        </table>
      </div>

    </div>
  </div>

`
return attachS2DragRotation(view, ".s2-logistic-globe", "View rotation", "View tilt", -60, 60, s2result, "logistic");
})()
Code
function vdot_s2(a, b) { return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]; }
(a)
(b)
(c)
(d)
(e)
(f)
(g)
(h)
(i)
(j)
(k)
(l)
(m)
(n)
(o)
(p)
Figure 4: Interactive: Metric-space logistic regression on the 2D sphere S²
NoteGeometric interpretation
  • The green dot is the estimated anchor \(\hat{\mu}\) (Fréchet mean of covariates).
  • The blue arrow shows the geodesic from \(\hat{\mu}\) toward \(\hat{\beta}\) — this is the discriminating direction.
  • The dashed red great circle is the estimated decision boundary — points whose geodesic from \(\hat{\mu}\) is orthogonal to the \(\hat{\beta}\) direction.
  • Points on the blue-arrow side of the boundary get higher predicted probability of \(Y = 1\).
  • Drag the sphere to rotate and tilt the view directly; the sliders remain available for preset camera angles.
NoteHow to read the plot

The anchor \(\hat{\mu}\) is the Fréchet mean of the covariates (green dot). The geodesic from \(\hat{\mu}\) toward \(\hat{\beta}\) (blue arc with arrow) is the discriminating direction. The dashed red great circle is the estimated decision boundary — points with positive Alexandrov inner product (on the \(\hat{\beta}\) side) are classified as \(Y = 1\); points on the opposite side as \(Y = 0\). Depth-cued rendering (lighter = closer to viewer) helps perceive the 3D structure.

TipTry these experiments
  • Rotate the view: Drag the sphere left/right to rotate and up/down to tilt. The 3D structure — especially the great-circle decision boundary — is much clearer when viewed from multiple perspectives.
  • Adjust class separation: Increase it to make the red and blue classes more distinct across the true boundary; decrease it to create greater overlap and a harder classification problem.
  • Increase \(\kappa\): Each class forms a tighter cloud around its own center, reducing overlap and making classification easier.
  • Increase \(n\): The estimated \(\hat{\beta}\) converges to \(\beta^*\) and the decision boundary aligns with the true boundary (green dashed).
  • Compare to S¹: On the sphere, the decision boundary is a great circle (a 1D curve) rather than two points — this richer geometry allows more flexible classification while remaining fully interpretable.
  • Observe \(\hat{\mu}\) estimation: The estimated anchor (green dot) should be near the north pole (the true \(\mu^*\)). The small geodesic error \(\|\hat{\mu} - \mu^*\|\) confirms that the Fréchet mean is well estimated even with moderate \(n\).

11 Key Takeaways

  • Metric-space logistic regression reverses the roles compared to Fréchet regression: the response is binary (\(Y \in \{0,1\}\)) and the covariate \(X\) lives in a metric space.
  • The model replaces the Euclidean inner product \(X^\top\beta^*\) with the Alexandrov inner product \(d(\mu^*, X) d(\mu^*, \beta^*) \cos\angle_{\mu^*}(X, \beta^*)\) anchored at the Fréchet mean \(\mu^*\) of the covariates.
  • Estimation is two-stage: estimate the anchor via the sample Fréchet mean, then estimate the coefficient \(\beta\) via maximum likelihood.
  • LIPO global optimization handles the non-convex, non-differentiable likelihood landscape using only metric distances and a Hölder continuity bound. The Hölder constant \(K\) is critical: too small leads to premature convergence, too large degenerates to random search.
  • The fitted geodesic from \(\hat{\mu}\) to \(\hat{\beta}\) is geometrically interpretable: on \(\mathbb{S}^2\) the decision boundary is a great circle orthogonal to the discriminating direction; on SPD manifolds it identifies which feature pairs (brain connections, asset correlations) drive the classification.
  • The plug-in classifier achieves competitive accuracy while remaining geometrically transparent — the decision boundary is orthogonal to \(\hat{\beta}\) at \(\hat{\mu}\).
  • Convergence rates and classification risk bounds depend on local metric entropy and the margin exponent \(\alpha_L\).

12 Exercises

  1. Alexandrov inner product in Euclidean space. Verify that for \(\mathcal{M} = \mathbb{R}^D\) with the Euclidean metric and anchor \(\mu^* = 0\), the metric-space logistic model reduces exactly to ordinary logistic regression. 📝 Show Solution

  2. Decision boundary geometry. Show that on \(\mathbb{S}^2\), the decision boundary \(\{x : h(\hat{\beta}; x, \hat{\mu}) = 0\}\) is a great circle lying in the plane orthogonal to the tangent direction of \(\hat{\beta}\) at \(\hat{\mu}\).📝 Show Solution

  3. Interpretability vs. black-box. The metric-space logistic model achieves slightly lower AUC than SPDNet (0.9060 vs 0.9205) but much higher interpretability. Discuss the tradeoff: in what applications would you prefer the interpretable model? 📝 Show Solution

  4. Implementation. Implement the metric-space logistic model on a manifold (e.g., hyperbolic space) and evaluate its performance on a synthetic dataset.

Exercise 1: Euclidean Recovery

Exercise: Verify the metric-space logistic model reduces to ordinary logistic regression when \(\mathcal{M} = \mathbb{R}^D\).

Solution:

For \(\mathcal{M} = \mathbb{R}^D\) with \(d(x, y) = \|x - y\|_2\) and anchor \(\mu^* = 0\):

\[ d(\mu^*, X) = \|X\|_2, \qquad d(\mu^*, \beta^*) = \|\beta^*\|_2. \]

The Alexandrov angle at \(0\) between \(X\) and \(\beta^*\) is the Euclidean angle:

\[ \cos\angle_0(X, \beta^*) = \frac{X^\top\beta^*}{\|X\|_2\|\beta^*\|_2}. \]

Therefore,

\[ h(\beta^*; X, \mu^*) = \|X\|_2 \|\beta^*\|_2 \cdot \frac{X^\top\beta^*}{\|X\|_2\|\beta^*\|_2} = X^\top\beta^*. \]

The model becomes \(\log\frac{P_X}{1-P_X} = X^\top\beta^*\), which is exactly ordinary logistic regression (without intercept). The anchor \(\mu^* = 0 = \mathbb{E}[X]\) for centered data, and the coefficient \(\beta^*\) is the usual logistic coefficient vector.

Exercise 4: Decision Boundary on 𝕊²

Exercise: Show that the decision boundary on \(\mathbb{S}^2\) is a great circle orthogonal to \(\hat{\beta}\).

Solution:

On \(\mathbb{S}^2\), the decision boundary is \(\{x \in \mathbb{S}^2 : h(\hat{\beta}; x, \hat{\mu}) = 0\}\).

The Alexandrov inner product is:

\[ h(\hat{\beta}; x, \hat{\mu}) = d(\hat{\mu}, x) d(\hat{\mu}, \hat{\beta}) \cos\angle_{\hat{\mu}}(x, \hat{\beta}). \]

Assuming \(d(\hat{\mu}, \hat{\beta}) > 0\), this is zero when either: 1. \(d(\hat{\mu}, x) = 0\) (i.e., \(x = \hat{\mu}\), a single point — the anchor), or 2. \(\cos\angle_{\hat{\mu}}(x, \hat{\beta}) = 0\) (i.e., \(\angle_{\hat{\mu}}(x, \hat{\beta}) = \pi/2\)).

Why it’s a great circle: The condition \(\angle_{\hat{\mu}}(x, \hat{\beta}) = \pi/2\) means the tangent vector at \(\hat{\mu}\) toward \(x\) is orthogonal to the tangent vector at \(\hat{\mu}\) toward \(\hat{\beta}\). Let \(v_\beta\) be the unit tangent vector at \(\hat{\mu}\) pointing toward \(\hat{\beta}\) (i.e., the direction of the geodesic \(\gamma_{\hat{\mu} \to \hat{\beta}}\)). The set of all unit tangent vectors at \(\hat{\mu}\) orthogonal to \(v_\beta\) forms a 1-dimensional circle in the tangent space \(T_{\hat{\mu}}\mathbb{S}^2 \cong \mathbb{R}^2\). Following each of these directions along a geodesic for distance \(\pi/2\) from \(\hat{\mu}\) sweeps out exactly a great circle on \(\mathbb{S}^2\).

Equivalently, the great circle lies in the plane whose normal vector is \(v_\beta\) (embedded in \(\mathbb{R}^3\)). The plane passes through the origin and is orthogonal to the direction of \(\hat{\beta}\) at \(\hat{\mu}\). The intersection of this plane with the unit sphere is the decision boundary great circle.

Exercise 5: Interpretability Tradeoff

Exercise: Discuss when you would prefer the interpretable metric-space logistic model over a black-box classifier like SPDNet.

Solution:

Prefer the interpretable model when:

  1. Scientific discovery is the goal. The fMRI application is a perfect example — the goal is not just to classify tasks but to understand which brain regions and connections drive the difference. The fitted geodesic directly identifies these.

  2. Regulatory or ethical requirements. In medical diagnosis, credit scoring, or criminal justice, black-box models face regulatory scrutiny (e.g., GDPR “right to explanation”). An interpretable model can justify its decisions.

  3. Small sample sizes. The metric-space logistic model has only one parameter object \(\beta\) (same “dimension” as a single covariate). SPDNet and other deep learning methods have thousands of parameters and require large \(n\).

  4. Generalization across domains. A geometrically motivated model may generalize better when the data-generating process changes slightly, because it encodes structural knowledge (the metric geometry) rather than learning everything from data.

  5. Debugging and iteration. When the model makes mistakes, you can inspect why: which geodesic direction was involved? This enables iterative model improvement.

Prefer a black-box model when:

  1. Pure prediction is the sole objective (e.g., winning a Kaggle competition).
  2. The decision boundary is highly nonlinear and cannot be well-approximated by a single geodesic half-space.
  3. Very large \(n\) is available, making deep learning feasible and likely to outperform.

A better approach is often to fit both: use the interpretable model for scientific insight and the black-box model as a performance benchmark.

13 Self-Assessment Quiz

Test your understanding of this lecture with the interactive MCQ quiz:

👉 Lecture 10 Quiz — 10 Multiple-Choice Questions

14 Further Reading

  • Lin and Lin (2025) — The foundational paper on metric-space logistic regression and classification.
  • Lin (2019) — Log-Cholesky metric on SPD matrices, used in the fMRI application.
  • Arsigny et al. (2007) — Log-Euclidean metrics for SPD matrices.
  • Moakher (2005) — Affine-invariant geometric mean on SPD matrices.
  • Sturm (2003b) — Hadamard space theory: convexity and uniqueness results.

References

Afsari, Bijan. 2011. “Riemannian \(L^p\) Center of Mass: Existence, Uniqueness, and Convexity.” Proceedings of the American Mathematical Society 139 (2): 655–73. https://doi.org/10.1090/S0002-9939-2010-10541-5.
Ahidar-Coutrix, Adil, Thibaut Le Gouic, and Quentin Paris. 2020. “Convergence Rates for Empirical Barycenters in Metric Spaces: Curvature, Convexity and Extendable Geodesics.” Probability Theory and Related Fields 177 (1): 323–68.
Albert, Adelin, and John A Anderson. 1984. “On the Existence of Maximum Likelihood Estimates in Logistic Regression Models.” Biometrika 71 (1): 1–10.
Arsigny, V., P. Fillard, X. Pennec, and N. Ayache. 2007. “Geometric Means in a Novel Vector Space Structure on Symmetric Positive-Definite Matrices.” SIAM Journal on Matrix Analysis and Applications 29 (1): 328–47. https://doi.org/10.1137/050637996.
Barch, Deanna M, Gregory C Burgess, Michael P Harms, et al. 2013. “Function in the Human Connectome: Task-fMRI and Individual Differences in Behavior.” Neuroimage 80: 169–89.
Bhattacharya, Rabi, and Vic Patrangenaru. 2003. “Large Sample Theory of Intrinsic and Extrinsic Sample Means on Manifolds. I.” The Annals of Statistics 31 (1): 1–29. https://doi.org/10.1214/aos/1046294456.
Chaudhuri, Kamalika, and Sanjoy Dasgupta. 2014. “Rates of Convergence for Nearest Neighbor Classification.” Advances in Neural Information Processing Systems 27.
Hastie, Trevor, Robert Tibshirani, Jerome H Friedman, and Jerome H Friedman. 2009. The Elements of Statistical Learning: Data Mining, Inference, and Prediction. Vol. 2. Springer.
Huang, Zhiwu, and Luc Van Gool. 2017. “A Riemannian Network for SPD Matrix Learning.” Thirty-First AAAI Conference on Artificial Intelligence.
Jayasumana, Sadeep, Richard Hartley, Mathieu Salzmann, Hongdong Li, and Mehrtash Harandi. 2013. “Kernel Methods on the Riemannian Manifold of Symmetric Positive Definite Matrices.” Proceedings of the IEEE Conference on Computer Vision and Pattern Recognition, 73–80.
Lin, Yinan, and Zhenhua Lin. 2025. “Binary Regression and Classification with Covariates in Metric Spaces.” Biometrics 81 (3): ujaf123. https://doi.org/10.1093/biomtc/ujaf123.
Lin, Zhenhua. 2019. “Riemannian Geometry of Symmetric Positive Definite Matrices via Cholesky Decomposition.” SIAM Journal on Matrix Analysis and Applications 40 (4): 1353–70. https://doi.org/10.1137/18M1221084.
Lin, Zhenhua, and Hans-Georg Müller. 2021. “Total Variation Regularized Fréchet Regression for Metric-Space Valued Data.” The Annals of Statistics 49 (6): 3510–33.
Moakher, M. 2005. “A Differential Geometric Approach to the Geometric Mean of Symmetric Positive-Definite Matrices.” SIAM Journal on Matrix Analysis and Applications 26 (3): 735–47. https://doi.org/10.1137/S0895479803436937.
Petersen, Alexander, and Hans-Georg Müller. 2019. “Fréchet Regression for Random Objects with Euclidean Predictors.” The Annals of Statistics 47 (2): 691–719.
Schötz, Christof. 2019. “Convergence Rates for the Generalized Fréchet Mean via the Quadruple Inequality.” Electronic Journal of Statistics 13 (2): 4280–345.
Sturm, Karl-Theodor. 2003a. “Probability Measures on Metric Spaces of Nonpositive.” Heat Kernels and Analysis on Manifolds, Graphs, and Metric Spaces 338: 357–90.
Sturm, Karl-Theodor. 2003b. “Probability Measures on Metric Spaces of Nonpositive Curvature.” In Heat Kernels and Analysis on Manifolds, Graphs, and Metric Spaces, vol. 338. Contemporary Mathematics. American Mathematical Society. https://doi.org/10.1090/conm/338/06080.
Wainwright, Martin J. 2019. High-Dimensional Statistics: A Non-Asymptotic Viewpoint. Vol. 48. Cambridge University Press.
Yang, Yuhong. 1999. “Minimax Nonparametric Classification. I. Rates of Convergence.” IEEE Transactions on Information Theory 45 (7): 2271–84.
Yu-Feng, Zang, He Yong, Zhu Chao-Zhe, et al. 2007. “Altered Baseline Brain Activity in Children with ADHD Revealed by Resting-State Functional MRI.” Brain and Development 29 (2): 83–91.