分散の最小化による母集団の分離アルゴリズム
混ざり合ってしまった2つの集団を、統計学的に最も妥当な境界線(しきい値)で切り分けるにはどうすればよいでしょうか。
まず、データ全体の散らばりを表す「グループ内分散」という指標に着目し、コンピュータのループ処理を用いて最適な閾値を自動的に探索するアルゴリズムの論理と、実際の実装コード、およびその計算結果について順を追って解説します。
分離アルゴリズムの3つのステップ
2つの集団が混ざっているときは全体の分散が大きく膨らんでいますが、もし最適な境界線で2つの集団に分離することができれば、分割後の各集団はそれぞれ本来の単峰分布に戻り、内部の分散(散らばり)は最小化されるはずです。
この「分割後における各グループ内の分散の小ささ」を指標にすることで、データを適切に分離するアルゴリズムを構築できます。具体的には、境界線の位置を1点ずつ動かしながら、以下の3つのステップをループ処理で計算していきます。
- ステップ1(データの分割):仮の境界線(例:50点など)を設定し、データを「境界線未満のグループ」と「境界線以上のグループ」の2つに分割します。
- ステップ2(グループ別分散の算出):分割された2つのグループそれぞれにおいて、個別に分散(平均からのズレの2乗の平均)を計算します。
- ステップ3(人数比による加重平均):それぞれのグループの人数(サンプルの重み)を考慮して、2つの分散を足し合わせます。これを統計学では「グループ内分散」と呼びます。
境界線探索プログラムの実装
この一連の探索手順を実装したのが、以下のコードです。最小点から最高点まで1点刻みで境界線を動かし、グループ内分散が最も小さくなったポイントを最適解として決定します。
export default class BasicContainer extends GrindController {
// (省略)
/**
* 混ざったデータから、グループ内分散が最小となる最適な境界線(カットライン)を探索する
* @param {number[]} samples 2つの山が混ざったスコアデータの配列
* @param {number} minScore 探索を開始する最低点(例: 0)
* @param {number} maxScore 探索を終了する最高点(例: 100)
* @returns {Object} 最適な境界線と、それによって分割された2つのグループのデータ
*/
findOptimalThreshold(samples, minScore = 0, maxScore = 100) {
let minWithinVariance = Infinity; // 最小のグループ内分散を記録する変数(初期値は無限大)
let bestThreshold = -1; // 最適な境界線を記録する変数
// (1) 最小点から最高点まで、1点刻みで境界線の位置を動かして総当たり戦を行う
// ※端すぎると分割できないため、実質的な探索範囲(例: 1〜99)でループ
for (let t = minScore + 1; t < maxScore; t++) {
// (2) 仮の境界線「t」でデータを2つのグループにぶった切る
const group1 = samples.filter(score => score < t);
const group2 = samples.filter(score => score >= t);
// どちらかのグループが空になってしまった場合はスキップ
if (group1.length === 0 || group2.length === 0) continue;
// (3) 分割された2つのグループそれぞれで、教科書通りの「分散」を個別に計算する
const var1 = d3.variance(group1) || 0;
const var2 = d3.variance(group2) || 0;
// (4) 2つのグループの人数比(ウエイト)を考慮して、分散をブレンド(加重平均)する
// これが統計学でいう「グループ内分散(内分散)」
const n1 = group1.length;
const n2 = group2.length;
const total = n1 + n2;
const withinVariance = (n1 * var1 + n2 * var2) / total;
// (5) もし計算したグループ内分散が、これまでの記録(最小値)を塗り替えたら
// その時の「境界線」と「分散の低さ」を最新レコードとして記憶する
if (withinVariance < minWithinVariance) {
minWithinVariance = withinVariance;
bestThreshold = t;
}
}
// (6) 最終的に勝ち残った「最も身内のバラつきをギュッと引き締めた境界線」で本分割する
const finalGroup1 = samples.filter(score => score < bestThreshold);
const finalGroup2 = samples.filter(score => score >= bestThreshold);
return {
threshold: bestThreshold, // 割り出された境界線
withinVariance: minWithinVariance, // その時の最小グループ内分散
group1: finalGroup1, // 分割された集団1(初心者層)
group2: finalGroup2 // 分割された集団2(経験者層)
};
}
// (省略)
}
コード内では、先ほど解説した3つのステップが以下のように実行されています。
ステップ1(データの分割)は、ループ処理内の(2)で実行されています。変数tを仮の境界線として、filterメソッドにより境界線未満(group1)と境界線以上(group2)の2つの配列へと機械的に分割しています。
ステップ2(グループ別分散の算出)は(3)に対応しています。D3のd3.variance関数を呼び出すことで、分割された2つのグループそれぞれの分散(var1とvar2)を個別に算出しています。
ステップ3(人数比による加重平均)は(4)で計算されています。各グループのデータ数(n1とn2)を重みとして掛け合わせ、全体のサンプル数で割ることで、目的指標である「グループ内分散(withinVariance)」を導き出しています。
この手順を(1)でひたすら繰り返す中で(5)の条件分岐によりグループ内分散が「最も小さくなった瞬間」の境界線の位置を記憶し、最終的に(6)でその最適な閾値を用いた本分割を行っています。
分離アルゴリズムの計算結果
この探索プログラムを実行し、グループ内分散が最も小さくなる閾値を求めた結果が図5です。
実際の計算結果は乱数の影響により理論値から多少の前後が生じますが、ヒストグラムの谷底(今回のサンプルでは50点付近)のデータが最も薄い位置に、自動的に境界線が設定される様子が確認できます。統計学的には、グループ内のバラつきを最小にすることは、グループ同士の離れ具合である「グループ間分散」を最大にすることと同じ意味になるので、この表現の方が分かりやすい方もいるでしょう。
実は、この「グループ内分散を最小化する位置を総当たりで探す」というアプローチは「大津の二値化(判別分析法)」として有名なアルゴリズムそのものです。画像認識に携わったことがある方であれば、聞いたことがあると思います。
この検知手法の前提と実務における限界
ここまで、範囲と標準偏差の関係から分布の二峰性を察知するアプローチを解説してきましたが、一つ重要な注意点があります。
現実のデータ分析において「標準偏差が大きければ、必ず二峰性分布である」と言い切ることはできません。
実際の実務データはさらに複雑であり、例えば「山が3つ以上ある(多峰性)」ケースや、山が割れていなくても全体が均等にスカスカな「一様分布」のケースでも、標準偏差は同じように大きな値を示します。そのため、標準偏差の数値単体から機械的に「二峰性である」と100%断定することは不可能です。
しかし、このアプローチを知っておくことには実務上大きな意味があります。
例えば、毎日計測している定常データにおいて、「範囲(データの端から端)」が変わらないにもかかわらず、ある日突然、標準偏差の数値だけが不自然に跳ね上がったとします。このとき、この手法を理解していれば、「母集団の中で何かしらの異変が起き、データが複数のクラスタに分裂し始めているのではないか」というアタリ(仮説)を素早く立てることができます。
このように、数値の背後で起きている「山の分裂(混合分布)」という特徴を捉え、その構造を予測するための強力なアプローチとして、この散らばりの指標の比較は極めて有効なのです。
