第 7 章: 線形回帰による数値予測¶
7.1 はじめに¶
第 1〜3 章では、グループを当てる 分類 を扱いました。この章からは、数値そのものを当てる 回帰 に進みます。題材は、映画の SNS での反響や主演俳優の露出度から、興行収入を予測する問題です。
この章では、最も基本的な回帰モデルである 線形回帰 を自作します。Java 版の第 7 章 は double[][] を包んだ行列クラスを作り、Clojure 版 はベクタのベクタをそのまま行列として扱い、どちらも行列の積・転置・ガウスの消去法を自分で書きました。PHP 版はそこまでしません。MathPHP に行列と連立方程式の解法があるので、自作するのは「正規方程式を組み立てて解く手順」だけ です(ADR 013)。Elixir 版 が Nx で同じ立ち位置にいます。
そしてこの章には、PHP 版でいちばん説明が要る事情があります。Rubix ML には素の線形回帰(LinearRegression)がありません。 あるのは L2 正則化つきの Ridge だけです。正則化の強さを 0 にしたリッジは最小二乗法そのものなので、それを突き合わせの相手にします。「ライブラリに無いものを、どう代用するか」を決める場面です。
そのうえで、この章には 直前の Elixir 版との対照 があります。Elixir 版では Scholar の線形回帰が実データで最小二乗解に届かず、なぜ届かないのかを突き止めるところまで進みました。PHP 版では同じことが起きるでしょうか。7.9 節で実測します。
Notebook による探索と可視化の節は設けません。散布図で外れ値を確かめる手順は Python 版 と Kotlin 版の 7.13 節 を参照してください。
7.2 線形回帰とは¶
特徴量の重み付きの和で予測する¶
線形回帰は、予測値を「切片」と「特徴量ごとの係数 × 特徴量」の和で表すモデルです。この章のデータに当てはめると次の式になります。
予測値 = 切片 + 係数1 × SNS1 + 係数2 × SNS2 + 係数3 × actor + 係数4 × original
学習とは、訓練データに対して予測値と実測値のずれが最も小さくなるように、切片と係数を決めることです。ずれの大きさには「誤差の 2 乗の合計」を使います。これを最小にする方法を 最小二乗法 と呼びます。
正規方程式¶
最小二乗法の解は、行列を使うと 1 つの式で求められます。特徴量を行列 X、実測値をベクトル t、切片と係数をまとめたベクトルを w とすると、誤差の 2 乗の合計を最小にする w は次の 正規方程式 を満たします。
(Xᵀ X) w = Xᵀ t
X の先頭には、すべての値が 1 の列を足しておきます。この列にかかる係数が切片になります。
逆行列を作らずに連立方程式として解くのは、Python 版・Java 版・Clojure 版・Elixir 版と同じく、そのほうが数値計算の誤差が小さくなるためです。MathPHP にも inverse() はありますが、使いません。この判断が 7.9 節で効いてきます。 Rubix ML の Ridge は逆に、inverse() を使って同じ正規方程式を解いているからです。
7.3 題材とデータ¶
この章で使うのは cinema.csv です。100 本の映画について、次の 6 列が記録されています。
| 列 | 内容 | 欠損値 |
|---|---|---|
| cinema_id | 映画の ID | なし |
| SNS1 | SNS での反響の数(1 つ目の指標) | 1 件 |
| SNS2 | SNS での反響の数(2 つ目の指標) | なし |
| actor | 主演俳優のメディア露出の指標 | 1 件 |
| original | 原作の有無(0 または 1) | なし |
| sales | 興行収入 | なし |
「SNS1」「SNS2」「actor」「original」が特徴量、「sales」が正解ラベル(予測したい数値)です。cinema_id は映画を区別するための番号なので、特徴量には使いません。
第 2 章の Csv::readTable はセルを文字列のまま持ち、Chapter02::number() で読むときに float か null にします。列の型を推論する段階が無いので、Kotlin 版が Kotlin DataFrame の型推論で困った問題(SNS1 が Int? と推論され、補完した平均値が入らない)は起きません。型がゆるい言語の、数少ない得をする場面 です。
7.4 TODO リストの作成¶
TODO リスト(第 7 章)
- [ ] 残差・MAE・RMSE・決定係数を計算する
- [ ] 切片と係数を持つモデルを表す
- [ ] 計画行列(先頭が 1 の列)を作る
- [ ] 正規方程式を解いて学習する
- [ ] 外れ値を取り除く
- [ ] 外れ値の除去・分割・補完を 1 つの手順にまとめる
- [ ] Rubix ML の Ridge(正則化 0)と突き合わせる
- [ ] 実データで学習し、係数と評価指標を表示する
Java 版・Clojure 版の TODO リストにあった「行列の積」「行列の転置」「ガウスの消去法」の 3 項目が、この章にはありません。MathPHP が持っているからです。第 3 章で決定木を全部自作したのと、ちょうど裏返しになります。
7.5 評価指標を計算する¶
Red: 残差から始める¶
回帰の評価指標は、すべて「実測値と予測値の差(残差)」から作れます。まず残差から書きます。
#[TestDox('残差は実測値から予測値を引いた値')]
public function test残差は実測値から予測値を引いた値(): void
{
$this->assertEqualsWithDelta([1.0, -2.0], Chapter07::residuals([3.0, 1.0], [2.0, 3.0]), 1e-12);
}
#[TestDox('件数が違えば残差を求められない')]
public function test件数が違えば残差を求められない(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('件数が違います: 2 と 1');
Chapter07::residuals([1.0, 2.0], [1.0]);
}
Green¶
/**
* 実測値と予測値の差を返す。件数が違えば失敗する。
*
* @param list<float> $t 実測値
* @param list<float> $y 予測値
*
* @return list<float>
*/
public static function residuals(array $t, array $y): array
{
if (count($t) !== count($y)) {
throw new InvalidArgumentException(sprintf('件数が違います: %d と %d', count($t), count($y)));
}
return array_map(static fn (float $actual, float $predicted): float => $actual - $predicted, $t, $y);
}
array_map に配列を 2 つ渡すと、同じ位置の要素を組にして callback を呼びます。zip に相当する書き方ですが、長さが違っても落ちず、短いほうが null で埋められる ので、先に件数を確かめておく必要があります。declare(strict_types=1) があるので float $actual に null が来れば TypeError になりますが、そこで初めて気づくのでは遅いというだけの話です。
3 つの指標を続けます。
/** 平均絶対誤差(MAE)。誤差の絶対値の平均。 */
public static function meanAbsoluteError(array $t, array $y): float
{
$residuals = self::residuals($t, $y);
return array_sum(array_map(abs(...), $residuals)) / count($residuals);
}
/** 平均二乗誤差の平方根(RMSE)。 */
public static function rootMeanSquaredError(array $t, array $y): float
{
$residuals = self::residuals($t, $y);
return sqrt(self::sumOfSquares($residuals) / count($residuals));
}
/** 決定係数(R²)。1 から「残差の 2 乗の合計 ÷ 平均との差の 2 乗の合計」を引いた値。 */
public static function r2Score(array $t, array $y): float
{
$residual = self::sumOfSquares(self::residuals($t, $y));
$mean = array_sum($t) / count($t);
$total = self::sumOfSquares(array_map(static fn (float $v): float => $v - $mean, $t));
if ($total === 0.0) {
throw new InvalidArgumentException('実測値がすべて同じ値です');
}
return 1.0 - $residual / $total;
}
abs(...) は PHP 8.1 の 第一級callable記法 です。'abs' という文字列や fn ($v) => abs($v) と書かずに、関数そのものを値として渡せます。文字列で書くより速く、そして PHPStan が中身を検査できます。存在しない関数名を書いても、文字列なら実行するまで気づけません。
決定係数の分母が 0 になる場合を弾いているのは、割り算の前に確かめないと INF や NAN が静かに流れていくからです。PHP の浮動小数点の割り算は 0 除算で例外を投げません(整数の / とは違います)。
#[TestDox('完全に当たれば決定係数は 1')]
public function test完全に当たれば決定係数は1(): void
{
$this->assertEqualsWithDelta(1.0, Chapter07::r2Score([1.0, 2.0, 3.0], [1.0, 2.0, 3.0]), 1e-12);
}
#[TestDox('平均を答え続けると決定係数は 0')]
public function test平均を答え続けると決定係数は0(): void
{
$this->assertEqualsWithDelta(0.0, Chapter07::r2Score([1.0, 2.0, 3.0], [2.0, 2.0, 2.0]), 1e-12);
}
決定係数の 0 と 1 は、定義から意味が決まっている値です。「完全に当たれば 1」「平均を答え続ければ 0」の 2 本があれば、式の形を取り違えていないことがほぼ保証されます。
7.6 モデルを表す¶
列名と係数を別々のリストで持つ¶
学習の結果は「切片」と「列ごとの係数」です。連想配列(['SNS1' => 1.38, …])でも書けますし、PHP の連想配列はキーの順を保ちます。それでも 列名のリストと、同じ順に並んだ係数のリスト に分けました。
final readonly class LinearModel
{
/**
* @param list<string> $columns
* @param list<float> $coefficients
*/
public function __construct(
public float $intercept,
public array $columns,
public array $coefficients,
) {
if (count($columns) !== count($coefficients)) {
throw new InvalidArgumentException(
sprintf('列名と係数の数が違います: %d と %d', count($columns), count($coefficients)),
);
}
}
理由は 2 つあります。1 つは、正規方程式の解が「先頭が切片、残りが列の順の係数」という 並びのあるベクトル で返ってくるので、そのまま受け取れること。もう 1 つは、「順がある」ことがコードの型から読み取れることです。list<float> は PHPStan にとって「0 から連続した整数キーの配列」を意味し、array<string, float> とは別の型です。連想配列にした瞬間、順に意味があることがコードから消えます。
readonly class なので、作った後は誰も書き換えられません。コンストラクタで件数の食い違いを弾いておけば、このクラスのインスタンスは常に整合している と後続のコードが信じられます。
列名で係数を読む¶
/** 列名で係数を読む。無ければ失敗する。 */
public function coefficient(string $column): float
{
$index = array_search($column, $this->columns, true);
if ($index === false) {
throw new InvalidArgumentException("係数がありません: {$column}");
}
return $this->coefficients[$index];
}
array_search の第 3 引数 true は厳密比較の指定です。省くと == で比べるので、'0' と '' のような組み合わせで取り違えます。PHP で比較を書くときは、常に厳密なほうを選ぶ というのが第 1 章からの方針です。
戻り値が false か整数なので、if (!$index) と書いてはいけません。添字 0(先頭の列)が偽になってしまいます。=== false で書きます。
/**
* 1 行分の特徴量の予測値。係数は列名で対応させるので、特徴量の列の並び順は問わない。
*
* @param array<string, float> $features
*/
public function predictOne(array $features): float
{
$sum = $this->intercept;
foreach ($this->columns as $index => $column) {
if (!array_key_exists($column, $features)) {
throw new InvalidArgumentException("特徴量がありません: {$column}");
}
$sum += $this->coefficients[$index] * $features[$column];
}
return $sum;
}
特徴量は array<string, float>(連想配列)で受け取り、モデルは list(順のあるリスト)で持つ——この 2 つを使い分けています。特徴量は名前で引くもの、係数は並びのあるものだからです。テストで「並び順が違っても同じ値になる」ことを固定しておきます。
// 列の並び順が違っても、列名で対応させるので同じ値になる。
$this->assertEqualsWithDelta(0.0, $model->predictOne(['b' => 1.0, 'a' => 1.0]), 1e-12);
array_key_exists を使い、isset を使っていないことにも意味があります。isset($features[$column]) は 値が null のときも偽になる ので、「キーはあるが値が null」と「キーが無い」を区別できません。前処理を通したあとの特徴量に null は入りませんが、区別できる書き方を選んでおきます。
7.7 正規方程式で線形回帰を学習する¶
Red: 答えの分かっているデータ¶
まず、手で解ける 4 点を用意します。切片 1.0、a の係数 2.0、b の係数 -3.0 で誤差なく当てはまるデータです。
/** @return list<array<string, float>> */
private function squareX(): array
{
return [
['a' => 0.0, 'b' => 0.0],
['a' => 1.0, 'b' => 0.0],
['a' => 0.0, 'b' => 1.0],
['a' => 1.0, 'b' => 1.0],
];
}
/** @return list<float> */
private function squareT(): array
{
return [1.0, 3.0, -2.0, 0.0];
}
#[TestDox('正規方程式で切片と係数を求める')]
public function test正規方程式で切片と係数を求める(): void
{
$model = Chapter07::fit($this->squareX(), $this->squareT(), ['a', 'b']);
$this->assertEqualsWithDelta(1.0, $model->intercept, 1e-9);
$this->assertEqualsWithDelta(2.0, $model->coefficient('a'), 1e-9);
$this->assertEqualsWithDelta(-3.0, $model->coefficient('b'), 1e-9);
}
このデータは、この章を通じて 3 回使います。自作の検証、Rubix ML との突き合わせ、正則化の効き方の確認です。答えを人間が知っているデータを 1 組用意しておく と、ライブラリを疑う場面でも足場になります。
計画行列¶
/**
* 先頭に 1 の列を足した特徴量の行列(計画行列)を作る。1 の列の係数が切片になる。
*
* @param list<array<string, float>> $x
* @param list<string> $columns
*
* @return list<list<float>>
*/
public static function designMatrix(array $x, array $columns): array
{
return array_map(
static fn (array $features): array => [1.0, ...self::featureRow($features, $columns)],
$x,
);
}
[1.0, ...$row] はスプレッド演算子です。PHP 8.1 から文字列キーの展開もできるようになりましたが、ここではリストを前に 1 つ伸ばすだけに使っています。array_merge([1.0], $row) より速く、読みやすい書き方です。
Green: 学習¶
public static function fit(array $x, array $t, array $columns): LinearModel
{
if ($x === []) {
throw new InvalidArgumentException('訓練データが空です');
}
if (count($x) !== count($t)) {
throw new InvalidArgumentException(
sprintf('特徴量と実測値の件数が違います: %d と %d', count($x), count($t)),
);
}
$design = MatrixFactory::createNumeric(self::designMatrix($x, $columns));
$transposed = $design->transpose();
// getVector は int と float の混ざった配列を返すので、float に寄せてから使う。
$weights = array_map(
floatval(...),
array_values(
$transposed->multiply($design)
->solve($transposed->vectorMultiply(new Vector($t)), NumericMatrix::LU)
->getVector(),
),
);
return new LinearModel($weights[0], $columns, array_slice($weights, 1));
}
MathPHP の solve() は、既定では「状況に応じて解き方を選ぶ」ようになっています。2×2 なら逆行列、逆行列が計算済みならそれを使い、そうでなければ LU 分解、失敗すれば QR 分解……という順です。便利ですが、行列の中身によって解き方が変わるということです。 数値を他の言語版と突き合わせる本シリーズでは困るので、NumericMatrix::LU を明示して固定しました。
floatval(...) で float に寄せているのは、getVector() の戻り値が array<float|int> と宣言されているためです。PHPStan のレベル 9 は「list<float> を期待しているのに array<float|int> が来た」と正しく指摘します。キャストで黙らせるのではなく、実際に変換する のが筋です。
$x === [] で空を判定しているのは、empty($x) を避けるためです。empty() は '0'・0・'0.0' ではない 0.0 など、驚くほど多くの値を真と見なします。配列が空かどうかを知りたいなら === [] が最も正確です。
7.8 外れ値の除去・分割・補完をまとめる¶
外れ値を取り除く¶
散布図を見ると、SNS2 が極端に大きいのに興行収入が小さい映画が 1 本だけあります(可視化の手順は Python 版 を参照)。この 1 本を外れ値として取り除きます。
/** SNS2 がこの値を超え、かつ興行収入が OUTLIER_SALES 未満の映画を外れ値とする。 */
private const float OUTLIER_SNS2 = 1000.0;
private const float OUTLIER_SALES = 8500.0;
/** 外れ値かどうかを判定する。 */
public static function isOutlier(array $row): bool
{
return self::requiredNumber($row, 'SNS2') > self::OUTLIER_SNS2
&& self::requiredNumber($row, self::TARGET) < self::OUTLIER_SALES;
}
/** 外れ値の行を除いた表を返す。列はそのまま残す。 */
public static function removeOutliers(Table $table): Table
{
$rows = array_values(
array_filter($table->rows, static fn (array $row): bool => !self::isOutlier($row)),
);
return new Table($table->columns, $rows);
}
private const float OUTLIER_SNS2 = 1000.0; の float は PHP 8.3 で入った定数の型宣言 です。型を書いておくと、= '1000' のような書き間違いをその場で弾けます。定数に型が書けるのは、Ruby 版・Python 版には無い道具です。
array_filter の結果を array_values で包んでいるのは、array_filter が元の添字を残す からです。3 番目の行を落とすと [0, 1, 3, 4, …] という飛び飛びの添字の配列になり、PHPStan の list<T> を満たさなくなります。第 2 章でも同じ包み直しをしました。PHP の配列は「リスト」と「連想配列」が同じ型なので、リストであり続けるには意識して保つ必要があります。
#[TestDox('外れ値は SNS2 が大きく興行収入が小さい行')]
public function test外れ値の判定(): void
{
$table = Csv::parseTable("SNS2,sales\n1200,8000\n1200,9000\n900,8000\n");
$this->assertCount(2, Chapter07::removeOutliers($table)->rows);
// 列はそのまま残る。
$this->assertSame(['SNS2', 'sales'], Chapter07::removeOutliers($table)->columns);
}
テストのデータは架空の値で作っています。学習データの行をコードやテストに書き写すことはしません(第 1 章 の方針)。境界の両側(SNS2 が 1200 と 900、sales が 8000 と 9000)を並べて、&& の両方が効いていることを確かめています。
前処理を 1 つの関数にまとめる¶
/**
* cinema.csv を読み込み、外れ値を除き、分割してから訓練データの平均値で両方を補完する。
*
* @return array{xTrain: list<array<string, float>>, xTest: list<array<string, float>>, tTrain: list<float>, tTest: list<float>}
*/
public static function prepareCinema(string $path, float $testSize, int $seed): array
{
$rows = self::removeOutliers(Chapter02::loadTable($path))->rows;
$t = array_map(
static fn (array $row): string => (string) self::requiredNumber($row, self::TARGET),
$rows,
);
$split = Chapter02::splitTrainTest($rows, $t, $testSize, $seed);
$means = Chapter02::columnMeans($split['xTrain'], self::FEATURE_COLUMNS);
return [
'xTrain' => Chapter02::fillMissing($split['xTrain'], self::FEATURE_COLUMNS, $means),
'xTest' => Chapter02::fillMissing($split['xTest'], self::FEATURE_COLUMNS, $means),
'tTrain' => array_map(floatval(...), $split['tTrain']),
'tTest' => array_map(floatval(...), $split['tTest']),
];
}
順番が重要です。外れ値を除いてから分割し、訓練データだけから平均を求めて、両方を補完します。 テストデータの平均を補完に混ぜると、テストデータの情報が訓練に漏れます(リーク)。第 2 章で立てた約束をそのまま使っています。
戻り値の型 array{xTrain: …, xTest: …, tTrain: …, tTest: …} は PHPStan の 配列の形(array shape) です。実行時には何の検査もされない、ただの連想配列です。それでも、$split['xTrian'] と打ち間違えれば PHPStan が止めてくれますし、$split['xTrain'] の要素の型まで追いかけてくれます。実行時に効く型と、静的解析だけが見る型の二層構造 が、PHP 版の全編を貫く形です(第 5 章)。
第 2 章の splitTrainTest は正解ラベルを list<string> で受け取る設計なので、数値のラベルをいったん文字列にして渡し、返ってきたものを float に戻しています。回りくどく見えますが、第 2 章の分割を「分類でも回帰でも同じもの」として使い回す ための代償です。分割の並びが Java 版・Elixir 版と一致するという性質は、この 1 つの関数に閉じ込めてあります。
7.9 Rubix ML の Ridge に置き換える¶
素の線形回帰が無い¶
ここが、この章でいちばん説明の要る場面です。Rubix ML の Regressors ディレクトリを見ると、こうなっています。
$ ls vendor/rubix/ml/src/Regressors/
Adaline.php ExtraTreeRegressor.php GradientBoost.php KDNeighborsRegressor.php
KNNRegressor.php MLPRegressor.php RadiusNeighborsRegressor.php RegressionTree.php
Ridge.php SVR.php
LinearRegression.php がありません。決定木もランダムフォレストも持っている Rubix ML が、いちばん基本的な線形回帰を持っていないのです。
代わりに Ridge があります。リッジ回帰は「誤差の 2 乗の合計」に「係数の 2 乗の合計 × 正則化の強さ」を足したものを最小にするモデルで、正則化の強さを 0 にすれば最小二乗法そのもの になります。正則化そのものは第 12 章で扱うので、ここでは「Ridge(0.0) が線形回帰である」という事実だけを使います。
/**
* Rubix ML の `Ridge` で学習し、自作と同じ形のモデルにする。
*
* Rubix ML は切片(bias)を自分で足すので、計画行列ではなく特徴量をそのまま渡す。
* `l2Penalty` の既定は 1.0 なので、最小二乗にするには 0.0 を明示する。
*/
public static function rubixFit(array $x, array $t, array $columns, float $l2Penalty = 0.0): LinearModel
{
if ($x === []) {
throw new InvalidArgumentException('訓練データが空です');
}
$samples = array_map(static fn (array $f): array => self::featureRow($f, $columns), $x);
$ridge = new Ridge($l2Penalty);
$ridge->train(new Labeled($samples, $t));
$bias = $ridge->bias();
$coefficients = $ridge->coefficients();
if ($bias === null || $coefficients === null) {
throw new InvalidArgumentException('リッジの学習に失敗しました');
}
return new LinearModel($bias, $columns, array_map(floatval(...), array_values($coefficients)));
}
渡すのは designMatrix() ではなく featureRow() の並び、つまり 1 の列を足さない特徴量 です。Rubix ML は切片を自分で足すので、こちらで足すと切片が二重になります。Elixir 版が Scholar に対して同じ注意をしていました。
$l2Penalty の既定が 1.0 であることも落とし穴です。new Ridge() と書くと、正則化が効いた別のモデルになります。既定値が「何もしない」でないライブラリは、引数を省かずに書く のが安全です。
bias() と coefficients() が null を返しうるのは、学習前に呼ばれた場合のためです。train() の直後なので実際には null になりませんが、PHPStan のレベル 9 は「?float を float に渡している」と正しく指摘します。型で弾かれたところに、本当に起こりうる状態が 1 つ隠れている という指摘なので、キャストではなく判定で応えます。
条件のよいデータでは一致する¶
#[TestDox('条件のよいデータなら Rubix ML のリッジと一致する')]
public function test条件のよいデータならリッジと一致する(): void
{
$mine = Chapter07::fit($this->squareX(), $this->squareT(), ['a', 'b']);
$theirs = Chapter07::rubixFit($this->squareX(), $this->squareT(), ['a', 'b']);
$this->assertEqualsWithDelta($mine->intercept, $theirs->intercept, 1e-9);
$this->assertEqualsWithDelta($mine->coefficient('a'), $theirs->coefficient('a'), 1e-9);
$this->assertEqualsWithDelta($mine->coefficient('b'), $theirs->coefficient('b'), 1e-9);
}
通ります。切片 1.0、係数 2.0 と -3.0 がぴったり返ってきます。
ついでに「正則化を強めると係数が 0 に近づく」ことも固定しておきます。Ridge(0.0) が最小二乗だという主張の、裏側からの確認です。
#[TestDox('正則化を強めるとリッジの係数は 0 に近づく')]
public function test正則化を強めるとリッジの係数は0に近づく(): void
{
$plain = Chapter07::rubixFit($this->squareX(), $this->squareT(), ['a', 'b']);
$penalized = Chapter07::rubixFit($this->squareX(), $this->squareT(), ['a', 'b'], 10.0);
$this->assertLessThan(abs($plain->coefficient('a')), abs($penalized->coefficient('a')));
$this->assertLessThan(abs($plain->coefficient('b')), abs($penalized->coefficient('b')));
}
実データでも一致する¶
さて、Elixir 版では実データで一致しませんでした。PHP 版はどうでしょうか。
#[Group('data')]
#[TestDox('実データで自作とリッジの係数が一致する')]
public function test実データで自作とリッジの係数が一致する(): void
{
// …(データが無ければ markTestSkipped)
$mine = Chapter07::fit($split['xTrain'], $split['tTrain'], $columns);
$theirs = Chapter07::rubixFit($split['xTrain'], $split['tTrain'], $columns);
$this->assertEqualsWithDelta($mine->intercept, $theirs->intercept, 1e-6);
foreach ($columns as $column) {
$this->assertEqualsWithDelta($mine->coefficient($column), $theirs->coefficient($column), 1e-9);
}
}
通ります。 実測した値を並べます。
| 列 | 自作(MathPHP の LU 分解) | Rubix ML の Ridge(0.0) |
|---|---|---|
| 切片 | 6114.5955056944 | 6114.5955056945 |
| SNS1 | 1.3803702543274086 | 1.3803702543274112 |
| SNS2 | 0.52177974764555191 | 0.52177974764555191 |
| actor | 0.29000510327204215 | 0.29000510327202567 |
| original | 208.88268148822766 | 208.88268148823408 |
有効数字 12〜13 桁まで一致しています。Elixir 版では original が 209 と 579 に分かれていた場所です。
残差平方和も比べておきます。最小二乗解はただ 1 つなので、両方が解に届いていれば残差平方和も一致するはず です。
// 最小二乗解はただ 1 つなので、両方が解に届いていれば残差平方和も一致する。
$this->assertEqualsWithDelta($mineSse, $theirsSse, 1e-6);
| モデル | 訓練データの残差平方和 |
|---|---|
| 自作(MathPHP の LU 分解) | 11083165.678050069 |
Rubix ML の Ridge(0.0) |
11083165.678050065 |
最後の 1 桁だけが違います。同じ解に、違う道筋で届いた ということです。
なぜ Elixir では届かず、PHP では届いたのか¶
Rubix ML の Ridge::train() を読むと、理由がはっきりします。
// vendor/rubix/ml/src/Regressors/Ridge.php
$biases = Matrix::ones($dataset->numSamples(), 1);
$x = Matrix::build($dataset->samples())->augmentLeft($biases);
$y = Vector::build($dataset->labels());
// …(正則化の対角行列 $penalties を作る。先頭=切片の分は 0)
$xT = $x->transpose();
$coefficients = $xT->matmul($x)
->add($penalties)
->inverse()
->dot($xT->dot($y))
->asArray();
自作とまったく同じことをしています。 1 の列を先頭に足し(augmentLeft)、Xᵀ X を作り、正則化の対角行列を足して(0 なら何も足さない)、解く。違うのは最後の一手だけで、Rubix ML は inverse() で逆行列を作ってから掛け、自作は solve() で連立方程式として解きます。
一方、Elixir 版の Scholar は Nx.LinAlg.pinv(特異値分解による擬似逆行列)で解いていました。この 2 つの差が、そのまま結果の差になりました。
| 解き方 | 桁の違う列が混ざったデータでの結果 | |
|---|---|---|
| 自作(PHP・Elixir・Java・Clojure) | 正規方程式を消去法・LU 分解で解く | 最小二乗解 |
Rubix ML の Ridge |
正規方程式を逆行列で解く | 最小二乗解(12 桁まで一致) |
Scholar の LinearRegression |
特異値分解で擬似逆行列を作る | 最小二乗解に届かない |
教科書は「逆行列を作るより連立方程式として解くほうが誤差が小さい」と書きますし、それは正しいのですが、この規模(5×5)・この条件の行列では、実用上の差は最後の 1 桁だけ でした。そして SVD のほうが数値的に安定とされているのに、Elixir 版では届きませんでした。Elixir 版が突き止めたとおり、原因はアルゴリズムの理論ではなく Nx.BinaryBackend の SVD の実装精度だったからです。
「どのアルゴリズムか」より「その実装がどれだけ正確か」のほうが効く場合がある。 2 つの言語版を並べて初めて言えることです。
何を学んだか¶
Elixir 版は「ライブラリと一致しなかったので、なぜかを突き止める」章になりました。PHP 版は一致したので、代わりに次の 2 つが残りました。
- 無いものを何で代用するかを、定義から決める — 「
Ridge(0.0)は最小二乗である」は、リッジ回帰の定義から出てくる事実です。そう決めたうえで、答えの分かっているデータで一致を確かめ、正則化を強めると係数が縮むことも確かめました。代用は、それが代用として成り立つ理由と、成り立っていることを示すテストとで支えます - ライブラリのソースを読む —
composer installがvendor/にソースをそのまま置くので、読むのに何の準備も要りません。Elixir のdeps/と同じです。「同じ正規方程式を解いている」ことを確かめられたからこそ、一致を偶然ではなく当然として書けます
7.10 実データで学習・評価する¶
結果を表示する¶
第 1〜3 章と同じく、章ごとの run() に結果の表示をまとめます。ほかの言語版と同じく、標準出力に書かずに 文字列を返します(第 1 章 の方針)。
$ php -r 'require "vendor/autoload.php"; echo GettingStartedMl\Chapter07::run();'
データ件数: 100
外れ値を除いた件数: 99
訓練データ: 79 件, テストデータ: 20 件
切片: 6114.60
係数: SNS1=1.3804, SNS2=0.5218, actor=0.2900, original=208.8827
Rubix ML の切片: 6114.60, 係数: SNS1=1.3804, SNS2=0.5218, actor=0.2900, original=208.8827
テストデータの評価: R2=0.6184, MAE=302.20, RMSE=376.14
この出力を、テストで 1 行ずつ固定します。
#[Group('data')]
#[TestDox('実データの学習と評価がほかの言語版と一致する')]
public function test実データの学習と評価が一致する(): void
{
if (!Dataset::exists('cinema.csv')) {
$this->markTestSkipped('学習データがありません: ' . Dataset::path('cinema.csv'));
}
$output = Chapter07::run();
$this->assertStringContainsString('データ件数: 100', $output);
$this->assertStringContainsString('外れ値を除いた件数: 99', $output);
$this->assertStringContainsString('訓練データ: 79 件, テストデータ: 20 件', $output);
$this->assertStringContainsString('切片: 6114.60', $output);
$this->assertStringContainsString(
'係数: SNS1=1.3804, SNS2=0.5218, actor=0.2900, original=208.8827',
$output,
);
$this->assertStringContainsString('テストデータの評価: R2=0.6184, MAE=302.20, RMSE=376.14', $output);
}
実データを使うテストには #[Group('data')] を付け、Dataset::exists() が偽なら markTestSkipped します。composer test は --exclude-group data 付きで走るので、データを持たない読者の環境でも検査が通ります。
sprintf('%.4f', …) はロケールに依存しません。PHP の printf 系はロケールの小数点を使わないので、環境によって 6114,60 になることはありません(number_format は違います)。
ほかの言語版との一致¶
この章の数値は、Java 版・Scala 版・Clojure 版・Elixir 版の第 7 章とすべて一致しました。
| 項目 | PHP 版 | Java 版・Scala 版・Clojure 版・Elixir 版 |
|---|---|---|
| データ件数 | 100 | 100 |
| 外れ値を除いた件数 | 99 | 99 |
| 訓練データ・テストデータ | 79 件・20 件 | 79 件・20 件 |
| 切片 | 6114.60 | 6114.60 |
| 係数 | SNS1=1.3804, SNS2=0.5218, actor=0.2900, original=208.8827 | 同じ |
| R²・MAE・RMSE | 0.6184・302.20・376.14 | 同じ |
切片は完全な精度でも 6114.5955056944 で、Elixir 版の 6114.595505694404 と 13 桁目まで一致します。
一致した理由は 2 つです。
- 分割が同じ — 第 2 章で
java.util.Randomと同じ 48 ビットの線形合同法を自作し、Fisher-Yates も同じ手順にそろえました。同じシードなら同じ行が同じ側に入ります - 倍精度で計算している — PHP の
floatは IEEE 754 の倍精度で、JVM のdoubleと同じ表現・同じ丸めです。Nx のように「既定が単精度」という罠がありません。型を書かなくても倍精度なのは、PHP では数値の型が 1 つしかないから です
ただし、「同じ計算をしている」わけではありません。Java 版・Clojure 版はガウスの消去法を自分で書き、Elixir 版は Nx.LinAlg.solve/2 を呼び、PHP 版は MathPHP の LU 分解を呼んでいます。残差平方和の最後の 1 桁が Rubix ML と違っていたのと同じ理由で、表示の桁(小数第 2 位・第 4 位)で一致していることを確かめただけ です。どの桁までの一致を主張しているかは、書く側が意識する必要があります。
係数を読む¶
係数は「その特徴量が 1 増えたとき、ほかの特徴量が同じなら、予測値がどれだけ増えるか」を表します。
original=208.8827: 原作がある映画は、ない映画より興行収入の予測が約 209 大きいSNS1=1.3804・SNS2=0.5218: SNS での反響が 1 増えるごとに、予測が約 1.38・約 0.52 大きくなるactor=0.2900: 主演俳優の露出の指標が 1 増えるごとに、予測が約 0.29 大きくなる
係数の大きさは、特徴量の単位に左右されます。この 4 列は桁が 4 つ以上違うので、係数の大小をそのまま「影響の大きさ」と読むことはできません。影響の大きさを比べるには、標準化してから学習します。標準化は第 9 章で扱います。
評価指標を読む¶
テストデータの決定係数は 0.6184 です。興行収入のばらつきのうち、約 62% をこのモデルで説明できているという意味になります。MAE は 302.20 で、平均すると約 302 の誤差で当たっています。RMSE は 376.14 で、MAE より大きくなっています。RMSE が MAE より大きいのは、大きく外した予測がいくつかある ことを示します。2 乗するので、大きな誤差の影響が強く出るためです。
7.11 品質チェック¶
nix develop .#php の中で、整形・静的解析・テストを順に実行します(第 7 章・第 8 章の分だけを走らせたところ)。リポジトリ全体の検査は npx gulp apps:check:php でまとめて実行します。
$ vendor/bin/php-cs-fixer check --diff
Found 0 of 44 files that can be fixed in 1.527 seconds, 36.00 MB memory used
$ vendor/bin/phpstan analyse --no-progress
[OK] No errors
$ vendor/bin/phpunit --filter 'Chapter07|Chapter08'
OK (71 tests, 181 assertions)
この章で書いたコードのカバレッジは次のとおりです。
| ファイル | 行カバレッジ |
|---|---|
src/Chapter07.php |
99.0%(103 行中 102 行) |
src/LinearModel.php |
100% |
Chapter07.php の残る 1 行は、7.9 節で触れた bias() が null を返す場合の分岐です。train() の直後なので実際には起こりませんが、型としては起こりうるので残してあります。「型のために書いたが実行されない行」がカバレッジに残る のは、null を許す型を持つ言語で必ず出会う小さな摩擦です。@phpstan-ignore で黙らせるよりは、1 行のカバレッジを諦めるほうを選びました。
検査に 3 回止められた¶
array_valuesが無意味 —array_slice($weights, 1)の結果はすでにリストなのにarray_values()で包んでいて、「呼んでも効果がない」と指摘されました(arrayValues.list)。第 2 章では逆に「array_values()で包み直さないと list とは限らない」と言われていたので、どちらが必要かを PHPStan に教わりながら書く ことになりますintとfloatの混ざった配列 —getVector()の戻り値をそのままlist<float>のところに渡していて止まりました。floatval(...)で実際に変換して直しましたmixedを float にキャストできない — Rubix ML のsamples()はlist<list<mixed>>を返すので、(float) $sample[0]が通りませんでした。is_numeric()で確かめてから変換する形に書き直しました。第 8 章で Rubix ML の前処理を触るときにも同じことが起きます
どれも「動くけれど、書き手の思い込みがコードに残っている」場所でした。レベル 9 は、ライブラリの境界で型がゆるむところを正確に突いてきます。
7.12 まとめ¶
この章では、MathPHP の行列を使って正規方程式による線形回帰を PHP の TDD で実装しました。
- 行列を自作しない — MathPHP があるので、Java 版・Clojure 版が書いた行列の積・転置・ガウスの消去法が要らなかった。TODO リストの先頭 3 項目がまるごと消えた。第 3 章の決定木を全部自作したのとちょうど裏返しになる
solve()の解き方を明示する — MathPHP のsolve()は既定で「行列の中身に応じて解き方を選ぶ」。数値を突き合わせる本シリーズではNumericMatrix::LUを明示して固定した- 素の線形回帰が無いので
Ridge(0.0)で代用した — Rubix ML にはLinearRegressionが無い。代用は「リッジの正則化が 0 なら最小二乗である」という定義から決め、答えの分かっているデータと正則化の効き方の 2 本のテストで支えた - 既定値が「何もしない」でない引数は省かない —
new Ridge()のl2Penaltyは 1.0。省くと別のモデルになる - 実データでも一致した — 切片も係数も 12〜13 桁、残差平方和は最後の 1 桁だけの差だった。Rubix ML が自作と同じ正規方程式を
inverse()で解いている ことをソースで確かめた - Elixir 版との対照が立った — Scholar は SVD で解くので最小二乗解に届かなかった。同じ「ライブラリと突き合わせる」章が、ライブラリの解き方の違いで正反対の結末になった。アルゴリズムの理論的な安定性より、その実装の精度のほうが効く場合がある
- リストであり続けるには意識が要る —
array_filterは添字を残す。PHP の配列は「リスト」と「連想配列」が同じ型なので、array_values()で包み直す判断を毎回することになる。ただし 不要な包み直しも PHPStan が指摘する empty()とisset()を避ける — 空の配列は=== []、キーの有無はarray_key_exists()。どちらも「それ以外の値も真(偽)になる」ことがある
TODO リスト(この章の完了時点):
- 残差・MAE・RMSE・決定係数を計算する
- 切片と係数を持つモデルを表す
- 計画行列(先頭が 1 の列)を作る
- 正規方程式を解いて学習する
- 外れ値を取り除く
- 外れ値の除去・分割・補完を 1 つの手順にまとめる
- Rubix ML の Ridge(正則化 0)と突き合わせる
- 実データで学習し、係数と評価指標を表示する
次の章では、欠損値や文字列の列が多いタイタニック号の乗客データを使い、より実践的な分類と前処理パイプラインを作ります。Rubix ML の前処理(MissingDataImputer・OneHotEncoder)とも突き合わせますが、この章とは逆に「ライブラリに口が足りない」場面 に出会います。
PHP 版のほかの章は PHP 版のトップ から辿れます。