ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

CSR稀疏矩阵实战:三数组压缩、内存优化与SciPy操作指南

CSR稀疏矩阵实战:三数组压缩、内存优化与SciPy操作指南 在处理推荐系统、图算法或者大规模文本特征的时候我几乎每次都要跟稀疏矩阵打交道。当年第一次把一个百万用户对百万物品的评分矩阵塞进内存直接吃掉了十几个 GB整个人都傻了。后来换成 CSR格式内存降到了原来的几十分之一运算速度反而快了不少。这篇就把我用 CSR 格式几年下来的学习笔记、实操经验和踩坑记录整理出来希望对正在跟大规模矩阵较劲的朋友有帮助。1. 稀疏矩阵到底是什么存储问题才是核心痛点1.1 稠密与稀疏同样的数据两种完全不同的存储思路先不急着看 CSR先把“稀疏矩阵”本身说透。所谓稀疏矩阵就是指矩阵中绝大多数元素都是零值。判断标准没有严格的数学定义但工程上有个很实用的经验值如果零元素占比超过 50%就可以考虑用稀疏格式如果超过 90%那基本就是“一定得用稀疏格式”了。我见过最夸张的场景是图神经网络里的邻接矩阵百万个节点的邻接关系千分之几都不到稀疏率高达 99.9% 以上。这时候如果你还用普通的二维数组去存每一格都占一个固定单位的内存比如 float64 占 8 字节那问题就大了。一个 100 万 × 100 万的矩阵哪怕全存 float32理论内存需求是1000000 × 1000000 × 4 字节 4 × 10¹² 字节 ≈ 4 TB这个量级单机根本扛不住。但实际业务场景中比如电商推荐系统里用户和商品发生交互的“用户-物品矩阵”每个用户可能就浏览或者购买过几十几百个商品和百万级的物品总量相比非零项寥寥无几。如果只记录非零元素的位置和值存储量一下子就降下来了。稀疏矩阵的全部意义就是用“不存零”的方式来节省内存同时尽量保证不牺牲运算速度。注意这里有个关键点节省内存是第一位目标运算速度是第二位的而三者之间往往需要权衡。不同的稀疏存储格式就是在“哪个操作更快”“构建是否方便”“占多少内存”这几个维度上做取舍。1.2 什么样的矩阵才值得用稀疏存储我自己判断一个矩阵要不要切换成稀疏存储主要看三个维度稀疏率非零元素占比低于 10%强烈建议使用稀疏格式。占比在 10%~30% 之间可以对比测试后再决定。占比超过 50%老老实实用稠密矩阵别折腾。矩阵规模小于 1000×1000 的小矩阵稀疏不稀疏差别不大直接用稠密更省心上千万级别的矩阵稀疏格式就是刚需。后续操作类型如果主要做加法、乘法、按行统计CSR/CSC 很合适如果主要是逐步动态添加元素LIL/DOK 更顺手如果需要频繁转置CSC 往往更合适。我早期犯过的一个错误是看网上都说稀疏矩阵省内存就把一个只有 200×300 的小矩阵也硬改成稀疏格式结果非零占比 40%稀疏索引本身的开销比省下来的内存还大纯属脱裤子放屁。后来我给自己定了一个死规矩矩阵维度低于 5000×5000除非非零占比低于 1%否则一律先用稠密格式跑通逻辑再说。从实际场景来看稀疏矩阵最常出现的领域包括推荐系统里的用户-物品评分矩阵NLP 里的 TF-IDF 特征矩阵、词共现矩阵图计算里的邻接矩阵、拉普拉斯矩阵数值模拟里的有限元刚度矩阵机器学习里 One-Hot 编码后的特征矩阵这些场景有一个共同特点矩阵维度巨大但“有效信息”很稀疏。这也是为什么所有主流机器学习框架和科学计算库从 SciPy、scikit-learn 到 PyTorch、TensorFlow 和 GraphBLAS全都内置了稀疏矩阵支持。2. CSR 格式核心结构三个数组如何压缩一个稀疏世界2.1 三数组的完整语义data、indices、indptrCSR 的全称是 Compressed Sparse Row按行压缩。它把整个矩阵用三个一维数组来表示分别是data、indices和indptr。data按行优先顺序存储所有非零元素的值。indices存储每个非零元素对应的列索引。indptr存储每一行第一个非零元素在data中的起始位置长度是“行数 1”。indptr的语义容易把人绕晕我当年也是看了好几遍才真正理解。它不是一个简单的行号数组而是一个“偏移量”数组。拿一个 3×4 的矩阵举例[[0, 5, 0, 8], [0, 0, 3, 0], [7, 0, 0, 0]]这个矩阵的非零元素一共 4 个第 0 行的 (0,1)5 和 (0,3)8第 1 行的 (1,2)3第 2 行的 (2,0)7。CSR 的三个数组就是data [5, 8, 3, 7]indices [1, 3, 2, 0]indptr [0, 2, 3, 4]怎么看indptr第 i 行的非零元素在data里的区间是data[indptr[i]:indptr[i1]]列索引对应的是indices同一区间的值。所以第 0 行indptr[0]0indptr[1]2对应data[0:2] [5, 8]indices[0:2] [1, 3]意思就是第 0 行的两个非零元素分别在第 1 列和第 3 列。 第 1 行indptr[1]2indptr[2]3对应data[2:3] [3]indices[2:3] [2]。 第 2 行indptr[2]3indptr[3]4对应data[3:4] [7]indices[3:4] [0]。注意indptr的最后一个值不一定等于非零元素总数它只是用来界定最后一行的区间上界。判断非零元素总数直接用len(data)就行。从内存开销角度算一笔账假设原始矩阵是 m×n非零元素个数为 nnznumber of nonzeros。稠密存储需要 m×n 个槽位CSR 存储需要 nnz 个data值、nnz 个indices索引以及 m1 个indptr偏移量。如果用 int32 做索引、float32 做值总字节数是 4nnz 4nnz 4(m1)。还是上面那个 100 万×100 万矩阵假设每个用户平均 100 个非零交互nnz 大约是 1 亿那么 CSR 总内存约为 12 亿字节也就是 1.2 GB 左右比起原来 4 TB 的稠密存储压缩比超过 3000 倍。这就是 CSR 最核心的价值。它的名字里的“压缩”不是数学意义的降维而是实打实的存储空间压缩。2.2 手工构造 CSR 矩阵把逻辑彻底走一遍为了把三个数组的关系彻底搞懂我建议新手都手写一次 CSR 构造过程而不是直接调库。这个动手过程对理解后面的所有操作都有帮助。我自己写过一个简单版本思路是先把稀疏元素收集成“三元组”行、列、值然后按“行优先、列优先”排序再切分成三个数组import numpy as np def build_csr_from_coo(rows, cols, values, shape): 从 COO 三元组构建 CSR 三数组 参数 rows/cols/values 是等长的 list 或 ndarray 返回 (data, indices, indptr) n_rows, m_cols shape # 1. 按行号排序行内按列号排序 order np.lexsort((cols, rows)) # 先按 rows 排序行内按 cols 排序 rows_sorted np.asarray(rows)[order] cols_sorted np.asarray(cols)[order] values_sorted np.asarray(values)[order] # 2. 处理重复项相同位置累加 unique_pos [] merged_values [] for i in range(len(rows_sorted)): if (unique_pos and rows_sorted[i] unique_pos[-1][0] and cols_sorted[i] unique_pos[-1][1]): merged_values[-1] values_sorted[i] else: unique_pos.append((rows_sorted[i], cols_sorted[i])) merged_values.append(values_sorted[i]) # 3. 统计每行非零元素个数 row_counts np.zeros(n_rows, dtypeint) for r, _ in unique_pos: row_counts[r] 1 # 4. 生成 indptr indptr np.zeros(n_rows 1, dtypeint) np.cumsum(row_counts, outindptr[1:]) data np.asarray(merged_values, dtypefloat) indices np.asarray([c for _, c in unique_pos], dtypeint) return data, indices, indptr这个实现不算最优但用来理解 CSR 的构造逻辑足够了。注意几个关键细节必须先排序。CSR 要求每一行的非零元素在data和indices里连续存放排序就是保证“连续”的手段。重复项的处理方式取决于业务。有些场景需要累加比如邻接矩阵里统计边权重有些场景需要覆盖比如有限元里同一个自由度位置被多个单元贡献。SciPy 默认是累加这也是很多人在构建时容易忽略的一点。indptr是累计和不是每行的独立偏移。这个细节看代码的cumsum那行就很直观了。手工构造一遍后再看 SciPy 的csr_matrix理解完全不同。你能猜到它在底层大概经历了哪些步骤也能理解为什么构造稀疏矩阵比构造稠密矩阵更耗时。3. Python 里的 CSR 操作实战SciPy 上手全记录3.1 四种构建方式选对场景事半功倍SciPy 里构建 CSR 矩阵的方式有好几种我按实际使用频率说说方式一从稠密矩阵直接转换import numpy as np from scipy.sparse import csr_matrix, coo_matrix, csc_matrix dense np.array([ [0, 5, 0, 8], [0, 0, 3, 0], [7, 0, 0, 0] ]) A csr_matrix(dense) print(A.toarray())这种方式适合小规模原型验证注意.toarray()会把稀疏矩阵完整展开成稠密矩阵大矩阵千万别轻易调用我吃过一次内存爆炸的亏。方式二从 COO 三元组构建row np.array([0, 0, 1, 2]) col np.array([1, 3, 2, 0]) data np.array([5, 8, 3, 7]) A coo_matrix((data, (row, col)), shape(3, 4)).tocsr()这是我最常用的构建方式尤其是从数据库、日志文件里读取非零元素场景。COO 构建非常快因为它本质上只是“装起来”真正的排序压缩发生在.tocsr()那一下。方式三通过 indptr 直接定义indptr np.array([0, 2, 3, 4]) indices np.array([1, 3, 2, 0]) data np.array([5, 8, 3, 7]) A csr_matrix((data, indices, indptr), shape(3, 4))这是最底层、最高效的方式性能上完全没有额外开销。如果你能直接从业务逻辑里生成三个数组直接用这个方式最好。方式四用 LIL 动态构建from scipy.sparse import lil_matrix L lil_matrix((1000, 1000)) # 逐条添加 L[0, 1] 5 L[0, 3] 8 L[1, 2] 3 L[2, 0] 7 # 最后转 CSR 做运算 A L.tocsr()LIL 格式更适合高频单点赋值场景它内部用 Python list 存储每行的索引和值插入效率高于 CSR。但 LIL 运算性能差所以正确做法是用 LIL 动态构建用完立刻转 CSR 运算。3.2 常用操作行切片、乘法、转置、求和一个都不能少CSR 最拿手的是行操作和矩阵乘法。实际场景中我用的最多的是下面几种取一行数据# 取第 0 行 row0 A.getrow(0) print(row0.toarray()) # 小规模才能这么看getrow返回一个新的 CSR 矩阵1×n 的。它内部实现实际上是先利用indptr定位这一行在data和indices里的区间然后再创建新对象所以速度非常快时间复杂度是 O(该行非零元素数)。矩阵乘法B csr_matrix(np.random.rand(4, 2)) # 和 A 的列数对齐 C A B注意A是 CSRB必须是“可识别的矩阵”SciPy 会自动判断。CSR 乘一个稠密小矩阵时SciPy 会为了性能自动做一些优化但尽量保持两个稀疏矩阵相乘才最能发挥出稀疏算法的优势。转置At A.TCSR 转置之后变成 CSC 布局但这在 SciPy 里是透明的。培训时我总强调一句话如果你频繁做列操作直接把矩阵转成csc_matrix这和 CSR 是同一套压缩逻辑只是“行压缩”变“列压缩”。按行求和row_sum np.asarray(A.sum(axis1)).flatten()A.sum(axis1)返回一个矩阵对象我习惯用np.asarray(...).flatten()转成普通一维数组。行切片sub A[1:3, :] # 取第 1、2 行CSR 的行切片效率极高因为它就是按indptr区间复制索引和值的区间不需要扫描整个矩阵。这一点是 CSR 最大的优势之一在很多推荐系统的分块处理里特别有用。索引赋值# 修改已有非零值 A[0, 1] 10 # 对原来为 0 的位置赋值 A[0, 0] 99CSR 的单点赋值其实是个“重操作”因为它可能涉及到插入新元素进而影响indptr后面的所有位置复杂度不是 O(1)。通俗点说CSR 格式不适合频繁“原地”修改。需要频繁改动就用 DOK 或 LIL。我做过的实测数据矩阵规模 5000×5000nnz 50000随机操作 10000 次单点赋值存储格式单点赋值总耗时是否建议频繁赋值稠密 ndarray0.08s可以LIL0.12s建议DOK0.10s建议CSR9.8s不建议CSR 单点赋值慢得让人怀疑人生。原因就是它需要维护有序索引结构在新插入非零元素时要移动后面的数据。不是说 CSR 不好而是要选对使用场景。4. 稀疏格式横向对比CSR 不是万能钥匙4.1 主流格式优缺点对照表我用一个表把几种主流格式的底细说清楚格式全称优点缺点推荐场景CSRCompressed Sparse Row行切片快、矩阵乘法快、节省内存单点赋值慢、构建需排序行操作、矩阵乘法、推理部署CSCCompressed Sparse Column列切片快、矩阵乘法快和 CSR 类似但列优势列操作、需要频繁取列的统计场景COOCoordinate构建极快、直观好懂运算效率低、内存开销大中间交换格式、从文件加载DOKDictionary of Keys单点赋值极快、支持稀疏增量运算不支持、内存分散动态构建小规模矩阵LILList of Lists逐行逐点赋值快运算性能差动态构建 后续转 CSRDIADiagonal对角线操作效率极高非对角线分布时浪费严重对角线密集矩阵、偏微分方程离散这里要特别点一下 COO。COO 的底层就是三个数组分别存“行、列、值”没有做压缩排序。所以它很占内存但构建起来非常快——因为不需要排序、不需要统计行偏移量只需要把三元组收集起来。正因为这个特性几乎所有文件格式比如 Matrix Market 的 .mtx 文件加载步骤都会先把数据读成 COO再转成其他格式。4.2 格式转换的代价与性能实测我统计过一批 SciPy 格式转换的实测数据矩阵规模 10000×10000nnz 500000硬件是一颗 i5-12600K 的普通台式机转换操作耗时COO → CSR0.22sCOO → CSC0.23sCSC → CSR0.18sCSR → COO0.02sCSR → CSC0.18sCSR → Dense小规模仅为展示明显变慢超大矩阵别试注意一个反直觉的结论CSR → COO 比 COO → CSR 快得多因为 CSR 转 COO 只是机械地把三个数组拆开复制不涉及排序和统计而 COO → CSR 需要做排序和压缩。所以如果你的程序需要频繁在 “加载文件COO→ 运算CSR” 之间切换一定要把转换放在加载阶段一次性完成别在热点代码里反复做。矩阵乘法性能上CSR 和 CSC 基本打平。实际跑了一个 10000×10000 的稀疏矩阵乘法nnz 接近 50 万两种格式的耗时差距在 5% 以内可以忽略。真正的性能差距出现在行访问为主的代码CSR 快 5 到 10 倍列访问为主的代码CSC 快 5 到 10 倍选对格式本质上就是对齐你的操作模式。用词类比的话CSR 是“按行摆放的档案柜”CSC 是“按列摆放的档案柜”。你经常按行取资料就选前者经常按列取就选后者。非要拿 CSR 去做大量列切片也不是不行就是每次都要内部转置顶多慢几倍而已。5. 实操中的高频坑点CSR 用起来的边界和排查心得5.1 我在真实项目中踩过的四个典型坑这一节是我最想分享的内容。很多教程只讲 CSR 怎么用、怎么快但实际工程里踩坑才是浪费时间最多的地方。第一个坑.toarray()的隐形炸弹刚开始用 SciPy 时为了打印结果、调试问题我动不动就.toarray()查看结果在项目里处理 50 万行 × 2 万列的稀疏特征矩阵时一次.toarray()直接把笔记本搞死机。从那之后我给自己立了几个规矩调试时只取局部切片再.toarray()比如A[:10, :10].toarray()检查规模用.shape、.nnz而不是打印结果生产代码里禁用.toarray()强制用repr(A)查看高阶信息第二个坑稀疏矩阵的布尔索引和条件筛选坑稠密矩阵里可以用布尔掩码做花式索引但稀疏矩阵的矩阵乘法以及比较操作语义上有细微差别。比如A[A 2]这种写法在稀疏矩阵上会直接报错或行为异常。要筛选元素正确做法是先.tocoo()后用底层数组操作或者用A.multiply()配合布尔条件矩阵。# 正确做法保留大于 2 的元素 mask A 2 # 返回布尔稀疏矩阵 filtered A.multiply(mask)这里A.multiply(mask)是逐元素乘法不是矩阵乘法。新手最容易在*和上翻车稀疏矩阵的*默认是逐元素乘法element-wise才是矩阵乘法而稠密 NumPy 数组的*就是逐元素操作。两种语义在不同类型下恰恰一致所以不熟悉的时候很容易产生误操作。第三个坑重复索引默认累加而非覆盖用coo_matrix((data, (row, col))).tocsr()构建时如果同一个(row, col)位置在列表里出现了多次SciPy 默认会把值累加而不是报错。这既是特性也是坑。我在统计用户行为频次时这种累加特性用起来痛快无比但在给图赋值时如果同一对节点有多条平行边我只想保留一条结果累加出来的权重大得离谱模型训练结果跑偏了很久才发现。解决方式有两种一是提前在业务逻辑里做一次去重明确覆盖规则二是如下面代码所示手动从 CSR 中提取并处理重复项# 如果你想覆盖而不是累加先转成 lil 或 dok再用赋值覆盖 L A.tolil() # 假设需要覆盖 (0, 1) 位置 L[0, 1] 99 A L.tocsr()第四个坑indptr和indices不一定有序虽说 CSR 按行压缩但同一个行内部的列索引并不保证严格升序尤其是经过.tocsr()从某些操作转换来的 CSR。虽然大多数操作不依赖列的有序性但下面两种情况会踩坑依赖列序做算法定制时把稀疏矩阵导出到 C/C 或 Java 项目里自己手写遍历时安全写法是在转换后做一次显式排序或者在接收外部矩阵时先用has_sorted_indices判断A csr_matrix(...) if not A.has_sorted_indices: A.sort_indices()5.2 常见问题排查速查表把我在各种排坑过程中积累下来的速查表整理出来症状可能原因解决办法构建时内存爆掉用csr_matrix(dense_matrix)构造超大稠密矩阵改用 COO 三元组构造打印报错 “matrix is too big”矩阵维度太大无法展示用.shape、.nnz查看乘法结果很奇怪*逐元素乘法和矩阵乘法混淆确认运算语义赋值后矩阵结构异常对 CSR 高频单点赋值改用 LIL/DOK赋值完成后再转 CSR转成其他格式后数据变多重复项累加或者未去重提前业务去重或显式处理重复项.T结果性能差列操作频繁但仍用 CSR用csc_matrix导入 mtx 文件后变形读取时 shape 参数给错明确.mtx头部的 size 信息排序相关算法结果不稳定indices未排序A.sort_indices()5.3 一条亲测有效的通用经验最后分享一个我自己总结出来的工作流适合大多数特征工程和科学计算项目从文件、数据库或日志中读取三元组用 COO 构建。需要动态添加数据时用 LIL 或 DOK 做增量写入。数据准备完成后一次性.tocsr()转成 CSR。以行为主做切片、乘法、求和等操作。如果需要按列操作就.tocsc()转成 CSC。整个生命周期内禁用.toarray()用局部切片代替。这套流程我用了几个大型项目内存占用和运算效率都相当稳定。特别是推荐系统里的用户特征矩阵和召回排序模型的特征拼接CSR 节省下来的内存让单机训练成了可能也让我彻底记住了“稀疏存储”的价值。从学习角度说CSR 格式是理解所有压缩稀疏矩阵体系的敲门砖。一旦把data、indices、indptr三个数组的关系揉碎了再看 CSC、BCSR、COO 甚至 GPU 上的稀疏存储格式基本就是触类旁通。希望这篇记录能让你少走一些我当年走过的弯路尤其是那些和内存、性能、数据语义相关的坑提前避开总比事后修复舒服太多。
返回列表