0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

散布図に直線を当てはめる「直線フィット」は、統計解析やデータサイエンスで頻繁に登場します。

Pythonでは、たとえばnumpy.polyfit()を使うと、わずか1行で直線を求められます。

しかし、

  • その直線は、どのような基準で選ばれているのでしょうか
  • 「最小二乗法」は、何を最小にしているのでしょうか
  • 観測値と直線の差は、どのように計算されるのでしょうか

と聞かれると、少し説明が難しいかもしれません。

この記事では、ダミーデータを作成し、直線、予測値、残差、残差平方和をPythonで可視化します。

Google Colabでコードを順番に実行しながら、最小二乗法が行っていることを図で理解することを目指します。

この記事で分かること

この記事では、次の内容を扱います。

  • 説明変数、目的変数、予測値、残差という用語
  • 最小二乗法が最小にする量
  • 残差を二乗する理由
  • 傾きと切片を変えたときの残差平方和の変化
  • numpy.polyfit()による直線フィット
  • 残差プロットの基本的な読み方
  • 最小二乗法を使うときの注意点

先に結論

$n$個のデータ点$(x_i, y_i)$に対して、次の直線を考えます。

$$
\hat{y}_i = ax_i + b
$$

ここで、

  • $a$:直線の傾き
  • $b$:直線の切片
  • $y_i$:実際に観測された値
  • $\hat{y}_i$:直線から予測された値

です。

観測値と予測値の差を残差と呼びます。

$$
e_i = y_i - \hat{y}_i
$$

最小二乗法は、残差を二乗して足し合わせた量、

RSS(a,b)=\sum_{i=1}^{n}\left\{y_i-(ax_i+b)\right\}^2

を最小にする傾き$a$と切片$b$を選びます。

したがって、最小二乗法が最小にしているものは、

観測値と予測値の差そのものではなく、残差の二乗和

です。

この残差の二乗和を、残差平方和、英語ではResidual Sum of Squares、略してRSSと呼びます。

Google Colabでの実行方法

Google Colabで新しいノートブックを作成し、以下のコードセルを上から順番に実行してください。

1. ライブラリを読み込む

今回使用するライブラリは、次の3つです。

  • NumPy:数値計算
  • pandas:表形式データの表示
  • Matplotlib:グラフの作成
from pathlib import Path

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

# 乱数を再現可能にする
SEED = 42
rng = np.random.default_rng(SEED)

# 図の保存先
output_dir = Path("least_squares_figures")
output_dir.mkdir(exist_ok=True)

# 図の基本設定
plt.rcParams["figure.figsize"] = (8, 5)
plt.rcParams["figure.dpi"] = 120

2. ダミーデータを作る

今回は、$x$と$y$の間におおよそ直線的な関係があるダミーデータを作ります。

データを生成する式は、次のとおりです。

$$
y_i=
2.5x_i + 1.0 + \varepsilon_i
$$

$\varepsilon_i$は、測定値のばらつきに相当するランダムなノイズです。

今回は、平均0、標準偏差2.0の正規分布からノイズを発生させます。

$$
\varepsilon_i
\sim
\mathcal{N}(0, 2.0^2)
$$

# データ点の数
n = 20

# 0から10までのxを20点作る
x = np.linspace(0, 10, n)

# ダミーデータを作るための真のパラメータ
true_slope = 2.5
true_intercept = 1.0
noise_std = 2.0

# ランダムなノイズ
noise = rng.normal(0, noise_std, size=n)

# y = 2.5x + 1.0 + ノイズ
y = true_slope * x + true_intercept + noise

# 表として確認する
df = pd.DataFrame({"x": x, "y": y})
display(df.head())

# 散布図
plt.figure()
plt.scatter(x, y, s=50)
plt.xlabel("x")
plt.ylabel("y")
plt.title("Dummy data")
plt.grid(alpha=0.3)
plt.tight_layout()

plt.savefig(
    output_dir / "fig01_dummy_data.png",
    dpi=160,
)
plt.show()

fig01_dummy_data.png

散布図を見ると、$x$が大きくなるにつれて$y$も大きくなる傾向があります。

ただし、すべての点が一本の直線上に並んでいるわけではありません。

これは、データにランダムなノイズを加えているためです。

ダミーデータなので、今回はデータを生成した「真の直線」が、

$$
y = 2.5x + 1.0
$$

であることを知っています。

一方、実際の研究データでは、通常、この真の関係は分かりません。

そこで、観測されたデータから、もっともよく当てはまる直線を推定します。

3. 直線フィットで使う用語

最小二乗法を理解する前に、基本的な用語を整理します。

用語 記号 意味
説明変数 $x_i$ 予測に使用する変数
目的変数 $y_i$ 説明したい、または予測したい観測値
回帰直線 $\hat{y}=ax+b$ データに当てはめる直線
予測値 $\hat{y}_i$ 回帰直線から計算された値
残差 $e_i$ 観測値と予測値の差
残差平方 $e_i^2$ 残差を二乗した値
残差平方和 $RSS$ すべての残差平方の合計

観測値と予測値

データとして実際に得られた値が、観測値$y_i$です。

一方、直線から計算される値が、予測値$\hat{y}_i$です。

$$
\hat{y}_i = ax_i+b
$$

残差

観測値と予測値の差を残差と呼びます。

$$
e_i = y_i-\hat{y}_i
$$

データ点が直線より上にある場合、残差は正になります。

データ点が直線より下にある場合、残差は負になります。

厳密には、統計学でいう「誤差」と「残差」は少し異なります。

  • 誤差:観測値と未知の真の値との差
  • 残差:観測値と推定したモデルの予測値との差

真の値は通常分からないため、実際のデータ解析では残差を調べます。

4. なぜ残差を二乗するのか

残差をそのまま足し合わせると、正の残差と負の残差が打ち消し合ってしまいます。

たとえば、残差が$+3$と$-3$であれば、その合計は0です。

しかし、どちらの点も直線から3だけ離れています。

そこで、残差を二乗します。

$$
(+3)^2 = 9
$$

$$
(-3)^2 = 9
$$

二乗すれば、正負にかかわらず非負の値になります。

さらに、大きな残差は強く評価されます。

たとえば、残差が1から2へ2倍になると、残差平方は1から4へ4倍になります。

$$
1^2 = 1
$$

$$
2^2 = 4
$$

このため、最小二乗法は大きく外れたデータ点の影響を受けやすいという性質も持ちます。

残差を二乗する主な理由は、次のように整理できます。

  1. 正と負の残差が打ち消し合わない
  2. 大きな残差をより強く評価できる
  3. 数学的に微分しやすく、解を求めやすい

また、誤差が独立な正規分布に従うと仮定すると、最小二乗法は最尤推定とも対応します。

5. numpy.polyfit()で直線を当てはめる

NumPyのpolyfit()を使って、ダミーデータに直線を当てはめます。

deg=1は、1次式、すなわち直線を当てはめることを意味します。

# 1次式を最小二乗法で当てはめる
slope_hat, intercept_hat = np.polyfit(
    x,
    y,
    deg=1,
)

# 各xに対する予測値
y_hat = slope_hat * x + intercept_hat

# 残差
residuals = y - y_hat

# 残差平方
squared_residuals = residuals**2

# 残差平方和
rss = np.sum(squared_residuals)

# 平均二乗誤差
mse = np.mean(squared_residuals)

# 二乗平均平方根誤差
rmse = np.sqrt(mse)

# 決定係数
sst = np.sum((y - np.mean(y))**2)
r2 = 1 - rss / sst

print(f"estimated slope     = {slope_hat:.3f}")
print(f"estimated intercept = {intercept_hat:.3f}")
print(f"RSS                 = {rss:.3f}")
print(f"MSE                 = {mse:.3f}")
print(f"RMSE                = {rmse:.3f}")
print(f"R^2                 = {r2:.3f}")

実行結果は、次のようになります。

estimated slope     = 2.596
estimated intercept = 0.452
RSS                 = 55.834
MSE                 = 2.792
RMSE                = 1.671
R^2                 = 0.957

推定された直線は、次のようになります。

$$
\hat{y}=
2.596x + 0.452
$$

ダミーデータを生成した真の直線は、

$$
y = 2.5x+1.0
$$

でした。

推定値は真の値に近いものの、完全には一致しません。

これは、データ点の数が有限であり、さらにランダムなノイズが含まれているためです。

RSS、MSE、RMSEの違い

RSSは、残差平方をすべて足し合わせた値です。

$$
RSS = \sum_{i=1}^{n} e_i^2
$$

MSEは、RSSをデータ点の数$n$で割った単純平均です。

$$
MSE = \frac{1}{n}\sum_{i=1}^{n}e_i^2
$$

RMSEは、MSEの平方根です。

$$
RMSE = \sqrt{MSE}
$$

RSSとMSEは、目的変数$y$の単位を二乗した単位になります。

一方、RMSEは$y$と同じ単位になるため、誤差の大きさを直感的に解釈しやすい指標です。

なお、ここでは予測誤差を要約する目的で$RSS/n$をMSEとしています。

切片を含む単回帰モデルから誤差分散を不偏推定する場合には、自由度を考慮して$RSS/(n-2)$を使います。両者は目的が異なるため、区別が必要です。

6. 複数の候補となる直線を比較する

最小二乗法がどの直線を選んでいるのかを見るために、3本の直線を比較します。

  • Candidate A:傾き1.5、切片4.0
  • Candidate B:傾き2.0、切片1.0
  • Least-squares fit:最小二乗法で求めた直線

それぞれについてRSSを計算します。

candidates = [
    ("Candidate A", 1.5, 4.0),
    ("Candidate B", 2.0, 1.0),
    (
        "Least-squares fit",
        slope_hat,
        intercept_hat,
    ),
]

x_line = np.linspace(
    x.min(),
    x.max(),
    200,
)

plt.figure()
plt.scatter(
    x,
    y,
    s=50,
    label="data",
)

for name, a, b in candidates:
    candidate_y_hat = a * x + b

    candidate_rss = np.sum(
        (y - candidate_y_hat) ** 2
    )

    plt.plot(
        x_line,
        a * x_line + b,
        linewidth=2,
        label=f"{name}: RSS={candidate_rss:.1f}",
    )

plt.xlabel("x")
plt.ylabel("y")
plt.title("Candidate lines and their RSS")
plt.grid(alpha=0.3)
plt.legend()
plt.tight_layout()

plt.savefig(
    output_dir / "fig02_candidate_lines.png",
    dpi=160,
)
plt.show()

fig02_candidate_lines.png

このコードでは、次のRSSが得られます。

直線 傾き 切片 RSS
Candidate A 1.5 4.0 352.1
Candidate B 2.0 1.0 239.9
Least-squares fit 2.596 0.452 55.8

最小二乗法で求めた直線のRSSが、もっとも小さくなっています。

ここで重要なのは、最小二乗法が「すべてのデータ点を通る直線」を探しているわけではないことです。

ノイズを含むデータでは、通常、すべての点を一本の直線で通ることはできません。

そこで、全データ点からの残差平方を合計し、その合計がもっとも小さくなる直線を選びます。

7. 傾きと切片を変えたときのRSSを見る

直線は、傾き$a$と切片$b$の2つのパラメータで決まります。

$$
\hat{y}=ax+b
$$

そこで、傾きと切片を少しずつ変えながら、すべての組み合わせについてRSSを計算してみます。

# 調べる傾きと切片の範囲
slope_values = np.linspace(
    1.0,
    4.0,
    241,
)

intercept_values = np.linspace(
    -4.0,
    6.0,
    241,
)

# 傾きと切片の格子を作る
SLOPE, INTERCEPT = np.meshgrid(
    slope_values,
    intercept_values,
)

# すべての傾き・切片について予測値を計算する
predicted = (
    SLOPE[..., np.newaxis] * x
    + INTERCEPT[..., np.newaxis]
)

# すべての組み合わせについてRSSを計算する
rss_grid = np.sum(
    (y - predicted) ** 2,
    axis=-1,
)

# 等高線として表示するRSS
levels = [
    60,
    80,
    100,
    150,
    250,
    400,
    700,
    1000,
    1500,
]

plt.figure(figsize=(8, 6))

contours = plt.contour(
    SLOPE,
    INTERCEPT,
    rss_grid,
    levels=levels,
)

plt.clabel(
    contours,
    inline=True,
    fontsize=8,
    fmt="%d",
)

# 最小二乗法で求めた位置
plt.scatter(
    slope_hat,
    intercept_hat,
    s=100,
    marker="x",
    linewidths=3,
    label="least-squares minimum",
)

plt.xlabel("slope a")
plt.ylabel("intercept b")
plt.title("RSS landscape")
plt.grid(alpha=0.2)
plt.legend()
plt.tight_layout()

plt.savefig(
    output_dir / "fig03_rss_landscape.png",
    dpi=160,
)
plt.show()

fig03_rss_landscape.png

横軸は傾き$a$、縦軸は切片$b$です。

それぞれの等高線上では、RSSが同じ値になっています。

内側へ進むほどRSSは小さくなり、図中のx印が最小二乗法によって求めた位置です。

今回の最小値は、およそ次の位置にあります。

$$
a = 2.596
$$

$$
b = 0.452
$$

等高線が斜めに伸びた楕円形になっている点にも注目できます。

傾きを少し大きくして切片を少し小さくすると、データが存在する範囲内では似たような直線になることがあります。

このため、傾きと切片は完全に独立に決まるのではなく、互いに関係しながらRSSを変化させます。

最小二乗法は、この2次元の「RSSの地形」のもっとも低い場所を探していると考えられます。

8. 残差を図で確認する

観測値と予測値の差を、図に描いてみます。

plt.figure()

# 観測値
plt.scatter(
    x,
    y,
    s=50,
    label="observed y",
)

# 最小二乗法で求めた直線
plt.plot(
    x_line,
    slope_hat * x_line + intercept_hat,
    linewidth=2,
    label="fitted line",
)

# 観測値と予測値を結ぶ縦線
plt.vlines(
    x,
    y_hat,
    y,
    linewidth=1.5,
    alpha=0.8,
    label="residuals",
)

# 予測値
plt.scatter(
    x,
    y_hat,
    s=25,
    marker="x",
    label="predicted y",
)

plt.xlabel("x")
plt.ylabel("y")
plt.title("Fitted line and residuals")
plt.grid(alpha=0.3)
plt.legend()
plt.tight_layout()

plt.savefig(
    output_dir / "fig04_residuals.png",
    dpi=160,
)
plt.show()

fig04_residuals.png

丸印が観測値$y_i$、回帰直線上のx印が予測値$\hat{y}_i$です。

両者を結ぶ縦線の長さが、残差の絶対値に相当します。

$$
e_i = y_i-\hat{y}_i
$$

データ点が直線より上にあれば、残差は正です。

データ点が直線より下にあれば、残差は負です。

ここで、通常の最小二乗法が最小化しているのは、$y$方向の縦の距離です。

データ点から直線までの最短距離、すなわち直線に垂直な距離を最小化しているわけではありません。

通常の回帰分析では、$x$を与えたときの$y$を説明または予測することを考えるためです。

$x$にも無視できない測定誤差がある場合は、直交距離回帰など、別の方法が適することがあります。

9. 各データ点の残差平方を見る

RSSは、各データ点の残差平方を足し合わせたものです。

$$
RSS=
e_1^2
+
e_2^2
+
\cdots
+
e_n^2
$$

各データ点がRSSにどのくらい寄与しているのかを、棒グラフで確認します。

plt.figure(figsize=(9, 5))

plt.bar(
    np.arange(n),
    squared_residuals,
)

plt.xlabel("data point index")
plt.ylabel("squared residual")
plt.title(
    f"Contribution of each point to RSS "
    f"(total RSS = {rss:.1f})"
)
plt.grid(axis="y", alpha=0.3)
plt.tight_layout()

plt.savefig(
    output_dir / "fig05_squared_residuals.png",
    dpi=160,
)
plt.show()

fig05_squared_residuals.png

各棒の高さが、そのデータ点の残差平方です。

棒の高さをすべて足し合わせると、RSSの約55.8になります。

今回の結果では、インデックス4、すなわち5番目のデータ点の寄与が比較的大きくなっています。

残差を二乗するため、直線から大きく離れた点はRSSへ強く影響します。

これは最小二乗法の便利な性質である一方、外れ値の影響を受けやすい理由でもあります。

10. 傾きと切片はどのように計算されるのか

単回帰の最小二乗法には、傾きと切片を直接求める公式があります。

$x$と$y$の平均を、それぞれ$\bar{x}$と$\bar{y}$とすると、傾きは次のように求められます。

$$
\hat{a}=
\frac{
\sum_{i=1}^{n}
(x_i-\bar{x})(y_i-\bar{y})
}{
\sum_{i=1}^{n}
(x_i-\bar{x})^2
}
$$

切片は次の式で求められます。

$$
\hat{b}=\bar{y}-\hat{a}\bar{x}
$$

これらの式は、RSSを$a$と$b$で偏微分し、両方を0と置くことで導けます。

$$
\frac{\partial RSS}{\partial a}=0
$$

$$
\frac{\partial RSS}{\partial b}=0
$$

公式をそのままPythonで計算して、np.polyfit()の結果と比較します。

# xとyの平均
x_bar = np.mean(x)
y_bar = np.mean(y)

# 最小二乗法の公式による傾き
slope_manual = np.sum(
    (x - x_bar) * (y - y_bar)
) / np.sum(
    (x - x_bar) ** 2
)

# 最小二乗法の公式による切片
intercept_manual = (
    y_bar
    - slope_manual * x_bar
)

print(
    f"manual slope     = "
    f"{slope_manual:.6f}"
)

print(
    f"manual intercept = "
    f"{intercept_manual:.6f}"
)

print(
    "np.polyfitと一致:",
    np.allclose(
        [slope_hat, intercept_hat],
        [
            slope_manual,
            intercept_manual,
        ],
    ),
)

実行結果は、次のようになります。

manual slope     = 2.596461
manual intercept = 0.451831
np.polyfitと一致: True

np.polyfit()の結果と、公式から計算した結果が一致しました。

切片の式、

$$
\hat{b}=\bar{y}-
\hat{a}\bar{x}
$$

を変形すると、

$$
\bar{y}=
\hat{a}\bar{x}
+
\hat{b}
$$

となります。

これは、切片を含む最小二乗法の回帰直線が、必ず平均の点$(\bar{x},\bar{y})$を通ることを表しています。

11. 残差プロットを確認する

直線を描いただけでは、モデルがデータの特徴を適切に表しているかを十分に判断できません。

そこで、横軸に$x$、縦軸に残差を置いた残差プロットを作ります。

plt.figure()

plt.scatter(
    x,
    residuals,
    s=50,
)

plt.axhline(
    0,
    linestyle="--",
)

plt.xlabel("x")
plt.ylabel("residual")
plt.title("Residual plot")
plt.grid(alpha=0.3)
plt.tight_layout()

plt.savefig(
    output_dir / "fig06_residual_plot.png",
    dpi=160,
)
plt.show()

fig06_residual_plot.png

今回の残差は、0の周囲におおむねランダムに分布しています。

少なくとも、この図からは明らかな曲線的パターンは見られません。

残差プロットに次のようなパターンが現れた場合は、注意が必要です。

残差のパターン 考えられる問題
U字形や逆U字形 関係が直線ではない可能性
右に進むほど広がる 残差の分散が一定でない可能性
一定の周期がある 時系列的な依存や周期成分の可能性
一部だけ極端に大きい 外れ値や影響力の強い点の可能性

最小二乗法は、どのようなデータに対しても計算自体はできます。

しかし、直線を計算できることと、その直線が適切なモデルであることは別の問題です。

12. 最小二乗法は万能ではない

最小二乗法は非常に便利ですが、いくつか注意点があります。

外れ値の影響を受けやすい

残差を二乗するため、大きな残差は強く評価されます。

たとえば、残差が2倍になると、残差平方は4倍になります。

外れ値がある場合、回帰直線がその点に強く引っ張られることがあります。

直線関係を仮定している

今回のモデルは、

$$
\hat{y}=ax+b
$$

という直線です。

実際の関係が曲線である場合、直線を当てはめてもデータの構造を適切に表せないことがあります。

高い決定係数だけでは十分ではない

今回の決定係数は約0.957でした。

これは、このダミーデータに対して直線が比較的よく当てはまっていることを示します。

しかし、決定係数が高いことだけで、

  • 因果関係がある
  • モデルが正しい
  • 外挿にも使える
  • 残差に問題がない

とは判断できません。

散布図、残差プロット、データの取得方法、解析目的を合わせて確認する必要があります。

回帰分析だけで因果関係は示せない

$x$と$y$に直線的な関係が見られても、それだけで$x$が$y$を引き起こしたとは言えません。

第三の変数や実験条件の違いが、両方に影響している可能性があります。

13. 残差平方以外を使う方法もある

最小二乗法は、残差平方和を最小化します。

$$
\sum_{i=1}^{n}e_i^2
$$

一方、残差の絶対値を足し合わせる方法もあります。

$$
\sum_{i=1}^{n}|e_i|
$$

これはL1損失や絶対誤差と呼ばれます。

残差を二乗しないため、一般に大きな外れ値の影響を最小二乗法より受けにくくなります。

また、データ点ごとに信頼度や測定精度が異なる場合は、重み付き最小二乗法を使うことがあります。

$$
\sum_{i=1}^{n}w_i e_i^2
$$

$w_i$は、各データ点に与える重みです。

最小二乗法は重要な基本手法ですが、データの性質や解析目的に応じて、損失関数や回帰方法を選ぶ必要があります。

14. 試してみると理解が深まる変更

コードの一部を変更して、結果がどう変わるか試してみてください。

ノイズを小さくする

noise_std = 0.5

データ点が真の直線の近くに集まり、推定値も真の傾きと切片に近づきやすくなります。

ノイズを大きくする

noise_std = 5.0

データのばらつきが大きくなり、推定された直線も不安定になります。

データ点を増やす

n = 100

ノイズがランダムであれば、データ点を増やすことで真の関係を推定しやすくなります。

外れ値を追加する

yを作成した直後に、次のコードを追加します。

y[4] += 15

その後、直線フィットをやり直し、傾き、切片、RSSがどのように変化するかを確認してみてください。

曲線的なデータを作る

次のように、2次関数からデータを作ります。

y = 0.4 * x**2 + noise

直線を当てはめたあと、残差プロットにどのようなパターンが現れるかを確認してみてください。

まとめ

最小二乗法による直線フィットでは、次の直線を考えます。

$$
\hat{y}_i=ax_i+b
$$

観測値と予測値の差が残差です。

$$
e_i=y_i-\hat{y}_i
$$

最小二乗法は、残差平方和、

RSS=\sum_{i=1}^{n}\left\{y_i-(ax_i+b)\right\}^2

を最小にする傾き$a$と切片$b$を選びます。

今回のポイントをまとめると、次のようになります。

  • 最小二乗法は、残差ではなく残差の二乗和を最小化する
  • 残差を二乗することで、正負の打ち消しを防げる
  • 大きな残差は、RSSへ強く影響する
  • 傾きと切片は、RSSがもっとも小さくなる組み合わせとして選ばれる
  • 最小二乗法で直線を計算できても、その直線が適切とは限らない
  • 散布図だけでなく、残差プロットも確認することが重要である

np.polyfit()を使えば、直線フィット自体は簡単に実行できます。

しかし、その背後で何が最小化されているのかを理解しておくと、結果をより適切に解釈できるようになります。

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?