線形判別分析 (LDA) とLefSe, 大津の二値化を理解する

# はじめに
　[スモールデータと機械学習](https://www.ohmsha.co.jp/book/9784274227783/)より、線形判別分析 (LDA) の理解を深めるために記事を作成します。n番煎じです。この記事では基本的にベクトルは縦ベクトルとして扱います。
　ついでに LDA の考え方を活用した事例として、腸内細菌叢の研究で用いられる [LEfSe](https://pmc.ncbi.nlm.nih.gov/articles/PMC3218848/)、画像処理の二値化で用いられれる[大津の二値化](https://www.frontier.maxell.co.jp/blog/posts/4.html)の処理内容も出力します。
# LDA の概要
* 分類問題に使われるアルゴリズム（下図は[明治大金子先生の HP](https://datachemeng.com/lineardiscriminantanalysis/) から一部借用）
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3230781/9d9ea8fe-89cf-4a63-b351-10d6e3757481.png
" width ="500">
* 分類に際し、クラスの平均ベクトル間の距離が最大に、クラス内の分散が最小になるような変換を行う方向ベクトル $w$ を求める

## 導出
　データセット ($X, X \in \mathbb{R}^{N \times M}$, $N$: データ数, $M$: 説明変数の次元) 、およびそれに対応する分類クラス ($C_1, C_2$) があります。クラスの分離度 (クラスの平均ベクトル間の距離) が最大になる、かつ、クラス内の分散が最小になる識別境界を引くことができれば、よくクラスを識別することができそうです。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3230781/c207b272-204e-4a9f-bef8-61c87f83efb0.gif"　width ="500">
　クラス $C_1, C_2$ の平均ベクトル $m_1, m_2$ を計算します。$N_1, N_2$ は $C_1, C_2$ のサンプル数です。
```math
\begin{align}
m_1 = \frac{1}{N_1} \sum_{n \in C_1} x_n \\ 
m_2 = \frac{1}{N_2} \sum_{n \in C_2} x_n
\end{align}
```
$m_1, m_2$ 間の距離が最大になるような方向ベクトル $w$ ($||w|| = 1$) を与えます。
```math
w^\mathsf{T} m_1 - w^\mathsf{T} m_2 = w^\mathsf{T} (m_1 - m_2)
```
　次に $w$ で一次元に変換した後の $C_1, C_2$ のクラス内分散 $s_1, s_2$ を考えます。
```math
\begin{align}
s_1 &= \sum_{n \in C_1} (w^\mathsf{T} x_n - w^\mathsf{T} m_1)^2 \\
s_2 &= \sum_{n \in C_2} (w^\mathsf{T} x_n - w^\mathsf{T} m_2)^2 \\
s &= s_1 + s_2
\end{align}
```
クラス間分散を分子に、クラス内変動を分母にとる関数　$J(w)$ を最大化する方向ベクトル $w$ を探索します。
```math
\begin{align}
J(w)&= \frac{w^\mathsf{T} S_B w}{w^\mathsf{T} S_W w} \\
S_B &= (m_1 - m_2)(m_1 - m_2)^\mathsf{T}　\\
S_W &= \sum_{n \in C_1}  (x_n -  m_1)(x_n - m_1)^\mathsf{T} + \sum_{n \in C_2}  (x_n -  m_2)(x_n - m_2)^\mathsf{T}
\end{align}
```
極値を探すため、$J(w)$ を微分し、$0$ とおきます。
```math
\begin{align}
\frac{\delta J(w)}{\delta w} 
&= \frac{(w^\mathsf{T} S_B w)'(w^\mathsf{T} S_W w) - (w^\mathsf{T} S_B w)(w^\mathsf{T} S_W w)'}{(w^\mathsf{T} S_W w)^2} \\
&= \frac{2 S_B w w^\mathsf{T} S_w w -2 w^\mathsf{T} S_B w S_w w}{(w^\mathsf{T} S_W w)^2}　\\
&= 0 \\
\end{align}
```

$w^\mathsf{T} S_B w$ および $w^\mathsf{T} S_W w$ の計算結果はスカラーなので、順序を入れかえて式を整理します。その後、両辺を $w^\mathsf{T} S_W w$ で除します。
```math
\begin{align}
(w^\mathsf{T} S_W w)S_B w &=  (w^\mathsf{T} S_B w)S_W w \\
S_B w &= \frac{w^\mathsf{T} S_B w}{w^\mathsf{T} S_W w} S_W w
\end{align}
```
定数 $\lambda = \frac{w^\mathsf{T} S_B w}{w^\mathsf{T} S_W w}$ とおくと
```math
\begin{align}
S_B w = \lambda S_W w \\
\underbrace{S_W^{-1}}_{S_W の逆行列を左からかける}　S_B w = \lambda w
\end{align}
```
となり、固有値問題として解くことで、固有値 $\lambda = \frac{w^\mathsf{T} S_B w}{w^\mathsf{T} S_W w}$ が最大の時の固有ベクトル $w$ をもとめることができます。

## 実装
`sklearn` の[`wine_dataset`](https://atmarkit.itmedia.co.jp/ait/articles/2208/25/news046.html)は 3 種類の異なる品種から作られたワインの化学分析結果をまとめたデータセットです。このデータセットの`class_0` `class_1` をLDAで分類してみます。ついでに、主成分分析の各主成分軸の寄与率のように、各固有値の占める割合を軸に書き加えてみます。
* **wine dataset**
<img src = "https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3230781/e068a189-6eb4-2b07-1435-05cc40cb4a73.png" width ="500">

```py
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.datasets import load_wine
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis

wine = load_wine()
data = wine.data
target = wine.target
columns = wine.feature_names


df_wine = pd.DataFrame(data, columns=columns)
df_wine["target"] = target
df_0 = df_wine[df_wine["target"]==0]
df_1 = df_wine[df_wine["target"]==1]
x_0 = np.array(df_0.iloc[:, :-1])
x_1 = np.array(df_1.iloc[:, :-1])
labels = np.unique(target)
```

```py
def LDA_2class(a, b, label):

    m_a, m_b = a.mean(axis=0).reshape(-1,1), b.mean(axis=0).reshape(-1,1)
    # S_B
    s_b =  np.outer((m_a - m_b), (m_a - m_b))
    list_ab = [a, b]
    list_m = [m_a, m_b]

    # S_w 
    s_w = np.zeros((a.shape[1], a.shape[1]))
    for c, m in zip(list_ab, list_m):
        for i in range(a.shape[0]):
            s_w += np.outer((c[i, :].reshape(-1, 1) - m), (c[i, :].reshape(-1, 1) - m))

    # solving an eigen value problem
    eig_vals, eig_vecs = np.linalg.eig(np.linalg.inv(s_w) @(s_b))

    idx = np.argsort(eig_vals)[::-1]
    eig_vals_sorted = eig_vals[idx]
    eig_vecs_sorted = eig_vecs[:, idx]

    eig_vals_ratio = eig_vals_sorted.real / eig_vals_sorted.real.sum()

    w1a = a @ eig_vecs_sorted[:, :2]
    w1b = b @ eig_vecs_sorted[:, :2]
    plt.scatter(w1a[:, 0], w1a[:, 1], color='blue', label=f'class_{label[0]}')
    plt.scatter(w1b[:, 0], w1b[:, 1], color='red', label=f'class_{label[1]}')
    plt.title('LDA')
    plt.xlabel(f'LD1 ({round(eig_vals_ratio[0]*100)} %)')
    plt.ylabel(f'LD2 ({round(eig_vals_ratio[1]*100)} %)')
    plt.legend()
    plt.show()

LDA_2class(x_0, x_1, labels[:2])
```
<img src = "https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3230781/255d3a97-f1da-41b7-b8f6-1b3c44bd229e.png"
width ="500">

3 クラス以上の LDA の計算をchatGPT に出力させてみると、以下のコードが `sklearn.discriminant_analysis.LinearDiscriminantAnalysis` の結果と重なりました。どうやらクラス間分散 $S_B$ の計算に全てのクラスの平均を用いているようです。
```py
# ----- 1) データ読み込み（例として Iris データセットを使用） -----
X, y = wine.data, wine.target
classes = np.unique(y)
target_names_numpy = [name + '_numpy' for name in wine.target_names]
target_names_sklearn = [name + '_sklearn' for name in wine.target_names]

# ----- 2) SW, SB の計算 -----
def compute_sw_sb(X, y):
    n_features = X.shape[1]
    overall_mean = np.mean(X, axis=0)
    SW = np.zeros((n_features, n_features))
    SB = np.zeros((n_features, n_features))
    
    for c in classes:
        Xc = X[y == c]
        mean_c = np.mean(Xc, axis=0)
        # クラス内散布
        SW += (Xc - mean_c).T @ (Xc - mean_c)
        # クラス間散布
        n_c = Xc.shape[0]
        mean_diff = (mean_c - overall_mean).reshape(n_features, 1)
        SB += n_c * (mean_diff @ mean_diff.T)
    return SW, SB

SW, SB = compute_sw_sb(X, y)

# ----- 3) 一般化固有値問題の解法 -----
eigvals, eigvecs = np.linalg.eig(np.linalg.inv(SW) @ SB)
# 固有値の大きい順に並び替え
idx = np.argsort(eigvals)[::-1]
W = eigvecs[:, idx[:2]]  # 上位2成分を選択
eigvals_ratio = eigvals[idx[:2]].real / eigvals[idx[:2]].real.sum()

# ----- 4) データを2次元に射影 -----
X_r2 = -X @ W  # (n_samples, 2)
X_r2 = (X_r2 - X_r2.mean(axis=0)) / X_r2.std(axis=0)  # 標準化
# ----- 5) 可視化 -----
colors = [ 'blue', 'red', 'green']
for c, color in zip(classes, colors):
    plt.scatter(X_r2[y == c, 0], X_r2[y == c, 1],
                color=color, label=target_names_numpy[c], alpha=0.5, marker="x")

# LDA で 2 次元射影
lda = LinearDiscriminantAnalysis(n_components=2)
X2 = lda.fit_transform(X, y)
X2 = (X2 - X2.mean(axis=0)) / X2.std(axis=0)  # 標準化
# 散布図を描く
for cls, color, label in zip([0,1,2], ['blue','red','green'], target_names_sklearn):
    plt.scatter(X2[y==cls,0], X2[y==cls,1],
                alpha=0.5, color=color, label=label, marker = "o", s=15)

plt.title('LDA Projection')
plt.xlabel(f'LD1 ({eigvals_ratio[0]*100:.2f}%)')
plt.ylabel(f'LD2 ({eigvals_ratio[1]*100:.2f}%)')
plt.legend(loc='best')#
plt.grid(True)
plt.tight_layout()
plt.show()
```
<img src = "https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3230781/97fe6815-3ad3-4913-8a5c-aa60a626559d.png" width ="500">

# LDA の活用事例
## [LEfSe](https://pmc.ncbi.nlm.nih.gov/articles/PMC3218848/)
　LefSe は腸内細菌叢や代謝物などの多変量データに対して、各クラスに特徴的な説明変数を抽出する方法です。Kruskal Wallis 検定でクラス間に差があるかを評価し、Wilcoxon の順位和検定でどの特徴量間に差があるのかを評価し、有意な差に関してLDAで効果量を算出します。LDAの効果量は最も分離する $w_1$ を絶対値に変換し、$w_1$ 内の最小の要素を $1$ に、最大の要素を $10^6$ にスケーリングしたのち、常用対数に変換して得られます。この効果量の算出には $30$ 回のブートストラップサンプリングを行い、平均値を用います。よく腸内細菌叢のデータは占有率で示されるため、定数和制約（特定の菌が多くなるとほかの菌の占有率が下がる）が存在します。LDA はデータに正規分布の性質や変数の独立性を仮定しますが、この時、定数和制約が邪魔になります。そのため、[CLR (centerd log-ratio) 変換](https://qiita.com/gen_nospare/items/54843762de57d1b05670) などの行ってユークリッド空間上にマッピングしてから `LEfSE` を行うほうが望ましいかもしれません。$D$ 次元の説明変数 ($ x_1 \cdots x_i \cdots x_d$) に対して、CLR 変換後の説明変数 ($ y_1 \cdots y_i \cdots y_d$) は以下のようにあらわせます。
```math
\begin{align}
y_i 
&=  \mathrm{clr}(x_i) \\
&=\ln\frac{x_i}{(\prod_{j=1}^{D} x_{j})^{1/D}}
\end{align}
```
 
 <img src = "https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3230781/31a71e5b-84b1-463a-b12e-49e177fd91b3.png
" width ="500">
## 大津の二値化
### 概要
　大津の二値化は画像を[二値化](https://www.frontier.maxell.co.jp/blog/posts/3.html)する際に使用される方法のひとつで、二値化に必要な閾値を自動的に決定します。
<img src = "https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3230781/17e001c8-af49-43d0-bcdd-2541e52d679e.png" width ="500">
　画像ヒストグラムにおいて閾値を決め、閾値未満または閾値以上で画素を2クラスに分けます。クラス間分散とクラス内分散の比を最大化する閾値を探索することで、適切な閾値を設定します。閾値未満をクラス1、閾値以上をクラス2、クラス1、2 の画素数 ($n$) をそれぞれ $n_1, n_2$、クラス1、2の画素値の平均 ($m$)、分散 ($\sigma$)を $m_1, m_2, \sigma_1^2, \sigma_2^2$ とするとクラス間分散 ($S_B$) 、クラス内分散 ($S_W$) は以下の式で表せます。
 ```math
 \begin{align}
 S_B 
 &= \frac{n_1 n_2 (m_1 - m_2)^2}{(n_1 + n_2)^2} \\
 S_W
 &= \frac{n_1 \sigma_1^2 + n_2 \sigma_2^2}{n_1 + n_2}
 \end{align}
 ```
グレースケール画像の画素値は $0-255$ の値をとるため、その範囲の中で最も $\frac{S_B}{S_W}$ が大きい値を示す時の閾値を採用して画像を二値化します。なお、全分散 $\sigma_t^2$ は $S_B + S_W$ となり、一定の値を示します。
#### 参考文献
* [ディジタル画像処理](https://www.cgarts.or.jp/books_detail/eip_2/)
### 実装
　大津の二値化を`numpy` で実装し、`OpenCV` との結果と比較します。
```py
import numpy as np
import matplotlib.pyplot as plt
import cv2

path_image = r"path_to_image"
img = cv2.imread(path_image, cv2.IMREAD_GRAYSCALE)
fig, ax = plt.subplots(1,2, tight_layout=True)
ax[0].imshow(img, cmap='gray')
ax[1].hist(img.flatten(), bins=256, range=(0, 256), alpha=1, color='gray')
ax[0].set_title('Image')
ax[1].set_title('Histogram')
plt.show()
```
```py
hist, _ = np.histogram(img.flatten(), bins=256, range=(0, 256))
pixel_value = np.arange(256)
hist_by_value = hist * pixel_value
var_totals = np.zeros(255)
var_ratios = np.zeros(255)
thr_best = 0


for thr in range(1, 255):
    w1 = np.sum(hist[:thr])     
    w2 = np.sum(hist[thr:])
    m1 = np.sum(hist_by_value[:thr]) / w1
    m2 = np.sum(hist_by_value[thr:]) / w2
    var1 = np.sum((pixel_value[:thr] - m1) ** 2 * hist[:thr]) / w1
    var2 = np.sum((pixel_value[thr:] - m2) ** 2 * hist[thr:]) / w2
    var_within = (var1 * w1 + var2 * w2) / (w1 + w2)
    var_inter = (m1 - m2) ** 2 * w1 * w2 / (w1 + w2)**2
    var_total = var_within + var_inter
    var_ratio = var_inter / var_within
    if var_ratio >= var_ratios[thr_best]:
        thr_best = thr
    var_ratios[thr] = var_ratio
    var_totals[thr] = var_total
fig_1, ax_1 = plt.subplots(1,2,  tight_layout=True)
ax_1[0].plot(var_totals, label="var_totals")
ax_1[0].set_ylabel("var_totals")
ax_1[0].set_xlabel('threshold')
ax_1[1].plot(var_ratios, label="var_ratios", color='orange')
ax_1[1].set_ylabel("var_ratios")
ax_1_r = ax_1[1].twinx()
ax_1_r.hist(img.flatten(), bins=256, range=(0, 256), alpha=0.5, color='gray')
ax_1_r.set_ylabel("count")
ax_1[1].set_xlabel('pixel value')
ax_1[1].axvline(thr_best, color='red', linestyle='--', label='thr_best')
ax_1[0].legend()
ax_1[1].legend()
plt.show()
```
<img src = "https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3230781/88395bc3-f912-4171-8436-c777f5fa8dc9.png" width ="500">

```py
thr_value, img_binary = cv2.threshold(img, thr_best, 255, cv2.THRESH_BINARY)
thr_otsu, img_binary_otsu = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
fig, ax = plt.subplots(1,2,  tight_layout=True)
ax[0].imshow(img_binary, cmap='gray')
ax[0].set_title(f"binary image_numpy, thr = {thr_best}")

ax[1].imshow(img_binary_otsu, cmap='gray')
ax[1].set_title(f"binary image_cv2, thr = {thr_otsu}")
plt.show()
```

<img src = "https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3230781/bc14ab3c-5795-4af7-a508-1b8604ed94b1.png" width ="500">

閾値探索は問題なさそうです。

# おわりに
　線形判別分析を理解するために、自身の学びを出力しました。違っている点など見つかれば適宜修正します。ご指摘いただけると感謝です