校正後の確率の上限が 96% から 99% に。原因は LightGBM で、96% は確率を式に入力したためにできた上限だった
LightGBM の分類器が出す確率を、scikit-learn(以下 sklearn)の CalibratedClassifierCV を使って、深く考えずに校正していました。CalibratedClassifierCV は、確率を実際に正例になる割合に近づける(校正する)ためのクラスです。交差検証を使って、分類器の学習と校正をまとめて行います。依存パッケージを更新したところ、校正後の確率の最大値が 96% から 99% に変わりました。sklearn の更新を疑いましたが、原因は一緒に上がった LightGBM にメソッドが 1 つ増えたことでした。この記事では、その仕組みを公開データで再現しながら、99% になったことを喜んでよいのかも確かめます。
結論
LightGBM 4.6.0
LightGBM 4.7.0
校正器(sigmoid)への入力
predict_proba の確率(0〜1)
decision_function のスコア(0 を境に正例と負例を分ける値。0〜1 に限られない)
校正後の確率の最大値(公開データの例)
0.96 前後で頭打ち
0.99 前後まで出る
確率として良いのはどちらか
必ず良くなるとは限らない。自分のデータの Log loss・Brier で確かめる
←
LightGBM 4.7.0 で LGBMClassifier に decision_function が追加されました。CalibratedClassifierCV は decision_function があればそちらを優先して使うので、校正器への入力が変わりました。
上限がなくなったことと、確率が良くなったことは別の話です。私の個人開発の本番データでは 4.7.0 のほうが良くなりましたが、人工データ(make_classification)では悪くなる例もありました。
はじめに
なぜこの記事を書いたか
Dependabot が作ったバージョンアップのプルリクエスト 1 つで、次の 3 つがまとめて上がりました。
パッケージ
変更前
変更後
scikit-learn
1.8.0
1.9.1
LightGBM
4.6.0
4.7.0
scipy
1.17.1
1.18.1
更新後、校正後の確率の最大値がそれまでの 96% から 99% になっていたことで、変化に気づきました。校正の方法(method)は、デフォルトの "sigmoid" のまま使っていました。
本番データのうちテスト用の 1,113 行について、校正後の確率を小さい順に並べたのが次のグラフです。4.6.0(青)は上限の 0.9604 で頭打ちになり(最大は 0.9602)、4.7.0(オレンジ)は 0.9991 まで上がっています。
本番データ:校正後の確率を小さい順に並べたもの(テストデータ 1,113 行)
3 つがまとめて上がったため、最初は sklearn の更新が影響したのだと考えて調べ始めました。
この記事で解決すること
CalibratedClassifierCV の出力が変わった原因と、その調べ方
LightGBM 4.7.0 で sigmoid 校正の結果が変わる理由と、それが改善かどうかの確かめ方
確率の校正(Calibration)とは
分類モデルには、クラスだけでなく、そのクラスである確率を出せるものがあります。scikit-learn のユーザーガイドでは、predict_proba の出力をそのまま確信度として読める分類器を「よく校正された(well calibrated)」分類器と呼んでいます。たとえば、よく校正された二値分類器なら、predict_proba が 0.8 前後になったサンプルのうち、およそ 80% が実際に正例です。ただし、モデルによっては、確率の見積もりがこれとずれることがあります。
確率の校正(キャリブレーション、Calibration)は、このずれを直す処理です。分類器の出力(decision_function か predict_proba)を、0〜1 の校正済みの確率に変換する回帰モデルを学習します。このモデルを校正器(キャリブレーター、calibrator)と呼びます。校正器は、分類器の学習に使ったデータとは別のデータで学習するのが理想とされています。学習データに対する分類器の出力を使うと、校正器が、本来より 0 や 1 に寄った確率を返すようになるためです。
校正器の入力になる 2 つのメソッドは、scikit-learn の用語集で次のように説明されています。
predict_proba:クラスごとの確率の推定値を返すメソッドです。二値分類と多クラス分類では、各行の合計が 1 になります。二値分類でもクラスごとに 1 列ずつ返し、列は classes_ の順に並びます。後で示すコードの [:, 1] は、正例(ラベル 1)の確率の列です。
decision_function:predict がクラスそのもの(二値分類なら 0 か 1)を返すのに対して、クラスを決める前の連続したスコアを返すメソッドです。二値分類では 1 次元の配列を返し、0 より大きい値が正例を表します。0 を境に正例と負例を分けるので、確率とは違い、0〜1 には収まりません。
CalibratedClassifierCV は、校正器を分類器の学習に使っていないデータで学習するために、交差検証を使います。学習データをいくつかの fold に分け、そのうち 1 つを校正用に取っておき、残りの fold で分類器を学習します。そのあと、校正用の fold での分類器の出力を使って校正器を学習します。これを、校正用にする fold を順に替えながら fold の数だけ繰り返します。たとえば fold が 3 つなら、2 つで分類器を、残りの 1 つで校正器を学習する組が 3 組できます。校正の方法には sigmoid(Platt の方法)、isotonic、temperature scaling があり、この記事で扱うのは sigmoid です。
予測した確率が実際の結果(正例か負例か)にどれだけ近いかを測る指標に、Log loss と Brier スコアがあります。どちらも scikit-learn のユーザーガイドで定義されていて、値が小さいほど良い指標です。正例を 1、負例を 0 とした正解ラベルを y、正例である予測確率を \hat{p} とすると、サンプル 1 つあたりの値は次のとおりで、これをサンプル全体で平均します。
Log loss(交差エントロピー損失とも呼ばれます):-(y \log \hat{p} + (1 - y) \log (1 - \hat{p}))。正解ラベルに対する負の対数尤度で、対数は自然対数です。sklearn.metrics.log_loss で計算できます。
Brier スコア:(y - \hat{p})^2。正解ラベルと予測確率の差の 2 乗で、平均すると平均二乗誤差と同じになります。二値分類では 0〜1 の値を取ります。sklearn.metrics.brier_score_loss で計算できます。
原因の候補1:scikit-learn
まず sklearn を疑い、sklearn だけを 1.8.0 に戻して確かめました。しかし、校正後の確率の最大値は変わりませんでした。1.8.0 と 1.9.1 の sklearn/calibration.py も比べましたが、sigmoid の係数を求める _sigmoid_calibration 関数には差分がありませんでした。
sklearn を元に戻しても変わらなかったので、同じプルリクエストで上がった LightGBM を疑いました。ここからは、公開データで再現しながら確かめます。
検証環境
sklearn に付属するデータと、sklearn で生成したデータで再現しました。
Python:3.13.5
numpy:2.5.4
scipy:1.18.1
scikit-learn:1.8.0 / 1.9.1
LightGBM:4.6.0 / 4.7.0
データ:
breast_cancer:load_breast_cancer() で読み込む、乳がんの診断データ(Breast Cancer Wisconsin (Diagnostic))です。UCI Machine Learning Repository のデータセットのコピーで、sklearn に同梱されているため、ファイルのダウンロードは要りません。569 行、特徴量 30 個の二値分類です。
make_classification:make_classification() で乱数から生成した人工のデータです。どこかから入手したものではありません。引数は n_samples=4000, n_features=40, n_informative=10, flip_y=0.05, random_state=0 です。
sklearn 2 通り × LightGBM 2 通りの 4 つの組み合わせを、それぞれ別の venv に入れて実行しました。どの組み合わせでも、次の同じコードを使っています。
from lightgbm import LGBMClassifier
from sklearn.calibration import CalibratedClassifierCV
from sklearn.datasets import load_breast_cancer
from sklearn.metrics import brier_score_loss, log_loss
from sklearn.model_selection import StratifiedKFold, train_test_split
SEED = 0
X, y = load_breast_cancer(return_X_y=True)
X_tr, X_te, y_tr, y_te = train_test_split(
X, y, test_size=0.3, stratify=y, random_state=SEED
)
folds = list(StratifiedKFold(n_splits=3, shuffle=True, random_state=SEED).split(X_tr, y_tr))
cal = CalibratedClassifierCV(
LGBMClassifier(random_state=SEED, verbose=-1), method="sigmoid", cv=folds
).fit(X_tr, y_tr)
p = cal.predict_proba(X_te)[:, 1]
print(p.max(), log_loss(y_te, p), brier_score_loss(y_te, p))
for cc in cal.calibrated_classifiers_:
print(cc.calibrators[0].a_, cc.calibrators[0].b_)
コードの処理は次のとおりです。
breast_cancer を読み込み、train_test_split で学習用 7 割(398 行)とテスト用 3 割(171 行)に分けます。stratify=y で、正例と負例の割合を学習用とテスト用でそろえています。
StratifiedKFold で学習用のデータを 3 つの fold に分けます。random_state を固定しているので、4 つの組み合わせのどれで実行しても、各 fold に入る行は同じです。このため、結果の違いはデータの分け方ではなく、バージョンの違いから来ていると言えます。
CalibratedClassifierCV で学習します。3 つの fold のうち 1 つを校正用に取っておき、LGBMClassifier を残り 2 つの fold で学習します。そのあと、校正用の fold での出力を使って sigmoid の校正器を学習します。これを、校正用にする fold を替えながら 3 回行います。
テストデータで正例の確率 p を出し、その最大値、Log loss、Brier スコアを表示します。
fold ごとの校正器の係数 a_、b_ を表示します。
make_classification では、1 の読み込みだけを make_classification() に変えています。
calibrated_classifiers_ には、fold ごとに学習した分類器と校正器の組が入っています。a_、b_ は sigmoid の係数で、後で説明する式の A、B にあたります。
原因の候補2:同じタイミングで上がった LightGBM
4 つの組み合わせで実行した、breast_cancer での結果です。
scikit-learn
LightGBM
校正後の最大確率
係数 a(fold 0)
1.8.0
4.6.0
0.9612
-6.31
1.9.1
4.6.0
0.9612
-6.31
1.8.0
4.7.0
0.9897
-0.40
1.9.1
4.7.0
0.9897
-0.40
sklearn のバージョンを変えても、校正後の最大確率、係数、指標、fold の分け方はすべて一致しました。結果を決めているのは LightGBM のバージョンだけです。make_classification でも同じで、4.6.0 では 0.9547、4.7.0 では 0.9957 でした。
breast_cancer のテストデータ 171 行の校正後の確率を、小さい順に並べると次のようになります。本番データと同じく、4.6.0 は 0.9612 で頭打ちになり、4.7.0 は 0.9897 まで上がっています。
breast_cancer:校正後の確率を小さい順に並べたもの(テストデータ 171 行、seed 0)
一方で、校正する前の LightGBM の出力(predict_proba の最大値)は、4.6.0 と 4.7.0 で同じでした(breast_cancer で 0.999984)。モデルそのものではなく、校正器に渡るものが変わったと考えられます。
原因:decision_function の追加
LightGBM の API リファレンスでは、LGBMClassifier.decision_function に「Added in version 4.7.0」とあり、説明は「Return the raw margin score for each sample.」です。v4.7.0 のリリースノートにも「Add decision_function() to LGBMClassifier」があります。
そして、scikit-learn の CalibratedClassifierCV のドキュメントには次のように書かれています。
The calibration is based on the decision_function method of the estimator if it exists, else on predict_proba.
つまり、4.6.0 までは predict_proba の確率が、4.7.0 からは decision_function のスコアが、校正器に渡るようになりました。sklearn は何も変わっていませんが、LightGBM にメソッドが 1 つ増えたことで、sklearn の選ぶ入力が切り替わったわけです。
fold ごとの係数も大きく変わっています(breast_cancer)。
fold
4.6.0 (a, b)
4.7.0 (a, b)
0
(-6.3081, 2.5142)
(-0.4029, -0.4885)
1
(-5.7854, 3.0844)
(-0.4111, 0.4613)
2
(-6.3146, 2.8882)
(-0.4384, -0.3435)
なぜ 96% で止まっていたのか
method="sigmoid" は Platt の方法で、scikit-learn のユーザーガイドでは次の式で説明されています。f は校正する前の分類器の出力、A と B は学習で決める係数です。
P(y = 1 \mid f) = \frac{1}{1 + \exp(A f + B)}
A が負のとき、f が大きいほど確率は 1 に近づきます。ただし、f が 0〜1 の確率なら、1 より大きくはなれません。出力は f = 1 のときの
\frac{1}{1 + \exp(A + B)}
で頭打ちになります。
breast_cancer の 4.6.0 の係数を入れてみます。fold 0 では A + B = -6.3081 + 2.5142 = -3.7939 なので、上限は 1 / (1 + e^{-3.7939}) \approx 0.978 です。同じように fold 1 は 0.937、fold 2 は 0.969 になります。CalibratedClassifierCV は、fold ごとの分類器と校正器の組の確率を平均します(ドキュメントの ensemble の説明。デフォルトの "auto" では、学習済みのモデルを FrozenEstimator で包んだとき以外はこの動作になります)。そのため上限は 3 つの平均の 0.9612 になり、実際に出た最大値の 0.9612 と一致しました。
fold 0 の校正器を図にすると次のとおりです。横軸が校正器への入力 f、縦軸が校正後の確率です。4.6.0 では f が 1 を超えられないので、テストデータの点は f = 1 の手前で止まり、確率は 0.978 より上に行けません。4.7.0 の f は -10.89〜11.11 に広がり、確率は 0.993 まで上がっています。この図は fold 0 の校正器だけを描いているので、上限は 3 つの fold の平均(0.9612)ではなく、fold 0 の 0.978 です。
breast_cancer:fold 0 の校正器の入力 f と校正後の確率
本番データでも同じ形でした。4.6.0 の fold 0 の係数は A = -6.570、B = 3.411 で、f = 1 での上限は 0.959 です。3 つの fold の上限の平均は 0.9604 で、最初のグラフの 4.6.0 の上限と同じ値です。4.7.0 では、fold 0 の確率はテストデータで最大 0.9997 まで上がりました(3 つの fold の平均では 0.9991)。
本番データ:fold 0 の校正器の入力 f と校正後の確率
96% は、モデルが「96% までしか自信を持てなかった」値ではありませんでした。確率をもう一度 sigmoid に通したことで、式の上で決まっていた上限だったのです。
では 99% のほうが良いのか ― 必ず良くなるとは限らない
上限がなくなったなら、4.7.0 のほうが良くなったと言いたくなります。私の本番データでは、実際にテストデータの Log loss が 0.3215 から 0.2933 に、Brier スコアが 0.0988 から 0.0939 に下がり、どちらも 4.7.0 のほうが良い結果でした。
公開データでも比べてみました。テストデータの分け方、fold、LightGBM の random_state を 20 通り(seed 0〜19)に変えて、4.6.0 と 4.7.0 を比べた結果です。値は 20 回の平均で、「4.7.0 が良かった回数」は値が小さくなった回数です。
データ
指標
4.6.0
4.7.0
4.7.0 が良かった回数
breast_cancer
Log loss
0.1291
0.1132
19/20
breast_cancer
Brier
0.0299
0.0288
12/20
make_classification
Log loss
0.2656
0.2712
2/20
make_classification
Brier
0.0749
0.0763
1/20
実データの breast_cancer では、本番データと同じく 4.7.0 が良い結果でした。Log loss では 20 回中 19 回で良くなりましたが、Brier では 12 回で、差ははっきりしません。一方、乱数で作った人工データの make_classification では、4.7.0 が Log loss・Brier とも悪くなりました。上限がなくなれば必ず良くなる、とは言えなさそうです。なお、校正後の最大確率は、20 回とも 4.6.0 で 0.936〜0.972、4.7.0 で 0.9745〜0.9977 でした。
上限がなくなったことは、確率が良くなったことを意味しません。確率が良くなったかどうかは、自分のデータの Log loss や Brier スコアで比べて決めることになります。ただし、scikit-learn のユーザーガイドによると、Log loss や Brier スコアは、校正の良さだけでなく、クラスを見分ける力やデータのばらつきもまとめて表す指標です。そのため、値が小さいほど校正が良いとは限りません。
本番では、コードは変えずに LightGBM 4.7.0 のまま、校正も続けることにしました。本番データでは 4.7.0 のほうが Log loss・Brier とも良く、4.7.0 で校正あり・なしを比べても、校正したほうがわずかに良かったためです。
まとめ
LightGBM 4.6.0
LightGBM 4.7.0
校正器(sigmoid)への入力
predict_proba の確率(0〜1)
decision_function のスコア(0 を境に正例と負例を分ける値。0〜1 に限られない)
校正後の確率の最大値(公開データの例)
0.96 前後で頭打ち
0.99 前後まで出る
確率として良いのはどちらか
必ず良くなるとは限らない。自分のデータの Log loss・Brier で確かめる
←
あまり深く考えずに CalibratedClassifierCV で校正していたのですが、見直す良い機会になりました。
参考
lightgbm.LGBMClassifier — LightGBM documentation
Release v4.7.0 · lightgbm-org/LightGBM
CalibratedClassifierCV — scikit-learn documentation
Probability calibration — scikit-learn documentation
Metrics and scoring: quantifying the quality of predictions — scikit-learn documentation
Glossary of Common Terms and API Elements — scikit-learn documentation
load_breast_cancer — scikit-learn documentation
make_classification — scikit-learn documentation
Breast Cancer Wisconsin (Diagnostic) - UCI Machine Learning Repository
Reverse Engineering Block Blast
Block Blast looks like a puzzle game. A decompile of the app suggests it's closer to a per-player ad pricing engine with a puzzle attached. Three tree-based models run on your phone, a bandit experiment tunes when you lose, and a rule decides your next three pieces based on what ad is sitting in memory.
The Problem It's Solving
The game is free and has no ad-free purchase, so revenue depends on rewarded and full-screen ads. Sensor Tower estimates put its ad revenue at roughly $127 million for January to May 2026, ahead of Candy Crush Saga at $81.1 million.
Ad value varies a lot per player (country, device, history), and the best moment to show a rewarded ad is right after a loss. Making that work needs per-player prediction, per-deal control of the pieces, and the ability to change both without shipping an app update.
How the System Is Built
The research is Ayo Omolewa's breakdown of Android 10.8.2 and iOS 7.4.9, using jadx and Ghidra. The store APK holds very little. Logic ships as "traits" (modules with variants called arms), and at launch the server tells the app which arm of which trait you get. His config named about 700 traits, selected 254, and 101 were fetched after install as XXTEA-encrypted, Brotli-compressed JavaScript. Two players can run different games, and the studio can change yours silently.
The Models
| Model | Size and inputs | What it decides |
|---|---|---|
| XGBoost regression (xgboost_regression_model111.json) | 59 trees, depth up to 8, 27 inputs (18 about ads) | finalEcpm: your estimated ad value |
| XGBoost classifier (predict_travel_difficulty_dead) | 500 trees, 52 inputs | Where in a level the lethal puzzle lands |
| LightGBM | 1,000 trees, 93 inputs | Whether you'll be less active tomorrow |
The ad value model. The inputs are mostly money-related: prices of ads loaded today and yesterday, country, phone model, RAM, install date, and how your first game went. The trees lean hardest on first score, latest ad price, RAM, phone model and country. The inference code is a plain loop: start each tree at the root, go left or right on a feature threshold (missing values follow a default direction), add the leaf weight to a running sum starting at the base score. Omolewa's port reproduces the game's outputs to the last decimal. Changing one input on a made-up US player on an iPhone 15 gives:
| Change | Ad value (eCPM) |
|---|---|
| Baseline (US, first score 1,000) | 25.48 |
| Brazil | 8.08 |
| India | 5.59 |
| First score 500 / 2,000 / 10,000 | 25.79 / 21.12 / 20.34 |
The same US baseline on Android scores 48.96. The output feeds other rules. One compares your rewarded and full-screen prices and, if rewarded is worth at least twice full-screen in the shipped version, grants an extra revive (which plays a rewarded ad). Others add a full-screen ad when your price is high or skip full-screen ads when you're worth almost nothing.
The "where do you lose" model. The game scores 12 candidate options (4 values of a sw_process setting times 3 difficulty levels) and keeps the argmax. Inputs are mostly about your history: current level, attempts, games finished this week, time since install. The winning sw_process maps to a stretch of the level (first 30%, 30 to 80%, last 20%). On reaching it, players who usually accept revives (above 75%) get an extra one, and from the fourth deal after that the game requests the lethal puzzle. Other rules read the same pick the opposite way and make the level easier when it lands on the fourth option.
The churn model. If the LightGBM score is 0.69 or higher, a pink revive button replaces the normal one and skips the ad, once a day. It leans on app age, high score, RAM and average score.
Server-side models. Rules also call models named for churn, LTV, revive-click and xDeepFM. Those weights never reach the phone, so the only visible part is the request. One call to /v1/ecpm_prediction sends 40 fields, including carrier, storage, hour of day and recent ad prices.
The Rules That Act on the Predictions
These are hand-written, not learned.
RewardHighValueAdHarvestTrait fires when you finished your last three rewarded ads, the ad currently loaded is worth at least 3.5 times your last one (read from AppLovin MAX eCPM), and you're 1,000 points past your high score (500 or 750 in other arms). It then requests generator ZHI_SI_TI. The generator picks two pieces that fit and one that doesn't, searches every order and placement including line clears, and keeps the set only if nothing works. The code labels the pieces "lethal puzzle" in Chinese.
PersonalMarginBreakTrait gives every deal a 20% chance of the same hand once you're 10% past your record. MultiClearBoostThenKillTrait sends your user ID to two server endpoints, and for the tiers labelled "not sensitive to being forced to die," waits for a big clear, then requests the lethal puzzle a few rounds later once the game reaches roughly your usual length. FastHandPunishTrait asks for hard pieces when your last six sets averaged under 4.2 seconds and you're past 93% of your high score. MaturityDevelopmentV2Trait scores habit, skill and ad history (10.98, 14.66 and 19.27 in the live test), and when the total (44.91) is under 65 it swaps hard puzzles for easy fills. AdPlusMinusStrategyTrait adds a random bonus of up to 10% of your high score after one type of revive.
MABXiBaiTrait is a six-arm experiment on how soon the lethal puzzle may be requested: never, or after 4, 6, 10, about 17 or 30 minutes. Per arm it logs whether you started a new game within 15 minutes and the share of revive offers you accepted.
Why This Is a Bigger Deal Than It Looks
The ML itself is cheap: tree ensembles walked in a loop over JSON, no GPU, runs on a low-end phone and offline. The engineering is the delivery layer. Server-assigned arms, encrypted hot-loaded JavaScript and per-player A/B tests make this a feature-flag platform with bandit tuning, which is why it can be changed daily. Because the lethal-puzzle rule needs a rewarded ad loaded, it can't fire offline or with a phone-wide DNS blocker, while ad-independent rules keep running.
Apple permits JavaScript updates that don't change what the app is. Whether this qualifies is Apple's call.
The usual caveat applies: this is one researcher's analysis. mobilegamer.biz reported no reply from Hungry Studio, and TechRadar noted that independent verification matters. The evidence bundles (decoded rules, test scripts, hashes) are public at Ayoships if you want to run the checks yourself.
Availability and Access
The game is on Google Play and iOS. The decryption key and the steps for getting past the apps' protection aren't published.
If a free puzzle game ships three on-device models and a bandit experiment, the real question for developers isn't whether consumer apps use ML. It's how much of their behavior now lives in a config server instead of the binary.
Follow for more coverage on MCP, agentic AI, and AI infrastructure.
Inspired to write this by @StompnWorldfan's video on YouTube: watch it here.
7 reactions
Mentions
校正後の確率の上限が 96% から 99% に。原因は LightGBM で、96% は確率を式に入力したためにできた上限だった LightGBM の分類器が出す確率を、scikit-learn(以下 sklearn)の CalibratedClassifierCV を使って、深く考えずに校正していました。CalibratedClassifierCV は、確率を実際に正例になる割合に近づける(校正する)ためのクラスです。交差検証を使って、分類器の学習と校正をまとめて行います。依存パッケージを更新したところ、校正後の確率の最大値が 96% から 99% に変わりました。sklearn の更新を疑いましたが、原因は一緒に上がった LightGBM にメソッドが 1 つ増えたことでした。この記事では、その仕組みを公開データで再現しながら、99% になったことを喜んでよいのかも確かめます。 結論 LightGBM 4.6.0 LightGBM 4.7.0 校正器(sigmoid)への入力 predict_proba の確率(0〜1) decision_function のスコア(0 を境に正例と負例を分ける値。0〜1 に限られない) 校正後の確率の最大値(公開データの例) 0.96 前後で頭打ち 0.99 前後まで出る 確率として良いのはどちらか 必ず良くなるとは限らない。自分のデータの Log loss・Brier で確かめる ← LightGBM 4.7.0 で LGBMClassifier に decision_function が追加されました。CalibratedClassifierCV は decision_function があればそちらを優先して使うので、校正器への入力が変わりました。 上限がなくなったことと、確率が良くなったことは別の話です。私の個人開発の本番データでは 4.7.0 のほうが良くなりましたが、人工データ(make_classification)では悪くなる例もありました。 はじめに なぜこの記事を書いたか Dependabot が作ったバージョンアップのプルリクエスト 1 つで、次の 3 つがまとめて上がりました。 パッケージ 変更前 変更後 scikit-learn 1.8.0 1.9.1 LightGBM 4.6.0 4.7.0 scipy 1.17.1 1.18.1 更新後、校正後の確率の最大値がそれまでの 96% から 99% になっていたことで、変化に気づきました。校正の方法(method)は、デフォルトの "sigmoid" のまま使っていました。 本番データのうちテスト用の 1,113 行について、校正後の確率を小さい順に並べたのが次のグラフです。4.6.0(青)は上限の 0.9604 で頭打ちになり(最大は 0.9602)、4.7.0(オレンジ)は 0.9991 まで上がっています。 本番データ:校正後の確率を小さい順に並べたもの(テストデータ 1,113 行) 3 つがまとめて上がったため、最初は sklearn の更新が影響したのだと考えて調べ始めました。 この記事で解決すること CalibratedClassifierCV の出力が変わった原因と、その調べ方 LightGBM 4.7.0 で sigmoid 校正の結果が変わる理由と、それが改善かどうかの確かめ方 確率の校正(Calibration)とは 分類モデルには、クラスだけでなく、そのクラスである確率を出せるものがあります。scikit-learn のユーザーガイドでは、predict_proba の出力をそのまま確信度として読める分類器を「よく校正された(well calibrated)」分類器と呼んでいます。たとえば、よく校正された二値分類器なら、predict_proba が 0.8 前後になったサンプルのうち、およそ 80% が実際に正例です。ただし、モデルによっては、確率の見積もりがこれとずれることがあります。 確率の校正(キャリブレーション、Calibration)は、このずれを直す処理です。分類器の出力(decision_function か predict_proba)を、0〜1 の校正済みの確率に変換する回帰モデルを学習します。このモデルを校正器(キャリブレーター、calibrator)と呼びます。校正器は、分類器の学習に使ったデータとは別のデータで学習するのが理想とされています。学習データに対する分類器の出力を使うと、校正器が、本来より 0 や 1 に寄った確率を返すようになるためです。 校正器の入力になる 2 つのメソッドは、scikit-learn の用語集で次のように説明されています。 predict_proba:クラスごとの確率の推定値を返すメソッドです。二値分類と多クラス分類では、各行の合計が 1 になります。二値分類でもクラスごとに 1 列ずつ返し、列は classes_ の順に並びます。後で示すコードの [:, 1] は、正例(ラベル 1)の確率の列です。 decision_function:predict がクラスそのもの(二値分類なら 0 か 1)を返すのに対して、クラスを決める前の連続したスコアを返すメソッドです。二値分類では 1 次元の配列を返し、0 より大きい値が正例を表します。0 を境に正例と負例を分けるので、確率とは違い、0〜1 には収まりません。 CalibratedClassifierCV は、校正器を分類器の学習に使っていないデータで学習するために、交差検証を使います。学習データをいくつかの fold に分け、そのうち 1 つを校正用に取っておき、残りの fold で分類器を学習します。そのあと、校正用の fold での分類器の出力を使って校正器を学習します。これを、校正用にする fold を順に替えながら fold の数だけ繰り返します。たとえば fold が 3 つなら、2 つで分類器を、残りの 1 つで校正器を学習する組が 3 組できます。校正の方法には sigmoid(Platt の方法)、isotonic、temperature scaling があり、この記事で扱うのは sigmoid です。 予測した確率が実際の結果(正例か負例か)にどれだけ近いかを測る指標に、Log loss と Brier スコアがあります。どちらも scikit-learn のユーザーガイドで定義されていて、値が小さいほど良い指標です。正例を 1、負例を 0 とした正解ラベルを y、正例である予測確率を \hat{p} とすると、サンプル 1 つあたりの値は次のとおりで、これをサンプル全体で平均します。 Log loss(交差エントロピー損失とも呼ばれます):-(y \log \hat{p} + (1 - y) \log (1 - \hat{p}))。正解ラベルに対する負の対数尤度で、対数は自然対数です。sklearn.metrics.log_loss で計算できます。 Brier スコア:(y - \hat{p})^2。正解ラベルと予測確率の差の 2 乗で、平均すると平均二乗誤差と同じになります。二値分類では 0〜1 の値を取ります。sklearn.metrics.brier_score_loss で計算できます。 原因の候補1:scikit-learn まず sklearn を疑い、sklearn だけを 1.8.0 に戻して確かめました。しかし、校正後の確率の最大値は変わりませんでした。1.8.0 と 1.9.1 の sklearn/calibration.py も比べましたが、sigmoid の係数を求める _sigmoid_calibration 関数には差分がありませんでした。 sklearn を元に戻しても変わらなかったので、同じプルリクエストで上がった LightGBM を疑いました。ここからは、公開データで再現しながら確かめます。 検証環境 sklearn に付属するデータと、sklearn で生成したデータで再現しました。 Python:3.13.5 numpy:2.5.4 scipy:1.18.1 scikit-learn:1.8.0 / 1.9.1 LightGBM:4.6.0 / 4.7.0 データ: breast_cancer:load_breast_cancer() で読み込む、乳がんの診断データ(Breast Cancer Wisconsin (Diagnostic))です。UCI Machine Learning Repository のデータセットのコピーで、sklearn に同梱されているため、ファイルのダウンロードは要りません。569 行、特徴量 30 個の二値分類です。 make_classification:make_classification() で乱数から生成した人工のデータです。どこかから入手したものではありません。引数は n_samples=4000, n_features=40, n_informative=10, flip_y=0.05, random_state=0 です。 sklearn 2 通り × LightGBM 2 通りの 4 つの組み合わせを、それぞれ別の venv に入れて実行しました。どの組み合わせでも、次の同じコードを使っています。 from lightgbm import LGBMClassifier from sklearn.calibration import CalibratedClassifierCV from sklearn.datasets import load_breast_cancer from sklearn.metrics import brier_score_loss, log_loss from sklearn.model_selection import StratifiedKFold, train_test_split SEED = 0 X, y = load_breast_cancer(return_X_y=True) X_tr, X_te, y_tr, y_te = train_test_split( X, y, test_size=0.3, stratify=y, random_state=SEED ) folds = list(StratifiedKFold(n_splits=3, shuffle=True, random_state=SEED).split(X_tr, y_tr)) cal = CalibratedClassifierCV( LGBMClassifier(random_state=SEED, verbose=-1), method="sigmoid", cv=folds ).fit(X_tr, y_tr) p = cal.predict_proba(X_te)[:, 1] print(p.max(), log_loss(y_te, p), brier_score_loss(y_te, p)) for cc in cal.calibrated_classifiers_: print(cc.calibrators[0].a_, cc.calibrators[0].b_) コードの処理は次のとおりです。 breast_cancer を読み込み、train_test_split で学習用 7 割(398 行)とテスト用 3 割(171 行)に分けます。stratify=y で、正例と負例の割合を学習用とテスト用でそろえています。 StratifiedKFold で学習用のデータを 3 つの fold に分けます。random_state を固定しているので、4 つの組み合わせのどれで実行しても、各 fold に入る行は同じです。このため、結果の違いはデータの分け方ではなく、バージョンの違いから来ていると言えます。 CalibratedClassifierCV で学習します。3 つの fold のうち 1 つを校正用に取っておき、LGBMClassifier を残り 2 つの fold で学習します。そのあと、校正用の fold での出力を使って sigmoid の校正器を学習します。これを、校正用にする fold を替えながら 3 回行います。 テストデータで正例の確率 p を出し、その最大値、Log loss、Brier スコアを表示します。 fold ごとの校正器の係数 a_、b_ を表示します。 make_classification では、1 の読み込みだけを make_classification() に変えています。 calibrated_classifiers_ には、fold ごとに学習した分類器と校正器の組が入っています。a_、b_ は sigmoid の係数で、後で説明する式の A、B にあたります。 原因の候補2:同じタイミングで上がった LightGBM 4 つの組み合わせで実行した、breast_cancer での結果です。 scikit-learn LightGBM 校正後の最大確率 係数 a(fold 0) 1.8.0 4.6.0 0.9612 -6.31 1.9.1 4.6.0 0.9612 -6.31 1.8.0 4.7.0 0.9897 -0.40 1.9.1 4.7.0 0.9897 -0.40 sklearn のバージョンを変えても、校正後の最大確率、係数、指標、fold の分け方はすべて一致しました。結果を決めているのは LightGBM のバージョンだけです。make_classification でも同じで、4.6.0 では 0.9547、4.7.0 では 0.9957 でした。 breast_cancer のテストデータ 171 行の校正後の確率を、小さい順に並べると次のようになります。本番データと同じく、4.6.0 は 0.9612 で頭打ちになり、4.7.0 は 0.9897 まで上がっています。 breast_cancer:校正後の確率を小さい順に並べたもの(テストデータ 171 行、seed 0) 一方で、校正する前の LightGBM の出力(predict_proba の最大値)は、4.6.0 と 4.7.0 で同じでした(breast_cancer で 0.999984)。モデルそのものではなく、校正器に渡るものが変わったと考えられます。 原因:decision_function の追加 LightGBM の API リファレンスでは、LGBMClassifier.decision_function に「Added in version 4.7.0」とあり、説明は「Return the raw margin score for each sample.」です。v4.7.0 のリリースノートにも「Add decision_function() to LGBMClassifier」があります。 そして、scikit-learn の CalibratedClassifierCV のドキュメントには次のように書かれています。 The calibration is based on the decision_function method of the estimator if it exists, else on predict_proba. つまり、4.6.0 までは predict_proba の確率が、4.7.0 からは decision_function のスコアが、校正器に渡るようになりました。sklearn は何も変わっていませんが、LightGBM にメソッドが 1 つ増えたことで、sklearn の選ぶ入力が切り替わったわけです。 fold ごとの係数も大きく変わっています(breast_cancer)。 fold 4.6.0 (a, b) 4.7.0 (a, b) 0 (-6.3081, 2.5142) (-0.4029, -0.4885) 1 (-5.7854, 3.0844) (-0.4111, 0.4613) 2 (-6.3146, 2.8882) (-0.4384, -0.3435) なぜ 96% で止まっていたのか method="sigmoid" は Platt の方法で、scikit-learn のユーザーガイドでは次の式で説明されています。f は校正する前の分類器の出力、A と B は学習で決める係数です。 P(y = 1 \mid f) = \frac{1}{1 + \exp(A f + B)} A が負のとき、f が大きいほど確率は 1 に近づきます。ただし、f が 0〜1 の確率なら、1 より大きくはなれません。出力は f = 1 のときの \frac{1}{1 + \exp(A + B)} で頭打ちになります。 breast_cancer の 4.6.0 の係数を入れてみます。fold 0 では A + B = -6.3081 + 2.5142 = -3.7939 なので、上限は 1 / (1 + e^{-3.7939}) \approx 0.978 です。同じように fold 1 は 0.937、fold 2 は 0.969 になります。CalibratedClassifierCV は、fold ごとの分類器と校正器の組の確率を平均します(ドキュメントの ensemble の説明。デフォルトの "auto" では、学習済みのモデルを FrozenEstimator で包んだとき以外はこの動作になります)。そのため上限は 3 つの平均の 0.9612 になり、実際に出た最大値の 0.9612 と一致しました。 fold 0 の校正器を図にすると次のとおりです。横軸が校正器への入力 f、縦軸が校正後の確率です。4.6.0 では f が 1 を超えられないので、テストデータの点は f = 1 の手前で止まり、確率は 0.978 より上に行けません。4.7.0 の f は -10.89〜11.11 に広がり、確率は 0.993 まで上がっています。この図は fold 0 の校正器だけを描いているので、上限は 3 つの fold の平均(0.9612)ではなく、fold 0 の 0.978 です。 breast_cancer:fold 0 の校正器の入力 f と校正後の確率 本番データでも同じ形でした。4.6.0 の fold 0 の係数は A = -6.570、B = 3.411 で、f = 1 での上限は 0.959 です。3 つの fold の上限の平均は 0.9604 で、最初のグラフの 4.6.0 の上限と同じ値です。4.7.0 では、fold 0 の確率はテストデータで最大 0.9997 まで上がりました(3 つの fold の平均では 0.9991)。 本番データ:fold 0 の校正器の入力 f と校正後の確率 96% は、モデルが「96% までしか自信を持てなかった」値ではありませんでした。確率をもう一度 sigmoid に通したことで、式の上で決まっていた上限だったのです。 では 99% のほうが良いのか ― 必ず良くなるとは限らない 上限がなくなったなら、4.7.0 のほうが良くなったと言いたくなります。私の本番データでは、実際にテストデータの Log loss が 0.3215 から 0.2933 に、Brier スコアが 0.0988 から 0.0939 に下がり、どちらも 4.7.0 のほうが良い結果でした。 公開データでも比べてみました。テストデータの分け方、fold、LightGBM の random_state を 20 通り(seed 0〜19)に変えて、4.6.0 と 4.7.0 を比べた結果です。値は 20 回の平均で、「4.7.0 が良かった回数」は値が小さくなった回数です。 データ 指標 4.6.0 4.7.0 4.7.0 が良かった回数 breast_cancer Log loss 0.1291 0.1132 19/20 breast_cancer Brier 0.0299 0.0288 12/20 make_classification Log loss 0.2656 0.2712 2/20 make_classification Brier 0.0749 0.0763 1/20 実データの breast_cancer では、本番データと同じく 4.7.0 が良い結果でした。Log loss では 20 回中 19 回で良くなりましたが、Brier では 12 回で、差ははっきりしません。一方、乱数で作った人工データの make_classification では、4.7.0 が Log loss・Brier とも悪くなりました。上限がなくなれば必ず良くなる、とは言えなさそうです。なお、校正後の最大確率は、20 回とも 4.6.0 で 0.936〜0.972、4.7.0 で 0.9745〜0.9977 でした。 上限がなくなったことは、確率が良くなったことを意味しません。確率が良くなったかどうかは、自分のデータの Log loss や Brier スコアで比べて決めることになります。ただし、scikit-learn のユーザーガイドによると、Log loss や Brier スコアは、校正の良さだけでなく、クラスを見分ける力やデータのばらつきもまとめて表す指標です。そのため、値が小さいほど校正が良いとは限りません。 本番では、コードは変えずに LightGBM 4.7.0 のまま、校正も続けることにしました。本番データでは 4.7.0 のほうが Log loss・Brier とも良く、4.7.0 で校正あり・なしを比べても、校正したほうがわずかに良かったためです。 まとめ LightGBM 4.6.0 LightGBM 4.7.0 校正器(sigmoid)への入力 predict_proba の確率(0〜1) decision_function のスコア(0 を境に正例と負例を分ける値。0〜1 に限られない) 校正後の確率の最大値(公開データの例) 0.96 前後で頭打ち 0.99 前後まで出る 確率として良いのはどちらか 必ず良くなるとは限らない。自分のデータの Log loss・Brier で確かめる ← あまり深く考えずに CalibratedClassifierCV で校正していたのですが、見直す良い機会になりました。 参考 lightgbm.LGBMClassifier — LightGBM documentation Release v4.7.0 · lightgbm-org/LightGBM CalibratedClassifierCV — scikit-learn documentation Probability calibration — scikit-learn documentation Metrics and scoring: quantifying the quality of predictions — scikit-learn documentation Glossary of Common Terms and API Elements — scikit-learn documentation load_breast_cancer — scikit-learn documentation make_classification — scikit-learn documentation Breast Cancer Wisconsin (Diagnostic) - UCI Machine Learning Repository
Reverse Engineering Block Blast Block Blast looks like a puzzle game. A decompile of the app suggests it's closer to a per-player ad pricing engine with a puzzle attached. Three tree-based models run on your phone, a bandit experiment tunes when you lose, and a rule decides your next three pieces based on what ad is sitting in memory. The Problem It's Solving The game is free and has no ad-free purchase, so revenue depends on rewarded and full-screen ads. Sensor Tower estimates put its ad revenue at roughly $127 million for January to May 2026, ahead of Candy Crush Saga at $81.1 million. Ad value varies a lot per player (country, device, history), and the best moment to show a rewarded ad is right after a loss. Making that work needs per-player prediction, per-deal control of the pieces, and the ability to change both without shipping an app update. How the System Is Built The research is Ayo Omolewa's breakdown of Android 10.8.2 and iOS 7.4.9, using jadx and Ghidra. The store APK holds very little. Logic ships as "traits" (modules with variants called arms), and at launch the server tells the app which arm of which trait you get. His config named about 700 traits, selected 254, and 101 were fetched after install as XXTEA-encrypted, Brotli-compressed JavaScript. Two players can run different games, and the studio can change yours silently. The Models | Model | Size and inputs | What it decides | |---|---|---| | XGBoost regression (xgboost_regression_model111.json) | 59 trees, depth up to 8, 27 inputs (18 about ads) | finalEcpm: your estimated ad value | | XGBoost classifier (predict_travel_difficulty_dead) | 500 trees, 52 inputs | Where in a level the lethal puzzle lands | | LightGBM | 1,000 trees, 93 inputs | Whether you'll be less active tomorrow | The ad value model. The inputs are mostly money-related: prices of ads loaded today and yesterday, country, phone model, RAM, install date, and how your first game went. The trees lean hardest on first score, latest ad price, RAM, phone model and country. The inference code is a plain loop: start each tree at the root, go left or right on a feature threshold (missing values follow a default direction), add the leaf weight to a running sum starting at the base score. Omolewa's port reproduces the game's outputs to the last decimal. Changing one input on a made-up US player on an iPhone 15 gives: | Change | Ad value (eCPM) | |---|---| | Baseline (US, first score 1,000) | 25.48 | | Brazil | 8.08 | | India | 5.59 | | First score 500 / 2,000 / 10,000 | 25.79 / 21.12 / 20.34 | The same US baseline on Android scores 48.96. The output feeds other rules. One compares your rewarded and full-screen prices and, if rewarded is worth at least twice full-screen in the shipped version, grants an extra revive (which plays a rewarded ad). Others add a full-screen ad when your price is high or skip full-screen ads when you're worth almost nothing. The "where do you lose" model. The game scores 12 candidate options (4 values of a sw_process setting times 3 difficulty levels) and keeps the argmax. Inputs are mostly about your history: current level, attempts, games finished this week, time since install. The winning sw_process maps to a stretch of the level (first 30%, 30 to 80%, last 20%). On reaching it, players who usually accept revives (above 75%) get an extra one, and from the fourth deal after that the game requests the lethal puzzle. Other rules read the same pick the opposite way and make the level easier when it lands on the fourth option. The churn model. If the LightGBM score is 0.69 or higher, a pink revive button replaces the normal one and skips the ad, once a day. It leans on app age, high score, RAM and average score. Server-side models. Rules also call models named for churn, LTV, revive-click and xDeepFM. Those weights never reach the phone, so the only visible part is the request. One call to /v1/ecpm_prediction sends 40 fields, including carrier, storage, hour of day and recent ad prices. The Rules That Act on the Predictions These are hand-written, not learned. RewardHighValueAdHarvestTrait fires when you finished your last three rewarded ads, the ad currently loaded is worth at least 3.5 times your last one (read from AppLovin MAX eCPM), and you're 1,000 points past your high score (500 or 750 in other arms). It then requests generator ZHI_SI_TI. The generator picks two pieces that fit and one that doesn't, searches every order and placement including line clears, and keeps the set only if nothing works. The code labels the pieces "lethal puzzle" in Chinese. PersonalMarginBreakTrait gives every deal a 20% chance of the same hand once you're 10% past your record. MultiClearBoostThenKillTrait sends your user ID to two server endpoints, and for the tiers labelled "not sensitive to being forced to die," waits for a big clear, then requests the lethal puzzle a few rounds later once the game reaches roughly your usual length. FastHandPunishTrait asks for hard pieces when your last six sets averaged under 4.2 seconds and you're past 93% of your high score. MaturityDevelopmentV2Trait scores habit, skill and ad history (10.98, 14.66 and 19.27 in the live test), and when the total (44.91) is under 65 it swaps hard puzzles for easy fills. AdPlusMinusStrategyTrait adds a random bonus of up to 10% of your high score after one type of revive. MABXiBaiTrait is a six-arm experiment on how soon the lethal puzzle may be requested: never, or after 4, 6, 10, about 17 or 30 minutes. Per arm it logs whether you started a new game within 15 minutes and the share of revive offers you accepted. Why This Is a Bigger Deal Than It Looks The ML itself is cheap: tree ensembles walked in a loop over JSON, no GPU, runs on a low-end phone and offline. The engineering is the delivery layer. Server-assigned arms, encrypted hot-loaded JavaScript and per-player A/B tests make this a feature-flag platform with bandit tuning, which is why it can be changed daily. Because the lethal-puzzle rule needs a rewarded ad loaded, it can't fire offline or with a phone-wide DNS blocker, while ad-independent rules keep running. Apple permits JavaScript updates that don't change what the app is. Whether this qualifies is Apple's call. The usual caveat applies: this is one researcher's analysis. mobilegamer.biz reported no reply from Hungry Studio, and TechRadar noted that independent verification matters. The evidence bundles (decoded rules, test scripts, hashes) are public at Ayoships if you want to run the checks yourself. Availability and Access The game is on Google Play and iOS. The decryption key and the steps for getting past the apps' protection aren't published. If a free puzzle game ships three on-device models and a bandit experiment, the real question for developers isn't whether consumer apps use ML. It's how much of their behavior now lives in a config server instead of the binary. Follow for more coverage on MCP, agentic AI, and AI infrastructure. Inspired to write this by @StompnWorldfan's video on YouTube: watch it here.