第150回 実務で使えるPython基礎:dataclassと型注釈で作る安全な表データスキーマとシリアライズワークフロー

はじめに — 「行データ」がいつも曲者に感じるあなたへ

CSVやJSONLで扱う「一行」の揺らぎ(欠損、型のばらつき、日時表記の差異)は、現場で繰り返し問題になります。辞書で取り回すと可読性が下がり、変換ルールが散らばって保守が難しくなります。この記事では、dataclass と型注釈を使って行データを型付きオブジェクトに落とし込み、変換・検証・シリアライズを一貫して扱う実務的な手順を示します。第149回のストリーミング埋め込みの続きとして、パイプラインに組み込みやすい層を作ることが狙いです。

1) なぜ dataclass と型注釈か — 現場でのメリットと適用範囲

短く言うと「可読性」「静的解析」「移行しやすさ」です。主な利点を表にまとめます。

項目 辞書ベース dataclass + 型注釈
可読性 キー文字列に依存しがちで散らばる フィールド名でまとまる。型が明示される
変換位置 処理ごとに分散しやすい 一箇所で変換・正規化できる
型チェック 実行時まで不明 mypy 等で静的チェックしやすい
互換性対策 キーの存在/欠損の追跡が難しい バージョンやデフォルトで一元管理可能

適用範囲は「テーブル状の行データ」を扱う処理、ETL の前段、埋め込み生成前の正規化、ログ行の正規化などです。大量行の超高頻度処理では dataclass のオーバーヘッドを考慮する必要があります(後述)。

2) 基本パターン:単純な行 → dataclass の定義と変換

型注釈を用いた基本例を示します。ここでは型には typing を使います。

例:行スキーマ
フィールド: id (str), created_at (datetime|None), score (Optional[float]), tags (List[str])

変換の雛形(読み取り → dataclass)は次のように整理できます。

雛形コード(概念)
from dataclasses import dataclass, field
from typing import Any, List, Mapping, Optional
from datetime import datetime

@dataclass
class Row:
id: str
created_at: Optional[datetime]
score: Optional[float]
tags: List[str] = field(default_factory=list)

# CSV/JSON の dict を Row に変換するユーティリティ
def dict_to_row(d: Mapping[str, Any]) -> Row:
# 日付文字列→datetime、空文字→None、tags を split などの正規化を行う
row_id = d[‘id’]
if not isinstance(row_id, str):
raise TypeError(‘id must be a string’)
created = parse_date_or_none(d.get(‘created_at’))
score = parse_float_or_none(d.get(‘score’))
tags = parse_tags(d.get(‘tags’))
return Row(id=row_id, created_at=created, score=score, tags=tags)

実務では parse_* 関数を小さく分けておくと再利用しやすく、単体テストも書きやすくなります。

3) 欠損値・デフォルト・型変換の実務ハンドリング

欠損値や空文字の扱いは現場で最も差が出る部分です。型が確定した値の内部整形には __post_init__ を利用できますが、CSVやJSONから来る文字列などの外部入力は、入力型を広く取る専用ファクトリ関数で受けて一元的に変換すると、dataclass の型注釈との矛盾を避けられます。

パターン 説明
__post_init__ 型に適合した値でインスタンス化した直後に、内部整形やフィールド間の整合性確認を行う。
ファクトリ関数 外部データ源(CSV/JSON)に特化した入力型と変換を分離。テストしやすい。
ユーティリティ関数 日付パーサ、数値パーサ、リスト正規化などを小さく作る。

実例(外部入力を専用ファクトリで受ける構成):

コードスニペット
from dataclasses import dataclass, field
from typing import Any, List, Mapping, Optional
from datetime import datetime

@dataclass
class Row:
id: str
created_at: Optional[datetime]
score: Optional[float]
tags: List[str] = field(default_factory=list)

@classmethod
def from_dict(cls, d: Mapping[str, Any]) -> “Row”:
row_id = d[‘id’]
if not isinstance(row_id, str):
raise TypeError(‘id must be a string’)

return cls(
id=row_id,
created_at=parse_date_or_none(d.get(‘created_at’)),
score=parse_float_or_none(d.get(‘score’)),
tags=parse_tags(d.get(‘tags’)),
)

# 必要に応じて既存の変換関数からファクトリを呼び出す
def dict_to_row(d: Mapping[str, Any]) -> Row:
return Row.from_dict(d)

4) ネスト・リスト・可変長フィールドの扱い

ネストした構造は dataclass をネストして表現します。可変長フィールドは List 型で表し、デフォルトは default_factory を使います。

from dataclasses import dataclass, field
from typing import List, Optional

@dataclass
class Item:
name: str
qty: int

@dataclass
class Row:
id: str
items: List[Item] = field(default_factory=list)

ネストの変換は再帰的にファクトリを呼び出すか、専用の parse_item_list 関数を用意して一元化します。

5) CSV/JSONL ⇄ dataclass シリアライズ/逆シリアライズ(ストリーミング対応)

大量行を扱う場合、メモリに全部読まないストリーミング処理が実務では重要です。読み取り側は行を順次 yield するジェネレータ、書き出し側は iterable を順次処理してファイルへ直接書き込む関数として構成できます。

CSV → dataclass(ジェネレータ)
import csv

def stream_rows_from_csv(fp):
reader = csv.DictReader(fp)
for d in reader:
try:
yield dict_to_row(d)
except Exception as e:
# ログに残してスキップか再試行のルールをここで適用
handle_conversion_error(d, e)

JSONL の場合は一行ずつ json.loads して同様に yield します。逆方向(dataclass → CSV/JSONL)は、受け取った行を一件ずつストリームへ書き出します。

dataclass → JSONL(ストリームへの逐次書き込み)
import json

def stream_write_jsonl(rows, fp):
for row in rows:
obj = asdict_for_serialization(row) # 日付は ISO 化など
fp.write(json.dumps(obj, ensure_ascii=False) + “\n”)

ポイント:

  • 日付は統一フォーマット(例: ISO 8601)で保存する
  • 列順を固定したい場合は列名リストを管理して出力順を制御する
  • 圧縮出力(gzip)を行うときはバッファリングと逐次処理を組み合わせる

6) 軽量バリデーション戦略と pydantic の使い分け

dataclass と小さな検証ロジックで十分なケースと、堅牢なランタイム検証が必要なケースは分けて考えます。

目的 推奨
軽量な正規化・開発効率重視 dataclass + 小さな parse/validate 関数
外部入力が不安定で安全性重視 pydantic によるランタイム検証、または attrs に validators/converters を明示的に実装する
静的解析と型互換チェック mypy と型注釈、テストで補完

attrs は型注釈を付けるだけで実行時の型検証を行うものではないため、必要な検証や変換は validators や converters などで明示します。pydantic も便利ですが、依存と検証・シリアライズの挙動を理解した上で使うことが重要です。検証エラーの記録やエラーレート閾値は運用で役立ちます。

7) テストとCIで防ぐ典型的な運用エラー

テスト設計は次の要素を含めます。

  • 変換ユーティリティの単体テスト(正常系/欠損/誤フォーマット)
  • 境界値テスト(長すぎる文字列、大きな数値、深いネスト)
  • パラメータ化テストで複数のフォーマット(日付形式など)をカバー
  • モック CSV/JSONL を使った E2E テスト(読み取り→変換→書き出し)
  • スキーマ差分検出テスト:古いスキーマ vs 新スキーマで互換性チェック

CI に入れるべき簡易スクリプト例:スキーマ差分チェック(型名と必須フィールドの差を検出)を自動化しておくと、運用時の誤変更を防げます。

8) 運用チェックリストと移行・バージョン管理の実践

運用フローに組み込む際の最小チェックリスト:

項目 説明
スキーマバージョン 各行に schema_version をメタデータで持たせる
エラー率モニタ 変換エラー率が閾値超えなら自動アラート
サンプル検査 一定割合のサンプル出力を人が確認するルール
再処理ルール 失敗行の隔離と再実行手順をドキュメント化
移行パス フィールド追加は Optional で始め、後で必須に移行する計画

既存パイプラインへの挿入例(第148/149回との接続):

  • 埋め込み生成直前に dataclass 層で正規化→埋め込み入力が安定する
  • スキーマバージョンをメタデータとして保存し、再処理時に変換ルールを選べるようにする

現場でよくある落とし穴と対策(短めチェックリスト)

問題 対策
パフォーマンスの低下 プロファイリングでホットスポットを特定、必要なら C もしくは vectorized 処理に切り替え
日時フォーマットのばらつき parse_date_or_none に複数フォーマット順試行を実装、ログで未対応フォーマットを収集
型の過信 外部入力は常に検証。pydantic を補助的に導入
スキーマ変更で壊れる バージョニングと互換性レイヤ(Optional→必須の移行プラン)

テスト設計の具体案(簡易)

代表的なテストケース例:

  • 正常行の変換が期待通りに行われる
  • 空文字・null が Optional フィールドにマップされる
  • 不正な日付はログに残して行をスキップ(あるいは None)にする
  • 大きなリスト(1000 要素)の items を扱えるか

CI ではこれらを pytest のパラメータ化で回し、カバレッジと型チェック(mypy)を組み合わせます。

まとめ — 実務で使うための短い指針

  • dataclass + 型注釈は「読みやすさ」と「移行性」を高める。まずは小さなスキーマで試す。
  • 外部入力の変換ルールは、入力型を広く取る専用ファクトリに集約し、ユーティリティ関数で分割する。
  • 読み取りはジェネレータ、書き出しは逐次処理でメモリを抑え、エラー記録と再実行ルールを運用に組み込む。
  • ランタイム検証が必要な領域は pydantic などを使い分ける。静的検査(mypy)とテストで品質を担保する。

次に繋げるトピック案

  • pydantic と attrs(validators/converters)を用いた検証の実務比較
  • mypy を組み込んだ CI の実装例
  • スキーママイグレーション自動化と再処理オーケストレーション

今回示した考え方と雛形は、現場での小さなミスや運用コストを減らすための実務改善です。まずは一つの CSV/JSONL パイプラインに導入して、変換エラー率や可読性の改善を測定してみてください。次回は pydantic を使ったランタイム検証の深掘りを予定しています。

第149回 実務で使えるPython基礎:ジェネレータとイテレータで設計するメモリ効率の良い埋め込み&検索パイプライン

はじめに:現場でつまずきやすい点に寄り添って

大きなCSVやログを扱うと、いつの間にかメモリが増えてプロセスが落ちる、あるいは埋め込みAPIに一度に大量送信して失敗する――こうした現場あるあるに悩んでいませんか。第148回を読んで埋め込みの概念は理解したが、いざ現場で大規模データを安定運用する段になると「どう設計し、どう復旧するか」が課題になりがちです。本記事では、Pythonのジェネレータ/イテレータを中心に、メモリを一定に保ちながら埋め込み→ベクトルDB登録→簡易検索までつなぐための設計上の手順とチェックリストを提示します。特定の埋め込みAPIやベクトルDBに依存しない、概念と実装方針の解説です。

学習ゴール(この記事を読み終えたとき)

  • ストリーミング読み出しとバッチ化でメモリを一定に保つ設計がわかる
  • 埋め込みAPI呼び出しの安全なリトライやレート制御の考え方を実務で使える形で理解できる
  • 障害時の途中再開(チェックポイント)や簡易メトリクスの実装ポイントが掴める
  • 小さなデータでのテスト方法とCIに組み込む観点が分かる

ステップ1:概念とパターン(ジェネレータ/イテレータの実務的理解)

ジェネレータの基本と利点

Pythonのジェネレータは、yieldで値を逐次返す仕組みです。データ全体をメモリに展開せずに「必要な分だけ処理する」ことで、長時間実行のバッチ処理や大規模ログ処理で安定化します。実務では次の点を押さえます。

  • 遅延評価:入力を1行ずつ(あるいは小さなチャンク単位で)処理することでメモリ使用量が一定に近づく。
  • パイプライン合成:読み出し→前処理→バッチ化→API送信 の各段をジェネレータで繋ぐと、各段の責務が明確になる。
  • 単体テストしやすい:入力ストリームをモック化してジェネレータ単体を検証できる。

よく使うパターン

  • chunked(固定サイズバッチ): バッチサイズNごとにまとめる
  • sliding window(可変・重複あり): 文脈ウィンドウが必要なとき
  • filter/mapチェーン: 前処理→フィルタ→正規化を逐次的に行う

メモリ使用イメージ比較

処理方法 主な特徴 メモリ使用の見積もり
一括ロード(pandas.read_csv) 実装は簡単だが全件をメモリに保持 データサイズ+行・列のオーバーヘッド(高)
ジェネレータで逐次処理 行単位や小チャンク単位で処理。低メモリ。 バッファサイズ+バッチサイズに依存(一定)
ハイブリッド(分割して複数ファイル) ファイル分割で並列処理。運用と整合性管理が必要。 プロセスごとの最大バッファを足し合わせた値

ステップ2:実装ガイド(現場で使える設計と小さな実装ヒント)

1) ストリーミング読み出しジェネレータ(CSV/JSONL/ログ)

ポイントは「逐次読取」と「最小限のパース」。簡単な考え方は次の通りです。

  • CSV: ファイルを逐行読み、必要な列だけパースしてdictをyieldする。
  • JSONL: 各行をjson.loadsしてyield。パース失敗はログに出してスキップ可能。
  • 圧縮ファイル: gzip.open や streaming ライブラリで逐次解凍しながら処理。

(実装ヒント)関数名の例:
read_csv_rows(file_path) → yield {‘id’: …, ‘text’: …}

2) 固定サイズ/可変サイズバッチの作り方

固定サイズバッチ(chunked)は最も実用的です。イメージは次の通り。

用途 実装方針 注意点
固定サイズバッチ ジェネレータからN個ずつ取り出してyield 最終バッチはサイズ未満になることを許容する
可変バッチ(サイズ上限のみ) 文字数やトークン数で上限を設定して詰める トークン推定が必要でやや複雑

(実装ヒント)chunkedの疑似手順: イテレータをループして一時リストにappend、サイズ到達でyieldしクリア。

3) 埋め込みAPI呼び出し:同期/非同期の使い分けと安全なリトライ

実務では次の観点で選択します。

  • 同期: 単純なワンオフ処理やデバッグ時に便利。失敗時の影響範囲が分かりやすい。
  • 非同期(async/await): 高スループットが必要な場合。イベントループ設計とエラーハンドリングが必要。

リトライ戦略(実務向け):

  • タイムアウト設定を必ず設ける(APIごとに最適値)。
  • 指数バックオフ+最大試行回数。短時間の一時障害を吸収する。
  • レート制御: APIのスロットリング(固定インターバル)かトークンバケット方式を採用。
  • 失敗時の扱い: 一時的失敗はバッチ単位で再試行、恒久的エラーはログと失敗キューに記録。

(実装ヒント)簡易流れ: chunkedジェネレータ → API送信(タイムアウト+リトライ)→ 成功した埋め込みは次段へyield

ステップ3:結合と耐障害設計

埋め込み失敗時の戦略

  • 短時間の失敗: バッチ単位で再試行(指数バックオフ)。再試行回数はビジネス要件で決める。
  • 恒久エラー(フォーマット不備等): そのレコードをスキップして失敗ログに保管。人手確認用のCSV/JSONとして残す。
  • 失敗キュー: 再処理用ファイルや小さなDBに失敗レコードを貯め、別ジョブで再投入する。

途中再開(チェックポイント)の実装案

長時間ジョブでは途中から再開できることが重要です。シンプルな手法:

  • 進捗マーカー(例: 最終処理行番号、最終ID)を定期的にファイルに書く。
  • 各バッチの登録完了後にチェックポイントを更新。チェックポイントは冪等に保つ(同じIDで再登録しない工夫)。
  • ベクトルDB側にアップサート(upsert)やidempotentなID設計をすることで重複登録を避ける。

ロギングと簡易メトリクス

指標 何を見るか 取得方法
処理レート records/s や batches/min 処理した件数を時間で割る(ジョブ内でカウント)
メモリ使用 プロセスのRSSやコンテナメモリ psutilやコンテナ監視で定期取得
失敗率 総バッチ数に対する失敗バッチ数 ログから集計、アラート閾値を設定

ステップ4:ベクトルDB登録と簡易検索

少量インサート vs バルクインサート

  • 少量インサート(逐次登録): レイテンシは低めだが総オーバーヘッドが大きくスループットが落ちる。
  • バルクインサート: スループット重視。失敗時のロールバック/再試行戦略が重要。

ID管理: アプリ側で一意のIDを発行しておくと、途中再開や重複検出が容易になります(例: ハッシュ+元ファイルの行番号)。

簡易検索とスコア調整

  • 検索テスト: 少量データでレイテンシと順位を確認(kとスコア閾値の調整)。
  • スコアリングの観察点: 同一ドメインの類似度分布を確認し、閾値を決める。

テストとCIのポイント

  • ジェネレータ単体テスト: ファイルストリームをモックして逐次出力を検証する。
  • 埋め込みAPIはモック化して遅延・エラーをシミュレートしたテストを用意する。
  • メモリ回帰テスト: 小データと大データでメモリ使用を比較し、閾値超過の検出をCIに組み込む。
  • E2Eテスト: 小さなサンプル(例: 100件)で読み出し→埋め込み→登録→検索までの流れを自動化する。

運用の現実的注意点と伸ばしどころ

  • Dockerでのメモリ制限: コンテナ側のメモリ上限を設定し、OOM発生時の挙動を確認する。
  • 長時間ジョブのタイムアウト: ジョブ管理(AirflowやKubernetes CronJobなど)で最大実行時間を設定。
  • 監視指標: 処理レート、メモリ、失敗率、最終正常実行時刻を継続監視する。
  • 将来的な拡張: RAGやキャッシュ戦略との連携、並列処理によるスケールアウトの設計を検討する。

実務チェックリスト

項目 確認ポイント
入力ストリーム 逐次読み出しでメモリ固定化されているか。パースエラーはログ化されるか。
バッチ設計 バッチサイズとレート制御が現場負荷に合っているか。
リトライ/バックオフ タイムアウト・最大試行回数・バックオフが設定されているか。
チェックポイント 定期的に進捗を保存し、再開時に重複登録を防げるか。
ベクトルDB整合性 ID管理とupsert戦略があるか。
テスト ジェネレータ単体・APIモック・E2EのテストがCIに含まれているか。
監視 処理レート、メモリ、失敗率をダッシュボードで確認できるか。

まとめ(実務でまず手を付ける優先順)

  • 1: まずはストリーミング読み出し(ジェネレータ)に置き換え、メモリ問題を排除する。
  • 2: 固定サイズバッチで埋め込みを試し、同期→非同期の切替はスループット要件に応じて行う。
  • 3: チェックポイントとID管理を実装して途中再開と整合性を確保する。
  • 4: ロギングと簡易メトリクスを整備して運用監視を始める。

次回候補として『RAGの品質評価とログ駆動の改善ループ』を想定しています。本稿で挙げたチェックポイントやログは、RAG運用に移行する際の良い出発点になります。ここで示したパターンは過度に特殊化せず、現場の多様なデータ形態に適用できる実務的な基準を目指しました。小さなサンプルでまず動かし、観測→改善を繰り返すことをおすすめします。

第148回 CSV逐次読み込み→断片化→モック埋め込み→SQLite保存の最小構成

CSV逐次読み込み→断片化→モック埋め込み→SQLite保存の最小構成

注意(必読)

この記事内で説明する埋め込みクライアントは配線確認用のモック実装です。実API接続、認証、レート制御、堅牢な再試行・監視などの実務要件は実装していません。本文は「CSVを逐次読み込み→断片化→埋め込み(モック)→SQLiteへ保存→小規模検索へ渡す」ための最小構成を分かりやすく説明することを目的とします。示す設計説明や検証フローは実装ガイドとして有用ですが、示した実装例がそのまま本番で動くとは受け取らないでください。

目的と範囲

大きなCSVを一括でメモリに載せず逐次処理し、各行(あるいは行から生成した断片)単位で埋め込みを作ってSQLiteに保存する。保存したベクトルはベクトル配列とメタ情報(row_id, fragment_id, model)を同じ順序で読み出し、小規模検索(Faiss等)へ渡せるようにする、という最小構成を示します。主キーや保存形式、テストでの検証ポイントを明確にします。

前提・依存

  • 想定するライブラリ(説明用): Python標準の csv/json/sqlite3、numpy(float32変換)、および任意で faiss。
  • 保存ファイルは1モジュール(例: pipeline.py)としてまとめる前提で説明します。
  • 埋め込みクライアントはモック。実APIに差し替える場合は認証・レート制御・再試行設計を追加してください。

処理の順序(概観)

  1. CSVをストリーミングで1行ずつ読み込む(メモリに全件を保持しない)。
  2. 各行を断片(fragment)に変換する。断片は少なくとも以下を含む: row_id, fragment_id, text, metadata。
  3. 断片をバッチ化(クライアントの batch_size に応じる)して埋め込みAPI(ここではモック)に投げる。
  4. 返却されたベクトルを float32 のバイナリに変換し、metadata(JSON)と共に SQLite に保存する。主キーは複合 (row_id, fragment_id, model) を採用する。
  5. 必要に応じて、SQLite からベクトルを ORDER BY row_id, fragment_id の順に読み出し、NumPy 配列(shape: N×D)と対応するレコード配列を得る。これをインデクシング(Faiss 等)や線形探索に渡す。

各段階で保存・保持する情報(明確化)

  • 断片変換段階: row_id(必須)、fragment_id(ユニーク化)、text(埋め込み対象)、metadata(title 等の補助情報)。
  • 保存段階(SQLite): row_id, fragment_id, model を複合主キーとして、vector(float32 のバイナリ)、metadata(JSON文字列)、タイムスタンプを格納する。
  • 読み出し段階: vector は NumPy の float32 配列に復元し、records には row_id/fragment_id/metadata を同じ順序で保持する(位置対応の保証)。

主要な関数・クラス(役割と相互関係)

ここではコード本文は掲載しませんが、実装時に最低限用意するべき関数とその役割を示します。すべて1ファイルにまとめる想定です。

  • stream_csv_rows(path): CSV を逐次的に読み、1行ずつ辞書で返すイテレータ。
  • row_to_fragment(row): CSV の行を断片に変換。id 列の存在を検証し、row_id, fragment_id, text, metadata を返す。
  • chunked(iterable, size): イテラブルを指定サイズで区切るユーティリティ。
  • EmbeddingClient クラス: モック埋め込みクライアント。embed_batch(texts) と embed_batch_with_retry(texts) を提供し、バッチ単位でベクトル(例: 128次元のランダム値)を返す。
  • Create_schema(conn): SQLite にテーブルを作成する。主キーは (row_id, fragment_id, model)。
  • save_embedding(conn, row_id, fragment_id, model, vector, metadata): ベクトルを float32 のバイナリに変換して保存。重複は上書き(INSERT OR REPLACE 相当)する運用を想定。
  • load_embeddings(conn, model): 指定モデルの行を ORDER BY row_id, fragment_id で取得し、(vectors: NumPy 配列, records: メタ情報リスト) を返す。np.frombuffer の場合は安全のためコピーする。
  • embed_csv(path, conn, client, model): 上記を組み合わせる本体。CSV をストリームし、断片化→バッチ埋め込み→保存→コミットを繰り返す。
  • search_sqlite(…): 小規模検証用の検索。クエリを埋め込み、Faiss が使える環境なら IndexFlatL2 を作成して検索、未導入なら単純な線形探索で距離を計算する。

pytest による最小検証の流れ(コードは掲載しません)

実際のテストでは以下の手順でパイプラインの基本動作を検証します。テストは pipeline.py を同一ディレクトリに保存する前提です。

  1. 一時ディレクトリに小さな CSV を作る(例: id,title,body のヘッダと 1 行)。※シェルの printf 等で作成可能だが、ここではコマンドは載せません。
  2. メモリ上の SQLite を開き、Create_schema を実行する。
  3. EmbeddingClient インスタンスの embed_batch をモックして、既知のベクトル(例: [0.1]*128)を返すようにする。
  4. embed_csv を呼び出す。呼び出し後、モックの embed_batch が想定されるテキスト(例: ‘Hello\nThis is a test’ のように title と body を結合した文字列)で呼ばれたことを assert する。
  5. SQLite の embeddings テーブルを読み、保存された行の row_id、fragment_id、model が期待値と一致すること、vector BLOB のバイト長が float32 の次元数×4 バイト(例: 128*4)であることを検証する。

主なトラブルシューティング項目(具体的に確認する点)

  • CSV に id 列は存在するか。欠落すると row_to_fragment で失敗する。
  • embed_csv 実行後、バッチごとのコミットが行われているか(途中失敗時の部分保存の有無を設計に合わせる)。
  • 埋め込みクライアントが返すベクトル数がバッチ内の断片数と一致するか。ミスマッチは ValueError 相当で検出する。
  • vector を float32 で保存しているか。テストでは BLOB 長=次元×4 バイトを使って検証するのが簡便。
  • load_embeddings の戻りベクトル配列と records 配列が同じ順序か。ORDER BY を使って読み出すこと、np.frombuffer の場合は copy を取ることを確認する。
  • 検索時に次元不一致がないか。クエリ埋め込みの次元と保存済みベクトルの次元は一致させる必要がある。

Faiss は任意の次段階

小規模であれば SQLite に読み出したベクトルを NumPy で線形探索するだけで十分です。高速化したい場合は Faiss を導入して IndexFlatL2(あるいは他のインデックス)を作り、NumPy 配列を一括で追加して検索する。Faiss は環境依存の導入手順が必要なため、ここでは任意の次段階として簡潔に扱います。

実装時の注意(まとめ)

  • 本文ではプレーンな設計とテスト方針を示しましたが、コード本文は掲載していません。実装例をそのままコピペして動くと保証するものではありません。
  • 本番移行時には埋め込み API 固有のエラー処理、レート制御、認証管理、監視、テレメトリを必ず追加してください。
  • 小規模検証→段階的スケールアップの手順を作り、各段階で性能・一貫性・復旧を確認してください。

以上が「CSVを逐次読み込み→断片化→埋め込み(モック)→SQLiteへ保存→小規模検索へ渡す」ための最小構成の説明です。実装時には上に挙げた関数群を1つのモジュールにまとめ、pytest による保存結果の検証(モック差し替えと BLOB 長の確認)を行うと、配線確認と基本品質の担保に有効です。

第147回 実務で使えるPython基礎:collectionsとitertoolsで書く効率的な表データ処理パターン

日々のCSVや表データ処理で「遅い」「メモリを食う」「コードが読みにくい」と感じたことはありませんか。この記事ではcollectionsとitertoolsを用いて、現場でよく出るパターン(グルーピング・集計・チャンク処理・スライディングウィンドウ・重複除去)を、貼り付けてすぐ使える関数例とともに整理します。第135回(CSVクリーニング)や第146回(スキーマ検証)の後処理として自然に使える手順を意識しています。

目的と対象読者

小規模〜中規模のCSV/表データを日常的に処理する実務担当者向け。読み終わったらそのまま業務に貼れるコード例を重視し、可読性とメモリ効率の両立を目指します。

導入:なぜcollectionsとitertoolsを使うか

標準ライブラリのcollectionsとitertoolsは追加依存が不要で、次の利点があります。

  • 可読性:意図がはっきりするデータ構造(Counter, defaultdict, deque)
  • 速度:C実装されたイテレータや最適化パターン
  • メモリ効率:ジェネレータやストリーミング処理で大きな中間リストを避けられる

実務フローでは第135回のCSVクリーニング→第146回のスキーマ検証→本記事の集計やバッチ処理、という流れでつなぐと作業が安定します。

主要ツールの早見表

モジュール/クラス 用途 注意点
collections.defaultdict 存在しないキーに対するデフォルト値の自動生成(集計が簡潔) defaultdict(list)では欠損キーごとに新しいリストが生成される。共有済みオブジェクトを返すファクトリを使う場合は共有に注意
collections.Counter 要素の頻度集計・最頻値取得 大きなユニーク数はメモリ増加の要因
collections.deque 固定長のスライディングウィンドウ、両端操作 maxlenを設定してメモリ上限を作ると安全
collections.namedtuple 軽量なレコード型(可読性向上) 不変なので安全に使える
itertools.groupby 連続する同キーのグルーピング(ソート済み前提) 事前にソートしないと期待したグループにならない
itertools.islice / tee / chain.from_iterable チャンク化、複製ストリーム、フラット化 teeはメモリを使う点に注意(複数消費時)
functools.lru_cache 関数結果のキャッシュ(計算コストの高い変換に有用) キャッシュサイズとメモリを設計する

実務パターンと短い解説

以下は現場で繰り返し出るパターンと、注意点・貼り付け可能な関数例です。

1) 安定した順序での重複除去(先頭を残す)

dictはPython3.7以降で挿入順を保ちます。行の順序を保ちながら重複を除去したいときに有用です。

def dedupe_preserve_first(rows, key_func):
    """rows: iterable of rows
    key_func: row -> key used for dedupe
    yields unique rows, keeping the first occurrence"""
    seen = {}
    for r in rows:
        k = key_func(r)
        if k not in seen:
            seen[k] = True
            yield r

注意点:ユニークキーの総数が極端に多いとseenがメモリを占有します。

2) ソート済みデータのgroupbyによる集計

groupbyは連続する同キーをまとめるため、事前にソートが必要です。

from itertools import groupby
from operator import itemgetter

def aggregate_sorted(rows, key_index, value_index, agg_func=sum):
    """rows: iterable of sequences already sorted by key_index"""
    for key, group in groupby(rows, key=itemgetter(key_index)):
        vals = (float(r[value_index]) for r in group)
        yield key, agg_func(vals)

注意点:groupbyの前に並べ替えが必要な場合、その処理で入力全体をメモリに保持することがあります。行数だけで判断せず、実データで処理時間と最大メモリ使用量を測り、収まらない場合は外部ソートや別の集計方法を検討します。

3) defaultdict/Counterによる高速集計・マージ

逐次読み込みで累積集計する基本パターン。

from collections import defaultdict, Counter

def accumulate_counts(rows, key_func):
    cnt = Counter()
    for r in rows:
        cnt[key_func(r)] += 1
    return cnt

def merge_counters(*counters):
    total = Counter()
    for c in counters:
        total.update(c)
    return total

注意点:Counterはキー数が増えるとメモリを使うため、必要に応じて中間集計をファイル化する。

4) isliceで作るチャンク処理(APIバッチ送信など)

イテレータをn件ずつ切って処理するテンプレート。nには正の整数を指定します。

from itertools import islice

def chunked(iterable, n):
    if not isinstance(n, int) or isinstance(n, bool) or n <= 0:
        raise ValueError("n must be a positive integer")
    it = iter(iterable)
    while True:
        chunk = list(islice(it, n))
        if not chunk:
            break
        yield chunk

ユースケース:APIのバッチ送信、分割書き出し。チャンクサイズはAPI制限やメモリと相談して決定。

5) dequeを使ったスライディングウィンドウ

固定長ウィンドウで近傍集計や異常検知をする場合に有用です。nには正の整数を指定します。

from collections import deque

def sliding_window(iterable, n):
    if not isinstance(n, int) or isinstance(n, bool) or n <= 0:
        raise ValueError("n must be a positive integer")
    it = iter(iterable)
    dq = deque(maxlen=n)
    for x in it:
        dq.append(x)
        if len(dq) == n:
            yield tuple(dq)

例:移動平均や最近n件の最大値を計算して閾値判定に使います。

6) chain.from_iterableでのフラット化/多段処理

入れ子になったイテラブルをフラット化して連続処理する時に便利です。

from itertools import chain

def flatten(list_of_lists):
    return chain.from_iterable(list_of_lists)

コード例とコピー可能なユースケース

ここでは「1関数=単一責務」でテストしやすい形にした実務向けサンプルを示します。標準ライブラリのみを使います。

① 行単位でメモリ節約しながら日次集計を作る(csv.reader+itertools+defaultdict)

import csv
from collections import defaultdict
from datetime import datetime

def daily_aggregate_csv(path, date_index, value_index, date_fmt='%Y-%m-%d'):
    """CSVを逐次読みして日次合計を返すジェネレータ"""
    totals = defaultdict(float)
    with open(path, newline='', encoding='utf-8') as f:
        reader = csv.reader(f)
        for row in reader:
            try:
                d = datetime.strptime(row[date_index], date_fmt).date()
                v = float(row[value_index])
            except Exception:
                continue  # 実務ではログを残す
            totals[str(d)] += v
    for day, total in totals.items():
        yield day, total

② API呼び出しをチャンク化してPrefect/スケジューラに渡す最小実装

def send_in_batches(iterable, batch_size, send_func):
    """send_funcはリストを受け取る関数(同期)"""
    for batch in chunked(iterable, batch_size):
        send_func(batch)

③ スライディングウィンドウで移動平均・閾値判定

def moving_average_threshold(iterable, window_size, threshold):
    for window in sliding_window(iterable, window_size):
        avg = sum(window) / window_size
        if avg > threshold:
            yield True, avg
        else:
            yield False, avg

④ 重複除去+最初の有効行を残す例

def dedupe_keep_first_by_key(rows, key_index):
    def key_func(r):
        return r[key_index]
    yield from dedupe_preserve_first(rows, key_func)

性能・メモリの実務チェックリスト

確認項目 測定方法 判断のしかた
入力全体を保持していないか 代表的な実データで最大メモリ使用量を測る 実行環境の利用可能メモリに余裕がなければ、逐次処理や分割処理へ変更する
チャンクサイズ 複数のサイズで処理時間と最大メモリ使用量を比較する APIの件数制限や書き込み先の制約を守り、実測値から決める
groupby前のソート ソートと集計を分けて処理時間・メモリ使用量を測る メモリや許容時間を超える場合は、外部ソートや別の集計方法を検討する
dequeのサイズ 業務上必要な期間・件数を確認する 目的からウィンドウ幅を決め、不要な履歴を保持しない
Counterのキー数 ユニークキー数と最大メモリ使用量を記録する メモリに収まらない場合は、分割集計やデータベースの利用を検討する

測定にはtimeittracemallocなどを利用できます。ただし、固定した行数や割合をすべての環境に当てはめることはできません。本番に近いデータ量、実行環境、許容時間をそろえて比較します。

落とし穴と運用上の注意

  • groupbyの事前ソート忘れ:ソートしないと意図しないグループに分割される。
  • defaultdictのファクトリの罠:defaultdict(list)では欠損キーごとに新しいリストが生成される。一方、同じ可変オブジェクトを返すファクトリを指定すると複数のキーで共有され、副作用が起きることがある。
  • 巨大ファイル処理でのバッファ戦略:読み込み時のバッファサイズやopenのencodingを運用基準として明記する。
  • デバッグ用サンプルデータ作成:代表的なエッジケース(空行、欠損、型エラー)を含めた小サンプルを最低1つ用意する。
  • ログ出力ポイント:中間集計時のキー数や最長処理時間をログに残すと再現可能な失敗ケースを作りやすい。

テスト・CI向けの小節

関数は純粋関数(副作用を少なく)にしてpytestでテストします。例:

import pytest

def test_dedupe_preserve_first():
    rows = [("a",1),("b",2),("a",3)]
    out = list(dedupe_preserve_first(rows, lambda r: r[0]))
    assert out == [("a",1),("b",2)]

def test_chunked_empty():
    assert list(chunked([], 10)) == []

def test_chunked_rejects_non_positive_size():
    with pytest.raises(ValueError):
        list(chunked([1, 2], 0))

def test_sliding_window_rejects_non_positive_size():
    with pytest.raises(ValueError):
        list(sliding_window([1, 2], 0))

API呼び出しはモック(unittest.mock)でsend_funcを代替して呼び出し回数や最後に送ったデータを検証します。

実装テンプレート配布案

標準ライブラリのみのミニテンプレートをGitHub Gistで配布すると実務導入が早まります。リポジトリには:

  • functions.py(今回の関数群)
  • tests/test_functions.py(pytestテスト)
  • examples/(小さなCSVサンプル、README)

依存が少ないため社内に取り込みやすく、CIへの組み込みも容易です。

まとめ

collectionsとitertoolsは標準ライブラリでありながら、実務で直接役立つ強力なツール群です。この記事で示したパターン(重複除去、groupby集計、defaultdict/Counter、チャンク処理、スライディングウィンドウ、フラット化)はそのまま業務に貼って使える形にしています。ポイントは「メモリを意識した設計」「単一責務の小さな関数」「ログとテストを忘れないこと」です。

次の一歩(第148回以降の案)

次回以降では、並列処理(concurrent.futuresやmultiprocessing)とcollections/itertoolsの組み合わせ、またストリーミングETLでのbackpressure対策や外部ソートの実践を扱う予定です。今回の関数群を土台に、並列化やスケーリングを段階的に導入していくことをおすすめします。

Manage AI シリーズ「AIとPythonの実務」 — 本記事は第147回目です。業務に直結する小さな改善を積み重ねていきましょう。

第146回 実務で使える表データのスキーマ検証と自動修復ワークフロー — Pythonで作る現場向けバリデーションの手順

現場で受け取るCSVやExcelがそのままではAIモデルや集計に使えず、作業が止まってしまう──そんな経験は多いはずです。本記事では「受け取り側の契約(スキーマ)」を明確にし、検証→自動修復→記録→隔離という実務ワークフローをPythonで実装します。CSVとExcel(.xlsx)の読み込み、検証失敗時の例外処理、修復ログと隔離ファイルの出力までを一つの最小構成にまとめます。第134回・第135回のファイルI/O/クリーニング記事の知識がある前提です。

狙いと前提

目的は、現場で受け取る表データを「そのまま処理できる状態」に自動で近づけることです。完全自動で正解にすることを約束するのではなく、許容できる自動修復ポリシーを定義して運用する点を重視します。

この記事の実装例では、入力ファイルを変更せず、処理結果を次の3種類に分けて出力します。

  • accepted.csv:検証を通過したレコード
  • rejected.csv:修復または検証に失敗したレコード
  • audit.jsonl:行番号、処理結果、修復前後、適用ルールを記録した監査ログ

ステップ1:スキーマ設計の実務手順

まずは業務要件から逆算して、スキーマ(契約)を設計します。下表は実務で使いやすいテンプレートです。

カラム名 必須 許容値/正規表現 欠損ポリシー ユニーク 外部参照
customer_id string 必須 ^[A-Z0-9\-]{8,}$ 欠損は隔離 顧客マスタ参照
email string 条件付き 業務で合意した形式 空文字を許容するか明示 業務次第 なし
order_date date 必須 出力はISO日付 パース不可は隔離 false なし
amount number 必須 >=0 欠損は隔離、または業務ポリシーを別途定義 false 通貨変換ルール

設計時のチェックリスト:

  • 「必須」か「許容する欠損」かを明確にする
  • 許容されるデータ型とフォーマットを具体的に例示する
  • 自動修復ルールを優先順位付きで決める
  • ユニーク制約や外部参照を行単位で確認するか、バッチ後に確認するかを決める
  • スキーマのバージョン管理と互換性方針を定める

ステップ2:環境構築と入力ファイル

以下の例はPython 3.10以降を想定します。CSVは標準ライブラリのcsv、Excel(.xlsx)はpandasopenpyxlで読み込みます。

mkdir table-validator
cd table-validator
python -m venv .venv

# macOS / Linux
source .venv/bin/activate

# Windows PowerShellの場合
# .venv\Scripts\Activate.ps1

python -m pip install --upgrade pip
python -m pip install pandas openpyxl jsonschema "pydantic>=2" pandera pytest

動作確認用のinput.csvを作成します。

customer_id,email,order_date,amount
ABCD-1234, MAILTO:USER@EXAMPLE.COM ,31/01/2025,"1,200"
BAD,user@example.com,not-a-date,-10
EFGH-5678,,2025-02-01,300

1行目は修復後に検証を通過し、2行目は日付のパースに失敗して隔離されます。3行目はそのまま、または軽微な正規化後に通過します。

ステップ3:dataclassからJSON Schemaを生成する軽量実装

次のコードをworkflow.pyとして保存してください。dataclassの型情報とメタデータからJSON Schemaの辞書を生成し、jsonschema.validateで行単位に検証します。

from __future__ import annotations

import argparse
import csv
import json
import unicodedata
from dataclasses import MISSING, asdict, dataclass, field, fields
from datetime import datetime
from pathlib import Path
from typing import Any, get_type_hints

import pandas as pd
from jsonschema import FormatChecker, ValidationError, validate


INPUT_FIELDS = ["customer_id", "email", "order_date", "amount"]


@dataclass
class OrderRow:
    customer_id: str = field(
        metadata={"pattern": r"^[A-Z0-9\-]{8,}$"}
    )
    order_date: str = field(
        metadata={"format": "date"}
    )
    amount: float = field(
        metadata={"minimum": 0}
    )
    email: str = ""


def dataclass_to_json_schema(model_class: type) -> dict[str, Any]:
    """この例で使うstrとfloatをJSON Schemaへ変換する。"""
    type_map = {
        str: "string",
        int: "integer",
        float: "number",
        bool: "boolean",
    }
    hints = get_type_hints(model_class)
    properties: dict[str, Any] = {}
    required: list[str] = []

    for item in fields(model_class):
        python_type = hints[item.name]
        if python_type not in type_map:
            raise TypeError(f"未対応の型です: {item.name}={python_type}")

        property_schema = {"type": type_map[python_type]}
        for key in ("pattern", "format", "minimum", "maximum"):
            if key in item.metadata:
                property_schema[key] = item.metadata[key]

        properties[item.name] = property_schema

        if item.default is MISSING and item.default_factory is MISSING:
            required.append(item.name)

    return {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": properties,
        "required": required,
        "additionalProperties": False,
    }


ORDER_SCHEMA = dataclass_to_json_schema(OrderRow)


def normalize_text(value: Any) -> str:
    text = "" if value is None else str(value)
    return unicodedata.normalize("NFKC", text).strip()


def repair_row(row: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
    """許可した修復だけを適用し、修復内容も返す。"""
    repaired = dict(row)
    changes: list[dict[str, Any]] = []

    def set_value(field_name: str, new_value: Any, rule: str) -> None:
        old_value = repaired.get(field_name)
        repaired[field_name] = new_value
        if old_value != new_value:
            changes.append(
                {
                    "field": field_name,
                    "rule": rule,
                    "before": old_value,
                    "after": new_value,
                }
            )

    customer_id = normalize_text(repaired.get("customer_id"))
    set_value("customer_id", customer_id, "NFKC変換と前後空白削除")

    email = normalize_text(repaired.get("email")).lower()
    if email.startswith("mailto:"):
        email = email.removeprefix("mailto:").strip()
    set_value("email", email, "小文字化、前後空白削除、mailto:削除")

    amount_text = normalize_text(repaired.get("amount")).replace(",", "")
    try:
        amount = float(amount_text)
    except ValueError as exc:
        raise ValueError(f"amountを数値に変換できません: {amount_text!r}") from exc
    set_value("amount", amount, "NFKC変換、カンマ削除、float変換")

    date_text = normalize_text(repaired.get("order_date"))
    parsed_date = None
    selected_format = None
    for date_format in ("%Y-%m-%d", "%d/%m/%Y"):
        try:
            parsed_date = datetime.strptime(date_text, date_format).date()
            selected_format = date_format
            break
        except ValueError:
            continue

    if parsed_date is None:
        raise ValueError(f"order_dateを日付に変換できません: {date_text!r}")

    set_value(
        "order_date",
        parsed_date.isoformat(),
        f"{selected_format}で解析しISO日付へ変換",
    )

    return repaired, changes


def read_rows(path: Path) -> list[dict[str, Any]]:
    """CSVまたは.xlsxを辞書のリストとして読み込む。"""
    suffix = path.suffix.lower()

    if suffix == ".csv":
        with path.open("r", encoding="utf-8-sig", newline="") as handle:
            return [dict(row) for row in csv.DictReader(handle)]

    if suffix == ".xlsx":
        dataframe = pd.read_excel(
            path,
            engine="openpyxl",
            dtype=str,
            keep_default_na=False,
        )
        return dataframe.to_dict(orient="records")

    raise ValueError("対応形式は.csvまたは.xlsxです")


def write_csv(
    path: Path,
    rows: list[dict[str, Any]],
    fieldnames: list[str],
) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8-sig", newline="") as handle:
        writer = csv.DictWriter(handle, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerows(rows)


def process_file(input_path: Path, output_dir: Path) -> None:
    accepted: list[dict[str, Any]] = []
    rejected: list[dict[str, Any]] = []
    audit_records: list[dict[str, Any]] = []

    for line_number, original in enumerate(read_rows(input_path), start=2):
        changes: list[dict[str, Any]] = []

        try:
            repaired, changes = repair_row(original)
            validate(
                instance=repaired,
                schema=ORDER_SCHEMA,
                format_checker=FormatChecker(),
            )

            # 検証後のキーと型を固定する。
            normalized = asdict(
                OrderRow(
                    customer_id=repaired["customer_id"],
                    email=repaired.get("email", ""),
                    order_date=repaired["order_date"],
                    amount=repaired["amount"],
                )
            )
            accepted.append(normalized)
            status = "repaired" if changes else "accepted"

            audit_records.append(
                {
                    "line": line_number,
                    "status": status,
                    "original": original,
                    "result": normalized,
                    "changes": changes,
                }
            )

        except (ValueError, ValidationError) as exc:
            rejected_row = dict(original)
            rejected_row["_line"] = line_number
            rejected_row["_error"] = str(exc)
            rejected.append(rejected_row)

            audit_records.append(
                {
                    "line": line_number,
                    "status": "rejected",
                    "original": original,
                    "changes": changes,
                    "error_type": type(exc).__name__,
                    "error": str(exc),
                }
            )

    output_dir.mkdir(parents=True, exist_ok=True)

    write_csv(
        output_dir / "accepted.csv",
        accepted,
        ["customer_id", "order_date", "amount", "email"],
    )

    extra_reject_fields = sorted(
        {
            key
            for row in rejected
            for key in row
            if key not in INPUT_FIELDS and key not in {"_line", "_error"}
        }
    )
    reject_fields = INPUT_FIELDS + extra_reject_fields + ["_line", "_error"]
    write_csv(output_dir / "rejected.csv", rejected, reject_fields)

    with (output_dir / "audit.jsonl").open("w", encoding="utf-8") as handle:
        for record in audit_records:
            handle.write(json.dumps(record, ensure_ascii=False) + "\n")

    print(f"accepted={len(accepted)} rejected={len(rejected)}")
    print(f"output={output_dir.resolve()}")


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("input", type=Path)
    parser.add_argument("--output-dir", type=Path, default=Path("output"))
    args = parser.parse_args()
    process_file(args.input, args.output_dir)


if __name__ == "__main__":
    main()

保存後、次の順番で実行します。

python workflow.py input.csv --output-dir output

Excelを入力する場合は、同じ列を持つinput.xlsxを用意して次のように実行します。出力形式はCSVとJSON Linesです。

python workflow.py input.xlsx --output-dir output

repair_row内の変換に失敗した場合はValueError、JSON Schemaに違反した場合はjsonschema.ValidationErrorを捕捉します。いずれも処理全体を停止させず、その行をrejected.csvへ隔離します。入力に想定外の列がある場合も、additionalProperties: falseにより検証エラーになります。

ステップ4:実装パターンの比較

パターン 概要 メリット デメリット
軽量(dataclass + jsonschema) dataclassからJSON Schemaを生成し、行単位で検証する 処理順と修復ルールを明示しやすい 型変換や複雑な修復は手実装になる
Pydantic モデル単位で型変換と検証を行う 詳細な検証エラーを取得しやすい 独自の修復ルールにはvalidatorの実装が必要
Pandera pandas DataFrame全体の列型や制約を検証する ユニーク制約などの表単位の検査を記述しやすい 行単位の修復・隔離は別途設計が必要

Pydanticによる行単位の検証例

Pydantic v2を使う場合は、model_validateで入力辞書を検証し、ValidationErrorを捕捉します。次は日付、金額、メールをモデル内で正規化する最小例です。

import unicodedata
from datetime import date, datetime
from typing import Any

from pydantic import (
    BaseModel,
    ConfigDict,
    Field,
    ValidationError as PydanticValidationError,
    field_validator,
)


class OrderModel(BaseModel):
    model_config = ConfigDict(extra="forbid")

    customer_id: str = Field(pattern=r"^[A-Z0-9\-]{8,}$")
    order_date: date
    amount: float = Field(ge=0)
    email: str = ""

    @field_validator("customer_id", mode="before")
    @classmethod
    def normalize_customer_id(cls, value: Any) -> str:
        return unicodedata.normalize("NFKC", str(value)).strip()

    @field_validator("amount", mode="before")
    @classmethod
    def normalize_amount(cls, value: Any) -> float:
        text = unicodedata.normalize("NFKC", str(value)).strip()
        return float(text.replace(",", ""))

    @field_validator("order_date", mode="before")
    @classmethod
    def normalize_order_date(cls, value: Any) -> date:
        if isinstance(value, date):
            return value

        text = str(value).strip()
        for date_format in ("%Y-%m-%d", "%d/%m/%Y"):
            try:
                return datetime.strptime(text, date_format).date()
            except ValueError:
                continue
        raise ValueError(f"日付に変換できません: {text!r}")

    @field_validator("email", mode="before")
    @classmethod
    def normalize_email(cls, value: Any) -> str:
        text = "" if value is None else str(value)
        text = unicodedata.normalize("NFKC", text).strip().lower()
        return text.removeprefix("mailto:").strip()


raw = {
    "customer_id": "ABCD-1234",
    "email": " MAILTO:USER@EXAMPLE.COM ",
    "order_date": "31/01/2025",
    "amount": "1,200",
}

try:
    order = OrderModel.model_validate(raw)
    print(order.model_dump(mode="json"))
except PydanticValidationError as exc:
    for error in exc.errors():
        print(error)

実運用では、成功したmodel_dump()の結果を正常系へ送り、PydanticValidationErrorを捕捉したレコードを隔離します。修復前の辞書は上書きせず、監査ログへ保存してください。

PanderaによるDataFrame全体の検証例

行単位の修復後に、ユニーク制約や列全体の型を確認する場合はPanderaを利用できます。次の例は、先ほど生成したoutput/accepted.csvを再検証します。

import pandas as pd
import pandera as pa


dataframe = pd.read_csv(
    "output/accepted.csv",
    keep_default_na=False,
)

order_schema = pa.DataFrameSchema(
    {
        "customer_id": pa.Column(
            str,
            checks=pa.Check.str_matches(r"^[A-Z0-9\-]{8,}$"),
            unique=True,
        ),
        "order_date": pa.Column("datetime64[ns]", nullable=False),
        "amount": pa.Column(float, checks=pa.Check.ge(0)),
        "email": pa.Column(str, nullable=False),
    },
    strict=True,
    coerce=True,
)

try:
    validated_dataframe = order_schema.validate(dataframe, lazy=True)
    print(validated_dataframe)
except pa.errors.SchemaErrors as exc:
    print(exc.failure_cases)
    exc.failure_cases.to_csv(
        "output/pandera_failure_cases.csv",
        index=False,
        encoding="utf-8-sig",
    )

lazy=Trueでは、検出した複数の違反をSchemaErrorsとしてまとめて確認できます。failure_casesは違反内容の調査に使えますが、元レコードを隔離する処理は、利用するインデックスや業務上の主キーに合わせて別途実装してください。

ステップ5:自動修復ポリシー

フィールド 修復ルール 優先度 ログ/差分
文字列 NFKC正規化、前後空白の削除 修復前後と適用ルールを保存
amount 全角数字の正規化、カンマ削除、float変換 変換前後を保存
order_date %Y-%m-%d、%d/%m/%Yの順で試し、ISO日付へ統一 採用した日付形式を保存
email 小文字化、前後空白削除、mailto:削除 低〜中 修復前後を保存

金額のクリッピングや欠損値の0埋めは、正しい値を推測する変更になり得ます。そのため上記の実装には含めていません。実施する場合は、業務上の閾値、承認条件、HITL(人の確認)を明文化してください。

  • 修復前の入力ファイルを上書きしない
  • 修復理由を構造化ログとして残す
  • 重大な修復は自動確定せず、担当者の確認対象にする
  • 日付のように解釈が分かれる形式は、送信元と優先順位を合意する

ステップ6:ワークフロー統合と運用フロー

バリデーションはETLの初段に組み込みます。定期実行にはcron、ワークフロー管理ツール、CIなど、運用環境に合う方法を選びます。重要なのは、検証失敗時に処理全体を停止するのか、行単位で隔離して継続するのかを事前に決めることです。

ケース 自動対応 運用アクション
軽微な修復で処理可能 修復後に再検証して正常系へ送る 修復ログを記録
自動修復では確定できない 隔離ファイルへ出力 担当者が原本とエラーを確認
重大な整合性違反 本番投入を止めるか対象行を隔離 送信元へのフィードバックとスキーマ再合意

SLOの例:

  • 受信ファイルの90%を自動修復で本番投入可能にする(週次評価)
  • 重大な隔離レコードは24時間以内に原因調査を開始する

テストとCI

次のコードをtest_workflow.pyとして保存します。

import pytest
from jsonschema import FormatChecker, ValidationError, validate

from workflow import ORDER_SCHEMA, repair_row


def test_repair_and_validate():
    raw = {
        "customer_id": " ABCD-1234 ",
        "email": " MAILTO:USER@EXAMPLE.COM ",
        "order_date": "31/01/2025",
        "amount": "1,200",
    }

    repaired, changes = repair_row(raw)

    validate(
        instance=repaired,
        schema=ORDER_SCHEMA,
        format_checker=FormatChecker(),
    )

    assert repaired["order_date"] == "2025-01-31"
    assert repaired["amount"] == 1200.0
    assert repaired["email"] == "user@example.com"
    assert changes


def test_negative_amount_is_rejected():
    raw = {
        "customer_id": "ABCD-1234",
        "email": "",
        "order_date": "2025-01-31",
        "amount": "-10",
    }

    repaired, _ = repair_row(raw)

    with pytest.raises(ValidationError):
        validate(
            instance=repaired,
            schema=ORDER_SCHEMA,
            format_checker=FormatChecker(),
        )


def test_invalid_date_is_rejected():
    raw = {
        "customer_id": "ABCD-1234",
        "email": "",
        "order_date": "not-a-date",
        "amount": "100",
    }

    with pytest.raises(ValueError):
        repair_row(raw)

テストを実行します。

pytest -q
CI段階 チェック内容
PR時 pytestで検証と自動修復のユニットテストを実行
マージ後 匿名化したサンプルデータで統合テストを実行

スキーマ設計用YAMLテンプレート

次のYAMLは送信元との合意やレビューに使う設計テンプレートです。上記のPythonコードがこのYAMLを直接読み込むわけではありません。

fields:
  - name: customer_id
    type: string
    required: true
    pattern: '^[A-Z0-9\-]{8,}$'
  - name: order_date
    type: date
    required: true
    formats:
      - '%Y-%m-%d'
      - '%d/%m/%Y'
  - name: amount
    type: number
    required: true
    minimum: 0
  - name: email
    type: string
    required: false

運用上の注意点と落とし穴

  • 過度な厳格化は業務停止を招くため、最低限の必須ルールから始める
  • 入力側とのスキーマ合意とサンプル交換を定期的に行う
  • スキーマをバージョン管理し、破壊的変更は事前に通知する
  • ログや監査情報を保存し、どの入力にどの修復を行ったか追跡できるようにする
  • CSVの文字コード、区切り文字、Excelの対象シートも受け渡し契約に含める

次の一歩

  • 主要テーブル1つから導入し、隔離理由の多い項目を確認する
  • ユニーク制約や外部参照をバッチ検証へ追加する
  • 生成したスキーマを使ったデータ生成テストで破壊的変更を検知する

まとめ

  • スキーマは受け取り側と渡し手の「契約」であり、型、必須、形式、修復範囲を合意することが重要です。
  • dataclassとjsonschemaを使うと、行単位の修復、検証、例外処理、隔離を小さく実装できます。
  • Pydanticはモデル単位の型変換と詳細なエラー、PanderaはDataFrame全体の制約確認に利用できます。
  • 自動修復では入力を上書きせず、修復前後と適用ルールを監査ログへ残してください。
  • pytestとCIで正常系、逸脱例、境界値を継続的に確認してください。

第145回 実務で使えるPython基礎:クラスとオブジェクト指向で作る再利用可能なデータ処理コンポーネント

実務でPythonを使っていると、「似た処理が何度も現れる」「一度作った処理のテストや運用が難しい」と感じることはありませんか。この記事では、クラス設計のパターンを、CSVクリーナー、推論ラッパー、Pipelineのコードで示します。標準ライブラリを中心に実装し、pytestによるテストまで確認します。

なぜクラスを使うか(現場メリット)

関数の集まりでも十分なケースは多いですが、状態やリソースの管理、初期化コストの節約、モックしやすいインターフェースを作る点でクラスは有用です。以下の表は代表的な利点と注意点です。

利点 現場で役立つ理由 注意点
状態管理 重いリソースを初回だけロードして再利用できる(モデル、セッション) メモリやマルチプロセスを考慮する必要あり
リソース管理 ファイルやセッションを確実に開放しやすい 誤ったライフサイクル設計でリークする
テスト性 モックしやすいインスタンスメソッドで外部依存を切れる 複雑な状態はテストを書く負担を増やす

状態を持つコンポーネント vs ステートレス関数

どちらを選ぶかは次の観点で判断します。

  • 初期化コストが高い処理(モデルロード、外部接続)には、状態を持つクラスを検討する。
  • 受け取ったデータだけを変換する処理には、ステートレス関数を使う。
  • テストやモックのしやすさを優先する場合は、外部依存をコンストラクターから注入できるようにする。

リソース管理:context managerを使う

ファイルやセッションは明示的に開閉する必要があります。context manager(withステートメント)を使うと、処理中に例外が発生した場合もファイルを閉じられます。

例1:ファイルハンドルを安全に扱うCSVクリーナークラス

以下のコードをcomponents.pyとして保存します。入力CSVの各文字列から前後の空白を取り除き、別のCSVへ保存する例です。

# components.py
import csv
from threading import Lock


class CSVCleaner:
    def __init__(self, input_path, output_path, encoding="utf-8"):
        self.input_path = input_path
        self.output_path = output_path
        self.encoding = encoding
        self._input_file = None
        self._output_file = None

    def __enter__(self):
        self._input_file = open(
            self.input_path,
            "r",
            encoding=self.encoding,
            newline="",
        )
        try:
            self._output_file = open(
                self.output_path,
                "w",
                encoding=self.encoding,
                newline="",
            )
        except Exception:
            self._input_file.close()
            self._input_file = None
            raise
        return self

    def __exit__(self, exc_type, exc, traceback):
        try:
            if self._output_file is not None:
                self._output_file.close()
        finally:
            if self._input_file is not None:
                self._input_file.close()
        return False

    def clean(self):
        if self._input_file is None or self._output_file is None:
            raise RuntimeError("CSVCleaner must be used with a with statement")

        reader = csv.DictReader(self._input_file)
        if reader.fieldnames is None:
            raise ValueError("CSV header is required")

        writer = csv.DictWriter(
            self._output_file,
            fieldnames=reader.fieldnames,
        )
        writer.writeheader()

        count = 0
        for row in reader:
            cleaned_row = {
                key: value.strip() if isinstance(value, str) else value
                for key, value in row.items()
            }
            writer.writerow(cleaned_row)
            count += 1

        return count

次のようにwith構文で使用します。clean()の途中で例外が発生した場合も、__exit__が入出力ファイルを閉じます。

from components import CSVCleaner

with CSVCleaner("raw.csv", "clean.csv") as cleaner:
    processed_count = cleaner.clean()

print(processed_count)

合成(composition)と継承(inheritance)の使い分け

一般の実務では合成を優先します。合成は役割ごとにコンポーネントを分け、再利用とテストを容易にします。継承は明確に「is-a」の関係があるとき、またはフレームワークの拡張時に限定して使います。

選び方 合成(composition) 継承(inheritance)
使う場面 異なる振る舞いを組み合わせるとき 共通の振る舞いを拡張するとき
メリット 変更に強く、テストしやすい コードの重複を減らせるが複雑になりがち

実践例:推論ラッパーとPipeline

次は、モデルを必要になるまでロードしない推論ラッパーと、CSV読み込み、前処理、推論、保存を合成するPipelineクラスです。以下のコードは、先ほど作成したcomponents.pyの末尾へ追記します。

例2:推論ラッパークラス(モデルロード/predict)

class ModelWrapper:
    def __init__(self, loader):
        self.loader = loader
        self._model = None
        self._load_lock = Lock()

    def _get_model(self):
        if self._model is None:
            with self._load_lock:
                if self._model is None:
                    self._model = self.loader()
        return self._model

    def predict(self, records):
        model = self._get_model()
        return model.predict(records)

loaderには、引数なしでモデルを返す関数を渡します。モデルは最初のpredict()呼び出し時にロードされ、その後は同じインスタンスが再利用されます。ロックが保護するのはロード処理であり、各モデルのpredict()自体がスレッドセーフであることを保証するものではありません。

例3:Pipelineクラス(CSV読み込み→前処理→推論→保存)

class Pipeline:
    def __init__(self, model, preprocessor):
        self.model = model
        self.preprocessor = preprocessor

    def run(self, input_path, output_path, encoding="utf-8"):
        with open(input_path, "r", encoding=encoding, newline="") as input_file:
            reader = csv.DictReader(input_file)
            if reader.fieldnames is None:
                raise ValueError("CSV header is required")

            fieldnames = list(reader.fieldnames)
            if "prediction" in fieldnames:
                raise ValueError("Input CSV already has a prediction column")

            original_rows = list(reader)

        processed_rows = [
            self.preprocessor(row) for row in original_rows
        ]
        predictions = list(self.model.predict(processed_rows))

        if len(predictions) != len(original_rows):
            raise ValueError(
                "The number of predictions must match the number of input rows"
            )

        output_fieldnames = fieldnames + ["prediction"]
        with open(output_path, "w", encoding=encoding, newline="") as output_file:
            writer = csv.DictWriter(
                output_file,
                fieldnames=output_fieldnames,
            )
            writer.writeheader()

            for row, prediction in zip(original_rows, predictions):
                output_row = dict(row)
                output_row["prediction"] = prediction
                writer.writerow(output_row)

        return len(original_rows)

Pipelineは、predict(records)を持つオブジェクトと、CSVの1行を前処理する関数を受け取ります。特定の機械学習ライブラリには依存していません。

Pipelineをローカルで実行する

動作確認用として、次の内容をinput.csvへ保存します。

id,amount
1,80
2,120

続いて、以下をrun_pipeline.pyとして保存します。

from components import ModelWrapper, Pipeline


class ThresholdModel:
    def __init__(self, threshold):
        self.threshold = threshold

    def predict(self, records):
        return [
            "high" if record["amount"] >= self.threshold else "low"
            for record in records
        ]


def load_model():
    return ThresholdModel(threshold=100.0)


def preprocess(row):
    return {"amount": float(row["amount"].strip())}


model = ModelWrapper(loader=load_model)
pipeline = Pipeline(model=model, preprocessor=preprocess)
processed_count = pipeline.run("input.csv", "output.csv")
print(f"processed: {processed_count}")

components.pyinput.csvrun_pipeline.pyを同じディレクトリへ置き、次の順序で実行します。

python run_pipeline.py

実行後のoutput.csvは次の内容になります。

id,amount,prediction
1,80,low
2,120,high

CSVが存在しない場合のFileNotFoundError、数値へ変換できない場合のValueError、モデル内部の例外は呼び出し元へ伝わります。ここでは広い例外を握りつぶさず、バッチ処理やAPIなどの呼び出し側でログ記録、再試行、処理中断を選べるようにしています。

テストとモックの方針

外部依存(ファイル、HTTP、モデル)はモックで切り離し、入出力の最小契約をテストします。ここではpytestのtmp_pathと、標準ライブラリのunittest.mock.Mockを使います。

次のコードをtests/test_components.pyとして保存します。

import csv
from unittest.mock import Mock

from components import CSVCleaner, ModelWrapper, Pipeline


def test_csv_cleaner_closes_files_and_strips_values(tmp_path):
    input_path = tmp_path / "raw.csv"
    output_path = tmp_path / "clean.csv"
    input_path.write_text(
        "id,name\n1, Alice \n",
        encoding="utf-8",
    )

    with CSVCleaner(input_path, output_path) as cleaner:
        assert cleaner.clean() == 1

    with output_path.open("r", encoding="utf-8", newline="") as file:
        rows = list(csv.DictReader(file))

    assert rows == [{"id": "1", "name": "Alice"}]


def test_model_wrapper_loads_model_only_once():
    loaded_model = Mock()
    loaded_model.predict.side_effect = [["first"], ["second"]]
    loader = Mock(return_value=loaded_model)
    wrapper = ModelWrapper(loader=loader)

    assert wrapper.predict([{"amount": 1}]) == ["first"]
    assert wrapper.predict([{"amount": 2}]) == ["second"]

    loader.assert_called_once_with()
    assert loaded_model.predict.call_count == 2


def test_pipeline_writes_predictions(tmp_path):
    input_path = tmp_path / "input.csv"
    output_path = tmp_path / "output.csv"
    input_path.write_text(
        "id,amount\n1,120\n",
        encoding="utf-8",
    )

    model = Mock()
    model.predict.return_value = ["high"]

    pipeline = Pipeline(
        model=model,
        preprocessor=lambda row: {"amount": float(row["amount"])},
    )

    assert pipeline.run(input_path, output_path) == 1
    model.predict.assert_called_once_with([{"amount": 120.0}])

    with output_path.open("r", encoding="utf-8", newline="") as file:
        rows = list(csv.DictReader(file))

    assert rows == [
        {"id": "1", "amount": "120", "prediction": "high"}
    ]

pytestが利用できる環境で、プロジェクトのルートディレクトリから次のコマンドを実行します。

python -m pytest

テストのポイントは次の通りです。

  • ModelWrapperのloaderはMockへ差し替えられ、ロード回数を検査できる。
  • Pipelineに渡すモデルもMockにできるため、実際のモデルをロードせず入出力を確認できる。
  • ファイル入出力にはtmp_pathを使い、テスト終了後に残るファイルを減らす。

シリアライズ・設定・バージョン対応(dataclass併用例)

設定をdataclassで型付きにし、JSONで保存する例です。長期運用では、設定データのバージョンを明示し、読み込めないバージョンを検出できるようにします。

項目 実務での推奨
設定管理 dataclassとJSONを使い、設定バージョンを明示する。環境変数で上書きする場合は優先順位も文書化する
シリアライズ 長期保存するデータでは、Python固有の形式だけを前提にせず、JSONなどでスキーマを管理する
モデルファイル ファイル名にバージョンを含め、manifestでメタ情報を管理する

次のコードは、設定の保存と読み込みを行う最小例です。

# config.py
import json
from dataclasses import asdict, dataclass
from pathlib import Path


@dataclass(frozen=True)
class AppConfig:
    model_version: str
    threshold: float
    schema_version: int = 1


def save_config(config, path):
    Path(path).write_text(
        json.dumps(asdict(config), ensure_ascii=False, indent=2),
        encoding="utf-8",
    )


def load_config(path):
    data = json.loads(Path(path).read_text(encoding="utf-8"))

    if data.get("schema_version") != 1:
        raise ValueError("Unsupported configuration schema version")

    return AppConfig(**data)


config = AppConfig(model_version="v1", threshold=100.0)
save_config(config, "config.json")
loaded_config = load_config("config.json")
print(loaded_config)

load_config()は未対応のschema_versionを検出するとValueErrorを送出します。実運用で環境変数による上書きを追加する場合は、JSONを読み込んだ後に適用し、その優先順位を明示してください。

運用チェックリストとデプロイ時の注意点

実務で詰まりやすい点をチェックリスト形式でまとめます。

カテゴリ チェック項目
初期化コスト 重いモデルは遅延ロードやwarmupを行っているか
メモリ/プロセス マルチプロセスやコンテナでモデルを使うときのメモリ増加を確認したか
シリアライズ 特定バージョンのPythonやライブラリだけで読める形式を長期互換の前提にしていないか
設定管理 環境変数とファイルの優先順位を文書化したか
ログ・メトリクス 処理時間、エラー率、入力サイズを計測しているか
障害時 再起動手順、再実行時の重複対策、ロールバック手順があるか

まとめと次の一歩

この記事の要点は次の通りです。

  • 状態を持つ処理(モデル、セッション)はクラスにして、初回ロードやライフサイクルを管理する。
  • 合成を優先してコンポーネントを小さく保ち、テストとモックで外部依存を切り離す。
  • context managerでファイルや接続を安全に閉じ、設定はdataclassとJSONで明示的に管理する。
  • 例外を無条件に握りつぶさず、ログや再試行を担当する呼び出し側へ伝える。

読了後のアクション提案:

  1. components.pyrun_pipeline.pyを保存し、サンプルPipelineをローカルで実行する。
  2. tests/test_components.pyを追加し、python -m pytestでテストする。
  3. 業務の小さな処理を1つ選び、外部依存を注入できるクラスへ分割する。
  4. デプロイ前に、ログ、設定の優先順位、再実行手順、バージョン互換性を確認する。

Manage AI(https://manageai.online)では、次回以降で運用・点検軸をさらに掘り下げます。まずは再利用可能な小さなコンポーネントを作り、実装からテストまでの流れを確認してみてください。

第144回 実務で使えるPython基礎:リストと辞書で作る表データ処理の基本パターン

はじめに — つまずきに寄り添う一言

CSVやJSONLを扱うと、欠損や型、行の重複、結合ルールといった「地味に面倒な問題」に時間を取られがちです。本記事では、現場で最も出番の多い「リスト(行の列挙)と辞書(行を表す)」を使った実務的な処理パターンを、すぐ使える関数設計と運用チェックリスト付きで整理します。134/135回のファイル入出力・クリーニング回とつなげて読めるよう設計しています。

基本方針(入出力分離・小さな再利用可能コンポーネント)

関数はできるだけ「入力(rows)を受け取って新しい出力を返す」スタイルにします。ミュータブルな引数を書き換えるとバグになりやすいため、状態変更は明示的に行い、副作用は最小限にします。型ヒントと短いdocstringをつけると再利用性が上がります。

よく使う基本パターン(一覧と例)

パターン 関数署名(例) 説明・使い方(例スニペット)
フィルタ def filter_rows(rows: list, pred) -> list rows = [r for r in rows if pred(r)] 例: pred = lambda r: int(r[‘qty’]) > 0
変換(map) def transform_rows(rows: list, fn) -> list rows = [fn(r.copy()) for r in rows] 例: fnで日付正規化や型変換
グルーピング def group_by(rows: list, key_fn) -> dict collections.defaultdict(list)で集約。agg関数で合計や平均を作る
マージ(アップサート) def merge_rows(base, updates, key: str, prefer=’updates’) -> list 主キーでインデックス化し優先度ルールで列の上書きと衝突ログを出す
チャンク処理(大規模データ) def chunked_iter(it, size: int) yieldでメモリを節約。itertools.isliceで実装

集約とグルーピングの使い分け

手法 適用例 メモリ ソート要否
collections.defaultdict(list) 任意のキーでまとめて集約・集計 キー数×行参照分のメモリ 不要
collections.Counter 出現頻度や簡単な頻度集計 キー数分のメモリ 不要
itertools.groupby ソート済みデータの連続ブロック処理(ストリーミング向け) ほぼ定数(ソート前提) 必要(事前に同キーでソート)

軽い比較ベンチマークのポイント: defaultdictはランダム順のまま使える反面、キー数が多いとメモリが増える。groupbyは事前ソートが必要でソートコストが主要なボトルネックになります。

マージ(アップサート)パターンの実務設計

運用的に重要なのは「冪等性」と「衝突の可視化」です。典型的な実装手順は次のとおりです。

  • baseをkey→rowの辞書に変換する(index化)
  • updatesを走査して既存行があれば優先度ルールで列を更新、なければ挿入
  • 変更点はログ(衝突レコード)として出力する
  • 最後に辞書をリスト化して返す(順序が必要なら元順序を保持)
ポイント 注意点
優先度設計 列ごとに“どちらを優先するか”を明示するルール表を運用ドキュメント化する
冪等性 同じ更新を何度適用しても結果が変わらないように設計する(タイムスタンプ比較など)
衝突ログ 変更前後の値をCSV/JSONで出力して監査できるようにする

大規模データ対策(チャンクとストリーミング)

技法 利点 実務向け注意点
ジェネレータ + itertools.islice メモリ使用量を一定に抑えられる ステートフル処理(グルーピングなど)はチャンク境界の扱いに注意
外部ソート(重複除去) メモリに乗らないキーでのユニーク化が可能 ディスクI/Oのコストを見積もること
ハッシュリング(簡易) キーをハッシュで分割し、分割ごとに処理・マージする 分割数設計とファイル管理が運用コストになる

小さな再利用コンポーネント設計の例

関数 署名 戻り値 docstringのポイント
filter_rows filter_rows(rows: list, pred: Callable[[dict], bool]) -> list フィルタ後の行リスト predの期待するキーと型、欠損時の動作を明記
merge_rows merge_rows(base, updates, key: str, prefer: str=’updates’) -> list マージ後の行リストとオプションで衝突ログ 優先ルールと冪等性の挙動を説明

pytestでのテスト例(境界ケース・欠損値)

テスト名 簡単な内容(擬似アサーション)
test_filter_missing_key assert filter_rows([{‘a’:’1′},{ }], lambda r: r.get(‘a’)==’1′) == [{‘a’:’1′}]
test_merge_prefer_updates assert merge_rows([{‘id’:’1′,’v’:’old’}],[{‘id’:’1′,’v’:’new’}],’id’,prefer=’updates’)[0][‘v’]==’new’
test_chunked_iter_boundary assert len(list(chunked_iter(range(5), 2))) == 3

実務運用チェックリスト

チェック項目 目的・方法
エンコーディング確認 ファイル読み込み時にencodingを明示(UTF-8/BOMなど)
欠損値ルール 欠損の扱い(除外/補完/デフォルト値)を自動チェックで実行
型変換の検証 サンプル変換後の型チェックと失敗行の報告
行数差分とサンプルハッシュ 入出力の行数、先頭N行のハッシュで大きなズレを検出
監査ログ出力 マージや上書きの前後をログ化(143回の監査ログ方針に準拠)

落とし穴と改善策(実務でよくある問題)

  • ミュータブルな行をそのまま編集して意図せず元データを書き換える: コピーして操作する。
  • キーの正規化ミス(空白・大文字小文字): 正規化ルールを統一して統合テストに入れる。
  • パフォーマンスの盲点: プロファイラでボトルネックを特定し、必要ならpandas導入を判断(全体の読みやすさと運用コストを比較)。

次の一歩(ワークフロー化への橋渡し)

作成したコンポーネント群をPrefectやcronに組み込み、リトライ、監査ログ、テストを回すことで運用に耐えるフローになります。まずは1つの取り込み->正規化->マージの小さなパイプラインを作り、SLO(処理時間・成功率)を設定してください。

まとめ

リストと辞書は、表データ処理の基本であり続けます。重要なのは「小さな、再利用可能で副作用の少ない関数」を設計すること、そして運用チェックリストで品質を保つことです。本記事のパターンをテンプレートにして、まずは小さなパイプライン一つを作ってみてください。次回以降はワークフロー化と監査自動化の具体例へつなげます。

第143回 実務で使える運用と点検:監査ログ・SLO・運用チェックリストをPythonで自動化する手順

運用を始めると「何を記録すれば良いか」「どこまで自動化できるか」で迷うことが多いです。本記事は、監査ログ(予測の起点・入力・モデルバージョン・出力)を確実に残し、SLO/SLIを定義して日次・週次チェックをPythonで自動化する実務手順をまとめます。現場でそのまま試せるスキーマ例、チェックリスト、簡易ランブック、サンプルコードと運用上の注意点を提供します。

目次

  • 概要と要件定義
  • 監査ログ設計:JSONLスキーマ例と必須フィールド
  • SLO/SLIの定義と閾値設計
  • Pythonで作る日次/週次自動チェック(サンプルスクリプト)
  • ランブックと対応フロー
  • CI/スケジューラへの組み込みとレポート配信
  • 運用でよくある落とし穴と改善ループ
  • まとめと次につなげる企画案

① 概要と要件定義

まず最初に決めるのは「どのイベントを残すか」と「保持期間・マスキング方針」です。実務では記録不足で原因追跡ができないことがよくあるため、後から必要になりそうな情報は最小限でも残す方針が安全です。以下のポイントを決めてから実装を始めてください。

  • 記録するイベント:リクエスト受信、前処理、モデル呼び出し、応答、エラー、モデルデプロイ/ロールバック
  • 保持期間:法規や契約に従う。一般的には30日~1年を検討(個人情報含む場合は短縮)
  • マスキング:個人情報(PII)は保存しないかハッシュ化/部分マスクする
  • アクセス制御:監査ログは読み取り権限を限定
  • 改ざん防止:ハッシュや署名で改ざん検知できる仕組みを検討

② 監査ログ設計:JSONLスキーマ例と必須フィールド

実務では行指向(JSONL)が扱いやすく、ストリーム処理や検索にも適しています。まずは必須フィールドを定義し、運用に必要なメタデータを含めます。

JSONLスキーマ(例)

{"timestamp": "2026-08-31T09:12:34.123Z", "request_id": "req_0001", "user_id_hash": "sha256:...", "model": "predictor", "model_version": "v1.2.3", "input_summary": "token_count=45, features_ok=true", "input_hash": "sha256:...", "prediction": "approve", "confidence": 0.87, "latency_ms": 210, "error": null, "provenance": {"pipeline_version": "p1.0"}, "signature": "hmac:..."}

上記の1行が1レコード(JSONL)です。運用ではgzipで圧縮して保管することもあります。

フィールド 説明 必須
timestamp UTCタイムスタンプ(ISO8601) 必須
request_id リクエストを一意に識別するID 必須
user_id_hash 個人を特定しないハッシュ/マスク済みID 必須(PIIは非保存が原則)
model / model_version 使用したモデルとバージョン 必須
input_hash / input_summary 入力のハッシュと簡易要約(全文保存しない場合) 必須
prediction / confidence 出力と確信度 必須
latency_ms 推論所要時間(ミリ秒) 推奨
error エラー情報(発生時) 推奨
provenance 処理パイプラインや前処理のバージョン 推奨
signature ログ改ざん検知用の署名(省略可) オプション

③ SLO/SLIの定義と閾値設計

SLO(Service Level Objective)は運用目標、SLI(Service Level Indicator)はそれを測る指標です。現実的な閾値を決めるためには、基線となるメトリクスを数週間観察することが重要です。

指標(SLI) 式(例) SLO目標例
応答時間(P95 latency) 95パーセンタイルの遅延(ms) P95 < 500ms(可用性:99%)
エラー率 エラー数 / 総リクエスト数 < 1%(月間)
データ品質(欠損率) 欠損あるいは不整合レコードの割合 < 0.5%(週次)

SLOは技術的に達成可能で、かつビジネスに意味のある水準で設定します。最初は緩めに設定して運用で引き締めるのが現実的です。

SLOを計算するサンプル関数(Python)

def calc_error_rate(records):
    total = len(records)
    errors = sum(1 for r in records if r.get("error"))
    return errors / total if total else 0.0

def calc_p95_latency(latencies):
    if not latencies:
        return None
    latencies = sorted(latencies)
    import math
    idx = math.ceil(0.95 * len(latencies)) - 1
    return latencies[idx]

④ Pythonで作る日次/週次自動チェック(サンプルスクリプト付き)

ここでは、JSONLログを読み込み、SLOを計算してCSVと簡易HTMLレポートを出力し、Slackへ送る軽量な流れを示します。標準ライブラリ中心で書き、必要に応じてpandas導入を検討します。

ポイントで使う標準要素

  • logging と RotatingFileHandler(ログローテーション)
  • json / csv モジュール
  • datetime, timezone(UTC管理)
  • pathlib(ファイル操作)
  • 例外処理と簡易リトライ

サンプル:日次チェックの簡易スクリプト

import logging
from logging.handlers import RotatingFileHandler
import json
from pathlib import Path
from datetime import datetime, timezone
import csv
import requests

# ログ設定
logger = logging.getLogger("audit_checker")
handler = RotatingFileHandler("logs/audit_checker.log", maxBytes=5*1024*1024, backupCount=3)
formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
handler.setFormatter(formatter)
logger.addHandler(handler)
logger.setLevel(logging.INFO)

DATA_DIR = Path("/var/logs/audit")
REPORT_DIR = Path("/var/reports/audit")
REPORT_DIR.mkdir(parents=True, exist_ok=True)

# JSONLを読み込むユーティリティ
def read_jsonl(path):
    with path.open("r", encoding="utf-8") as f:
        for line in f:
            try:
                yield json.loads(line)
            except json.JSONDecodeError:
                logger.warning(f"invalid json: {path} : {line[:100]}")

# 集計処理
def aggregate_for_period(paths):
    records = []
    latencies = []
    for p in paths:
        for r in read_jsonl(p):
            records.append(r)
            if r.get("latency_ms") is not None:
                latencies.append(r["latency_ms"])
    return records, latencies

# SLO計算(先ほどの関数を活用)
def calc_error_rate(records):
    total = len(records)
    errors = sum(1 for r in records if r.get("error"))
    return errors / total if total else 0.0

def calc_p95_latency(latencies):
    if not latencies:
        return None
    latencies = sorted(latencies)
    import math
    idx = math.ceil(0.95 * len(latencies)) - 1
    return latencies[idx]

# レポート出力
def write_csv_report(path, summary):
    with path.open("w", newline="", encoding="utf-8") as f:
        writer = csv.writer(f)
        writer.writerow(["metric", "value"])
        for k, v in summary.items():
            writer.writerow([k, v])

# Slackへ送る(軽量な例)
def post_slack(webhook_url, text):
    try:
        requests.post(webhook_url, json={"text": text}, timeout=5)
    except Exception as e:
        logger.warning(f"slack post failed: {e}")

# メイン処理(例:前日分)
def run_daily_check(date_str, slack_webhook=None):
    # 日付文字列 -> ファイルを選ぶルールは運用で決める
    day_file = DATA_DIR / f"audit_{date_str}.jsonl"
    records, latencies = aggregate_for_period([day_file])
    error_rate = calc_error_rate(records)
    p95 = calc_p95_latency(latencies)
    summary = {"date": date_str, "total_requests": len(records), "error_rate": error_rate, "p95_latency_ms": p95}
    csv_path = REPORT_DIR / f"summary_{date_str}.csv"
    write_csv_report(csv_path, summary)
    logger.info(f"daily check done: {summary}")
    if slack_webhook:
        post_slack(slack_webhook, f"Daily audit report {date_str}: {summary}")

# 実行例
if __name__ == '__main__':
    today = datetime.now(timezone.utc).strftime('%Y-%m-%d')
    run_daily_check(today, slack_webhook=None)

上記は最小限の例です。実運用ではログのローテーション、古いログの削除、圧縮、取得失敗時のリトライなどを追加してください。

⑤ ランブックと対応フロー(トリアージ、エスカレーション、復旧手順)

平時から復旧までの手順を文章化しておくと、担当が不在でも対応が回ります。以下は簡易ランブックの例です。

アラート種別 優先度 初動対応(担当) エスカレーション先
SLO違反(エラー率急上昇) ログ確認、直近のデプロイ確認(オンコール) 開発リード/インフラ担当
モデルパフォーマンス劣化(急低下) サンプルの入力と出力を抽出して比較(運用担当) MLエンジニア
ログ書き込み失敗 ディスク容量・権限を確認(運用担当) インフラ担当

トリアージのポイント

  • まずは影響範囲(ユーザー数、バッチ処理かリアルタイムか)を評価する
  • 短時間で復旧可能かを判断し、ロールバックや一時遮断を検討する
  • 根本原因調査は別タスクにして、まずサービス維持を優先する

⑥ CI/スケジューラへの組み込みとレポート配信

定期チェックはcronやGCP Cloud Scheduler、GitHub Actionsなどに組み込みます。成果物はCSV/HTMLレポートとして保存し、Slackやメールで配信します。レポートは短く要点を示す形式が受け入れられやすいです。

軽量なSlack送信例(requests使用)は前述の post_slack を参照してください。メール送信はSMTPライブラリ、あるいは外部サービスのAPI利用を検討します。

⑦ 運用でよくある落とし穴と改善ループ

  • 大量ログのコスト増:すべて保存せずサンプリングや条件付き保存(エラー時のみ詳細を保存)を設計する
  • プライバシー:PIIは生データで保存しない。ハッシュ化や部分マスキングの具体例をポリシー化する
  • 改ざん対策:ログに単純なHMACを付与して改ざん検知の第一歩とする(秘密鍵は別管理)
  • SLOの現実的妥協:初期は広めに設定し、運用で引き締める。過度なSLOは誤検知や過剰対応を招く
  • 担当1人でも回せる仕組み:手順を簡潔に、復旧手順を優先度付きで定義する

運用チェックリスト(そのまま使える表)

項目 チェック内容 推奨頻度 責任者 備考
ログ保持 保存ポリシーが適用されているか(古いログは削除/アーカイブ) 月次 運用担当 法規遵守を確認
アクセス制御 ログストアのアクセス権を確認 月次 セキュリティ担当 最小権限の原則
マスキング PIIがマスク/ハッシュされているか 週次 運用担当 抜き打ち検査を推奨
異常検知閾値 SLO/SLIの閾値が現状に合っているか 四半期 SRE/運用 基線を見て調整
通知先 Slack/メールの通知先が最新か 週次 運用担当 オンコール表と同期
抜き打ちテスト テストリクエストでログ整合性を確認 月次 運用担当 結果は記録して改善へ

まとめ

監査ログ設計、SLO/SLI定義、日次/週次の自動チェック、そしてランブック化は運用の基本です。本記事では現場で使えるJSONLスキーマ例、SLO計算、Pythonを使った自動チェックの骨子、実務的な注意点を示しました。まずは最小限のログを確実に取り、SLOを設定して自動化することを優先してください。運用は作って終わりではなく、定期的な見直し(改善ループ)が重要です。

次につなげる企画案

  • ポストモーテムと改善ループの作り方(テンプレート付き)
  • 監査ログを検索・分析する軽量な検索基盤構築(ELK代替)
  • 監査ログからの自動改善サイクル(フィードバックループ)実装例

この記事は「AIとPythonの実務」シリーズの一部です。実務での導入や具体的なスクリプト改良の相談があれば、次回以降でより深掘りします。