奇异值分解(SVD):从几何直觉理解矩阵的本质

奇异值分解(SVD):从几何直觉理解矩阵的本质

一篇写给"想真正看懂矩阵在干什么"的人的可视化笔记

引言:矩阵到底在做什么?

当我们写下一个2x2矩阵时,它仅仅是四个数字的排列吗?

不是。矩阵是一个变换------它把空间中的每一个向量送到另一个位置。但仅仅说"它是一个线性变换"过于抽象,我们需要一个更直观的画面:

任何矩阵作用在单位圆上,都会把它变成一个椭圆。

这个看似简单的事实,背后藏着线性代数中最优雅的定理之一:奇异值分解(Singular Value Decomposition, SVD)

这篇博客的目标是:通过一个交互式可视化工具,让你亲眼看见奇异值、奇异向量、特征值之间的差异,并建立一套"看到矩阵数字就能想象出几何形状"的直觉。

我们要回答的核心问题

读完这篇文章并把玩可视化工具后,你应该能够:

  1. 看到一个 2×2 矩阵的四个数字,立刻在脑中画出它的椭圆形状
  2. 区分"特征向量"和"奇异向量" ------ 它们经常被混淆,但其实是两个完全不同的概念
  3. 理解 SVD 的三步分解:旋转 → 拉伸 → 旋转
  4. 从 2D 推广到 3D 乃至更高维,理解为什么 n 维空间有 n(n−1)/2 个旋转平面

一、奇异值是什么?

定义

对于任意一个 m×n 的矩阵 A,奇异值分解告诉我们它一定可以写成:

A=UΣVT A = U \Sigma V^T A=UΣVT

其中:

  • V :n×n 正交矩阵,列向量是右奇异向量 (vi\mathbf{v}_ivi
  • Σ:对角矩阵,对角线上是奇异值
  • U :m×m 正交矩阵,列向量是左奇异向量 (ui\mathbf{u}_iui)

几何意义(这才是重点)

把 A 作用在单位圆上,会得到一个椭圆。那么:

  • 奇异值 (σi\sigma_iσi) = 椭圆半轴的长度
  • 右奇异向量 (vi\mathbf{v}_ivi) = 单位圆上"将被拉成主轴"的那些方向
  • 左奇异向量 (ui\mathbf{u}_iui) = 椭圆主轴的方向 (即 (AviA\mathbf{v}_iAvi) 归一化后)

换句话说:SVD 找到了输入空间中的一组正交方向,使得它们经过 A 变换后依然正交,并且各自被拉伸 (σi\sigma_iσi) 倍。

二、SVD 的三步几何分解

矩阵 A 的作用可以被理解为三个连续步骤:

复制代码
单位圆 ──[V^T 旋转]──> 旋转后的圆 ──[Σ 拉伸]──> 轴对齐椭圆 ──[U 旋转]──> 最终椭圆
  1. V^T:把右奇异向量旋转到标准坐标轴上
  2. Σ :沿坐标轴方向各自拉伸 (σ1,σ2,...\sigma_1, \sigma_2, \ldotsσ1,σ2,...) 倍
  3. U:把椭圆旋转到最终的姿态

任何线性变换,本质上都是"转一下、拉一下、再转一下"。 这就是 SVD 的几何灵魂。

三、奇异向量 ≠ 特征向量

这是最容易混淆、也是可视化工具最有助益的地方。

概念 定义 几何含义 是否正交
特征向量 (Ax=λxA\mathbf{x} = \lambda \mathbf{x}Ax=λx) 经过变换后方向不变的向量 一般不正交
右奇异向量 (ATAA^T AATA) 的特征向量 经过变换后变成椭圆主轴的输入方向 始终正交

只有当 A 是对称矩阵时,特征向量和奇异向量才重合。

对于 (A=[2135]A = \begin{bmatrix} 2 & 1 \\ 3 & 5 \end{bmatrix}A=[2315]):

  • 特征向量夹角不是 90°(紫色箭头)
  • 右奇异向量始终垂直(玫红色虚线)
  • 经过 A 变换后,奇异向量变成椭圆的长短轴(玫红色实线),仍然垂直

四、看数字读矩阵:建立直觉

对于 2×2 矩阵 (A=[abcd]A = \begin{bmatrix} a & b \\ c & d \end{bmatrix}A=[acbd]),我们可以训练这样一套读法:

1. 对角线决定"基础体型"

  • a, d 控制 x、y 方向的基础拉伸
  • d > a → 瘦高椭圆;a > d → 矮胖椭圆

2. 反对角线决定"扭转"

将矩阵分解为对称部分(拉伸)+ 反对称部分(旋转):

A=A+AT2⏟纯拉伸+A−AT2⏟纯旋转 A = \underbrace{\frac{A+A^T}{2}}{\text{纯拉伸}} + \underbrace{\frac{A-A^T}{2}}{\text{纯旋转}} A=纯拉伸 2A+AT+纯旋转 2A−AT

  • c − b > 0:逆时针扭转 ↺
  • c − b < 0:顺时针扭转 ↻
  • c = b(对称):不扭转,纯拉伸
  • 扭转角度 ≈ (arctan⁡c−ba+d\arctan\frac{c-b}{a+d}arctana+dc−b)

3. 行列式决定"是否翻转"

  • det(A) > 0:保持手性
  • det(A) < 0:镜像翻转
  • det(A) = 0:椭圆退化为线段(矩阵奇异)

实例:A = [[2, 1], [3, 5]]

  • 对角线 (2, 5) → 瘦高椭圆,y 方向拉得更长
  • c − b = 3 − 1 = +2 > 0 → 逆时针扭转 ≈ 16°
  • det = 7 > 0 → 不翻转

大概得感觉:一个瘦高的椭圆,向左上方倾斜约 16°。从上面的示例图我们可以看到大概确实是这样

五、从 2D 到 n D:维度的推广

这套直觉可以无缝推广到高维:

维度 拉伸自由度 旋转平面数 旋转自由度
2D 3 1(xy) 1
3D 6 3(xy, xz, yz) 3
4D 10 6 6
n D n(n+1)/2 n(n−1)/2 n(n−1)/2

3D 中的反对称部分恰好对应一个"角速度向量"------这就是物理学中刚体旋转的数学基础。

六、为什么 SVD 重要?

SVD 不仅仅是一个数学工具,它是众多现代算法的核心:

  • 主成分分析(PCA):本质上就是对数据矩阵做 SVD
  • 图像压缩:保留前 k 个最大的奇异值,丢弃其余
  • 推荐系统:矩阵分解(如 Netflix Prize)
  • 最小二乘 :伪逆 (A+=VΣ+UTA^+ = V\Sigma^+U^TA+=VΣ+UT) 直接由 SVD 给出
  • 数值稳定性 :条件数 = (σ1/σn\sigma_1 / \sigma_nσ1/σn)
  • 大语言模型:LoRA 等低秩适配技术、注意力矩阵的低秩近似

奇异值告诉我们:在所有可能的方向中,矩阵最看重哪些方向,最忽略哪些方向。

七、动手实验:可视化工具说明

下面的可视化工具让你可以:

  • 拖动矩阵元素 a, b, c, d,实时看到椭圆的形变
  • 同时观察单位圆、椭圆、特征向量、奇异向量、SVD 三步分解
  • 对比预设矩阵:纯拉伸、纯旋转、剪切、对称矩阵、奇异矩阵......
  • 在 2D 中建立直觉后,再用 3D 模式观察高维推广

建议的探索路径:

  1. 从 ([[2,0],[0,1]][[2,0],[0,1]][[2,0],[0,1]]) 开始 ------ 纯拉伸,所有箭头共线

  2. 调到 ([[2,1],[1,2]][[2,1],[1,2]][[2,1],[1,2]]) ------ 对称矩阵,特征向量与奇异向量重合

  3. 调到 ([[2,1],[3,5]][[2,1],[3,5]][[2,1],[3,5]]) ------ 一般矩阵,三种箭头各不相同

  4. 调到 ([[0,−1],[1,0]][[0,-1],[1,0]][[0,−1],[1,0]]) ------ 纯旋转,椭圆 = 单位圆

  5. 调到 ([[1,2],[2,4]][[1,2],[2,4]][[1,2],[2,4]]) ------ 奇异矩阵,椭圆退化为线段

带着这些问题去玩:

  • 什么时候特征向量与奇异向量重合?
  • 什么时候椭圆"翻面"了?(提示:看 det 的符号)
  • 调到什么矩阵时,椭圆变成圆?

接下来的代码实现部分

源码如下

jsx 复制代码
import React, { useState, useEffect, useRef } from 'react';
import { Play, RotateCcw, SkipForward } from 'lucide-react';

export default function MatrixVisualizer() {
  const [aVal, setA] = useState(2);
  const [bVal, setB] = useState(1);
  const [cVal, setC] = useState(0);
  const [dVal, setD] = useState(3);
  const [tVal, setT] = useState(1);
  const [playing, setPlaying] = useState(false);
  const [showSquare, setShowSquare] = useState(true);
  const [showCircle, setShowCircle] = useState(true);
  const [showEigen, setShowEigen] = useState(false);
  const [showSvd, setShowSvd] = useState(true);

  const rafRef = useRef(null);

  useEffect(() => {
    if (!playing) return;
    let start = null;
    setT(0);
    const step = (ts) => {
      if (start === null) start = ts;
      const elapsed = (ts - start) / 1800;
      const newT = Math.min(1, elapsed);
      setT(newT);
      if (newT < 1) {
        rafRef.current = requestAnimationFrame(step);
      } else {
        setPlaying(false);
      }
    };
    rafRef.current = requestAnimationFrame(step);
    return () => {
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
    };
  }, [playing]);

  const m00 = 1 + (aVal - 1) * tVal;
  const m01 = bVal * tVal;
  const m10 = cVal * tVal;
  const m11 = 1 + (dVal - 1) * tVal;

  const apply = (xx, yy) => [m00 * xx + m01 * yy, m10 * xx + m11 * yy];

  const computeSVD = (p, q, r, s) => {
    const ata00 = p * p + r * r;
    const ata11 = q * q + s * s;
    const ata01 = p * q + r * s;
    const trace = ata00 + ata11;
    const determinant = ata00 * ata11 - ata01 * ata01;
    const discr = Math.max(0, trace * trace - 4 * determinant);
    const lam1 = (trace + Math.sqrt(discr)) / 2;
    const lam2 = Math.max(0, (trace - Math.sqrt(discr)) / 2);
    const sig1 = Math.sqrt(Math.max(0, lam1));
    const sig2 = Math.sqrt(lam2);
    let vx, vy;
    if (Math.abs(ata01) > 1e-9) {
      vx = ata01;
      vy = lam1 - ata00;
      const norm = Math.hypot(vx, vy);
      if (norm > 1e-9) {
        vx /= norm;
        vy /= norm;
      } else {
        vx = 1;
        vy = 0;
      }
    } else {
      if (ata00 >= ata11) {
        vx = 1;
        vy = 0;
      } else {
        vx = 0;
        vy = 1;
      }
    }
    return {
      s1: sig1,
      s2: sig2,
      v1x: vx,
      v1y: vy,
      v2x: -vy,
      v2y: vx,
    };
  };

  const svdA = computeSVD(aVal, bVal, cVal, dVal);
  const svdM = computeSVD(m00, m01, m10, m11);

  const detA = aVal * dVal - bVal * cVal;
  const trA = aVal + dVal;
  const discA = trA * trA - 4 * detA;
  let eigVals = null;
  let eigVec1 = null;
  let eigVec2 = null;
  if (discA >= -1e-9) {
    const sq = Math.sqrt(Math.max(0, discA));
    const lam1 = (trA + sq) / 2;
    const lam2 = (trA - sq) / 2;
    const findVec = (lam) => {
      let xx, yy;
      if (Math.abs(bVal) > 1e-9) {
        xx = bVal;
        yy = lam - aVal;
      } else if (Math.abs(cVal) > 1e-9) {
        xx = lam - dVal;
        yy = cVal;
      } else {
        if (Math.abs(lam - aVal) < 1e-9) {
          xx = 1;
          yy = 0;
        } else {
          xx = 0;
          yy = 1;
        }
      }
      const norm = Math.hypot(xx, yy);
      if (norm > 1e-9) return [xx / norm, yy / norm];
      return [1, 0];
    };
    eigVals = [lam1, lam2];
    eigVec1 = findVec(lam1);
    eigVec2 = findVec(lam2);
  }

  const SIZE = 480;
  const SCALE = 30;
  const CENTER = SIZE / 2;
  const toSvg = (xx, yy) => [CENTER + xx * SCALE, CENTER - yy * SCALE];
  const safeNum = (val) => (Number.isFinite(val) ? val : 0);

  const gridLines = [];
  for (let i = -7; i <= 7; i++) {
    const aPt = apply(i, -7);
    const bPt = apply(i, 7);
    gridLines.push({ x1: aPt[0], y1: aPt[1], x2: bPt[0], y2: bPt[1], axis: i === 0 });
    const cPt = apply(-7, i);
    const dPt = apply(7, i);
    gridLines.push({ x1: cPt[0], y1: cPt[1], x2: dPt[0], y2: dPt[1], axis: i === 0 });
  }

  const N = 80;
  let circlePath = '';
  for (let i = 0; i <= N; i++) {
    const th = (i / N) * Math.PI * 2;
    const pt = apply(Math.cos(th), Math.sin(th));
    const sv = toSvg(pt[0], pt[1]);
    circlePath += (i === 0 ? 'M' : 'L') + ' ' + sv[0].toFixed(1) + ' ' + sv[1].toFixed(1) + ' ';
  }

  const sqCornersData = [[0, 0], [1, 0], [1, 1], [0, 1]];
  const sqCorners = [];
  for (let i = 0; i < sqCornersData.length; i++) {
    const pt = apply(sqCornersData[i][0], sqCornersData[i][1]);
    sqCorners.push(toSvg(pt[0], pt[1]));
  }
  const sqPath =
    sqCorners.map((p, i) => (i === 0 ? 'M' : 'L') + ' ' + p[0] + ' ' + p[1]).join(' ') + ' Z';

  const ihatPt = apply(1, 0);
  const jhatPt = apply(0, 1);

  const Arrow = ({ fromX, fromY, toX, toY, color, width }) => {
    if (!Number.isFinite(toX) || !Number.isFinite(toY)) return null;
    const sv1 = toSvg(fromX, fromY);
    const sv2 = toSvg(toX, toY);
    const dx = sv2[0] - sv1[0];
    const dy = sv2[1] - sv1[1];
    const len = Math.hypot(dx, dy);
    if (len < 2) return null;
    const ux = dx / len;
    const uy = dy / len;
    const headLen = 10;
    const headW = 6;
    const baseX = sv2[0] - ux * headLen;
    const baseY = sv2[1] - uy * headLen;
    const px = -uy;
    const py = ux;
    return (
      <g>
        <line
          x1={sv1[0]}
          y1={sv1[1]}
          x2={baseX}
          y2={baseY}
          stroke={color}
          strokeWidth={width}
          strokeLinecap="round"
        />
        <polygon
          points={
            sv2[0] + ',' + sv2[1] + ' ' +
            (baseX + px * headW) + ',' + (baseY + py * headW) + ' ' +
            (baseX - px * headW) + ',' + (baseY - py * headW)
          }
          fill={color}
        />
      </g>
    );
  };

  const presets = [
    { name: '纯拉伸', vals: [2, 0, 0, 0.5] },
    { name: '旋转 90°', vals: [0, -1, 1, 0] },
    { name: '剪切', vals: [1, 1, 0, 1] },
    { name: '降秩', vals: [1, 2, 2, 4] },
    { name: '反射', vals: [1, 0, 0, -1] },
  ];

  const Mv1 = apply(svdM.v1x, svdM.v1y);
  const Mv2 = apply(svdM.v2x, svdM.v2y);

  const inputCell =
    'bg-slate-700 rounded px-2 py-1.5 text-center font-mono text-base focus:outline-none focus:ring-2 focus:ring-blue-500 w-full';

  const eigenElements = [];
  if (showEigen && eigVec1 && eigVec2) {
    const vectors = [
      { vec: eigVec1, color: '#a855f7' },
      { vec: eigVec2, color: '#d8b4fe' },
    ];
    for (let i = 0; i < vectors.length; i++) {
      const item = vectors[i];
      const evx = item.vec[0];
      const evy = item.vec[1];
      const Llen = 6;
      const inS = toSvg(-evx * Llen, -evy * Llen);
      const inE = toSvg(evx * Llen, evy * Llen);
      const tStart = apply(-evx * Llen, -evy * Llen);
      const tEnd = apply(evx * Llen, evy * Llen);
      const outS = toSvg(tStart[0], tStart[1]);
      const outE = toSvg(tEnd[0], tEnd[1]);
      eigenElements.push(
        <g key={'eig-' + i}>
          <line
            x1={inS[0]} y1={inS[1]} x2={inE[0]} y2={inE[1]}
            stroke={item.color} strokeWidth={1} strokeDasharray="4,3" opacity={0.4}
          />
          <line
            x1={outS[0]} y1={outS[1]} x2={outE[0]} y2={outE[1]}
            stroke={item.color} strokeWidth={2.5}
          />
        </g>
      );
    }
  }

  let svdDashed = null;
  if (showSvd) {
    const Llen = 5;
    const i1 = toSvg(-svdM.v1x * Llen, -svdM.v1y * Llen);
    const i2 = toSvg(svdM.v1x * Llen, svdM.v1y * Llen);
    const j1 = toSvg(-svdM.v2x * Llen, -svdM.v2y * Llen);
    const j2 = toSvg(svdM.v2x * Llen, svdM.v2y * Llen);
    svdDashed = (
      <g>
        <line x1={i1[0]} y1={i1[1]} x2={i2[0]} y2={i2[1]}
          stroke="#fb7185" strokeWidth={1} strokeDasharray="4,3" opacity={0.35} />
        <line x1={j1[0]} y1={j1[1]} x2={j2[0]} y2={j2[1]}
          stroke="#fda4af" strokeWidth={1} strokeDasharray="4,3" opacity={0.35} />
      </g>
    );
  }

  // 把 LaTeX 字符串放到这里,避免 JSX 把 {v} 当成表达式
  const latexV1V2 = "\\(\\mathbf{v}_1, \\mathbf{v}_2\\)";
  const latexMvi = "\\(M\\mathbf{v}_i = \\sigma_i \\mathbf{u}_i\\)";

  return (
    <div className="min-h-screen bg-slate-900 text-slate-100 p-3 md:p-6">
      <div className="max-w-5xl mx-auto">
        <h1 className="text-xl md:text-2xl font-bold mb-1">SVD 主轴 = 椭圆长短轴</h1>
        <p className="text-slate-400 text-xs md:text-sm mb-4">
          每帧重算 M(t) 的 SVD,玫红箭头始终对齐当前椭圆的长短轴。
        </p>

        <div className="grid md:grid-cols-5 gap-3 mb-3">
          <div className="bg-slate-800 rounded-lg p-3 md:col-span-2">
            <div className="text-xs text-slate-400 mb-2">矩阵 A</div>
            <div className="grid grid-cols-2 gap-2">
              <input type="number" step="0.5" value={aVal}
                onChange={(e) => setA(safeNum(parseFloat(e.target.value)))}
                className={inputCell} />
              <input type="number" step="0.5" value={bVal}
                onChange={(e) => setB(safeNum(parseFloat(e.target.value)))}
                className={inputCell} />
              <input type="number" step="0.5" value={cVal}
                onChange={(e) => setC(safeNum(parseFloat(e.target.value)))}
                className={inputCell} />
              <input type="number" step="0.5" value={dVal}
                onChange={(e) => setD(safeNum(parseFloat(e.target.value)))}
                className={inputCell} />
            </div>
          </div>

          <div className="bg-slate-800 rounded-lg p-3 md:col-span-3">
            <div className="grid grid-cols-2 gap-3 text-sm">
              <div>
                <div className="text-slate-400 text-xs">det(A)</div>
                <div className="text-lg font-mono text-cyan-400">{detA.toFixed(2)}</div>
              </div>
              <div>
                <div className="text-slate-400 text-xs">特征值 λ</div>
                <div className="text-lg font-mono text-purple-400">
                  {eigVals
                    ? eigVals[0].toFixed(2) + ', ' + eigVals[1].toFixed(2)
                    : '复数'}
                </div>
              </div>
              <div>
                <div className="text-slate-400 text-xs">奇异值 σ (A)</div>
                <div className="text-lg font-mono text-rose-400">
                  {svdA.s1.toFixed(2)}, {svdA.s2.toFixed(2)}
                </div>
              </div>
              <div>
                <div className="text-slate-400 text-xs">σ₁ × σ₂</div>
                <div className="text-lg font-mono text-amber-400">
                  {(svdA.s1 * svdA.s2).toFixed(2)}
                </div>
                <div className="text-xs text-slate-500">= |det| ✓</div>
              </div>
            </div>
          </div>
        </div>

        <div className="flex flex-wrap gap-2 mb-3">
          {presets.map((p) => (
            <button
              key={p.name}
              onClick={() => {
                setA(p.vals[0]);
                setB(p.vals[1]);
                setC(p.vals[2]);
                setD(p.vals[3]);
                setT(1);
                setPlaying(false);
              }}
              className="px-3 py-1 rounded text-xs bg-slate-700 hover:bg-slate-600"
            >
              {p.name}
            </button>
          ))}
        </div>

        <div className="flex flex-wrap gap-2 mb-3">
          <button onClick={() => setShowSquare(!showSquare)}
            className={'px-3 py-1.5 rounded text-xs ' +
              (showSquare ? 'bg-cyan-600 text-white' : 'bg-slate-700 text-slate-400')}>
            单位正方形
          </button>
          <button onClick={() => setShowCircle(!showCircle)}
            className={'px-3 py-1.5 rounded text-xs ' +
              (showCircle ? 'bg-amber-600 text-white' : 'bg-slate-700 text-slate-400')}>
            单位圆 → 椭圆
          </button>
          <button onClick={() => setShowEigen(!showEigen)}
            className={'px-3 py-1.5 rounded text-xs ' +
              (showEigen ? 'bg-purple-600 text-white' : 'bg-slate-700 text-slate-400')}>
            特征向量
          </button>
          <button onClick={() => setShowSvd(!showSvd)}
            className={'px-3 py-1.5 rounded text-xs ' +
              (showSvd ? 'bg-rose-600 text-white' : 'bg-slate-700 text-slate-400')}>
            SVD 主轴
          </button>
        </div>

        <div className="bg-slate-800 rounded-lg p-2 flex justify-center mb-3">
          <svg viewBox={'0 0 ' + SIZE + ' ' + SIZE} className="w-full max-w-lg bg-slate-950 rounded">
            <g opacity={0.35}>
              {gridLines.map((ln, i) => {
                const sv1 = toSvg(ln.x1, ln.y1);
                const sv2 = toSvg(ln.x2, ln.y2);
                return (
                  <line key={i} x1={sv1[0]} y1={sv1[1]} x2={sv2[0]} y2={sv2[1]}
                    stroke={ln.axis ? '#64748b' : '#334155'}
                    strokeWidth={ln.axis ? 1 : 0.5} />
                );
              })}
            </g>

            <line x1={0} y1={CENTER} x2={SIZE} y2={CENTER} stroke="#475569" strokeWidth={0.5} strokeDasharray="2,4" />
            <line x1={CENTER} y1={0} x2={CENTER} y2={SIZE} stroke="#475569" strokeWidth={0.5} strokeDasharray="2,4" />

            {showSquare && (
              <path d={sqPath} fill="rgba(6,182,212,0.18)" stroke="#06b6d4" strokeWidth={2} />
            )}

            {showCircle && (
              <path d={circlePath} fill="none" stroke="#f59e0b" strokeWidth={2} />
            )}

            {eigenElements}
            {svdDashed}

            {showSvd && (
              <g>
                <Arrow fromX={0} fromY={0} toX={Mv1[0]} toY={Mv1[1]} color="#fb7185" width={3.5} />
                <Arrow fromX={0} fromY={0} toX={Mv2[0]} toY={Mv2[1]} color="#fda4af" width={3.5} />
              </g>
            )}

            <Arrow fromX={0} fromY={0} toX={ihatPt[0]} toY={ihatPt[1]} color="#ef4444" width={3.5} />
            <Arrow fromX={0} fromY={0} toX={jhatPt[0]} toY={jhatPt[1]} color="#22c55e" width={3.5} />
            <circle cx={CENTER} cy={CENTER} r={3} fill="#f1f5f9" />
          </svg>
        </div>

        <div className="bg-slate-800 rounded-lg p-3 mb-3">
          <div className="flex items-center gap-2 mb-3 flex-wrap">
            <button onClick={() => setPlaying(true)} disabled={playing}
              className="bg-blue-600 hover:bg-blue-500 disabled:opacity-50 px-3 py-2 rounded flex items-center gap-2 text-sm">
              <Play size={14} /> 播放
            </button>
            <button onClick={() => { setPlaying(false); setT(0); }}
              className="bg-slate-700 hover:bg-slate-600 px-3 py-2 rounded flex items-center gap-2 text-sm">
              <RotateCcw size={14} /> 起点
            </button>
            <button onClick={() => { setPlaying(false); setT(1); }}
              className="bg-slate-700 hover:bg-slate-600 px-3 py-2 rounded flex items-center gap-2 text-sm">
              <SkipForward size={14} /> 终点
            </button>
            <span className="text-xs text-slate-400 font-mono ml-auto">t = {tVal.toFixed(2)}</span>
          </div>
          <input type="range" min="0" max="1" step="0.01" value={tVal}
            onChange={(e) => { setPlaying(false); setT(parseFloat(e.target.value)); }}
            className="w-full accent-blue-500" />
        </div>

        <div className="bg-slate-800 rounded-lg p-3 text-xs md:text-sm space-y-1.5">
          <div className="font-semibold text-slate-200 mb-1">读图:</div>
          <div>
            <span className="text-rose-300 font-semibold">玫红虚线</span>
            :当前 M(t) 的右奇异向量 {latexV1V2}(输入空间正交)
          </div>
          <div>
            <span className="text-rose-400 font-semibold">玫红实箭头</span>
            :{latexMvi} --- 椭圆的长轴 / 短轴
          </div>
          <div className="pt-2 border-t border-slate-700 mt-2 text-slate-400">
            拖动滑块时虚线会随之旋转(每个 M(t) 都有自己的最优正交输入),但实箭头始终对齐椭圆主轴 ✓
          </div>
        </div>
      </div>
    </div>
  );
}
相关推荐
迦南的迦 亚索的索4 小时前
机器学习_05_k-means算法
算法·机器学习·kmeans
大模型最新论文速读4 小时前
利用异步编程的 future 思想,让 LLM Agent 快 1.44 倍
人工智能·深度学习·算法·机器学习·自然语言处理
Bingorl4 小时前
机器学习之线性回归算法
算法·机器学习·线性回归
一个王同学12 小时前
从零到一 | CV转多模态大模型 | week09 | Minillava Refactor结合手搓和llava源码深入理解多模态大模型原理
人工智能·深度学习·机器学习·计算机视觉·改行学it
2601_9577875813 小时前
全场景矩阵系统多端统一体验与跨端实时同步技术实践
大数据·人工智能·矩阵·多端统一·跨端同步
赢乐14 小时前
大模型学习笔记:检索增强生成(RAG)架构
人工智能·python·深度学习·机器学习·智能体·幻觉·检索增强生成(rag)
雁迟15 小时前
第八章:矩阵与数组操作
线性代数·矩阵·r语言
2601_9577875817 小时前
星链引擎矩阵系统:插件化多平台 API 网关与账号级隔离技术实践
java·矩阵·插件化架构