【Python】SQLAlchemyで不意に現れる『float32 is not JSON serializable』の正体――NumpyとDBの深い溝を埋める究極の技術解説

Python

今日は久しぶりに「一見シンプルに見えて、実はコンピュータサイエンスの深淵を覗かせる非常に興味深いエラー」について徹底的に深掘りしていこうと思います。

今回のターゲットは、SQLAlchemyを利用してJSON型のカラムにデータを保存しようとした際に突如として現れる、あの忌々しいエラーです。

StatementError: (builtins.TypeError) Object of type float32 is not JSON serializable

データサイエンス(AI/機械学習)のプロジェクトで、NumPyを用いて計算した結果をそのままPostgreSQLやMySQLのJSONBカラムに突っ込もうとした際、このエラーに遭遇した方は多いはずです。「いや、ただの浮動小数点数(float)だろ? なぜJSONに変換できないんだ?」と。しかし、このエラーの裏側には、Pythonの型システム、IEEE 754浮動小数点数規格、C言語レベルでのメモリ管理、そしてOSSライブラリ間の哲学的な乖離が複雑に絡み合っています。

今回は、このエラーを単に「回避するコード」を紹介するだけではありません。なぜこのエラーが起きるのかを、CPUのレジスタレベルから、SQLAlchemyのアーキテクチャ、そしてPythonの標準ライブラリ `json` の内部実装まで、徹底解剖します。この記事を読み終える頃には、あなたは単にエラーを直せるだけでなく、モダンなPythonシステム設計における「データシリアライズの本質」を理解しているはずです。

1. 現象の再現:なぜ「普通」に書くと落ちるのか

まずは、どのようなコードがこの悲劇を招くのか、具体的なシナリオを見てみましょう。例えば、機械学習モデルの推論結果(確率値など)をデータベースのメタデータとして保存したいケースです。

import numpy as np
from sqlalchemy import create_engine, Column, Integer, JSON
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker

Base = declarative_base()

class PredictionResult(Base):
    __tablename__ = 'prediction_results'
    id = Column(Integer, primary_key=True)
    # JSONBまたはJSONカラム
    meta_data = Column(JSON)

# DB接続設定
engine = create_engine('sqlite:///:memory:') # 今回は簡単のためSQLite
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()

# NumPyを使用した計算結果
prob = np.float32(0.8574) # 機械学習でよくある32bit浮動小数点

# ここでエラーが発生する!
result = PredictionResult(meta_data={"probability": prob})
session.add(result)
session.commit() # <-- StatementError: Object of type float32 is not JSON serializable

実行した瞬間に投げつけられる `TypeError`。初心者なら「0.8574は数字なのに、なぜシリアライズできないんだ?」と首を傾げるでしょう。しかし、熟練のエンジニアならここでピンときます。`prob` の型は `float` ではなく `numpy.float32` なのです。

2. コンピュータサイエンスの視点:NumPy型とPython組み込み型の「深い溝」

なぜ `numpy.float32` はJSONにできないのでしょうか。これを理解するためには、メモリ上のデータ表現という低レイヤーの話をする必要があります。

2.1. Pythonの `float` は「オブジェクト」である

Python(CPython実装)における `float` 型は、実はC言語の `double` 型(通常64bit)をラップした「PyObject」という巨大な構造体です。単なる数値だけをメモリに置いているのではなく、参照カウントや型情報など、多くのオーバーヘッドを持っています。Pythonの標準 `json` ライブラリは、この `PyObject` としての `float` を認識し、文字列へと変換するロジックを持っています。

2.2. NumPyの型は「Cのプリミティブ」である

対して、NumPyは大規模な数値計算を高速に行うために設計されています。`numpy.float32` は、Pythonのオブジェクトとしての性質を最小限に抑え、CPUのレジスタやキャッシュラインが直接扱える「生のC言語の32bit浮動小数点数(IEEE 754形式)」としてメモリ上に連続して配置されることを前提としています。

NumPyの型(`np.float32`, `np.int64` 等)は、便宜上Pythonのクラスを継承しているように見えますが、その実体はメモリ効率を極限まで追求した「スカラ型(Scalar Types)」です。Python標準の `isinstance(obj, float)` を実行すると、`np.float32` は `False` を返します。ここに、全ての悲劇の根源があります。

2.3. JSON規格とシリアライザの制約

JSON(JavaScript Object Notation)というプロトコル自体には、本来32bitや64bitといった数値の精度に関する厳密な区別はありません。しかし、Python標準の `json` エンコーダ(`json.JSONEncoder`)は、**「Pythonの組み込み型(dict, list, str, int, float, bool, None)」しか知らない**のです。エンコーダのソースコード(`json/encoder.py`)を覗くと、型の判別には明示的なチェックが行われており、未知の型(NumPy型など)に遭遇すると、「お前は誰だ?」とエラーを投げる仕様になっています。

3. SQLAlchemyの内部挙動:データがDBに届くまでの旅路

次に、SQLAlchemyがこのエラーにどう関わっているのかを解説しましょう。SQLAlchemyは単なるSQL生成器ではありません。Pythonのオブジェクトとデータベースの型を変換する強力な「Type System」を持っています。

3.1. TypeEngineとDialectの役割

SQLAlchemyで `Column(JSON)` を定義すると、SQLAlchemy内部の `JSON` 型クラス(`sqlalchemy.types.JSON`)が利用されます。このクラスには `bind_processor` というメソッドがあり、PythonのデータをDBAPI(psycopg2やmysql-connectorなど)に渡す前に、データをJSON文字列に変換する処理を担っています。

具体的には、以下のような流れで処理が進みます:

  1. ユーザーが `session.add(obj)` を実行。
  2. `session.commit()` 時、SQLAlchemyが `INSERT/UPDATE` 文を生成。
  3. JSON型のカラムに渡されたデータ(辞書など)を、SQLAlchemyの内部エンコーダが `json.dumps(data)` にかける。
  4. **ここで `json.dumps` が NumPy型に遭遇し、例外を投げる。**
  5. SQLAlchemyがその例外をキャッチし、`StatementError` として再パッケージ化してユーザーに報告する。

つまり、SQLAlchemy自体が悪いのではなく、SQLAlchemyがデフォルトで使用している「Python標準のJSONシリアライザ」がNumPyを知らないことが問題なのです。

4. 解決策レベル1:その場しのぎの「キャスト」

もっとも簡単な解決策は、データをDBに渡す前に明示的にPythonの組み込み型へ変換することです。NumPyにはそのための `.item()` メソッドや `.tolist()` メソッドが用意されています。

# 単一のスカラ値の場合
prob_py = prob.item() # numpy.float32 -> python float
result = PredictionResult(meta_data={"probability": prob_py})

# 配列の場合
arr = np.array([0.1, 0.2, 0.3], dtype=np.float32)
result = PredictionResult(meta_data={"values": arr.tolist()})

メリット: 追加のライブラリや複雑な設定が不要。
デメリット: 変換を忘れるとエラーになる。データの構造が深いネスト(辞書の中にリストがあり、その中にNumPyスカラがある等)の場合、再帰的に変換するボイラープレートコードを書くのが非常に苦痛になる。

5. 解決策レベル2:カスタムJSONエンコーダの実装

プロジェクト全体でNumPyを多用している場合、毎回キャストするのは非現実的です。そこで、Pythonの `json.dumps` に「NumPy型を見つけたら、こうやって変換してね」という指示書(カスタムエンコーダ)を渡す方法があります。

import json
import numpy as np

class NumpyEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, np.integer):
            return int(obj)
        if isinstance(obj, np.floating):
            return float(obj)
        if isinstance(obj, np.ndarray):
            return obj.tolist()
        return super(NumpyEncoder, self).default(obj)

# 使い方
json_str = json.dumps(data, cls=NumpyEncoder)

しかし、SQLAlchemyでこれを使うにはどうすればいいでしょうか? 実はSQLAlchemyの `create_engine` には、JSONのシリアライズに使用する関数を指定するオプションがあります。

import json
from functools import partial

# カスタムエンコーダを適用した dumps 関数を作成
numpy_dumps = partial(json.dumps, cls=NumpyEncoder)

engine = create_engine(
    'postgresql://...',
    json_serializer=numpy_dumps # SQLAlchemyにこの関数を使うよう指示
)

これで、SQLAlchemy経由で保存される全てのJSONデータにおいて、NumPy型が自動的に変換されるようになります。非常にクリーンな解決策です。

6. 解決策レベル3:SQLAlchemy TypeDecorator によるカプセル化

さらに高度なアプローチとして、特定のカラムに対してのみ変換ロジックを適用したい、あるいは読み込み時(デシリアライズ時)にも特殊な処理をしたい場合は、`TypeDecorator` を使用します。

from sqlalchemy import TypeDecorator, JSON
import json

class SmartJSON(TypeDecorator):
    """NumPy型を自動処理するカスタムJSON型"""
    impl = JSON
    cache_ok = True

    def process_bind_param(self, value, dialect):
        if value is not None:
            # ここで変換ロジックを走らせる(レベル2のエンコーダを併用)
            return json.loads(json.dumps(value, cls=NumpyEncoder))
        return value

# モデルでの利用
class Analysis(Base):
    __tablename__ = 'analysis'
    id = Column(Integer, primary_key=True)
    results = Column(SmartJSON) # 自動でNumPyを処理

この手法は、アーキテクチャ的に非常に堅牢です。データベースの型定義自体に「Python側の都合を吸収する層」を設けることで、ビジネスロジック側を汚さずに済みます。

7. なぜ NumPy は JSONSerializable ではないのか?(哲学的な考察)

ここで少し立ち止まって考えてみましょう。「NumPyが最初から `__json__` メソッドでも持っていれば、こんな苦労はしなくて済んだのではないか?」と。実は、これにはオープンソースコミュニティにおける深い議論があります。

Pythonの標準ライブラリは、サードパーティ製ライブラリ(NumPyなど)に依存することを極端に嫌います(標準ライブラリの純粋性)。一方で、NumPy側も「どのシリアライズ形式(JSON, MessagePack, Pickle, etc…)を公式にサポートするか」という問題に対し、特定の形式を優遇することを避けてきました。JSONはあくまで「テキスト形式」であり、バイナリデータを効率よく扱うNumPyの哲学とは本来相容れないものだからです。

また、浮動小数点数の精度問題もあります。`float32` を JSON のテキスト(10進数表現)に変換し、それを再び読み戻すと、ビットレベルでの一致は保証されません。この「情報喪失」を暗黙的に許容するかどうかが、ライブラリ設計者たちの悩みどころなのです。

8. パフォーマンスの落とし穴:大規模データのシリアライズ

もしあなたが、数百万要素のNumPy配列をJSONとしてDBに保存しようとしているなら、たとえ上記の方法でエラーを解決できたとしても、別の問題に直面するでしょう。**「パフォーマンス」と「ストレージ容量」**です。

  • **CPU負荷**: `json.dumps` は本質的にシングルスレッドかつ重い処理です。巨大な配列を文字列に変換するのは、CPU資源を激しく消費します。
  • **DB容量**: JSONBカラムは、バイナリデータを文字列として保存するため、元の `float32` 配列に比べて数倍のストレージサイズを消費します。

このような場合、JSONに固執するのではなく、`LargeBinary` 型(BLOB)を使用して、NumPy配列を `tobytes()` でバイナリ化して保存するか、あるいは Apache Parquet 形式などで保存したパスをDBに記録するのが、真のプロフェッショナルの仕事です。

9. まとめ

`StatementError: Object of type float32 is not JSON serializable` というエラーは、単なるバグではなく、**「汎用的なPythonの世界」と「特化型のNumPyの世界」が衝突した瞬間の火花**です。

  • 原因は、NumPyのスカラ型がPython標準の `float` ではなく、Cプリミティブに近い別物であること。
  • SQLAlchemyのデフォルト設定では、標準の `json.dumps` が使われるため、この「未知の型」に対応できない。
  • 解決策としては、キャスト(`.item()`)、カスタムエンコーダの作成、または `TypeDecorator` による抽象化がある。
  • 真の解決は、データの性質を見極め、JSONにすべきか、あるいはバイナリで持つべきかを判断することにある。

このエラーを深く知ることで、あなたはPythonの型システム、そしてライブラリ間のインターフェース設計の本質に一歩近づいたはずです。次にこのスタックトレースを見たときは、自信を持って最適な設計を選択してください。


データサイエンスを支えるエンジニアリングを極めるために

今回のような「NumPyと他のライブラリの連携」で発生する問題は、データサイエンスの実践においては避けて通れない道です。データの型、メモリ効率、そしてシリアライズの仕組みをより深く理解するためには、NumPyの内部構造やデータ操作のベストプラクティスを網羅的に学習することをお勧めします。

『Pythonデータサイエンスハンドブック 第2版』は、NumPy、Pandasといったデータサイエンスの必須ツールから、それらがどのようにデータを扱うのかという基礎までを圧倒的に詳しく解説しています。今回のようなトラブルに遭遇した際、「なぜそう動くのか」という原理原則に立ち返るための最高のガイドブックになるでしょう。

コメント

タイトルとURLをコピーしました