2. COO 到 CSR 的转换实现与正确性验证

问题拆解:COO 的三元组如何变成 CSR 的行结构

COO 用三个等长数组 row、col、val 逐条记录非零元,天然允许重复索引且无序;CSR 则要求按行分组、行内列索引升序,并用 indptr 标记每行在 indices/data 中的起止位置。转换的核心不是简单搬运,而是完成三件事:重复项合并(求和)、按行稳定分组、行内按列排序,最后生成长度为 n_rows+1 的行指针。

COO 三元组 → 合并重复 → 行分组 → 行内排序 → CSR COO 输入(含重复、乱序) row = [2,0,1,0,2,1] col = [1,0,2,0,1,2] val = [5,1,3,4,7,6] (0,0) 出现两次:1 与 4 (2,1) 出现两次:5 与 7 ① 合并重复索引 以 (row,col) 为键累加 (0,0): 1+4 = 5 (2,1): 5+7 = 12 非零元 6 → 4 个 ② 行分组 + 行内排序 行0: (0,0)=5 行1: (1,2)=3,(1,2)=6 行2: (2,1)=12 列索引升序排列 ③ CSR 输出 indptr= [0,1,3,4] indices= [0,2,2,1] data=[5,3,6,12] CSR 行指针与数据槽位的对应关系 0 1 2 3 槽位索引 → 行0: [0,1) 行1: [1,3) 行2: [3,4) 验证要点 • indptr 单调不减,末项 = nnz • 每行 indices 严格升序(无重复列) • 与稠密矩阵逐元素比对,误差 ≤ 1e-12 • 行和 = 稠密矩阵对应行非零和

转换算法:计数排序两遍扫描

最稳妥的实现是“计数—前缀和—填充”三段式,时间复杂度 O(nnz + n_rows),且天然得到行内按原顺序的稳定排列。若输入未排序,需在填充后对每行做一次列索引排序(或先对三元组整体按 (row,col) 排序再合并)。下面给出完整可运行实现,包含重复合并与排序。

import numpy as np

def coo_to_csr(row, col, val, n_rows, n_cols, sort_cols=True):
    row = np.asarray(row, dtype=np.int64)
    col = np.asarray(col, dtype=np.int64)
    val = np.asarray(val, dtype=np.float64)
    nnz = row.size

    # 1) 合并重复索引:按 (row, col) 排序后相邻累加
    order = np.lexsort((col, row))          # 先按 row,再按 col
    row_s, col_s, val_s = row[order], col[order], val[order]

    keep_r, keep_c, keep_v = [], [], []
    for i in range(nnz):
        if keep_r and row_s[i] == keep_r[-1] and col_s[i] == keep_c[-1]:
            keep_v[-1] += val_s[i]          # 重复项求和
        else:
            keep_r.append(row_s[i]); keep_c.append(col_s[i]); keep_v.append(val_s[i])
    keep_r = np.asarray(keep_r, dtype=np.int64)
    keep_c = np.asarray(keep_c, dtype=np.int64)
    keep_v = np.asarray(keep_v, dtype=np.float64)

    # 2) 计数:每行非零元个数
    counts = np.bincount(keep_r, minlength=n_rows)

    # 3) 前缀和得到 indptr
    indptr = np.zeros(n_rows + 1, dtype=np.int64)
    indptr[1:] = np.cumsum(counts)

    # 4) 填充 indices / data(keep_r 已按行升序,故直接顺序写入)
    indices = keep_c.copy()
    data = keep_v.copy()

    # 5) 行内列索引排序(若 sort_cols 且未保证有序)
    if sort_cols:
        for r in range(n_rows):
            s, e = indptr[r], indptr[r + 1]
            if e - s > 1:
                seg = np.argsort(indices[s:e], kind="stable")
                indices[s:e] = indices[s:e][seg]
                data[s:e] = data[s:e][seg]

    return indptr, indices, data

# ---- 小规模稠密矩阵对照验证 ----
row = [2, 0, 1, 0, 2, 1]
col = [1, 0, 2, 0, 1, 2]
val = [5, 1, 3, 4, 7, 6]
n_rows, n_cols = 3, 3

indptr, indices, data = coo_to_csr(row, col, val, n_rows, n_cols)
print("indptr =", indptr)   # [0 1 3 4]
print("indices=", indices)  # [0 2 2 1]
print("data   =", data)     # [5. 3. 6. 12.]

# 稠密对照
dense = np.zeros((n_rows, n_cols))
for r, c, v in zip(row, col, val):
    dense[r, c] += v
print("dense =\n", dense)

# 由 CSR 还原稠密并比对
recon = np.zeros((n_rows, n_cols))
for r in range(n_rows):
    for k in range(indptr[r], indptr[r + 1]):
        recon[r, indices[k]] = data[k]
assert np.allclose(recon, dense), "CSR 与稠密矩阵不一致"
print("验证通过:CSR 与稠密矩阵逐元素一致")
示例中 (0,0) 的 1 与 4 合并为 5,(2,1) 的 5 与 7 合并为 12,因此 nnz 从 6 降为 4。若你的数据本身无重复,可跳过合并步骤,但排序与 indptr 生成仍不可省。

正确性验证的三条硬指标

  • 结构不变量:indptr[0]==0、indptr[-1]==len(data)==len(indices)、indptr 单调不减;每行 indices[indptr[r]:indptr[r+1]] 严格升序且无重复列。
  • 数值等价:用 CSR 还原稠密矩阵,与 COO 直接累加得到的稠密矩阵做 np.allclose 比对,容差取 1e-12 量级。
  • 边界覆盖:空行(counts 为 0)、全零矩阵、单行/单列、含负值或浮点重复项,均应通过上述两项检查。
常见错误:用 np.bincount 时忘记 minlength=n_rows,导致末尾空行的 indptr 缺失;或在合并重复项时未先按 (row,col) 排序,使相同索引不相邻而漏合并。两者都会让 indptr[-1] 与 nnz 对不上,务必先断言再使用。