Skip to the content.

← 目次← 前: 08次: 10 →

09. 単回帰分析をゼロから実装

テーマ: 最小二乗法(OLS)による広告費と売上の関係の推定

学習点: 数式の実装, t検定, 決定係数, 残差分析, scipy との照合

依存: SciPy / 難易度: 中級

実行方法

uv run 09_ols_simple.py

スクリプト冒頭の PEP 723 メタデータ(# /// script)により、必要なライブラリは uv が自動的に仮想環境へ導入します。事前の pip install は不要です。

解説

何をするプログラムか

「広告費を増やせば売上はどれだけ伸びるのか」というマーケティングの典型的な問いを、単回帰分析(最小二乗法, OLS)で推定するスクリプトです。15 期分の広告費と売上(いずれも百万円)のデータから、売上 = α + β×広告費 + u というモデルの係数を推定し、係数の標準誤差・t 値・p 値・95% 信頼区間・決定係数 R² までを教科書の公式どおりに自前実装します。

推定だけで終わらず、scipy.stats.linregress と 10 桁一致することの照合、残差プロットによる線形仮定の目視チェック、Durbin-Watson 統計量による系列相関の確認まで行う、実証分析のミニチュア版です。

コードの読みどころ

理論的背景

OLS は残差平方和を最小にする直線を選ぶ方法で、傾きの推定量は β̂ = Σ(x−x̄)(y−ȳ) / Σ(x−x̄)² です。その標準誤差は SE(β̂) = √(s²/Σ(x−x̄)²)、s² = RSS/(n−2) で与えられ、誤差項の正規性・等分散・独立の仮定のもとで t = β̂/SE(β̂) は帰無仮説 β = 0 の下で自由度 n−2 の t 分布に従います。R² は売上の変動のうちモデルで説明できる割合、自由度調整済み R² は説明変数を増やすことへのペナルティを加えた版です。

実行結果の見方

β̂ = 4.9366(SE 0.0891)、t = 55.4 で p 値は表示桁上ゼロとなり、「広告費 100 万円の増加は売上約 494 万円の増加と関連する」ことが強く支持されます。R² = 0.9958 とあてはまりも極めて良好です。scipy との照合では slope・intercept・stderr が 10 桁まで一致しており、自前実装の正しさが確認できます。ただし残差プロットを見ると、x の小さい端と大きい端で負、中央で正という緩やかな山型のパターンがあり、Durbin-Watson 統計量も 0.549 と 2 を大きく下回ります。係数が有意でも残差診断までは合格とは限らない、という残差分析の重要性を示す結果です。

ソースコード

# /// script
# requires-python = ">=3.11"
# dependencies = [
#     "scipy",
# ]
# ///
"""09: 単回帰分析をゼロから実装 -------------------------------------------
テーマ: 最小二乗法(OLS)による広告費と売上の関係の推定
学習点: 数式の実装, t検定, 決定係数, 残差分析, scipy との照合
根拠: β̂ = Σ(x-x̄)(y-ȳ) / Σ(x-x̄)^2、SE(β̂) = √(s^2 / Σ(x-x̄)^2)、
      s^2 = RSS/(n-2)。t = β̂/SE(β̂) は帰無仮説 β=0 の下で自由度 n-2 の
      t 分布に従う(誤差項の正規性・等分散・独立を仮定)。
"""
import math
from scipy import stats


def ols(x, y):
    n = len(x)
    mx, my = sum(x) / n, sum(y) / n
    sxx = sum((xi - mx) ** 2 for xi in x)
    sxy = sum((xi - mx) * (yi - my) for xi, yi in zip(x, y))
    beta = sxy / sxx
    alpha = my - beta * mx
    fitted = [alpha + beta * xi for xi in x]
    resid = [yi - fi for yi, fi in zip(y, fitted)]
    rss = sum(r ** 2 for r in resid)
    tss = sum((yi - my) ** 2 for yi in y)
    s2 = rss / (n - 2)                       # 誤差分散の不偏推定量
    se_b = math.sqrt(s2 / sxx)
    se_a = math.sqrt(s2 * (1 / n + mx ** 2 / sxx))
    r2 = 1 - rss / tss
    adj_r2 = 1 - (1 - r2) * (n - 1) / (n - 2)
    return {
        "alpha": alpha, "beta": beta, "se_a": se_a, "se_b": se_b,
        "t_b": beta / se_b, "r2": r2, "adj_r2": adj_r2,
        "rss": rss, "n": n, "fitted": fitted, "resid": resid,
        "sigma": math.sqrt(s2),
    }


def main() -> None:
    # 広告費(百万円) と 売上(百万円)
    ad = [1.2, 2.0, 2.4, 3.1, 3.5, 4.0, 4.6, 5.2, 5.8, 6.5,
          7.0, 7.7, 8.1, 8.9, 9.4]
    sales = [22.1, 27.3, 29.8, 33.4, 36.0, 37.9, 41.2, 44.8, 47.1, 50.9,
             53.2, 56.0, 57.3, 61.4, 63.0]

    r = ols(ad, sales)
    df = r["n"] - 2
    p = 2 * (1 - stats.t.cdf(abs(r["t_b"]), df))
    tcrit = stats.t.ppf(0.975, df)

    print("=== 単回帰 売上 = α + β×広告費 + u ===")
    print(f"  n = {r['n']},  自由度 = {df}")
    print(f"  α̂ = {r['alpha']:>8.4f}  (SE {r['se_a']:.4f})")
    print(f"  β̂ = {r['beta']:>8.4f}  (SE {r['se_b']:.4f})  "
          f"t = {r['t_b']:.3f}  p = {p:.3e}")
    print(f"  95%信頼区間 β: "
          f"[{r['beta'] - tcrit * r['se_b']:.4f}, "
          f"{r['beta'] + tcrit * r['se_b']:.4f}]")
    print(f"  R² = {r['r2']:.4f} / 自由度調整済 R² = {r['adj_r2']:.4f}")
    print(f"  残差標準誤差 σ̂ = {r['sigma']:.4f}")
    print(f"\n  解釈: 広告費を100万円増やすと売上は約 {r['beta']:.2f} 百万円増加"
          f"(p<0.001 で有意)。")

    print("\n[scipy.stats.linregress との照合]")
    lr = stats.linregress(ad, sales)
    print(f"  slope     : 自前 {r['beta']:.10f} / scipy {lr.slope:.10f}")
    print(f"  intercept : 自前 {r['alpha']:.10f} / scipy {lr.intercept:.10f}")
    print(f"  stderr    : 自前 {r['se_b']:.10f} / scipy {lr.stderr:.10f}")
    assert abs(r["beta"] - lr.slope) < 1e-9

    print("\n[残差プロット(テキスト)] 系統的パターンがなければ線形仮定は妥当")
    scale = max(abs(v) for v in r["resid"])
    for xi, res in zip(ad, r["resid"]):
        pos = int(20 + res / scale * 18)
        line = [" "] * 41
        line[20] = "|"
        line[pos] = "*"
        print(f"  x={xi:>4.1f} {''.join(line)} {res:+.3f}")
    dw = (sum((r["resid"][i] - r["resid"][i - 1]) ** 2
              for i in range(1, len(r["resid"]))) / r["rss"])
    print(f"\n  Durbin-Watson統計量 = {dw:.3f} (2付近なら系列相関なし)")


if __name__ == "__main__":
    main()

実行結果

=== 単回帰 売上 = α + β×広告費 + u ===
  n = 15,  自由度 = 13
  α̂ =  17.9621  (SE 0.5218)
  β̂ =   4.9366  (SE 0.0891)  t = 55.419  p = 0.000e+00
  95%信頼区間 β: [4.7442, 5.1291]
  R² = 0.9958 / 自由度調整済 R² = 0.9955
  残差標準誤差 σ̂ = 0.8652

  解釈: 広告費を100万円増やすと売上は約 4.94 百万円増加(p<0.001 で有意)。

[scipy.stats.linregress との照合]
  slope     : 自前 4.9366333555 / scipy 4.9366333555
  intercept : 自前 17.9620874383 / scipy 17.9620874383
  stderr    : 自前 0.0890787424 / scipy 0.0890787424

[残差プロット(テキスト)] 系統的パターンがなければ線形仮定は妥当
  x= 1.2   *                 |                     -1.786
  x= 2.0               *     |                     -0.535
  x= 2.4                    *|                     -0.010
  x= 3.1                     |*                    +0.134
  x= 3.5                     |      *              +0.760
  x= 4.0                     |*                    +0.191
  x= 4.6                     |    *                +0.529
  x= 5.2                     |          *          +1.167
  x= 5.8                     |    *                +0.505
  x= 6.5                     |       *             +0.850
  x= 7.0                     |     *               +0.681
  x= 7.7                     *                     +0.026
  x= 8.1              *      |                     -0.649
  x= 8.9               *     |                     -0.498
  x= 9.4       *             |                     -1.366

  Durbin-Watson統計量 = 0.549 (2付近なら系列相関なし)

← 目次← 前: 08次: 10 →