precisionとrecallには、対応する閾値を持たない最後の1点が加わります。 閾値ごとの表を作るなら両方の末尾を除き、PR曲線を描くなら両方をそのまま組み合わせます。エラーを消すためだけに、thresholdsへ0や1を追加しないでください。

対象は、scikit-learnで分類の評価を学び、配列をDataFrameやグラフへ渡す際に長さが合わなくなった人です。配列のスライスと、適合率・再現率の基本を前提にします。式を忘れていても、本文の8行から件数を数えて確認できます。

公式資料と掲載例の確認日は2026年11月3日です。scikit-learn 1.9.1のAPIに照合します。データは架空の色カード8枚と、手で用意したスコアです。モデルの学習結果やスクールの実績ではなく、人の診断・選考・融資などを判定する例でもありません。外部データの取得やAPIの呼び出しは行いません。

precision・recallとthresholdsが1要素違う理由を確認する

何を陽性と呼ぶかと、スコアの向きを先にそろえる

この教材では赤いカードをred、青いカードをblueと呼び、赤を拾う側として評価します。スコアは大きいほど赤として扱いたい、という約束です。0.9だから実際に9割の確率で赤になる、といった校正の意味は持たせていません。

import numpy as np
import pandas as pd
from sklearn.metrics import precision_recall_curve

cards = pd.DataFrame({
    "id": [f"K{i:02d}" for i in range(1, 9)],
    "truth": ["red", "blue", "red", "red", "blue", "red", "blue", "blue"],
    "score_red": [0.9, 0.8, 0.8, 0.6, 0.4, 0.4, 0.2, 0.1],
}).set_index("id")
assert len(cards) == 8
assert (cards["truth"] == "red").sum() == 4
assert cards["score_red"].nunique() == 6
assert cards.index.is_unique
print(cards)

公式のprecision_recall_curveは二値分類を対象にしています。文字列ラベルを使う本稿では、pos_label="red"を指定します。どちらを陽性にするかは、列の順番やラベルの見た目から推測しません。

手作りの色カードとスコア。モデル性能の測定値ではありません
ID本当の色赤として使うスコア
K01red0.9
K02 / K03blue / red0.8 / 0.8
K04red0.6
K05 / K06blue / red0.4 / 0.4
K07 / K08blue / blue0.2 / 0.1

実際のモデルでpredict_probaを使う場合は、赤に対応する列をclasses_から確認する工程が先に必要です。青のスコアを渡したまま、陽性ラベルだけ赤にしても、意図した比較にはなりません。本稿はその前工程を省略するため、score_redという列名と意味を固定しています。

また、関数へ渡せるスコアは、確率の推定値だけに限られません。公式資料にはdecision_functionなどの、しきい値を適用する前のスコアも案内されています。そのため、自分の出力の意味を調べずに「0〜1の範囲外だから全て削る」という前処理を入れないようにします。本稿が0〜1の値を使うのは、8枚の計算を追いやすくするためです。

ラベルとスコアには別の役割があります。赤か青かという最終判定だけをスコアの代わりに渡すと、元の細かい順序を使えません。先に0.5で二値化してから曲線を作っていた場合は、その入力が何種類の値を持つかを調べ、評価したいスコアと合っているかを確認してください。

返ってきた3配列を、別々に数えて表示する

最初から一つの表へまとめず、三つの配列の長さを表示します。エラー文だけを見るより、どの組み合わせが合わないのかを確認できます。列名の取り違えや、別の実行結果を混ぜた可能性も先に排除します。

precision, recall, thresholds = precision_recall_curve(
    cards["truth"], cards["score_red"],
    pos_label="red", drop_intermediate=False
)
assert (len(precision), len(recall), len(thresholds)) == (7, 7, 6)
np.testing.assert_allclose(thresholds, [0.1, 0.2, 0.4, 0.6, 0.8, 0.9])
assert precision[-1] == 1.0
assert recall[-1] == 0.0
print("lengths", len(precision), len(recall), len(thresholds))

この入力では8行ありますが、スコアは6種類です。0.8と0.4がそれぞれ2回現れるため、8個の別々の閾値ができるわけではありません。行数、異なるスコアの数、曲線の点数の三つを分けて記録します。

また、ここではdrop_intermediate=Falseを明示しています。後で途中の点を間引く設定へ変えた場合まで、閾値数が常に元のスコアの種類数と同じだと覚えないようにしてください。「今回の設定と入力では6」という確認です。

配列の長さが想定と違ったら、正解ラベルとスコアの元の行数も見ます。読み込みや抽出の段階で片方だけ行を除外していれば、今回の末尾1点とは別の問題です。最初に入力同士のIDと行数をそろえ、その後で出力配列を確認します。

Notebookで何度も実行している場合は、配列を作ったセルと、表を作ったセルの実行順も確認します。以前の入力から作ったthresholdsと、新しい入力から作ったprecisionを混ぜると、今回の関係を検算できません。一度の関数呼び出しで得た三つの返り値を、一組として扱ってください。

表示を整えるときも、最初に値を丸める必要はありません。0.6のような境界を使う本稿の検算では、元の配列を保ったまま、該当行を近い値として照合しています。グラフの軸表示や表の桁数を変える工程は、その後へ分けます。

最後のprecision=1・recall=0には、対応するthresholdがない

公式APIは、最後の適合率と再現率の値に、対応する閾値がないことを説明しています。この点を含めてPR曲線を扱います。数値の1と0が現れたからといって、閾値1の性能を測った行という意味にはなりません。

endpoint = {
    "precision": float(precision[-1]),
    "recall": float(recall[-1]),
    "threshold": None,
    "kind": "curve_endpoint",
}
assert endpoint == {
    "precision": 1.0, "recall": 0.0,
    "threshold": None, "kind": "curve_endpoint",
}
print(endpoint)

ここでthreshold=Noneは、この教材の記録方法です。scikit-learnがNone入りの閾値配列を返すわけではありません。実際に返された6個の閾値と、対応なしと分かる補足情報を、別のものとして持たせています。

例えばthresholdsの末尾へ1を足すと、作った表を読む人は「スコア1以上で適合率1、再現率0になった」と受け取る可能性があります。本稿の返り値はその測定結果を与えていません。実際に1を境界にした件数を調べたい場合は、その条件で別途計算し、曲線の補助点とは分けます。

「何も赤と判定しないなら適合率はどうするのか」という運用上の定義も、別の論点です。今回の曲線の末尾と、別の評価関数のzero_division設定を、一つのルールとして混ぜないようにしてください。

末尾の適合率1だけを見て「この設定が良い」と選ぶことも避けます。そこには対応するthresholdsの要素がなく、今回の実際の候補の一行ではありません。候補を比較したい処理は、6行のthreshold_tableから始めます。曲線の描画用の7点を、そのまま運用候補の7設定と数えないようにします。

記録を渡す場合は、閾値別の6行と、対応なしの補助点の1行を、シートや項目の種類で区別できます。空欄を機械的に0へ置き換える取り込み処理があるなら、その前に補助点を別の記録へ分けます。「対応なし」と「閾値0」が混ざらないことを確認してください。

閾値別の表はprecisionとrecallの末尾を除いて作る

実際の閾値を横に並べ、各閾値での値を読みたい場合は、末尾を除いた適合率と再現率を使います。どの配列を切るのかを明示すると、後でコードを読む人にも対応が伝わります。

threshold_table = pd.DataFrame({
    "threshold": thresholds,
    "precision": precision[:-1],
    "recall": recall[:-1],
})
assert len(threshold_table) == 6
row08 = threshold_table.loc[np.isclose(threshold_table["threshold"], 0.8)].iloc[0]
assert np.isclose(row08["precision"], 2 / 3)
assert np.isclose(row08["recall"], 2 / 4)
print(threshold_table)

この切り方は、先頭の1点を捨てる手順ではありません。precision[1:]やrecall[1:]でも長さだけは6になりますが、閾値との対応が一つずれます。要素数が一致したことだけを成功条件にしないようにします。

同じ理由で、zipが短い配列に合わせて止まることへ任せるより、対応する範囲をコードに残す方が説明しやすくなります。学習時には、0.8の行が2/3と2/4になることまで照合すると、ずれを見つけられます。

データフレームの列を作る段階で長さのエラーが出たら、そこで止まり、3配列を確認します。適合率だけ末尾を切って、再現率は別の変数を使っていた、といった混在も避けましょう。

ずれの検算として、意図的に先頭を除いた表と比較する練習もできます。以下は、誤った対応を見つけるために作る別の表です。学習用に差を出すものであり、使うべき閾値表ではありません。

shifted_example = pd.DataFrame({
    "threshold": thresholds,
    "precision": precision[1:],
    "recall": recall[1:],
})
wrong08 = shifted_example.loc[np.isclose(shifted_example["threshold"], 0.8)].iloc[0]
assert wrong08["precision"] == 1.0
assert wrong08["recall"] == 0.25
assert not np.isclose(wrong08["precision"], row08["precision"])

間違った表でも6行あり、全ての値が0〜1へ収まっています。それでも0.8の行は、原表の3枚を選んだ結果と一致しません。行数や値の範囲だけを検査しても、このずれは見つけられないという例です。

0.8の一行を元のカードへ戻すと、スライスのずれが見つかる
作り方表示される適合率表示される再現率原表との対応
末尾を除く2/32/4K01・K02・K03と一致
先頭を除いた誤例11/40.8以上の3枚に対応しない

PR曲線用の配列では、最後の1点を残す

PR曲線は横軸が再現率、縦軸が適合率です。この二つはどちらも7要素なので、そのまま組み合わせられます。一方、横軸を閾値にする比較図では、6要素のthresholdsに対応する値を使います。

pr_points = pd.DataFrame({"recall": recall, "precision": precision})
threshold_points = threshold_table.copy()
assert len(pr_points) == 7
assert len(threshold_points) == 6
assert pr_points.iloc[-1].to_dict() == {"recall": 0.0, "precision": 1.0}
何を横軸にするかで、使う配列を選ぶ
見たい関係横に使う値縦に使う値本稿の点数
PR曲線recall全部precision全部7
閾値と適合率thresholdsprecisionの末尾を除く6
閾値と再現率thresholdsrecallの末尾を除く6

グラフを描く前に、この表のどの行を作りたいかを決めます。「PR曲線を描きたい」と言いながら、横軸へthresholdsを入れると、長さの問題を直した後も、違う関係の図になります。軸ラベルもデータの意味に合わせてください。

公式のPrecision-Recallの例も、基本となる軸と評価の意味を確認する際の参照先です。本稿では既存のモデルやグラフを複製せず、まず配列の対応を表で検算しています。

表から図へ進むときは、タイトルにも見たい関係を入れます。例えば「閾値による適合率の変化」と「適合率と再現率の関係」は、読む人が答えられる質問が違います。前者は0.8という設定を探せますが、後者では各点の閾値を別に対応させる必要があります。

図の線が滑らかかどうかも、配列対応の正しさを証明しません。今回のようにスコアが6種類なら、確認できる候補の点は限られます。見やすくつないだ線の途中に、実際に検算した別の閾値の結果が存在するかは、元の表へ戻って判断します。

同点スコアと境界の件数を数え、使える評価表にする

0.8以上なら3枚、0.8より大きいなら1枚になる

この教材では、スコアが閾値以上のカードを赤と扱います。0.8以上にはK01、K02、K03が入ります。本当に赤いのはK01とK03の2枚、青いのに赤扱いしたのはK02の1枚です。

selected_ge = cards.loc[cards["score_red"] >= 0.8]
selected_gt = cards.loc[cards["score_red"] > 0.8]
assert selected_ge.index.tolist() == ["K01", "K02", "K03"]
assert selected_gt.index.tolist() == ["K01"]
assert (selected_ge["truth"] == "red").sum() == 2

適合率の分母は、赤と扱った3枚です。そのうち本当に赤い2枚なので2/3です。再現率の分母は、原表にある本当の赤4枚です。そのうち2枚を拾ったので2/4です。分子が同じでも、分母が違います。

>へ変えると、0.8の2枚がまとめて対象外になります。残るK01だけを見ると適合率は1ですが、本当の赤4枚のうち1枚しか拾っていないので、再現率は1/4です。数式の境界記号一つが、見ているカードの集合を変えています。

この例から、ある閾値が実務で良いと結論を出すことはできません。何を見落とし、何を誤って含めたかを説明する教材です。点数の大小だけでなく、対象IDと元のラベルを一緒に確認しましょう。

六つの閾値を手計算の件数へ戻して照合する

0.8の一行だけでなく、返された全ての閾値を数え直します。関数の配列と、元のカードから求めた件数を照合すれば、切り出す位置とスコアの向きの両方を確認できます。

counts = []
is_red = cards["truth"].eq("red")
for i, threshold in enumerate(thresholds):
    picked = cards["score_red"].ge(threshold)
    tp = int((picked & is_red).sum())
    fp = int((picked & ~is_red).sum())
    fn = int((~picked & is_red).sum())
    manual_precision = tp / (tp + fp)
    manual_recall = tp / (tp + fn)
    assert np.isclose(manual_precision, precision[i])
    assert np.isclose(manual_recall, recall[i])
    counts.append([float(threshold), tp, fp, fn])
print(pd.DataFrame(counts, columns=["threshold", "TP", "FP", "FN"]))
8枚へ戻って数えた結果。各行はスコアが閾値以上の条件
閾値TPFPFN適合率再現率
0.14404/84/4
0.24304/74/4
0.44204/64/4
0.63113/43/4
0.82122/32/4
0.91031/11/4

閾値を上げると適合率が単調に良くなる、と今回の表から覚えないでください。0.6では3/4、0.8では2/3です。同点の集まりに赤と青がどう含まれるかによって、適合率が上下することがあります。

一方、同じ原表で閾値を上げると、対象となる集合は狭くなります。どのカードが境界で動いたかを見ると、曲線の形だけを眺めるより、再現率が下がる理由を説明できます。公式の分類評価の資料にも照合し、式の分母を確認してください。

例えば「赤4枚のうち少なくとも3枚を対象にしたい」という練習上の条件を先に置くと、0.8と0.9はその条件を満たしません。0.6では3枚、0.4以下では4枚が入ります。そこから、同時に入る青が何枚かを見る、という順番で候補を比較できます。

この条件は教材のために置いたもので、一般に採用すべき再現率の基準ではありません。自分の課題では、何を何件まで見落としてよいか、後で人が確認するのかなどを整理します。適合率の大きさだけを最大化する目標へ置き換える前に、元の依頼を件数で表してみてください。

同点があると、一枚ずつ選ぶ境界を作れない場合がある

K02とK03は同じ0.8ですが、本当の色は別です。スコアだけを境界にすると、この2枚は一緒に入るか、一緒に外れるかになります。K03だけ拾ってK02だけ外す境界を、この同じスコアのまま指定することはできません。

ここで、カードをスコア順に並べて先頭2枚を切る処理に変更すると、閾値以上という条件から離れます。同点のどちらを先に置いたかで結果が変わるためです。今回のPR曲線の一行として、その処理を追加してはいけません。

実際の課題で「確認できるのは上位何件まで」という制約があるなら、件数で選ぶ方法と、閾値で選ぶ方法を分けて設計します。同点を全て含めるか、別の確認手順へ回すかなどの扱いも必要です。関数が返す曲線は、その運用上の約束まで決めません。

教材では、0.8以上の3枚を紙に書き、0.8より大きい1枚との差を丸で囲むと確認しやすくなります。その差の2枚のうち、赤1枚と青1枚が外れたと説明できれば、適合率と再現率の変化を件数から追えています。

表示の丸めにも注意します。実際には0.801と0.799なのに、表で両方0.80と表示している場合は、元の値として同点ではありません。今回の教材はコードに書いた同じ0.8を2回使っています。自分の表でも、表示桁と計算に使う値を区別してください。

間引く設定と、入力の不足・ずれを同じ原因にしない

drop_intermediate=Trueは、曲線上の一部の閾値を省くための設定です。これは入力カードの一部を削除することとは異なります。返った閾値の数が変わる場合でも、末尾の1点の対応をあらためて確認します。

p_short, r_short, t_short = precision_recall_curve(
    cards["truth"], cards["score_red"],
    pos_label="red", drop_intermediate=True
)
assert len(p_short) == len(r_short) == len(t_short) + 1
assert len(t_short) <= len(thresholds)
assert p_short[-1] == 1.0 and r_short[-1] == 0.0

別の原因として、正解ラベルの欠損だけを落とした、スコアだけ並べ替えた、違う版のモデルの出力を混ぜた、といった入力のずれがあります。長さが8でそろっていても、K01の正解とK02のスコアが対応していれば、その評価は意図した内容ではありません。

原表のID、陽性ラベル、スコアの定義、使った版、入力の抽出条件を一組にします。本稿は同じDataFrameから2列を取り出し、別々にソートしない形です。実データへ移すときも、行の対応を保つ工程を先に作ってください。

別の表からスコアを受け取る場合には、IDによる照合が特に役立ちます。正解表がK01からK08、スコア表がK08からK01の順なら、配列の長さは両方8でも、同じ位置同士は別のカードです。IDを使って一対一に対応させた後に、二つの列を取り出します。

また、同じIDへ複数のスコアがある場合は、どの実行のスコアを使うかを決めます。値の一番大きいものを都合よく選ぶのではなく、モデルの版や対象時点をそろえます。評価のために使った結果が一つの条件で作られたものか、配列を計算する前に確認してください。

赤が1枚もない、スコアが全て同じ、欠損や無限値を含むなどの入力は、この8枚と条件が違います。警告だけを消して同じ解釈を当てはめず、評価対象として成立しているかを確認します。データを除いた場合は、除外したIDと理由も残します。

閾値を選ぶ課題と、最後の性能を評価する課題を分ける

今回の例は、関数の返り値の対応を理解するためのものです。8枚を見ながら都合の良い閾値を選び、その同じ8枚の値を最終性能として報告する練習ではありません。実際のモデルでは、閾値を調整するデータと、決めた後に評価するデータの役割を考える必要があります。

独学では、まず0.4と0.8の違いを言葉で説明してみましょう。0.4では赤4枚を全て含み、青2枚も含みます。0.8では青の混入は1枚ですが、赤2枚を取り逃します。何を減らし、何が増えたのかを件数で記録します。

追加の練習では、K02の本当の色だけを青から赤へ変えた、別の8枚を考えます。スコアの種類と閾値数は変わりませんが、本当の赤は5枚になります。0.8以上では選ぶ3枚が全て赤なので適合率は3/3、再現率は3/5です。同じ閾値配列でも、ラベルが変われば曲線の値が変わることを紙で確かめられます。

逆に、色を変えずにスコアを変えれば、閾値の種類や、同時に動くカードの組が変わる場合があります。どちらを変えたのかを記録せず、長さの差だけを比べないようにします。入力の変更、閾値、適合率、再現率を、一つの実行単位で保存する練習につなげてください。

質問先へ渡すときは「配列の長さが違う」に加え、入力8行、陽性red、3配列の長さ7・7・6、作りたいものが閾値別の表かPR曲線かを添えます。すでに[:-1]へ変更したなら、0.8の一行をどう照合したかも示すと、対応のずれを相談できます。

学習サービスを比較する場合は、モデルを動かす内容だけでなく、正解ラベル・スコア・評価の分母を説明する支援があるかを確認します。自作の小さい表を持ち込めるか、質問方法と期間、総額も別に確認してください。この教材の個別添削を特定の講座で受けられると断定するものではありません。

分類モデルの実装から学び直したい人は、Python WinnerのAI・機械学習マスターも比較できます。Pythonの基礎から機械学習の実践までを扱う講座です。閾値や評価指標について質問できる範囲は、受講前に確認してください。

広告:【PythonWinner】

よくある質問

Q. thresholdsだけ1要素短いのは不具合ですか?
A.

precision_recall_curveの仕様です。最後のprecision=1・recall=0には対応する閾値がありません。本稿の例では適合率と再現率が7要素、閾値が6要素になります。

Q. precisionとrecallは、いつも末尾を除けばよいですか?
A.

閾値ごとの表や、横軸を閾値にする図では末尾を除きます。横軸をrecall、縦軸をprecisionにするPR曲線では、両方の全要素を使います。作りたいものから選んでください。

Q. thresholdsへ1を足して長さをそろえてよいですか?
A.

返り値にない閾値を作ると、そこで性能を測定したような表になります。曲線の補助点と実際の閾値を分け、対応なしと記録してください。

Q. precision[1:]でも長さはそろいました。
A.

長さだけでは対応を確認できません。本稿の閾値別の表はprecision[:-1]とrecall[:-1]です。0.8の行が適合率2/3・再現率2/4か、元の8行へ戻って照合します。

Q. 0.8以上と0.8より大きいでは結果が違いますか?
A.

今回の0.8には2枚の同点があるため、以上では3枚、より大きいでは1枚を選びます。関数の閾値に対応する判定はスコアが閾値以上です。IDと境界記号を一緒に確認してください。

Q. スコアの種類数と、閾値数が一致しません。
A.

まずdrop_intermediateなどの設定と、実際に渡した入力を確認します。本稿のFalseの例では6種類で6閾値ですが、間引く設定では省かれる場合があります。元の行数と曲線の点数も別々に数えてください。