pandasのcategory型で学習したモデルに、CSVから読み直したデータを渡すと、予測が止まりました。LightGBM 4.7.0で出たのは次の例外です。

ValueError: train and valid dataset categorical_feature do not match.

CSVには列の型が残りません。文字列のまま読んだ列はcategory列として数えられないので、学習時に記録したcategory列の数と合わなくなり、予測の手前で止まります。

学習に使ったカテゴリ一覧はmodel.txtに残っていました。ただし、モデルファイルだけで元の入力を完全に復元できるわけではありません。この記事では、学習時の列型とCSVの欠損表現を確認できる場合に、保存モデルのカテゴリ一覧を使って入力を戻します。対象は順序なしの文字列カテゴリとfloat64の数値列です。

model.txtに残っている記録

検証に使ったモデルのmodel.txtには、次の2行がありました。

feature_names=amount city color
pandas_categorical:[["NA", "001", ""], ["red", "blue", "green"]]

1行目はモデルの全列名、2行目はcategory列ごとのカテゴリ一覧(以下、語彙)です。語彙には列名が付いていません。LightGBMのPythonパッケージは、学習時のDataFrameに並んでいたcategory列の順に語彙を記録します(v4.7.0のbasic.py)。学習コードからcategory列がcityとcolorだと分かれば、全列名の並びから次のように対応づけられます。

列学習時の型(学習コードで確認)語彙
amountfloat64なし
citycategory"NA", "001", ""
colorcategory"red", "blue", "green"

この2行だけでは、どの列がcategory型だったか、NAが欠損のつもりだったのか文字列のカテゴリなのか、順序ありの列だったのかは分かりません。そこは学習時のコードで確かめます。

手元のCSVは戻せるか

CSVでは、空文字のカテゴリと欠損を区別できるかが問題になります。to_csvの設定によって、読み方を指定すれば戻るものと、書き出した時点で区別が失われているものに分かれます。入力全体の復旧には、次節の型・版の条件も必要です。

CSVの書き出し方語彙の空文字区別して読めるか欠損として読む表現
na_repで欠損表現を指定した問わない読める指定した文字列。語彙に同じ値がないこと
既定のto_csvない読める空欄(recover_csvではmissing_token="")
既定のto_csvある区別できない元のデータから入力を作り直す

to_csvのna_repは既定で空文字です。NAや001はファイルに文字として残りますが、空文字のカテゴリと欠損は、どちらも空欄になります。そこまで失われた区別は、読み込みの設定では戻せません。

この記事の検証用CSVはna_rep="__CSV_MISSING__"で書き出したので、表の1行目に当たります。

使える条件

次のすべてに当てはまるモデルとCSVが対象です。

  • 学習・保存した環境と、読み込む環境のLightGBMがともに4.7.0
  • 学習時にcategory型だった列をすべて挙げられる
  • それ以外の列はすべてfloat64だった。整数・bool・日時の型の復元は検証していないため、その列があるモデルは対象外
  • category列の値は文字列で、ordered=Trueの順序ありカテゴリではない
  • CSVを書き出したときの欠損の表し方が分かる

外れる場合は、学習時のデータと前処理から入力を作り直すほうが確実です。

また、CSV出力時に小数を丸めていた場合、失われた桁は読み込み側では戻せません。

モデルの語彙を使ってcategory型に戻す

上の3列のモデルと、欠損を__CSV_MISSING__で書いたCSVなら、次のコードで予測まで進めます。pandas 3.0.5とLightGBM 4.7.0で確認しました。自分のファイルに使うときは列名と欠損表現を合わせてください。既定のto_csvで、語彙に空文字がない場合の欠損表現は""です。

import lightgbm as lgb
import pandas as pd

if lgb.__version__ != "4.7.0":
    raise ValueError("検証対象はLightGBM 4.7.0です")
model = lgb.Booster(model_file="model.txt")
names = model.feature_name()
if set(names) != {"amount", "city", "color"}:
    raise ValueError("この例の学習列と一致しません")
category_names = [name for name in names if name in {"city", "color"}]
vocabularies = dict(zip(category_names, model.pandas_categorical, strict=True))
missing_token = "__CSV_MISSING__"
if any(missing_token in values for values in vocabularies.values()):
    raise ValueError("欠損トークンがカテゴリ語彙と衝突しています")
restored = pd.read_csv(
    "input.csv", dtype={"city": "string", "color": "string", "amount": "float64"},
    keep_default_na=False, na_values=[missing_token], float_precision="round_trip")
if set(restored.columns) != set(names):
    raise ValueError("CSVの列がモデルと一致しません")
for name, values in vocabularies.items():
    unknown = restored[name].notna() & ~restored[name].isin(values)
    if unknown.any():
        raise ValueError(f"未知カテゴリ: {name}")
    restored[name] = pd.Categorical(restored[name], categories=values, ordered=False)
restored = restored.loc[:, names]
prediction = model.predict(restored, validate_features=True)
print(restored.dtypes)
print(prediction)

read_csvの既定の読み方では、NAや空欄は欠損になり、列の中身によっては001も数値になります。そこでcategory列は文字列として読み、keep_default_na=Falseとna_valuesで欠損表現を限定しています。指定した欠損表現が語彙にもある場合は、区別できないので読込前に止めます。

語彙には列名が付いていないので、学習時のcategory列の順に対応づけます。また、pd.Categoricalは語彙にない値を欠損に変えるため、変換前に未知の値がないか確認します。欠損扱いにするか別の値に置き換えるかは、このコードで自動的に決めず、業務上の意味を確認する必要があります。

予測時にもvalidate_features=Trueで列名と並びを照合します。既定はFalseです。

この例の対象は、確認済みの3列と正常なヘッダー付きUTF-8のCSVです。語彙メタデータの形式・重複や、CSV各行の列数も検査したい場合は、後のrecover_csvを使います。学習時の版と元の列型は、どちらの方法でも学習コードでの確認が必要です。

列を入れ替えると、12行とも同じ予測になった

語彙を都市名(Tokyo・Osaka・Nagoya)にした別の合成データでも検証しました。環境と学習設定は、末尾の「詳しい検証条件」と同じです。予測の直前にcityとcolorの列を入れ替えた入力は、次のようになりました。

  • validate_features=Trueでは、LightGBMError: Expected 'city' at position 1 but found 'color'で拒否された
  • 検査なし(既定のvalidate_features=False)では例外が出ず、12行すべてが約31.378になった。保存前の予測との最大絶対差は約13.058

確認用の12行には、cityとcolorが両方とも欠損の行も含めています。約31.378は、その行の予測と同じ値でした。入れ替わった列の値が相手の語彙に存在せず、欠損として扱われたと読めます(basic.pyの処理と観測値からの推測です)。予測値の見た目だけでは、この入力ミスに気づけません。

戻ったかどうかの確かめ方

cityにNA・001・空文字・欠損を含む検証用の12行で、上の短い例とrecover_csvをそれぞれ実行すると、元のDataFrameと型・値が一致し(pandas.testing.assert_frame_equal)、保存前の予測との最大絶対差は0.0でした。

自分のデータでも、元のDataFrameがあれば型・語彙・値を直接照合してください。保存前の予測との比較もできますが、違う入力が同じ予測になることはあるので、予測一致だけでは入力の復旧を証明できません。比較するものが何も残っていなければ、動いたという理由だけで本番へ戻さず、学習データからの作り直しも検討します。

検査をまとめたrecover_csvで手元のCSVを読む

必要なライブラリを入れます。

python -m pip install "lightgbm==4.7.0" "pandas==3.0.5" "numpy==2.4.6"

recover.pyを次のスクリプトと同じフォルダに保存してください。列名やCSVの不備を検査してから、前半と同じ復旧処理を行う関数です。

missing_tokenにはCSV出力側の欠損表現を指定します。この検証の__CSV_MISSING__を、自分のCSVにもそのまま使えるとは限りません。既定の空欄欠損なら""ですが、空文字が語彙にもある場合は衝突として拒否します。

"""呼出例。学習時の前処理とCSV出力側の契約を確認して設定する。"""
import lightgbm as lgb
import json
from recover import recover_csv

model = lgb.Booster(model_file="model.txt")
data = recover_csv(
    model, "input.csv",
    categorical_columns=["city", "color"],  # 学習時の全category列名。推測しない。
    float64_columns=["amount"],  # 学習時float64だった残りの列。
    missing_token="__CSV_MISSING__",  # CSV出力側が使った欠損表現。
)
predictions = model.predict(data, validate_features=True)
print(json.dumps(predictions.tolist(), allow_nan=False))
# 実行成功だけでは復旧を証明できない。保存前の参照予測があれば照合する。

自分のファイルに合わせて差し替えるのは次の箇所です。

  • "model.txt"と"input.csv":自分のモデルと入力CSVのパス
  • categorical_columns:学習時にcategory型だった全列名
  • float64_columns:残りの全列名(float64だったことを学習コードで確認したもの)
  • missing_token:CSVを書き出した側が欠損に使った文字列。上の例は、検証でto_csv(na_rep="__CSV_MISSING__")と書き出したCSVに合わせた値

CSVはUTF-8(BOM付きも可)、1行目がヘッダー、カンマ区切りを前提にしています。列の並び順は学習時と違っていてもかまいません。

recover_csvは列を学習時の順へ並べ替えるので、CSVの列順が違っていても対応できます。ただし、元の型の指定ミスまでは検出できません。例えば、学習時にint64だった列をfloat64_columnsに入れても、モデルには元の型が残っていないため、関数はそのまま読み込みます。こうした指定ミスを見つけるには、学習時の前処理や元の入力と型・値を照合する必要があります。予測が一致しても、宣言が正しいとは限りません。

復旧を確かめたデータと、数値・CSV形式の追加検証

ここからは検証用データの詳細です。型情報のファイルは作らず、モデル・CSV・学習時の列名と欠損表現だけで戻しました。

わざと紛らわしい語彙で学習した

300行の合成データで学習しました。amountは0以上100未満の一様乱数、colorはred・blue・greenです。cityの語彙は、CSVで意味が変わりやすいNA・001・空文字の3つにしました。目的値は0.3×amountに、cityとcolorのカテゴリ番号に応じた値を足したもので、雑音は入れていません。木は30本、CPU1スレッドです。

確認用の入力は、学習データとは別の12行です。amountはすべて50で、cityとcolorの全9通りの組み合わせに、city欠損・color欠損・両方欠損の3行を加えました。欠損は__CSV_MISSING__としてCSVに書き出しています。

どちらのカテゴリ列もモデルが使っていなければ、型を取り違えても予測は変わらず、一致を確かめる意味がありません。木の分岐に使われた回数はamount70回、city66回、color44回で、どちらのカテゴリ列も予測に効いていました。

12行は完全に元どおりになった

この12行では、NA・001・空文字・欠損の区別も、保存前の予測も保たれました。

数値列も確かめました。0.1、学習済みの木がamountの分割に使った閾値のうち最小の7.464442843880837、その直下と直上の浮動小数点値、欠損の5行をCSVで往復させると、float64の値も予測も完全に一致しました。分割の境目のすぐそばの値でも、CSVを経由して別の枝に落ちることはありませんでした。

この検証では、to_csvのfloat_formatを指定せずに書き出しています。出力時に小数を丸めていた場合、失われた桁は読み込み側では戻せません。

ほかに、BOM付きUTF-8のCSVと、列の並びをcolor, amount, cityに入れ替えたCSVも、元のDataFrameに戻りました。後半のエラー表のメッセージは、列名の宣言漏れ・二重指定、語彙の記録の欠落や形式違い、CSVの列の過不足・重複、壊れたCSV、未知カテゴリ、欠損表現の衝突を、それぞれ意図的に作って出したものです。すべて例外で止まりました。

既定のCSV出力で、空文字と欠損を読み直した結果

前半の表の分岐は、12行の復旧とは別のCSVでも確かめました。NA・001・空文字・欠損を1列に持つ表を既定のto_csvで書き出し、dtype="string"とkeep_default_na=Falseで読み直すと、NAと001は文字列のまま、空文字と欠損は両方とも空文字になりました。

空文字を語彙に含まない別の人工例では、missing_token=""でカテゴリと数値の欠損を戻せました。文字のNAと001も保たれ、元の型・値・予測と一致しました。空欄が欠損を意味することを出力側で確認できる場合の結果です。

recover_csvの検査内容とエラーメッセージ

前半の短い例に加え、recover_csvは列宣言とモデルの全列名が一致するか、語彙メタデータの数・型・重複に問題がないかを調べます。pandas_categoricalは列名を持たない内部属性なので、検査したうえで、学習時のcategory列の順に対応づけています。

CSVもpandasで読む前に標準のcsvモジュールで全行を読み、列数がヘッダーと一致するかを確かめます。文字列・float64を指定した読込、欠損表現の衝突検査、未知カテゴリの拒否、列順と順序なしcategory型の復元は前半と同じです。呼び出す側でもpredict(..., validate_features=True)を指定します。

止まったときのメッセージの読み方

列の不足や未知のカテゴリなどを検出すると、関数は例外で止まります。検証で出たメッセージと確認箇所をまとめました。

メッセージ(例)意味と確認すること
列宣言不一致: missing=['amount'], extra=[], duplicates=[]2つの列名リストの合計がモデルの全列名と合わない。missingは宣言漏れ、extraはモデルにない列、duplicatesは二重指定
カテゴリ語彙メタデータがないか、指定列数と一致しませんモデルに語彙の記録がないか、宣言したcategory列の数と記録の数が違う
対応対象は順序なしの文字列カテゴリです。語彙形式を確認してください語彙に文字列以外の値や重複がある。対象外のモデル
CSV欠損表現がカテゴリ語彙と衝突しています欠損の表し方が、カテゴリ値そのものと同じ。例えば語彙にNAがある列で欠損表現をNAにすると区別できない
CSV列不一致: missing=['color'], extra=[], duplicates=[]CSVの列が足りない・余分・重複している
CSVが空かヘッダーがありません/CSVにデータ行がありません/CSVレコード2の列数がヘッダーと一致しませんCSVそのものが壊れている。閉じていない引用符はunexpected end of data(csv.Error)で止まった
未知カテゴリを拒否しました: {'city': ['NEW']}学習時の語彙にない値がある。欠損に置き換えるかは業務判断なので、関数は自動では変えない
could not convert string to float: ''数値列の欠損が、指定した欠損表現ではなく空欄で書かれている

最後の行は、数値の欠損を空欄で書いたCSVにmissing_token="__CSV_MISSING__"を指定したときに出ました。欠損表現を取り違えると、この形で気づけることがあります。

次に同じことが起きないように

学習のときに、列名と列順、category列ごとの語彙と順序の有無、数値列の型、CSVの欠損表現を、モデルと一緒にJSONなどで保存しておけば、後から列の型や欠損表現を調べ直さずに済みます。予測側ではその記録からdtypeとcategory型を戻し、validate_features=Trueで予測します。保存前の予測を数行分、入力とともに残しておくと、後で復旧の正しさを照合する材料にもなります。

詳しい検証条件

  • 環境:Python 3.11.15、LightGBM 4.7.0、pandas 3.0.5、NumPy 2.4.6、macOS arm64、CPU1スレッド
  • 学習設定:LGBMRegressor(n_estimators=30, num_leaves=7, min_child_samples=2, min_data_per_group=1, cat_smooth=0, random_state=42, deterministic=True, force_col_wise=True, n_jobs=1)。小さな合成データで入力の違いを確かめるための設定で、実データへの推奨値ではない
  • 比較の基準:保存前のモデルに元のcategory型の入力を渡した予測。復旧後は、保存したファイルから読み込んだモデルにrecover_csvの出力を渡した。一致は丸める前の配列をコードで比較した
  • 呼び出しスクリプトは、モデル・CSV・recover.pyを置いたフォルダで別プロセスとして実行し、出力したJSONを参照予測と比べた
  • 列入れ替えは都市名の語彙を使った別の合成データ、既定CSVの読み直しと空文字の欠損トークンはそれぞれ別の確認用入力での結果。本文の12行の結果とは分けて確認した
  • feature_infosやcategorical_featureは、LightGBMがカテゴリとして扱う列の手がかりであり、元のpandas型の一覧とは限らない。モデル内の列情報は、別の60行の小例でも確認した。pを語彙a・b・cの順序なしcategory列、qを0・1・2のfloat64列とし、categorical_feature=["q"]を指定すると、feature_infosでカテゴリの列挙が付くのはq、pandas_categoricalの語彙はpのものだった。両方とも1列分あるが、対応は一致しない。Python側の変換処理でも、pandasの型から集める語彙と、LightGBMへ渡すカテゴリ列の指定は別に扱われている

適用範囲と限界

  • 確かめたのは、同じ入力が同じ予測になるかです。モデルの精度や、未知カテゴリに対する予測の良し悪しは測っていません。
  • 対象はLightGBM 4.7.0、順序なしの文字列カテゴリ列、float64の数値列、UTF-8のCSVです。整数・bool・日時の列、順序ありカテゴリ、数値のカテゴリ、cp932のCSV、壊れたCSVの修復は扱っていません。
  • 空文字と欠損が同じ空欄で保存されたCSVでは、その2つを後から区別できません。