Open In Colab

6. 相関・回帰分析

相関関係は因果関係を含意しない (Correlation does not imply causation)

[この章の目的] 初歩的な相関分析と回帰分析がPythonで出来るようになる。

今回使用するライブラリをインポートしておきましょう。

from matplotlib import pyplot as plt 
!pip install japanize-matplotlib 
import japanize_matplotlib 
import numpy as np 

6.1. 相関分析 (復習)

高校数学のデータの分析やデータサイエンス入門などでも学習する相関分析について扱う。

解析したいデータが2種類だけなら、プログラムを使うご利益はそれほど感じられないが「多くのデータ間の相関関係を系統的に調べたい」「複数年度に渡るデータを解析したい」
あるいは「その結果をベクタ画像として出力したい」となると、これまで学習してきた繰り返し操作や作図が役に立つ。

まずは簡単な例から初めよう。

x= [3.1, 4.3, 6.6, 13.2, 19.1, 20.9, 26.4, 25.1, 21.9, 15.7, 9.6, 3.8]
y= [568, 572, 804, 833, 930, 965, 1213, 1120, 835, 540, 451, 502]

上に示したのは、2017年の宇都宮市における月別の平均気温\(x\)
世帯ごとのアイスクリーム・シャーベットの平均消費金額\(y\)で、
散布図にすると↓こんな感じ

plt.figure(figsize=(6,6)) 
plt.title("宇都宮市") 
plt.xlabel("平均気温 (℃)")
plt.ylabel("世帯あたりのアイスクリム・シャーベットの消費金額 (円)")
plt.scatter(x,y)
plt.show()
plt.close()

「平均気温とアイスの消費には相関がありそう」という直感の通り、正の相関があることが見て取れる。

では”どれほどの”相関を持つかを表す量として相関係数を算出してみよう。
相関係数\(r\)は以下のように定義され

\[ r = \frac{ \sum^n_i (x_i-\bar{x})(y_i-\bar{y})}{ \sqrt{\sum^n_i (x_i-\bar{x})^2 \sum^n_i (y_i-\bar{y})^2} } \]

\(\bar{x},\bar{y}\)はそれぞれ\(x,y\)の平均値で

\[\begin{split} \bar{x} = \frac{1}{n} \sum^n_i x_i, \\ \bar{y} = \frac{1}{n} \sum^n_i y_i \end{split}\]

と書ける。

下付き添字\(i\)\(x\)\(i\)番目の要素であることを表し(つまり\(x\)をn次元ベクトルとみなしたときの第\(i\)成分が\(x_i\))
今考えているデータの場合、\(\sum\)の和記号は\(i\)は1から12までの値を取り、対応する値を足し上げることを意味する。(“\(i\)の和が1から12までを走る”と言ったりもする)

\(r\)は必ず-1から1までの値を取り1.0(-1.0)に近づくにつれ強い正(負)の相関を示す。
(強いというのは曖昧な表現で絶対的な線引がある訳では無いことに注意)

\(|r|\leq1\)は、コーシーシュワルツの不等式を用いるか
上の\(r\)の定義と\(n\)次元ベクトル同士の内積の定義とを見比べると示せる(暇があればやってみよう)。

次にxy、2つのリストを引数に持ち、相関係数\(r\)を返す関数を作成してみよう。

にらめっこするために式を再掲:

\[ r = \frac{ \sum^n_i (x_i-\bar{x})(y_i-\bar{y})}{ \sqrt{\sum^n_i (x_i-\bar{x})^2 \sum^n_i (y_i-\bar{y})^2} } \]
### ライブラリを一切使わない方法
def cor_coeff(x, y):
    # xとyの長さが違う場合や長さ0の場合はエラーを出す (おまけ)
    if len(x) != len(y) or len(x)==len(y)==0:
        raise ValueError("Error: x&y must satisfy len(x) = len(y) != 0")

    n = len(x)
    ## 平均を計算
    xbar = sum(x)/n; ybar = sum(y)/n 

    ##分子(numerator)の和を計算 (初期値を0にしてループで加算していく)
    s_n = 0.0 
    for i in range(n):
        s_n += (x[i]-xbar)*(y[i]-ybar)

    ##分母(denominator)の計算 (和を先に計算して積を取り、最後にsquare rootをとる)
    s_x = 0.0; s_y = 0.0
    for i in range(n):
        s_x += (x[i]-xbar)**2 
        s_y += (y[i]-ybar)**2
    s_d = (s_x * s_y)**0.5
    # 一行で書くならリスト内包表記を用いて以下のようにも書ける
    #s_d = ( sum([(x[i]-xbar)**2 for i in range(n)]) * sum([(y[i]-ybar)**2 for i in range(n)]) )**0.5

    return s_n/s_d 

cor_coeff(x,y)

という風に、\(r\)が約0.83で、非常に強い正の相関を示すことが分かる。

少しずつ自作関数に慣れてきたら、上のように意図しない引数を入れたときの挙動なども設定すると
より安全なコードを作る事ができる。

xyの長さが違う場合(上のraise文でエラーが生じさせる場合)を試しておこう。

cor_coeff(x,y[1:])

相関係数の計算は、numpyライブラリを使うと実はもう少しシンプルに書ける

def cor_coeff_np(x,y):
    xbar = np.mean(x); ybar = np.mean(y)
    return np.dot(x - xbar, y - ybar) / np.sqrt( np.dot(x-xbar,x-xbar) * np.dot(y-ybar,y-ybar) ) 

cor_coeff_np(x,y) 

とすると、関数自体は3行で書けてしまう。さらに\(\bar{x},\bar{y}\)をいちいち定義しないように書き換えれば、関数の中身自体は一行でかける。

上のコードを少し補足しておくと…分子や分母に現れる\(\sum^n_i (x_i-\bar{x})(y_i-\bar{y})\)\(\sum^n_i (x_i-\bar{x})^2 \)といった項は、
\(i\)番目の成分に\(x_i-\bar{x}\)を持つベクトル\(\tilde{x}\)\(i\)番目の成分に\(y_i-\bar{y}\)を持つベクトル\(\tilde{y}\)を定義しておくと、
\(\tilde{x}\cdot\tilde{y}\), \(\tilde{x}\cdot\tilde{x}\), \(\tilde{y}\cdot\tilde{y}\)といったように、ベクトルの内積の形でいずれも表すことができる。

numpyにはブロードキャスト機能(Numpyのノートを参照)やベクトル積を計算する関数dotが備わっているので、
それらを活用することで相関係数の計算を短く実装することができた。

更に言うと実はnumpyには相関係数を計算する関数corrcoef()が予め用意されていて

print(np.corrcoef(x,y))
print("r(x,y)=", np.corrcoef(x,y)[0,1])

を使えば
[ xとxの相関(=1.0), xとyの相関;
yとxの相関, yとyの相関(=1.0)]
といった2行2列の相関行列を取得することが出来る。
確かに上の相関行列の[0,1]成分は、さっき計算した\(r\)の値と一致している。

「初めからそれを教えろ!」と思うかもしれないが
考えたい量を数式として定義してそれをプログラムに変換し、値が正しいことを確認する作業
式(考え方)とプログラミング双方の理解を深める上で非常に重要になる。

6.1.1. 相関分析と因果関係

以下では、ある一つのグラフの例を見ながら、冒頭の*相関関係は因果関係を含意しない (Correlation does not imply causation)*に関して説明する。

下の図は、2017年の家計調査・気候データから作成した散布図で、千葉市での平均気温と、しめじの消費支出の間の相関を示している。

生産量と平均気温の間に、強い負の相関が見て取れますが、これはどうしてでしょう?
「寒い季節には鍋が食べたくなるから」と言われるとふむふむと感じる一方で
「そもそも生産量が冬に多く、市場に出回る量が多いから」と考えることもできる。
したがって、このデータを見ただけでは、しめじが冬によく売れる理由までははっきりとは分からない。

事実、しめじの旬はGoogle検索によると9月下旬から11月初旬とのことで、最も売れている時期(12月1月)とは少し時期にズレがあり、購買意欲は必ずしも”旬”によって決まっている訳ではなく、複合的な要因がありそうな印象を受ける。

気温と特定の野菜の購買意欲の真の関係を知りたければ、「その野菜はビニールハウスなどの生産設備の向上で年中、安定した味で生産ができる」 「比較的新しい品種で〇〇といえば秋、のような固定観念がない」「季節ごとの生産量がほぼ同じ」など、他の条件が揃った状況下で比較しなければ確度の高い議論は難しい。

このように、因果関係を紐解くことは、我々が思うほど容易ではなく、それ自体が一つの学問分野になっている。 気になった方は、たとえば”因果推論”で調べてみよう。

疑似相関をまとめたおもしろいサイトのように顕著な例ならば「あぁ疑似相関だな」と気がつくが、我々が普段見ている情報の中には、擬似相関であるとひろく認識されていない情報もあるはず。 物事の因果関係を断定するような言説に対しては一歩引いて見る姿勢も重要なように思う。

6.2. 回帰分析

以下では自分が立てたモデルを表現する関数のことをモデル関数
モデル関数とデータとの齟齬を最小化するようにモデル関数の係数を決定することを回帰
そして回帰に基づく分析を指して回帰分析と呼ぶことにする。

この授業では、\(x\)に対する\(y\)の振る舞い、といういわゆる単回帰分析のみを扱う。

データとモデル間の齟齬を表現する方法はいくつかあるが、以下では最もポピュラーな誤差の二乗和を採用することとし、
その最小化を考える(最小二乗法)。データや関数、最小二乗法をもう少しきちんと定義しよう。

\(D\)個の点\(\{x_1,x_2,...,x_D\}\)でのyの値\(\{y_1,y_2,...,y_D\}\)が観測されているとき、
最小二乗法とは、ある決められたモデル関数\(f(x)\)との齟齬\(\chi^2 = \sum^D_{i=1} (y_i - f(x_i))^2\)
最小化するように関数\(f\)の係数を調整すること。

以下では、\(f(x)\)として単純な多項式のみを考えることにする。
\(f\)自体をどう決める/設計するかも重要な話題だが、この授業では深入りしない。

まず回帰を学ぶために、適当なデータを生成しておく。

"""
0,1で定義された区間でsample_size(int)個の点で
sin関数に正規乱数に従う誤差を加えた値を返す関数。
- sample_size: データの数
- std: standard deviation (標準偏差σ)
"""
def create_toy_data(sample_size, std):
    x = np.linspace(0, 1, sample_size)
    t = np.sin(2*np.pi*x) + np.random.normal(scale=std, size=x.shape)  
    return x, t

#私と皆さんで結果が変わらないよう乱数のseedを固定
#randomモジュールの関数を使うときはrandom.seedを、numpyのrandom関数を使うときはnp.random.seedを用いる
np.random.seed(1234) 

x,y = create_toy_data(10, 1.e-1) 

これをグラフにしてみると…

fig = plt.figure(figsize=(10,4))
ax = fig.add_subplot(111)
ax.set_xlabel("x"); ax.set_ylabel("y")
ax.scatter(x, y, facecolor="none", edgecolor="b", s=50, label="Data")
ax.legend()
plt.show()
plt.close()

こんな感じ。

このデータを、\(p\)次元の多項式(p=0,1,2,…)を最適化することを考えてみよう。
\(p\)次式(\(p\)次元多項式)は、\(p+1\)個の係数, \(a_0\)から\(a_p\)を使って \(a_0 + a_1x + a_2x^2\cdots +a_p x^p \)と書くことが出来る。

上で定義した最小二乗法は、この関数と各データ点の齟齬が二乗誤差を最小にする係数\(a_0,a_1,...,a_p\)を求めることに相当する。

\(p\)次元の多項式の最適化は、実はnumpyにある関数polyfit()を利用すれば簡単に実行できる。
他にもscikit-learnなどのライブラリもより高度な関数のフィッティングが可能。

\(\clubsuit\)進んだ注:
多項式で回帰を行う場合には、実はパラメータの最適解は”閉じた形”で与えられる。
この辺りのことは、おまけのノートブック:ベイズ線形回帰で詳しく書いています。
なお”閉じた形”というのは、数学や物理をやっていると出てくる表現で、答えが具体的な形で書き下せる、程度の意味。
たとえば 行列\(A\)、ベクトル\(\vec{x},\vec{y}\),スカラー\(\lambda\)について方程式\(A\vec{x}=\lambda \vec{y}\)が成り立つとき、
\(A\)の逆行列をどうやって求めるか(数値的にやるのか解析的に求めるのか)はさておき、
\(\vec{x} = \lambda A^{-1}\vec{y}\)と書き直せるので「\(\vec{x}\)は閉じた形で与えられる」と言ったりもする。

6.2.1. polyfit/poly1d関数

たとえば今のデータを3次式でフィットしたければ、以下のようにする。

## 多項式をplotするためのxの値を準備(グラフをなめらかにするために、0から1までの間の500点を等間隔に作る)
xp = np.linspace(0, 1, 500) 

#多項式の次元pを決める. 今は3次式.
p = 3 

#polyfit関数で最適化し、返り値(係数)を取得する
coeff = np.polyfit(x, y, p) 

#最適化された係数と、1次元入力xに対する多項式を計算してくれるpoly1d関数を用いて描画点xpでのモデル関数の値を計算する。
yp = np.poly1d( coeff )(xp)

print("係数",coeff)
  • np.polyfit(x, y, p):

    データのx,yの値と多項式の次元pを引数として与え\(p\)次の多項式でデータ\((x,y)\)をfitしなさい(\(p\)次までの係数を関数がデータと整合するように”最適化”しなさい)という指令になっている。

  • np.poly1d( np.polyfit(x, y, p) )(xp):

    fitしたp次元の係数をもつ多項式にxp(今は500点)を代入して、対応するyの値を返す。
    上のコードはこの返り値をypという変数に格納している。

最後に、printで調整(最適化)された3次式の係数を表示してみた。
ちなみに、表示される係数は次数が高いところから\(a_3,a_2,a_1,a_0\)です(ややこしいね…)。

グラフを描いてみるとこんな感じ。

#お絵かき
fig = plt.figure(figsize=(10,4))
ax = fig.add_subplot(111)
ax.set_xlabel("x"); ax.set_ylabel("y")
ax.scatter(x, y, facecolor="none", edgecolor="b", s=50, label="Data")
ax.plot(xp, yp,label="p=3")
ax.legend()
plt.show()
plt.close()

さて、\(p\)次の多項式は\(p-1\)次の多項式を特別な場合として含むため、
一般に\(p\)(多項式の次元)を増やせば、より複雑な関数を表現することができる。
(2次式は3次式の\(a_3=0\)の場合ですよね?)

\(p\)を複数変えながら比較した図を作ってみよう。
その方法は、\(p\)に関するループを回すだけ。

ps = [0,1,3,6,9]
xp = np.linspace(0, 1, 500) 

# 各pでのfitの結果(xpでの対応する値のリスト)をysに入れ子のリストにしていく
ys = []
for p in ps:
    ys += [np.poly1d(np.polyfit(x, y, p))(xp)]

# データの背後にある"真の関数"(本当は知り得ない)の値をxpの各点で計算
ytrue = np.sin(2*np.pi*xp) 

# お絵かき
fig = plt.figure(figsize=(12,5))
ax = fig.add_subplot(111)
ax.set_xlabel("x"); ax.set_ylabel("y")
ax.scatter(x, y, facecolor="none", edgecolor="b", s=80, label="Data")
for i in range(len(ps)):
    ax.plot(xp, ys[i],label="p="+str(ps[i]),alpha=0.8)
ax.plot(xp,ytrue,linestyle="dotted", label="True",color="k")
ax.legend(loc="upper right")
plt.show()
plt.close()

注: 今の場合、回帰に用いたデータはsin関数に適当なノイズを足して作られている。
解析の手法を学ぶ際には、このように答えを知っている状態からはじめて、
手法がうまくデータを説明しているかどうかを検証したりする。
一見ズルっぽいが、理論を理解したりプログラムで確認するためには重要なプロセスとなる。

現実のデータ解析の状況では、背後にある”真の関数”が分かっていることは非常に稀で、
「興味のあるデータが、人間がよく知っている単純な式(有限次元の多項式や指数関数)で
完全に表現できる道理はない」ということも抑えておくべき重要な点となる.
真の関数というのは一般に神のみぞ知るで、人間ができることは、出来るだけ尤もらしい関数を見つけ、
その背後にあるメカニズム(の主要部分)を解明することと言える.

一般に、関数をどんどん複雑なものにしていくにつれて、関数の表現力(表現できるデータの幅)は大きく拡がる。
その一方で、用意した関数がデータに過度に適合するあまり、未知の点での値の予測精度(汎化性能)が著しく損なわれている危険性がある。
このことを予言能力がない(データに過適合している) と言う。
データの背後にあるメカニズムが何かを考えたり理論的な解析をして初めて、回帰に用いる関数の妥当性が検証できるという点に注意しよう。

6.2.2. \(\clubsuit\) モデルの複雑さとモデル選択

上の多項式回帰では、たとえば9次式はデータをピッタリと再現している一方で
真の関数(sin関数)の振る舞いよりもむしろ、測定誤差のようなものにまで過適合してしまっている。

ここで過適合を防ぐためにデータとの整合性(二乗誤差)だけでなく
モデルの複雑さも定量化し、なるべく複雑すぎない関数が選ばれるよう勘定することを考える。

ここではこのモデルの複雑さ\(C\)として多項式の係数の絶対値の2乗和:
\(C= \sum_i |a_i|^2\)を採用することにしよう。

さらに、”モデルを選択するための基準\(L\)”を
\(L = \)(二乗誤差) + \(\lambda\) log10(モデルの複雑さ\(C\))で定量化し
この\(L\)が最小になる多項式を採用することにしよう。
(この選択はあくまで例であることに注意)

各次数での多項式のモデルの複雑さ\(C\)と二乗誤差、そしてモデル選択基準量\(L\)を表示してみると…

def complexity(r):
    return np.sqrt(np.dot(r,r))
def my_criteria(comp,err,lam=1.0): #lambda=1.0
    return err + lam * np.log10(comp)

for p in ps:
    coeff = np.polyfit(x, y, p)
    diff = np.poly1d(np.polyfit(x, y, p))(x) - y
    chi2 = np.dot(diff,diff)
    comp = complexity(coeff)
    print("p",p, "モデルの複雑さ(log10)→", np.log10(comp),
          "二乗誤差", chi2, "モデル選択基準量", my_criteria(comp,chi2))

9次式は、データをよく説明する一方で、非常に複雑なモデルになっている。

上記のモデル選択基準量\(L\)\(p=3\)で最小となるため、この\(L\)の定義のもとでは3次式が選ばれることになる。

このように実際のデータ分析や機械学習などのモデル選択では、既知のデータの記述能力(二乗誤差の最小化)とモデルの複雑さの低減(過適合を避ける)との トレードオフでモデルを選択することも多い。

上の\(L\)の定義中の\(\lambda\)の大きさを変えることは、”データとの整合性を高める”のか、”モデルの複雑さを抑える”のか、 どちらを重視するかの”度合い”を決めることに相当している。(\(\lambda\)を適当に変えてみよう)

機械学習の文脈でもよく使われるこのような手法は、正則化と呼ばれる。

6.2.3. 予言能力のない例: 100メートル走のタイム

予言能力がないモデルとして、以下の例を考えてみよう。

y = [10.06, 10.03, 10.02, 9.95, 9.93, 9.92, 9.9, 9.86, 9.85, 9.84, 9.79, 9.78, 9.77, 9.74, 9.72, 9.69, 9.58 ]
x = [1964, 1968, 1968, 1968, 1983, 1988, 1991, 1991, 1994, 1996, 1999, 2002, 2005, 2007, 2008, 2008, 2009]

fig = plt.figure(figsize=(12,3))
ax = fig.add_subplot(111)
ax.set_xlabel("year"); ax.set_ylabel("Mens 100m")
ax.scatter(x,y,marker="o",color="red")
plt.show()
plt.close()

図にしたのは、男子100mの世界記録の推移.
このデータに対して「\(p=3\)の多項式でフィットして予測する」という立場をとってみる。

xp = np.arange(2020,2101,1)
fig = plt.figure(figsize=(12,6))
ax = fig.add_subplot(111)
ax.set_xlabel("year"); ax.set_ylabel("Mens 100m")
ax.set_xlim(1960,2100)
ax.set_ylim(0,12)
for p in [3]:
    yp = np.poly1d(np.polyfit(x, y, p))(xp)
    ax.plot(xp,yp,marker="x",label="p="+str(p))
ax.scatter(x,y,marker="x",color="red")    
ax.legend(loc="upper right")
plt.show()
plt.close()

2080年代には100m走のタイムがゼロになってしまうおかしな予測だと気がつく。

今の場合、我々はこのデータが100走の世界記録のタイムの推移であること、つまり

  • 非増加関数であること

  • 必ず正の値であること

など、データが持つべき性質を予め知っているので、
「このデータに対して単純な多項式回帰を当てはめるのはおかしい」
と気がつくことが出来る。

でも、他のデータではどうだろう?

データを分析するためには、データの値だけをみて闇雲に分析するだけではダメで、データの背景やドメイン知識が不可欠である、という好例。

6.2.4. 回帰分析における注意点: 感染症の罹患者数を例に

我々が現実世界で観測することのできる種々の”値”というのは、何らかの関数\(f(x)\)の、ある\(x\)での(実現)値と言える。

感染者数の推移は日付に対する関数として示される事も多い。
日付に対して陽性者数の推移をプロットして変動の予想を立てることは簡単だが、感染者数の推移も単なる時間に対する1変数の関数である道理などなく、 たとえば検査数や報道などの”空気感”、国・都道府県ごとの取り組み・政策、ウイルスの変異,その他様々な要素に左右される。 同じことは例えば株価や人間の行動などにも言える。

我々人間がグラフにして理解できるのはたかだか3次元(3つの変数がある状況)まで。
言い換えれば、人間は物事を理解するときに本来D次元(D>>3, Dは3よりずっと大きい)の変数で定義される関数を、 3次元以下に射影した「影」をみて理解しようとする生き物だということは意識しておくべきだろう。

多項式に限らずより広範なクラスの関数を考えることはもちろんできる。 やみくもに無限の数の関数を考えれば、データに適合するものが存在してもおかしくはない。
が、そのような関数を見つけることができたとしても、それがデータの背後にあるメカニズムを説明しているとは限らない。 何にでも言えることだが、モデルを立てて終わり、ではなく検証し続ける姿勢が重要である。