スパース行列(疎行列)とは?わかりやすく解説して実装する

スパース行列(sparse matrix)とは、日本語で疎行列と呼ばれ、行列の成分のほとんどがゼロである行列のことです。大規模な数値計算や機械学習では、スパース行列を効率的に扱うことが計算速度とメモリ使用量の両面で非常に重要です。

本記事では、スパース行列の基本概念から格納形式、Pythonでの実装までを解説します。

本記事の内容

  • スパース行列の定義と具体例
  • スパース行列の格納形式(COO, CSR, CSC)
  • 密行列と比較した計算上の利点
  • SciPyを使ったPythonでの実装

スパース行列とは

定義

$m \times n$ の行列 $\bm{A}$ がスパース行列であるとは、行列の成分のうち非零要素の数 $\text{nnz}(\bm{A})$ が全体の要素数 $mn$ に対して非常に少ない状態をいいます。

スパース率(sparsity)は次のように定義されます。

$$ \text{sparsity} = 1 – \frac{\text{nnz}(\bm{A})}{mn} $$

例えば、$1000 \times 1000$ の行列で非零要素が5000個であれば、スパース率は $1 – 5000/1000000 = 0.995$(99.5%がゼロ)です。

具体例

スパース行列は多くの実応用で自然に現れます。

  • 有限要素法: 各要素は少数の隣接要素としか結合しないため、剛性行列はスパース
  • グラフの隣接行列: 大規模ネットワークでは各ノードの接続は少数なのでスパース
  • 自然言語処理: 文書-単語行列は、各文書に含まれる単語の種類が全語彙に対してごく少数なのでスパース
  • 推薦システム: ユーザー-アイテム評価行列は、大部分のアイテムが未評価なのでスパース

スパース行列の格納形式

密行列(通常の2次元配列)でスパース行列を格納すると、ゼロ要素にも無駄にメモリを使ってしまいます。そこで、非零要素だけを効率的に格納する様々な形式が考案されています。

COO形式(Coordinate Format)

非零要素の(行番号, 列番号, 値)のリストで格納します。最も直感的な形式です。

$$ \bm{A} = \begin{pmatrix} 5 & 0 & 0 & 0 \\ 0 & 8 & 0 & 0 \\ 0 & 0 & 3 & 0 \\ 0 & 6 & 0 & 0 \end{pmatrix} $$

COO形式: * row: [0, 1, 2, 3] * col: [0, 1, 2, 1] * data: [5, 8, 3, 6]

メモリ使用量は非零要素数に比例し、$3 \times \text{nnz}$ の記憶が必要です。

CSR形式(Compressed Sparse Row)

行方向に圧縮した形式で、行列-ベクトル積の計算に最適です。

  • data: 非零要素の値(行順)
  • indices: 各非零要素の列番号
  • indptr: 各行の非零要素がdataのどこから始まるかを示すポインタ

先ほどの行列の場合: * data: [5, 8, 3, 6] * indices: [0, 1, 2, 1] * indptr: [0, 1, 2, 3, 4]

indptrの意味は、第 $i$ 行の非零要素はdata[indptr[i]:indptr[i+1]]にあるということです。

CSC形式(Compressed Sparse Column)

列方向に圧縮した形式で、列スライスの取得に最適です。CSRの行と列の役割を入れ替えた構造です。

計算上の利点

メモリ効率

$n \times n$ の密行列のメモリ使用量は $O(n^2)$ ですが、非零要素数 $\text{nnz}$ のスパース行列(CSR形式)は $O(\text{nnz} + n)$ で済みます。

例えば $n = 10^6$ で $\text{nnz} = 5 \times 10^6$ の場合、密行列では約8TB(float64)必要ですが、CSR形式では約60MBで済みます。

行列-ベクトル積の計算量

密行列とベクトルの積 $\bm{A}\bm{x}$ は $O(n^2)$ の計算量ですが、スパース行列では $O(\text{nnz})$ に削減されます。

$$ (\bm{A}\bm{x})_i = \sum_{j} a_{ij} x_j = \sum_{j \in \text{nz}(i)} a_{ij} x_j $$

ここで $\text{nz}(i)$ は第 $i$ 行の非零要素の列インデックス集合です。

Pythonでの実装

SciPyのsparseモジュールを使ったスパース行列の操作と性能比較を行います。

import numpy as np
import matplotlib.pyplot as plt
from scipy import sparse
import time

# スパース行列の生成(COO形式)
row = np.array([0, 1, 2, 3, 0, 2])
col = np.array([0, 1, 2, 1, 3, 0])
data = np.array([5, 8, 3, 6, 2, 7])

A_coo = sparse.coo_matrix((data, (row, col)), shape=(4, 4))
print("COO形式:")
print(A_coo)
print(f"\n密行列:\n{A_coo.toarray()}")

# CSR形式への変換
A_csr = A_coo.tocsr()
print(f"\nCSR形式:")
print(f"  data:    {A_csr.data}")
print(f"  indices: {A_csr.indices}")
print(f"  indptr:  {A_csr.indptr}")

# ランダムスパース行列の生成と性能比較
n = 5000
density = 0.01  # 1%が非零

# スパース行列の生成
A_sparse = sparse.random(n, n, density=density, format='csr')
A_dense = A_sparse.toarray()
x = np.random.randn(n)

print(f"\n=== 性能比較 ({n}x{n}, density={density}) ===")
print(f"非零要素数: {A_sparse.nnz}")
print(f"スパース率: {1 - A_sparse.nnz / (n*n):.4f}")
print(f"密行列メモリ: {A_dense.nbytes / 1024**2:.1f} MB")
print(f"スパース行列メモリ: {(A_sparse.data.nbytes + A_sparse.indices.nbytes + A_sparse.indptr.nbytes) / 1024**2:.3f} MB")

# 行列-ベクトル積の速度比較
n_trials = 100
t_start = time.time()
for _ in range(n_trials):
    y_dense = A_dense @ x
t_dense = (time.time() - t_start) / n_trials

t_start = time.time()
for _ in range(n_trials):
    y_sparse = A_sparse @ x
t_sparse = (time.time() - t_start) / n_trials

print(f"\n行列-ベクトル積の平均時間:")
print(f"  密行列:      {t_dense*1000:.3f} ms")
print(f"  スパース行列: {t_sparse*1000:.3f} ms")
print(f"  速度比:       {t_dense/t_sparse:.1f}x")

# 可視化
fig, axes = plt.subplots(1, 2, figsize=(12, 5))

# 左: スパースパターンの表示
A_small = sparse.random(50, 50, density=0.05, format='csr')
axes[0].spy(A_small, markersize=2, color='navy')
axes[0].set_title(f'Sparsity Pattern (50x50, nnz={A_small.nnz})')
axes[0].set_xlabel('Column')
axes[0].set_ylabel('Row')

# 右: density vs 計算時間
densities = [0.001, 0.005, 0.01, 0.05, 0.1, 0.2, 0.5]
times_sparse = []
times_dense = []
n_test = 2000

for d in densities:
    A_sp = sparse.random(n_test, n_test, density=d, format='csr')
    A_de = A_sp.toarray()
    x_test = np.random.randn(n_test)

    t0 = time.time()
    for _ in range(20):
        A_sp @ x_test
    times_sparse.append((time.time() - t0) / 20 * 1000)

    t0 = time.time()
    for _ in range(20):
        A_de @ x_test
    times_dense.append((time.time() - t0) / 20 * 1000)

axes[1].semilogy(densities, times_sparse, 'bo-', label='Sparse (CSR)')
axes[1].semilogy(densities, times_dense, 'rs-', label='Dense')
axes[1].set_xlabel('Density')
axes[1].set_ylabel('Time (ms)')
axes[1].set_title(f'Matrix-Vector Product Time ({n_test}x{n_test})')
axes[1].legend()
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

このコードでは、スパース行列と密行列の行列-ベクトル積の計算時間を比較しています。スパース率が高い場合、スパース行列の計算が密行列よりも大幅に高速であることが確認できます。

まとめ

本記事では、スパース行列(疎行列)について解説しました。

  • スパース行列は非零要素がごく少数の行列であり、大規模数値計算で頻繁に現れる
  • COO, CSR, CSC などの格納形式により、メモリ使用量を $O(n^2)$ から $O(\text{nnz})$ に削減できる
  • 行列-ベクトル積の計算量も $O(n^2)$ から $O(\text{nnz})$ に削減され、大規模問題で大きな速度向上が得られる
  • PythonではSciPyのsparseモジュールで簡単にスパース行列を扱える