向量空间视角:机器学习中的线性代数本质理解

📅 2026/7/21 4:12:42
向量空间视角:机器学习中的线性代数本质理解
1. 为什么我坚持用“向量空间”而不是“解方程”来教机器学习里的线性代数你翻过任何一本《机器学习实战》或者《深度学习入门》十有八九会在第一章末尾看到这样一行小字“建议掌握基础线性代数”。但紧接着它就跳到了梯度下降、损失函数、反向传播——仿佛线性代数只是个需要背诵的公式清单矩阵乘法怎么算转置是什么逆矩阵存在的条件SVD分解的三个矩阵长什么样这不对。不是说这些不重要而是这种教法把线性代数彻底工具化、黑箱化了。它让你误以为只要会调np.linalg.inv()或者torch.svd()就算“懂”了。可现实是当你在调试一个训练崩溃的Transformer时发现注意力权重矩阵的奇异值谱突然塌缩到1e-12量级当你用PCA降维后前两个主成分解释方差比例只有37%而第15个却突然跳升当你把一个32×32的图像展平成1024维向量喂给全连接层模型却在batch size64时显存爆得比batch size128还快——这些时刻你真正需要的从来不是“怎么算”而是“它在空间里到底发生了什么”。我带过27个工业界ML项目从医疗影像分割到金融时序预测最常被低估的瓶颈从来不是GPU算力或数据量而是工程师对向量空间几何直觉的缺失。比如为什么BatchNorm要对每个特征通道单独做归一化因为输入数据在原始像素空间里根本不是各向同性的球状分布而是被拉长、扭曲、倾斜的椭球体而BN的本质就是用可学习的仿射变换把这个椭球体“扶正”并“缩放”回单位球附近——这个操作在向量空间里叫“白化”whitening它的数学表达就是 $z \gamma \cdot \frac{x - \mu}{\sigma} \beta$但它的几何意义是让每一层的输入分布在高维空间中重新获得近似各向同性的结构。没有这个直觉你调BN参数就是在猜有了这个直觉你一眼就能看出当$\sigma$在某个维度上持续趋近于0时说明该维度的信息已经坍缩模型可能正在丢失关键判别特征。再比如为什么Dropout在RNN里效果往往不如CNN因为RNN的隐藏状态$h_t$本质上是一条在隐空间中穿行的轨迹而Dropout随机抹掉某些维度相当于在轨迹上不断制造“断点”破坏了状态转移的连续性流形结构而CNN的卷积核作用于局部感受野其输出特征图天然具有平移不变性Dropout抹掉的是冗余的局部响应反而增强了泛化。这不是玄学这是向量空间中流形manifold与子空间subspace稳定性的差异。所以这篇内容不叫《线性代数速查手册》也不叫《ML必备公式集锦》。它是我过去十年在真实项目里反复验证、推翻、再重建的一套认知框架把矩阵看作空间变换的说明书把向量看作空间中的位置坐标把特征值看作变换在特定方向上的伸缩倍率把奇异值分解看作在输入和输出空间里同时寻找最优对齐坐标系的过程。它不教你如何解线性方程组但它会告诉你当你用最小二乘拟合一条直线时你其实是在二维平面上把所有散点垂直投影到由设计矩阵列向量张成的子空间里——那个“垂直”就是欧氏距离最小化的几何本质而那个“子空间”就是所有可能的线性组合结果构成的集合。关键词“Towards AI - Medium”提醒我这篇文章的读者大概率是已经写过model.fit()、跑过sklearn.linear_model.Lasso、甚至自己手写过反向传播的实践者。你们不需要从“什么是行列式”开始启蒙。你们需要的是当代码报错LinAlgError: Singular matrix时能立刻意识到——不是数据里有零而是你的特征向量在100维空间里实际只张成了一个92维的“瘪气球”剩下的8个维度是线性相关的冗余褶皱当t-SNE降维后聚类效果诡异时能想到t-SNE优化的是高维空间中点对之间的相似度概率分布而它默认的困惑度perplexity参数本质上是在控制你希望保留的邻域半径——这个半径直接对应着局部流形的曲率尺度。这些才是线性代数在ML现场真正呼吸的方式。2. 核心思路拆解为什么“空间变换”视角比“数值计算”视角更能穿透ML本质2.1 从“解方程”到“找映射”一次认知范式的迁移传统线性代数教学习惯从求解线性方程组 $Ax b$ 开始。这很自然中学代数解决的是“未知数是多少”大学延续这个逻辑问“多个未知数一起满足哪些约束”。于是我们花大量时间学习高斯消元、克拉默法则、秩的判定——所有这些都服务于一个目标找到那个唯一的、或无穷多的、或不存在的 $x$ 解。但在机器学习里$Ax b$ 这个等式几乎从不以“求解”为目的出现。它更常见的形态是前向传播$h^{(l1)} \sigma(W^{(l)} h^{(l)} b^{(l)})$这里 $W^{(l)}$ 不是待求的未知量而是模型参数是定义变换规则的函数本身损失计算$\mathcal{L} |y_{\text{true}} - y_{\text{pred}}|^2 |y_{\text{true}} - Wx|^2$这里 $W$ 是变量$x$ 和 $y_{\text{true}}$ 是已知数据目标是调整 $W$ 让残差向量长度最小——这不再是解方程而是在矩阵空间里搜索一个最优的线性映射正则化$\min_W |y - Wx|^2 \lambda |W|_F^2$弗罗贝尼乌斯范数 $|W|_F$ 的平方等于 $W$ 所有元素的平方和它在几何上意味着我们不仅要求映射 $W$ 能把 $x$ 变到接近 $y$ 的地方还要求这个映射本身不能“太剧烈”——即它在所有方向上的最大伸缩倍率也就是最大的奇异值不能过大否则模型就容易过拟合噪声。你看问题的核心早已从“找一个满足条件的向量 $x$”悄然转移到了“找一个满足性能指标的矩阵 $W$”。而矩阵 $W$在几何上就是一个从输入空间 $\mathbb{R}^n$ 到输出空间 $\mathbb{R}^m$ 的线性变换linear map。它像一个精密的“空间变形器”可以旋转、拉伸、压缩、剪切甚至把高维空间“压扁”到低维子空间里这就是秩亏缺的本质。提示理解“矩阵变换”是打通任督二脉的第一步。下次看到一个 $512 \times 768$ 的Transformer词嵌入矩阵 $E$别再只把它想成“512个词每个词768维向量”。试着想象它定义了一个从“词汇语义空间”离散、稀疏、高维到“上下文感知表征空间”连续、稠密、768维的映射。$E$ 的列向量就是新空间的768个基底而 $E$ 的行向量则代表了每个词在这个新基底下的坐标。这个视角会让你瞬间理解为什么词向量类比king - man woman ≈ queen在向量空间里成立——因为语义关系在这个精心学习出的线性或近似线性映射下被保留在了向量的加减运算中。2.2 为什么“并行性好”不是线性代数在ML中流行的根本原因原文提到“Linear algebra finds widespread application because it generally parallelizes extremely well.” 这没错但它是结果不是原因。就像说“汽车流行是因为轮胎能滚动”——滚动是必要条件但不是驱动力。线性代数在ML中不可替代的根本原因在于它提供了一套无与伦比的、描述“关系”的通用语言。ML的本质就是从数据中学习变量之间的关系。而线性代数恰好是描述“线性关系”最简洁、最强大、最可计算的数学工具。标量Scalar描述的是“大小”比如一个样本的标签值向量Vector描述的是“大小方向”比如一个样本的所有特征它指向了特征空间中的一个具体位置矩阵Matrix描述的是“从一个向量空间到另一个向量空间的线性关系”比如一个全连接层的权重它规定了输入特征如何线性组合成输出特征张量Tensor是矩阵的推广描述更高阶的关系比如卷积核它定义了局部邻域内像素值与输出特征之间的线性关系模式。这种层级递进的抽象能力让线性代数成为构建复杂模型的“乐高积木”。你可以把一个ResNet看作一系列矩阵卷积核、BN参数、全连接权重的复合变换$x \xrightarrow{W_1} h_1 \xrightarrow{W_2} h_2 \xrightarrow{W_3} ... \xrightarrow{W_L} y$。而反向传播就是利用链式法则计算这个复合变换对每一个“积木块”即每个矩阵 $W_i$的梯度——这个过程本身就是在线性代数的框架内完成的。并行化好是因为矩阵乘法天然适合GPU的SIMD单指令多数据架构一次指令可以同时对成百上千个向量做相同的线性变换。但这只是工程实现的红利。如果线性代数不能精准刻画ML的核心——即“学习数据间的结构化关系”那么再好的并行性也毫无意义。就像给你一台超算却只让它算11那并行性再好也是浪费。2.3 “最小知识集”的再审视为什么“矩阵分解”是真正的分水岭原文提出的“最小三件套”——符号、运算、矩阵分解——非常精炼。但我想强调“矩阵分解”绝不仅仅是“一种数值技巧”它是从“静态数据表示”跃迁到“动态结构理解”的关键门槛。符号Notation是读写能力。它让你能看懂论文里的 $\nabla_W \mathcal{L} 2X^T(XW - Y)$知道这代表损失函数对权重 $W$ 的梯度。运算Operations是动手能力。它让你能用numpy.dot(X.T, X)算出设计矩阵的Gram矩阵知道np.linalg.eigvals(X.T X)给出的是特征值。但这两者都停留在“操作层面”。而矩阵分解是建模层面的思维升级。以主成分分析PCA为例。它的标准实现是from sklearn.decomposition import PCA pca PCA(n_components50) X_pca pca.fit_transform(X) # X is (n_samples, n_features)这行代码背后是X的协方差矩阵 $C \frac{1}{n} X^T X$ 的特征值分解$C V \Lambda V^T$。其中$V$ 的列是主成分principal components即数据方差最大的那些正交方向$\Lambda$ 的对角线是对应的方差大小。但如果你只停留在“调用API”你就错过了最重要的洞见PCA不是一个黑盒降维器它是一个在原始特征空间中自动寻找最优坐标系的过程。这个最优坐标系由数据自身的散布结构决定——它把原来杂乱无章的坐标轴比如“像素0的灰度值”、“像素1的灰度值”旋转到了一组全新的、彼此正交的轴上而新轴的方向恰好是数据变化最剧烈的方向。这就像你走进一个光线昏暗、布满杂物的仓库原始高维空间PCA做的不是简单地关掉几盏灯删掉几个特征而是帮你安装了一套智能照明系统它自动把光束聚焦在仓库里物品摆放最密集、最有规律的那几个主干道上主成分让你一眼看清整体格局。同样奇异值分解SVD $A U \Sigma V^T$在推荐系统中$U$ 可以理解为“用户偏好向量”的集合$V$ 是“物品特征向量”的集合$\Sigma$ 是它们之间匹配强度的对角矩阵。SVD不是在分解一个数字表格它是在从海量用户-物品交互数据中挖掘出潜藏的、低维的、可解释的“兴趣因子”和“属性因子”。这才是它能用于冷启动推荐、解释性分析的深层原因。所以“学矩阵分解”不是为了记住SVD的算法步骤而是为了培养一种本能每当看到一个大矩阵第一反应不是“它有多大”而是“它背后隐藏着什么样的低维结构它的行空间和列空间分别由哪些核心模式主导”3. 核心细节解析与实操要点从理论到代码的“翻译”陷阱与避坑指南3.1 符号体系为什么“列向量约定”是唯一安全的选择线性代数教材里关于向量是行还是列常常一笔带过。但在编程实践中这是一个足以让你调试三天的“翻译陷阱”。数学约定Column Vector Convention在绝大多数严肃的线性代数和ML文献中向量默认是列向量。一个 $n$ 维向量 $x$ 写作 $x \in \mathbb{R}^{n \times 1}$。矩阵 $A \in \mathbb{R}^{m \times n}$ 作用于 $x$写作 $Ax$结果是 $m$ 维列向量。这个约定保证了矩阵乘法的维度兼容性$(m \times n) \times (n \times 1) (m \times 1)$。NumPy的“灵活”约定NumPy的ndarray没有内在的“行/列”属性。一个形状为(n,)的一维数组既不是行向量也不是列向量它只是一个“扁平”的向量。当你写A x时NumPy会进行广播broadcasting如果A是(m, n)x是(n,)它会自动将x视为列向量(n, 1)来计算结果是(m,)。这听起来很友好但危险就在这里。考虑以下场景import numpy as np A np.random.randn(3, 4) # (3, 4) matrix x np.random.randn(4) # (4,) vector - a flat array y A x # Works! y.shape is (3,) # Now, what is x.T A.T ? # x.T is still (4,), so x.T A.T is (4,) (4, 3) - (3,) # But mathematically, (x^T A^T) should be (1, 3), a ROW vector!问题来了y是(3,)它到底是列向量还是行向量答案是它既是也不是。它只是一个形状为3的数组。当你后续想把它和另一个(3, 5)的矩阵B相乘时y B会成功(3,) (3,5) - (5,)但B y就会报错(3,5) (3,)不兼容因为(3,5)需要(5,)或(5,1)。实操心得永远显式地使用二维数组来表示向量。这是我在所有团队代码规范里强制要求的第一条。# ✅ 好习惯明确维度 x_col np.random.randn(4, 1) # Column vector: (4, 1) x_row np.random.randn(1, 4) # Row vector: (1, 4) y A x_col # (3, 4) (4, 1) (3, 1) # ✅ 现在 y 是明确的列向量后续操作不会歧义 # ❌ 坏习惯依赖广播 x_flat np.random.randn(4) # (4,) y_flat A x_flat # (3,) - 模糊这个习惯带来的好处是巨大的。当你写一个自定义的线性层时class Linear: def __init__(self, in_features, out_features): self.W np.random.randn(out_features, in_features) # (out, in) self.b np.random.randn(out_features, 1) # (out, 1) def forward(self, x): # x must be (in, 1) for this to work cleanly return self.W x self.b # (out, in) (in, 1) (out, 1) (out, 1)所有维度都清晰可见forward的输入输出形状一目了然。而如果x是(in,)self.W x的结果是(out,)那么 self.b就会触发广播self.b被隐式地拉伸虽然结果数值上正确但代码的可读性和可维护性大打折扣。3.2 关键运算不只是“怎么算”更是“为什么这么算”3.2.1 矩阵乘法从“行乘列”到“线性组合”的视角转换C A B的标准定义是$C_{ij} \sum_k A_{ik} B_{kj}$。这被称为“行乘列”规则。但这个定义对理解ML中的前向传播帮助有限。更强大的视角是矩阵乘法 $C AB$等价于将矩阵 $B$ 的每一列作为输入向量用矩阵 $A$ 进行线性变换得到的结果就是 $C$ 的对应列。换句话说$B$ 的第 $j$ 列 $b_j$是一个向量$A b_j$就是 $A$ 对 $b_j$ 的变换结果那么 $C$ 的第 $j$ 列 $c_j A b_j$。这个视角完美对应神经网络的前向传播。假设一个全连接层的输入是一个 batch形状为(batch_size, in_features)。在标准实现中我们会把 batch 的每个样本看作一个行向量所以输入矩阵 $X$ 是(N, D_in)。权重 $W$ 是(D_in, D_out)。那么输出 $Y X W$。现在用“列视角”重读$X$ 的每一行是一个样本行向量。但 $W$ 的每一列 $w_j$是一个长度为D_in的向量它代表了输出第 $j$ 个神经元的“权重模板”。$X W$ 的第 $j$ 列就是 $X w_j$。这意味着输出的第 $j$ 列即所有样本在第 $j$ 个神经元上的激活值是输入矩阵 $X$ 的所有行向量与权重向量 $w_j$ 做点积的结果。这正是全连接层的定义每个输出神经元是所有输入特征的加权和。注意这个视角也解释了为什么torch.nn.Linear的weight参数形状是(out_features, in_features)而不是(in_features, out_features)。因为 PyTorch 的forward方法期望输入x是(N, in_features)然后执行x weight.t()注意.t()是转置从而在内部实现了x的每一行与weight的每一列做点积。这是一种设计上的便利但底层的数学含义不变。3.2.2 转置Transpose不只是“翻转”而是“对偶空间”的桥梁矩阵 $A$ 的转置 $A^T$在数值上是行列互换。但在几何上它扮演着一个极其深刻的角色它定义了原变换 $A$ 在对偶空间dual space上的伴随变换adjoint transformation。这听起来很抽象但在反向传播中它无处不在。反向传播的核心就是链式法则。对于一个简单的线性层 $y Wx$损失 $\mathcal{L}$ 对 $x$ 的梯度是 $$\frac{\partial \mathcal{L}}{\partial x} \frac{\partial \mathcal{L}}{\partial y} \cdot \frac{\partial y}{\partial x} \frac{\partial \mathcal{L}}{\partial y} \cdot W^T$$为什么是 $W^T$而不是 $W$因为 $\frac{\partial y}{\partial x}$ 是一个雅可比矩阵Jacobian其形状是(out_dim, in_dim)而 $W$ 的形状也是(out_dim, in_dim)所以雅可比矩阵就等于 $W$。而链式法则要求上游梯度 $\frac{\partial \mathcal{L}}{\partial y}$ 是(out_dim,)的行向量或(1, out_dim)要乘以雅可比矩阵(out_dim, in_dim)得到下游梯度(1, in_dim)。这在矩阵乘法中就是(1, out_dim) (out_dim, in_dim) (1, in_dim)即 $\frac{\partial \mathcal{L}}{\partial y} \cdot W$。但因为我们通常把梯度也存储为列向量所以需要转置$\left(\frac{\partial \mathcal{L}}{\partial y}\right)^T W^T \cdot \left(\frac{\partial \mathcal{L}}{\partial y}\right)^T$。实操心得在手写反向传播时永远检查维度。如果你的上游梯度dy形状是(N, D_out)权重W形状是(D_in, D_out)那么dx对输入的梯度必须是(N, D_in)。唯一能达成这个维度的运算就是dy W.T。如果你写了dy WPyTorch 或 NumPy 会报错因为(N, D_out) (D_in, D_out)是非法的。这个错误就是维度检查在保护你。3.3 矩阵分解SVD的“三幕剧”与你在项目中真正会用到的形态SVD $A U \Sigma V^T$ 常被描述为“将任意矩阵分解为旋转-缩放-旋转”。但这过于简化。在ML实践中SVD 更像一部三幕剧每一幕都有其独特的实用价值。第一幕U—— 行空间的“用户画像”Row Space$U$ 是一个(m, m)的正交矩阵其列向量是 $A A^T$ 的特征向量。在推荐系统中如果 $A$ 是用户-物品评分矩阵m用户n物品那么 $U$ 的每一列就代表了一个“用户类型”或“用户群组”。$U$ 的第 $i$ 行就代表了第 $i$ 个用户在这些“类型”上的混合权重。实操要点U的行向量即每个用户的表示可以直接用于用户聚类、相似度计算。scipy.sparse.linalg.svds函数可以高效计算部分SVD这对于大型稀疏矩阵如推荐矩阵至关重要。第二幕$\Sigma$ —— “奇异值谱”与模型的“健康诊断书”$\Sigma$ 是一个(m, n)的对角矩阵对角线上的元素 $\sigma_1 \geq \sigma_2 \geq ... \geq \sigma_r 0$ 就是奇异值。奇异值的大小直接反映了矩阵 $A$ 在对应方向上的“信息强度”。大的奇异值意味着该方向承载了主要的结构信息小的奇异值尤其是趋近于0的往往对应着噪声或冗余。实操要点绘制奇异值谱scree plot是诊断数据质量的黄金标准。例如在NLP中对一个词共现矩阵做SVD如果前10个奇异值很大第11个开始急剧衰减说明词义可以用10个潜在主题很好地概括如果奇异值缓慢衰减说明词义结构复杂可能需要更复杂的模型如BERT而非简单的LSA。第三幕V—— 列空间的“物品基因”Column Space$V$ 是一个(n, n)的正交矩阵其列向量是 $A^T A$ 的特征向量。在推荐系统中$V$ 的列就是“物品类型”或“物品主题”。$V$ 的第 $j$ 行代表了第 $j$ 个物品属于各个主题的程度。实操要点V的列即每个物品的主题向量是做物品推荐、内容解释的基础。例如要为一个新物品其特征向量为v_new找相似物品你计算v_new V.T得到它在主题空间的坐标再与V的所有列计算余弦相似度即可。注意在实际项目中你几乎永远不会用到完整的U和V。因为它们是方阵尺寸巨大。你真正需要的是U_k,Σ_k,V_k即只取前k个最大的奇异值及其对应的左右奇异向量。这称为截断SVDTruncated SVD它不仅是降维更是去噪。sklearn.decomposition.TruncatedSVD就是为此而生。4. 实操过程与核心环节实现一个端到端的“手写PCA”项目4.1 项目背景为什么我们要亲手实现PCA而不是只用sklearn我曾在一个医疗影像项目中遇到一个棘手问题模型在训练集上AUC高达0.95但在一个独立的外部测试集上骤降至0.72。数据科学家的第一反应是“过拟合”于是加大正则化、增加Dropout。但问题依旧。最后我们决定对输入的1024维特征向量做一次彻底的“体检”——手动实现PCA并可视化其奇异值谱。结果令人震惊前50个奇异值之和占总和的99.99%而第51到1024个奇异值全部小于1e-10。这意味着整个1024维的特征空间实际上只在一个50维的子空间里活动其余974个维度全是数值噪声由浮点精度误差和预处理中的微小bug引入。sklearn.PCA默认会保留所有非零奇异值这导致模型在训练时无意中学习了这些噪声维度的虚假模式。当我们强制n_components50后外部测试集AUC立刻回升到0.93。这个案例说明库函数是强大的但亲手实现核心算法是理解其行为边界、发现数据真相的唯一途径。下面我们就从零开始手写一个功能完整、可调试、可解释的PCA。4.2 步骤一数据中心化——为什么“减均值”是不可省略的基石PCA的目标是找到数据方差最大的方向。方差的定义是 $\text{Var}(x) \mathbb{E}[(x - \mu)^2]$。因此计算方差的前提是数据必须以均值为原点。如果数据没有中心化计算出的“最大方差方向”很可能只是数据整体偏移的方向而非其内在的结构方向。def center_data(X): X: (n_samples, n_features) numpy array Returns: centered X and the mean vector mu np.mean(X, axis0, keepdimsTrue) # (1, n_features) X_centered X - mu return X_centered, mu # 实操记录在我们的医疗影像项目中我们发现 # 即使是微小的中心化偏差比如用 int16 读取图像后未转为 float32 # 导致均值计算有整数截断误差也会在SVD中被放大 # 导致前几个奇异向量出现系统性偏移。所以务必确保数据类型一致。4.3 步骤二协方差矩阵计算——两种路径的权衡与选择PCA的标准路径是计算协方差矩阵 $C \frac{1}{n} X^T X$然后对其做特征值分解。但这里有两条路路径A小特征多样本如果n_features n_samples例如100个基因表达10000个病人那么 $C$ 的尺寸是(100, 100)计算X.T X快且内存友好。路径B多样本小特征如果n_samples n_features例如1000张图片每张展平为10000像素那么 $C$ 的尺寸是(10000, 10000)内存爆炸。此时应计算 $X X^T$尺寸(1000, 1000)然后利用其特征向量与 $C$ 的特征向量的关系若 $X X^T v_i \lambda_i v_i$则 $C$ 的特征向量为 $u_i \frac{1}{\sqrt{\lambda_i}} X^T v_i$。我们选择路径A因为它更直观def compute_covariance_matrix(X_centered): X_centered: (n_samples, n_features) Returns: C, (n_features, n_features) n X_centered.shape[0] C (X_centered.T X_centered) / n # (n_features, n_features) return C # 实操记录这里有一个关键细节——除以 n 还是 n-1 # 统计学中样本方差用 n-1 无偏估计。但在PCA中我们关心的是数据本身的散布结构 # 而非对总体方差的无偏估计所以用 n 是标准做法。np.cov 默认用 n-1 # 所以我们不直接用它而是手动计算。4.4 步骤三特征值分解——np.linalg.eighvsnp.linalg.eig协方差矩阵 $C$ 是一个实对称矩阵$C C^T$。实对称矩阵有一个黄金性质它的特征值一定是实数且特征向量可以选为正交的。因此我们必须使用专为对称矩阵优化的np.linalg.eigh而不是通用的np.linalg.eig。eigh保证返回的特征向量是正交的且特征值是实数、按升序排列。eig对于对称矩阵可能返回复数特征值由于数值误差且特征向量不一定正交。def decompose_covariance(C): C: (n_features, n_features) symmetric matrix Returns: eigenvalues (ascending), eigenvectors (columns are eigenvectors) # eigh returns eigenvalues in ascending order eigenvals, eigenvecs np.linalg.eigh(C) # eigenvecs: (n_features, n_features) # We want descending order for convenience idx np.argsort(eigenvals)[::-1] eigenvals eigenvals[idx] eigenvecs eigenvecs[:, idx] return eigenvals, eigenvecs # 实操记录在调试过程中我们打印了 eigenvals 的前10个值。 # 如果发现 eigenvals[0] / eigenvals[-1] 1e12这通常意味着矩阵是病态的ill-conditioned # 可能由数据中存在完全线性相关的特征如两列完全相同导致。 # 这时eigh 可能给出不稳定的特征向量。解决方案是先做QR分解或SVD。4.5 步骤四投影与重构——完整的PCA流水线现在我们拥有了所有零件中心化后的数据X_c协方差矩阵C以及其特征向量V按特征值降序排列。接下来就是核心的投影Projection和重构Reconstruction。class HandwrittenPCA: def __init__(self, n_components): self.n_components n_components self.mean_ None self.components_ None # V_k, shape (n_features, n_components) self.explained_variance_ None def fit(self, X): # Step 1: Center X_c, self.mean_ center_data(X) # Step 2: Covariance C compute_covariance_matrix(X_c) # Step 3: Decompose eigenvals, eigenvecs decompose_covariance(C) # Step 4: Store top-k components self.components_ eigenvecs[:, :self.n_components] # (n_features, k) self.explained_variance_ eigenvals[:self.n_components] # (k,) return self def transform(self, X): # Center using fitted mean