こんにちは。Anagraftの伊藤です。
画像の検品、文書の自動分類、問い合わせの自動応答、そして生成AIの業務組み込み。ディープラーニング(深層学習)は、いまや特定の研究者だけの技術ではなく、実務のデータ活用の標準的な道具になりました。一方で、「理論の本は読んだが、実際のコードがどうなるのか分からない」「サンプルは動いたが、自社のデータへの応用の仕方が分からない」という声も多く聞かれます。
本コラムは、PyTorchを軸に、TensorFlow(Keras)やHugging Face Transformersといった実務標準のライブラリを使って、ディープラーニングの定番タスクをコードから逆引きできる100本のレシピ集としてまとめました。テンソルの基礎から、ニューラルネットワークの学習、画像認識、セグメンテーション、物体検出、自然言語処理、LLM(大規模言語モデル)の活用、そして本番運用の技術までを、1レシピ1目的の短いコードで積み上げていきます。
既刊の「データ分析と機械学習のレシピ100選」「データ可視化レシピ106選」の姉妹編にあたり、体系的な理解を得たい方向けの「機械学習の仕組みと使いどころ」とも対をなす、手を動かす側の1冊です。コードをコピーして動かし、自社のデータに差し替えるところから始められる構成にしています。
想定している読者は次のような方々です。
100本のレシピは8つのセクションに分けました。セクション1と2がテンソルと学習ループの土台、セクション3から5が画像を扱う3つのタスク(分類・領域分割・検出)、セクション6と7が言語とLLM、セクション8が学習後の運用です。序章のあとに全体の地図として一覧表を置いていますので、目的に近いセクションを先に見つけてから個々のレシピに入る読み方も想定しています。各レシピの見出しには主要なクラス名・関数名をそのまま入れてありますので、技術の名前から探すこともできます。
一方で、誤差逆伝播の数式の導出や、各手法が提案された論文の理論的な背景には踏み込んでいません。実装の型を揃えることを目的にしているためです。理論を並行して学びたい方のために、各セクションの末尾に参考書籍を挙げました。
目次
ディープラーニングの実装には、いくつかの定番ライブラリがあります。本コラムでは、研究から実務まで最も広く使われているPyTorchを軸に据えました。TensorFlow(Keras)は簡潔な高水準APIに強みがあり、基礎パートで対比のレシピを用意しています。そして自然言語処理とLLMの領域では、事前学習済みモデルの流通基盤となっているHugging Faceのエコシステム(Transformers、Datasets、PEFTなど)が事実上の標準であり、後半のセクションはこれを前提に構成しています。
どのライブラリを選んでも、テンソル・損失関数・勾配降下・ミニバッチという基本概念は共通です。最初の2セクションでこの共通の土台を固めれば、残りのセクションはどこからでも読める作りにしています。
本コラムを通して繰り返し出てくる言葉を、最初にまとめて説明しておきます。厳密な定義ではなく、コードを読むときに困らない程度の意味です。
lrという引数で指定しますディープラーニングの学習にはGPUがあると快適ですが、本コラムのレシピの多くは、動作確認だけならCPUでも実行できる規模に絞っています。手元にGPUがない場合は、無料で始められるクラウドのノートブック環境(GPUランタイム)を使う方法をセクション1で扱います。また、学習済みモデルを利用するレシピでは、初回実行時にモデルのダウンロードが発生します。その旨は各レシピに明記しました。
本コラムの方針は「1レシピ1目的の短いコード」です。長大なサンプルを1つ理解するより、目的の明確な部品を組み合わせられるほうが実務では応用が利くと考えています。最終レシピ100では、部品を組み合わせて学習から運用までを通す道筋を示します。
各レシピは、何のためのコードかと実務のどこで使うかを述べる解説、そのまま実行できるコード、そして結果の読み方や注意点を添えた補足の3点で構成しています。レシピどうしは原則として独立させ、前のレシピの変数を引き継がなければ動かないという書き方は避けました。ただしセクション2以降では、データの読み込みやモデルの定義といった共通の手順を毎回書き写すと本題が埋もれてしまうため、直前のレシピで作った変数をそのまま使っている箇所があります。その場合は、どのレシピの続きなのかを解説文に明記しました。
目的から引く場合は、次章の全体の地図で近いセクションを見つけ、そのセクションの冒頭に置いた位置づけの説明を読んでから個々のレシピに入ると、前後のつながりがつかみやすくなります。技術の名前から引く場合は、レシピの見出しに主要なクラス名・関数名をそのまま入れてありますので、見出しを拾っていけば目的の項目に届きます。本文中でほかのレシピに触れるときは番号で参照していますので、行き来しながら読む使い方も想定しています。
ライブラリのバージョンは、あえて固定した数字を明記していません。深層学習まわりのライブラリは更新が速く、特定のバージョンを書いても短い期間で古くなるためです。そのかわり、引数名の変更や非推奨になった関数など、APIが変わりやすい箇所については、変わりうるという事実そのものを解説文に書き添えました。動かない場合は公式ドキュメントで現在の書き方を確認する前提でご覧ください。
冒頭で触れたとおり、理論的な背景の導出には踏み込みません。そのほかに扱っていない範囲を、先に挙げておきます。
学習の高速化のうち、複数のGPUや複数のノードにまたがる分散学習も扱っていません。環境の構築とジョブ管理の比重が大きく、1レシピ1目的という粒度に収まらないためです。単一GPU上での高速化、すなわち混合精度学習、勾配の蓄積、データ読み込みの並列化はセクション8で扱います。また、モデルの学習に使うデータをどう集め、どう権利関係を確認するかという調達の話も本コラムの範囲外です。
『ディープラーニングを支える技術』(岡野原大輔、技術評論社):なぜ深層学習がこれほど広い課題に効くのかという原理を、数式を最小限にして解き明かす読み物です。個々のレシピを動かしながら、その裏側で何が起きているのかを言葉で押さえておきたいときに、通読の相棒になります。
Anagraftでは、AIプロジェクトの構想・課題設計から、データ分析・機械学習モデルの開発、AI人材の育成まで一貫したご支援を行っています。会社概要・ご支援内容の詳細は、以下の資料からご覧いただけます。
本コラムの構成です。基礎(S1〜S2)を固めたあとは、業務に近いセクションからの拾い読みも想定しています。
| セクション | レシピ | 扱う内容 | 実務での代表用途 |
|---|---|---|---|
| 1. セットアップとテンソルの基礎 | 1〜12 | 環境確認・テンソル・自動微分・DataLoader | すべての土台 |
| 2. ニューラルネットワークの基本 | 13〜27 | モデル定義・学習ループ・正則化・保存 | テーブルデータの回帰・分類 |
| 3. 画像認識 | 28〜42 | CNN・転移学習・データ拡張・Grad-CAM | 検品・仕分け・画像分類 |
| 4. セグメンテーション | 43〜52 | U-Net・DeepLab・Dice損失・IoU | 外観検査・領域抽出 |
| 5. 物体検出 | 53〜62 | Faster R-CNN・YOLO・NMS・動画処理 | 人流計測・在庫棚・防犯 |
| 6. 自然言語処理 | 63〜77 | Transformers・分類・NER・要約・意味検索 | 問い合わせ分類・文書処理 |
| 7. LLM活用 | 78〜90 | ローカルLLM・API・RAG・LoRA | 生成AIの業務組み込み |
| 8. 実務運用 | 91〜100 | 高速化・ONNX・API化・監視 | PoCから本番への橋渡し |
セクション1と2は、以降のすべてのセクションが前提にしている土台です。テンソルの扱いと学習ループの型がすでに身についていれば飛ばして差し支えありませんが、後半のセクションはこの2つで作った型の上に立っています。たとえばセクション8の高速化は、セクション2で組んだ学習ループのどこに手を入れる話なのかが分かっていないと読みにくいはずです。そのため、後半から読み始めた場合でも戻れるよう、前提になるレシピは番号で参照するようにしました。
セクション3から5までは画像を扱う3つのタスクです。セクション3の分類は画像1枚に1つのラベルを与え、セクション4の領域分割は画素ごとにクラスを塗り分け、セクション5の検出は対象を矩形で囲んで個数と位置を出します。同じ画像を入力にしていても、学習データに付けるべき正解の作り方と、その作成にかかる手間はまったく違います。どの粒度の出力が業務で本当に必要かを先に決めてから該当セクションに入るほうが、遠回りが減ります。
セクション6と7は言語を扱う領域で、6が公開済みの事前学習モデルを目的別に使い分ける話、7が生成モデルを業務システムに組み込む話です。この2つは技術的には地続きですが、社内データを外部に送信してよいかという判断が入るかどうかで検討の進め方が変わるため、セクションを分けました。セクション8はどのタスクからでも合流する運用の話で、学習が終わったあとに必要になる作業をまとめています。
ディープラーニングのコードは、扱う対象が画像でも文章でも、「データをテンソルという多次元の配列に載せ、勾配を自動で計算し、まとまった単位で取り出して学習に流す」という共通の骨組みの上に建っています。このセクションでは、その骨組みにあたる部分だけを12本のレシピに分けて確認します。
扱うのは、実行環境の確認、テンソルの生成と形の操作、NumPyやPandasとの行き来、CPUとGPUの間のデータ移動、自動微分の仕組み、そしてDatasetとDataLoaderによるデータ供給です。ひとつひとつは数行のコードですが、後半のセクションで遭遇するエラーの多くは、テンソルの形(shape)の食い違い、データ型の不一致、テンソルが置かれているデバイスの食い違いという3つに集約されます。ここで手を動かして感覚をつかんでおくと、後で原因の切り分けが早くなります。
本セクションのレシピは、レシピ11(クラウドのノートブック環境)とレシピ12(学習済みモデルのダウンロード)を除き、CPUだけで実行できます。GPUを前提にした記述が出てくる箇所では、GPUが無い環境ではどう振る舞うかを併記しました。
PyTorchを実行環境に導入し、GPUが利用できる状態かどうかを確認するレシピです。学習を始める前の最初の一歩であり、社内のサーバーやクラウドインスタンスを切り替えるたびに同じ確認作業が発生します。GPUがなくてもCPUだけで動作しますので、まずはコードの動作確認をノートPCで行い、本格的な学習だけGPU環境に移すという進め方も可能です。CUDA対応版のインストールコマンドはOSやCUDAのバージョンによって変わるため、公式サイトの案内に沿ってコマンドを生成することをおすすめします。
# CPU版(GPUなしの環境や動作確認用)
pip install torch torchvision torchaudio
# GPU版(CUDA対応)はCUDAのバージョンによってコマンドが変わります
# 公式サイト(https://pytorch.org/get-started/locally/)のセレクタで
# 環境に合ったコマンドを生成してから実行してください
import torch
# バージョンとGPU利用可否の確認
print("PyTorchバージョン:", torch.__version__)
print("GPU利用可能:", torch.cuda.is_available())
if torch.cuda.is_available():
print("GPU数:", torch.cuda.device_count())
print("GPU名:", torch.cuda.get_device_name(0))
print("CUDAバージョン:", torch.version.cuda)
else:
print("GPUが検出されませんでした。CPUで実行します。")
# 簡単な動作確認(CPUでも問題なく動きます)
x = torch.rand(3, 3)
print("動作確認用テンソル:\n", x)
torch.cuda.is_available()がFalseの場合でも、以降のレシピのコードの多くはCPU上でそのまま動作します。学習時間が長くなる点だけ注意してください。ただし、レシピ11のようにクラウドのノートブック環境を前提にしたコードや、学習済みモデルのダウンロードが必要なレシピは、そのままでは動きません。該当する箇所には各レシピで断りを入れています。
PyTorchの基本データ構造であるテンソルを生成し、形状(shape)やデータ型(dtype)を確認しながら基本的な演算を行うレシピです。以降すべてのレシピの土台になる操作であり、モデルへの入力データやパラメータは最終的にすべてテンソルとして扱われます。torch.tensor、torch.zeros、torch.randnなど用途別の生成方法と、四則演算・行列積・形状変換(reshape)の基本を押さえておくと、後続のレシピの理解がスムーズになります。
import torch
# 代表的なテンソル生成方法
a = torch.tensor([[1.0, 2.0], [3.0, 4.0]])
zeros = torch.zeros(2, 3)
ones = torch.ones(2, 3)
randn = torch.randn(2, 3) # 標準正規分布からサンプリング
print("shape:", a.shape)
print("dtype:", a.dtype)
print("ndim:", a.ndim)
# 四則演算(要素ごと)
b = a * 2 + 1
print("要素ごとの演算:\n", b)
# 行列積
c = a @ a # torch.matmul(a, a) と同じ
print("行列積:\n", c)
# 形状変換
d = torch.arange(12)
e = d.reshape(3, 4)
print("reshape後のshape:", e.shape)
形状の不一致はエラーの原因として最も多いため、演算の前にshapeを確認する習慣をつけておくと、デバッグの時間を大きく減らせます。
NumPy配列やPandasのDataFrameとテンソルを相互変換するレシピです。実務データの多くはCSVやデータベースからPandasで読み込むため、PyTorchで学習させる前に必ずこの変換工程を通ることになります。torch.from_numpyはメモリを共有するため高速ですが、NumPy側がfloat64の場合はテンソルもfloat64になり、モデルの重み(通常float32)と型が合わずにエラーになることがあるため、変換時の型指定に注意が必要です。なお、型を変換すると新しい領域が確保されるため、そこでメモリの共有は切れます。共有したまま渡したい場合は、NumPy配列を作る段階でfloat32にしておきます。
import numpy as np
import pandas as pd
import torch
# NumPy配列からテンソルへ
# NumPyの既定はfloat64なので、共有したまま渡すために配列側をfloat32で作る。
# ここで .float() を挟むと型変換のコピーが発生し、メモリ共有は切れる。
arr = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32)
tensor_from_np = torch.from_numpy(arr)
print("NumPy -> Tensor:", tensor_from_np.dtype)
# テンソルからNumPy配列へ
back_to_np = tensor_from_np.numpy()
print("Tensor -> NumPy:", type(back_to_np))
# DataFrameからテンソルへ
df = pd.DataFrame({
"身長": [170.0, 165.0, 180.0],
"体重": [65.0, 55.0, 75.0],
})
tensor_from_df = torch.tensor(df.values, dtype=torch.float32)
print("DataFrame -> Tensor:\n", tensor_from_df)
上のように型を揃えたうえでfrom_numpyで作ったテンソルは、元のNumPy配列とメモリを共有するため、片方を書き換えるともう片方も変化します。独立したコピーが必要な場合はtorch.tensor(arr)を使うと安全です。
テンソルやモデルをCPUとGPUの間で転送し、実行環境に応じてデバイスを切り替えるレシピです。GPUの有無に関わらず同じコードが動くよう、device変数を先頭で定義しておくのが定番のイディオムであり、社内の共有コードでもほぼ必ず登場するパターンです。演算の際は関わるすべてのテンソルが同じデバイス上に存在している必要があり、片方だけGPUに乗せ忘れるとエラーになります。
import torch
# 定番のデバイス定義イディオム
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print("使用デバイス:", device)
# テンソルをデバイスに転送
x = torch.randn(4, 4)
x = x.to(device)
print("転送後のデバイス:", x.device)
# モデルもto(device)で丸ごと転送する
model = torch.nn.Linear(4, 2)
model = model.to(device)
# 演算前に両者のデバイスを揃える必要がある
y = model(x) # xとmodelが同じdeviceにあるため問題なく実行できる
print("出力:\n", y)
# 結果をCPUに戻してからNumPy変換する(GPU上のテンソルは直接numpy()できない)
y_cpu = y.detach().cpu().numpy()
print("NumPy化後の型:", type(y_cpu))

requires_grad属性とbackwardメソッドを使い、PyTorchの自動微分(Autograd)の仕組みを確認するレシピです。ニューラルネットワークの学習は「誤差を各パラメータで微分し、その勾配の逆方向にパラメータを更新する」という処理の繰り返しであり、そのコア部分を最小構成で体験できます。ここでは簡単な二乗誤差を例に、勾配の計算から1ステップ分のパラメータ更新までを手書きで実装しています。
import torch
# requires_grad=Trueで勾配計算の対象にする
x = torch.tensor([2.0], requires_grad=True)
target = torch.tensor([10.0])
# 簡単な損失関数(二乗誤差)
loss = (x * 3 - target) ** 2
print("損失:", loss.item())
# 逆伝播で勾配を計算
loss.backward()
print("xの勾配:", x.grad)
# 勾配降下法の1ステップを手書きで実装
lr = 0.01
with torch.no_grad():
x -= lr * x.grad
# 次の反復に備えて勾配をリセット(累積されてしまうため)
x.grad.zero_()
print("更新後のx:", x.item())
backwardを呼ぶたびに勾配は加算されていく仕様のため、更新のたびにgrad.zero_()でリセットする点を忘れないようにしてください。
手元にあるテーブルデータをPyTorchのDatasetクラスに変換するレシピです。画像やテキストと違い、業務で扱う表形式データにはtorchvisionのような専用クラスが用意されていないため、多くの現場でこの自作Datasetを1から書くことになります。__len__でデータ件数を返し、__getitem__でインデックス指定した1件分を返すという2つのメソッドを実装するだけで、あとのDataLoaderに渡せる形になります。
import torch
from torch.utils.data import Dataset
import pandas as pd
class TabularDataset(Dataset):
"""テーブルデータ用の最小構成Dataset"""
def __init__(self, df: pd.DataFrame, feature_cols: list, target_col: str):
self.X = torch.tensor(df[feature_cols].values, dtype=torch.float32)
# 回帰の出力層は [バッチ, 1] を返すので、正解も [N, 1] に揃えておく。
# [N] のままだとMSELossでブロードキャストが起き、意図しない損失になる
self.y = torch.tensor(df[target_col].values, dtype=torch.float32).view(-1, 1)
def __len__(self):
return len(self.X)
def __getitem__(self, idx):
return self.X[idx], self.y[idx]
# サンプルデータで動作確認
df = pd.DataFrame({
"気温": [20.0, 25.0, 15.0, 30.0],
"湿度": [50.0, 60.0, 40.0, 70.0],
"来客数": [100.0, 150.0, 80.0, 200.0],
})
dataset = TabularDataset(df, feature_cols=["気温", "湿度"], target_col="来客数")
print("データ件数:", len(dataset))
print("1件目:", dataset[0])
DatasetからDataLoaderを作成し、ミニバッチ単位でデータをモデルに供給するレシピです。数百万件規模のデータを一度にメモリへ載せて学習するのは現実的ではないため、実務では必ずこのミニバッチ処理を経由します。batch_sizeでバッチの大きさを、shuffleで各エポックごとにデータ順をシャッフルするかどうかを指定でき、学習ループの中でforループを回すのが定番の書き方です。
import torch
from torch.utils.data import Dataset, DataLoader
import pandas as pd
class TabularDataset(Dataset):
def __init__(self, df, feature_cols, target_col):
self.X = torch.tensor(df[feature_cols].values, dtype=torch.float32)
# 回帰の出力層は [バッチ, 1] を返すので、正解も [N, 1] に揃えておく。
# [N] のままだとMSELossでブロードキャストが起き、意図しない損失になる
self.y = torch.tensor(df[target_col].values, dtype=torch.float32).view(-1, 1)
def __len__(self):
return len(self.X)
def __getitem__(self, idx):
return self.X[idx], self.y[idx]
# 20件のダミーデータを用意
df = pd.DataFrame({
"気温": range(20),
"湿度": range(20, 40),
"来客数": range(100, 120),
})
dataset = TabularDataset(df, ["気温", "湿度"], "来客数")
# バッチサイズ4、シャッフルありのDataLoader
loader = DataLoader(dataset, batch_size=4, shuffle=True)
for epoch in range(1):
for batch_idx, (X_batch, y_batch) in enumerate(loader):
print(f"バッチ{batch_idx}: X shape={X_batch.shape}, y shape={y_batch.shape}")
1エポックあたりのバッチ数はデータ件数をbatch_sizeで割った値になります。端数が出た場合の扱いはdrop_last引数で制御できます。
random、NumPy、PyTorchそれぞれの乱数シードを固定し、実験結果を再現できるようにするレシピです。同じコードを実行したのに前回と精度が変わってしまうと原因調査に時間を取られるため、実験の比較や検証を行う前提として必ず設定しておきたい項目です。GPU利用時はcudnn.deterministicをTrueにすることで計算の再現性が高まりますが、その分だけ処理速度が低下する場合がある点は把握しておく必要があります。
import random
import numpy as np
import torch
def set_seed(seed: int = 42):
"""再現性確保のためのシード固定"""
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed) # GPU未使用時も無害
# cudnnの挙動を決定論的にする(速度は低下する場合がある)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
set_seed(42)
# 同じシードなら同じ乱数列になることを確認
print(torch.randn(3))
set_seed(42)
print(torch.randn(3)) # 上と同じ値になる
厳密な再現性が不要な通常の学習では、torch.backends.cudnn.benchmark = Trueにしておくと入力サイズが固定の場合に処理速度が向上します。用途に応じて使い分けてください。
作成したモデルの層構成やパラメータ数を一覧で確認するレシピです。モデルが意図通りの構造になっているかの確認や、モデルサイズが実行環境のメモリに収まるかどうかの見積もりに使います。パラメータ数はsum(p.numel() for p in model.parameters())で自前でも数えられますが、torchinfoライブラリのsummary関数を使うと各層の出力形状やパラメータ数を表形式で確認でき、確認作業が大幅に効率化されます。
pip install torchinfo
import torch
import torch.nn as nn
from torchinfo import summary
class SimpleNet(nn.Module):
def __init__(self):
super().__init__()
self.fc1 = nn.Linear(10, 32)
self.fc2 = nn.Linear(32, 16)
self.fc3 = nn.Linear(16, 1)
self.relu = nn.ReLU()
def forward(self, x):
x = self.relu(self.fc1(x))
x = self.relu(self.fc2(x))
return self.fc3(x)
model = SimpleNet()
# 自前でパラメータ数を数える方法
total_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print("総パラメータ数:", total_params)
# torchinfoによる詳細サマリー(層ごとの出力形状も確認できる)
summary(model, input_size=(1, 10))
同じ内容のモデルをKeras(TensorFlow)で組んだ場合の最小例を示すレシピです。PyTorchでは学習ループを自分でforループとして書くのに対し、KerasはSequentialで層を積み、compileで最適化手法と損失関数を指定し、fitを呼ぶだけで学習が完了するという宣言的な流儀を採用しています。案件によってはチーム内の標準がKerasになっている場合もあるため、両方の流儀を知っておくと現場での対応の幅が広がります。
import numpy as np
import tensorflow as tf
# ダミーデータ(気温・湿度から来客数を予測する回帰タスク)
np.random.seed(42)
X = np.random.rand(200, 2).astype("float32")
y = (X[:, 0] * 100 + X[:, 1] * 50).astype("float32")
# Sequentialで層を積むだけでモデルを定義できる
model = tf.keras.Sequential([
tf.keras.layers.Input(shape=(2,)),
tf.keras.layers.Dense(32, activation="relu"),
tf.keras.layers.Dense(16, activation="relu"),
tf.keras.layers.Dense(1),
])
# compileで最適化手法・損失関数・評価指標をまとめて指定
model.compile(optimizer="adam", loss="mse", metrics=["mae"])
# fitを呼ぶだけで学習ループが実行される
model.fit(X, y, epochs=5, batch_size=16, verbose=1)
PyTorchは学習ループの中身が見えるぶん自由度が高く、Kerasはcompileとfitで完結するぶん立ち上げが速いという性質の違いがあります。プロトタイピングの速さを優先するか、ループ内の処理を細かく制御したいかで選ぶとよいでしょう。

Google ColabでGPUを使った学習環境を整えるレシピです。手元のPCにGPUがない場合でも、Colabを使えば無料枠の範囲でGPUを利用でき、社内の検証や小規模な学習であればColabだけで完結するケースも少なくありません。ランタイムの設定でGPUを有効化する操作、Googleドライブをマウントしてデータや学習済みモデルを永続化する操作、追加ライブラリをインストールする操作が定番の組み合わせです。
# Colabの手順(メニュー操作):
# 1. 上部メニュー「ランタイム」->「ランタイムのタイプを変更」
# 2. 「ハードウェアアクセラレータ」で GPU(例: T4)を選択して保存
# 3. 以下のコードセルで割り当てられたGPUを確認
import torch
print("GPU利用可能:", torch.cuda.is_available())
!nvidia-smi
# Googleドライブのマウント(データや学習済みモデルの永続化に使う)
from google.colab import drive
drive.mount("/content/drive")
# 追加ライブラリのインストール(セッションごとに再インストールが必要)
!pip install -q torchinfo
# ドライブ上のフォルダをそのまま作業ディレクトリとして使う例
import os
save_dir = "/content/drive/MyDrive/dl_recipes"
os.makedirs(save_dir, exist_ok=True)
print("保存先:", save_dir)
Colabのランタイムは一定時間の無操作や利用状況によって切断されることがあるため、途中経過のモデルやログはドライブなど永続的な保存先にこまめに書き出しておくことをおすすめします。
torchvision.modelsやHugging Face Hubから学習済みモデルを取得し、キャッシュの仕組みを理解するレシピです。転移学習やファインチューニングの起点として学習済みモデルを使う場面は多く、初回実行時には自動的にインターネットからモデルの重みがダウンロードされます。2回目以降はローカルのキャッシュフォルダから重みが読み込まれるため、ダウンロードはやり直しになりません。ただしHugging Face Hubの場合は、読み込みのたびにキャッシュが最新かどうかを確認する通信が入ります。通信を完全に断つには、環境変数HF_HUB_OFFLINE=1を設定するか、from_pretrainedにlocal_files_only=Trueを渡します。キャッシュの保存場所を把握しておくと、社内プロキシ環境での事前配布やディスク容量の管理がしやすくなります。
import os
# キャッシュ先を変更したい場合は、モデルを読み込む前に環境変数を設定する
# (デフォルト: torchvisionは~/.cache/torch/、Hugging Faceは~/.cache/huggingface/)
os.environ["TORCH_HOME"] = "/path/to/custom_cache" # torchvision系のキャッシュ先
os.environ["HF_HOME"] = "/path/to/custom_hf_cache" # Hugging Face系(HF_HUB_CACHEでハブのみの指定も可)
import torchvision.models as models
# 初回実行時はインターネットから重みがダウンロードされる
model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT)
print("ResNet18のロード完了(2回目以降はキャッシュから読み込まれます)")
# Hugging Face Hub側のモデルも同様にキャッシュされる
from transformers import AutoModel, AutoTokenizer
model_name = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(model_name) # 初回のみダウンロード
bert_model = AutoModel.from_pretrained(model_name)
環境変数(TORCH_HOMEやHF_HOME)はモデルを読み込むコードより前に設定しておく必要があります。オフライン環境で使う場合は、事前にキャッシュフォルダごと社内サーバーに配布したうえで、HF_HUB_OFFLINE=1を設定して更新確認の通信そのものを止める運用も検討してください。

『ゼロから作るDeep Learning』(斎藤康毅、オライリー・ジャパン):フレームワークを使わずNumPyだけでニューラルネットワークと誤差逆伝播を実装しながら学べる一冊で、本セクションで扱ったテンソル演算や自動微分の裏側で何が行われているかを理解する助けになります。PyTorchの各APIが何を計算しているのかを腑に落として使いたい方に向いています。
セクション1で用意した部品を組み合わせて、学習が回る最小のプログラムを作るのがこのセクションです。モデルの定義、損失関数と最適化手法の指定、学習ループ、検証、過学習への対処、そして重みの保存と読み込みまでを15本のレシピで順に押さえます。
題材はテーブルデータの回帰と分類にしました。画像や文章に比べて前処理が軽く、学習の挙動そのものに集中できるためです。ここで身につける学習ループの型は、画像認識でも自然言語処理でも変わりません。以降のセクションでは、この型のうちデータの読み込み方とモデルの中身だけが差し替わっていく、と捉えていただくと全体の見通しがつきます。
あわせて、同じ内容をKerasで書くとどうなるかをレシピ27で対比しました。どちらか一方だけを使う場合でも、書き方の違いがどこから来ているのかを知っておくと、他人の書いたコードを読む速度が上がります。
PyTorchでニューラルネットワークを定義する最も基本的な形として、nn.Moduleを継承し、__init__で層を登録し、forwardで計算の流れを記述する2層の全結合ネットワークを作成するレシピです。回帰・分類を問わずあらゆる自作モデルの出発点になる書き方で、実務でも新しいモデルを組む際にまずこの型から書き始めます。ポイントは、__init__では層のインスタンス化のみを行い、実際の計算順序はforwardに記述すること、そしてsuper().__init__()を必ず呼ぶことです。
import torch
import torch.nn as nn
torch.manual_seed(0)
class TwoLayerNet(nn.Module):
def __init__(self, input_dim, hidden_dim, output_dim):
super().__init__()
self.fc1 = nn.Linear(input_dim, hidden_dim)
self.relu = nn.ReLU()
self.fc2 = nn.Linear(hidden_dim, output_dim)
def forward(self, x):
x = self.fc1(x)
x = self.relu(x)
x = self.fc2(x)
return x
model = TwoLayerNet(input_dim=4, hidden_dim=8, output_dim=1)
print(model)
# ダミー入力で動作確認(バッチサイズ5、特徴量4次元)
dummy_input = torch.randn(5, 4)
output = model(dummy_input)
print(output.shape) # torch.Size([5, 1])

forward計算、損失計算、backwardによる勾配計算、optimizer.stepによるパラメータ更新という、PyTorchの学習ループの定番構造をエポックループの形で示すレシピです。扱うモデルやデータセットが変わっても、この4ステップの順序自体は変わらないため、実務ではまずこの型を押さえておくと応用が効きます。ポイントは、毎イテレーションの冒頭でoptimizer.zero_grad()を呼んで前回の勾配をリセットすることと、loss.backward()とoptimizer.step()の順序を崩さないことです。
import torch
import torch.nn as nn
torch.manual_seed(0)
X = torch.randn(100, 4)
y = X.sum(dim=1, keepdim=True) + 0.1 * torch.randn(100, 1)
model = nn.Sequential(nn.Linear(4, 8), nn.ReLU(), nn.Linear(8, 1))
criterion = nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
n_epochs = 50
for epoch in range(n_epochs):
optimizer.zero_grad() # 1. 勾配のリセット
y_pred = model(X) # 2. forward
loss = criterion(y_pred, y) # 3. 損失計算
loss.backward() # 4. backward(勾配計算)
optimizer.step() # 5. パラメータ更新
if (epoch + 1) % 10 == 0:
print(f"epoch {epoch+1}, loss {loss.item():.4f}")

回帰タスクで使う代表的な3つの損失関数、MSELoss・L1Loss・HuberLossを同じ予測値と正解値の組に適用し、値の違いと使い分けの目安を確認するレシピです。MSELossは外れ値に敏感、L1Lossは外れ値に頑健、HuberLossは両者の中間という性質を持つため、実務ではデータに含まれる外れ値の多さに応じて選びます。ポイントは、HuberLossのdelta引数が、誤差の小さい部分はMSE的に、大きい部分はMAE的に振る舞う切り替わりの閾値を決めることです。
import torch
import torch.nn as nn
torch.manual_seed(0)
y_pred = torch.tensor([2.5, 0.0, 2.1, 7.8, 5.0])
y_true = torch.tensor([3.0, -0.5, 2.0, 7.0, 10.0]) # 最後の1点だけ大きく外れた値
mse_loss = nn.MSELoss()
l1_loss = nn.L1Loss()
huber_loss = nn.HuberLoss(delta=1.0)
print(f"MSELoss : {mse_loss(y_pred, y_true).item():.4f}") # 外れ値の影響を強く受ける
print(f"L1Loss : {l1_loss(y_pred, y_true).item():.4f}") # 外れ値の影響が小さい
print(f"HuberLoss: {huber_loss(y_pred, y_true).item():.4f}") # 両者の中間的な挙動
分類タスクの定番損失であるnn.CrossEntropyLossの正しい使い方をまとめるレシピです。モデルが最後に出す変換前の生の数値をロジットと呼びます。PyTorchのCrossEntropyLossは、このロジットを確率に直す処理と損失の計算をまとめて内部で行うため、モデルの出力層にSoftmaxを入れず、ロジットをそのまま渡すのが正しい使い方になります。誤ってSoftmax適用後の値を渡すと、すでに0から1に押し込められた確率値に、内部でもう一度同じ変換がかかることになります。クラス間の差が潰れて勾配が小さくなります。パラメータの更新幅は勾配の大きさに比例するため、勾配が小さいとほとんど動かず、学習が進みません。エラーにはならず精度だけが伸びないので、実務でも見落としやすい注意点です。
import torch
import torch.nn as nn
torch.manual_seed(0)
# 3クラス分類、バッチサイズ4のロジット(Softmax適用前の生の値)
logits = torch.randn(4, 3)
targets = torch.tensor([0, 2, 1, 1]) # 正解クラスのインデックス
criterion = nn.CrossEntropyLoss()
# 正しい使い方: ロジットをそのまま渡す
loss = criterion(logits, targets)
print(f"CrossEntropyLoss(ロジット直接) : {loss.item():.4f}")
# 誤った使い方の例: Softmaxを適用してから渡すと計算がずれる
softmax_probs = torch.softmax(logits, dim=1)
wrong_loss = criterion(softmax_probs, targets)
print(f"CrossEntropyLoss(Softmax後、誤り): {wrong_loss.item():.4f}")
SGD・Momentum付きSGD・Adam・AdamWという4種類の最適化アルゴリズムのインスタンス化方法を並べ、選び方の目安を整理するレシピです。実務では、まずAdamWを既定値付近のlrで試し、収束が不安定な場合や汎化性能を重視したい場合にSGD+Momentumへ切り替える、という進め方が目安になります。ポイントは、AdamWがAdamと異なりweight_decay(重み減衰)を勾配の更新式から切り離して正しく適用する点で、正則化を効かせたい場合はAdamより優先して検討します。
import torch
import torch.nn as nn
torch.manual_seed(0)
model = nn.Sequential(nn.Linear(4, 8), nn.ReLU(), nn.Linear(8, 1))
optimizers = {
"SGD": torch.optim.SGD(model.parameters(), lr=0.01),
"SGD+Momentum": torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9),
"Adam": torch.optim.Adam(model.parameters(), lr=0.001),
"AdamW": torch.optim.AdamW(model.parameters(), lr=0.001, weight_decay=0.01),
}
for name, optimizer in optimizers.items():
print(f"{name}: lr={optimizer.defaults['lr']}")
# 使い分けの目安
# SGD : 学習の挙動を素直に把握したいベースライン検証向け
# SGD+Momentum : 収束を安定させたい画像系モデルの定番
# Adam : 収束が速く、まず試す既定の選択肢
# AdamW : weight_decayを正しく効かせたいTransformer系で優先
StepLRとCosineAnnealingLRという2つの学習率スケジューラを使い、エポックが進むにつれて学習率を段階的または滑らかに下げる書き方をまとめるレシピです。学習の後半で学習率を下げることで、損失の谷に対してより細かく収束させる目的で使います。ポイントは、scheduler.step()を呼ぶ位置で、optimizer.step()の後、エポックループの末尾で1回呼ぶのが基本形になります。
import torch
import torch.nn as nn
torch.manual_seed(0)
model = nn.Sequential(nn.Linear(4, 8), nn.ReLU(), nn.Linear(8, 1))
optimizer = torch.optim.Adam(model.parameters(), lr=0.1)
# StepLR: 10エポックごとに学習率を0.5倍にする
step_scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.5)
X = torch.randn(20, 4)
y = torch.randn(20, 1)
criterion = nn.MSELoss()
for epoch in range(30):
optimizer.zero_grad()
loss = criterion(model(X), y)
loss.backward()
optimizer.step()
step_scheduler.step() # optimizer.step()の後、エポック末尾で呼ぶ
if (epoch + 1) % 10 == 0:
print(f"epoch {epoch+1}, lr {optimizer.param_groups[0]['lr']:.5f}")
CosineAnnealingLRを使う場合もscheduler.step()を呼ぶ位置は同じで、torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30)のようにschedulerを差し替えるだけでコードの構造は変わりません。
検証データに対する評価を行う際の定番ペアである、model.eval()とtorch.no_grad()の使い方をまとめるレシピです。model.eval()はDropoutやBatchNorm(いずれも過学習を抑えたり学習を安定させたりする層で、訓練時と推論時で計算内容が変わります。それぞれレシピ21とレシピ22で扱います)を推論モードに切り替え、torch.no_grad()は勾配計算を無効化してメモリと計算時間を節約します。ポイントは、評価が終わったあと、次の学習に入る前に必ずmodel.train()へ戻すことです。下のコードでは各エポックの先頭でmodel.train()を呼ぶことでこれを担保していますが、評価の直後に戻す書き方でも結果は同じです。戻し忘れるとDropoutが無効のまま学習が進み、過学習を抑える効果が失われます。
import torch
import torch.nn as nn
torch.manual_seed(0)
model = nn.Sequential(nn.Linear(4, 8), nn.Dropout(0.3), nn.ReLU(), nn.Linear(8, 1))
criterion = nn.MSELoss()
X_train = torch.randn(80, 4)
y_train = torch.randn(80, 1)
X_val = torch.randn(20, 4)
y_val = torch.randn(20, 1)
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
for epoch in range(5):
model.train() # 訓練モード(Dropoutが有効)
optimizer.zero_grad()
train_loss = criterion(model(X_train), y_train)
train_loss.backward()
optimizer.step()
model.eval() # 推論モード(Dropoutが無効)
with torch.no_grad(): # 勾配計算を無効化
val_loss = criterion(model(X_val), y_val)
print(f"epoch {epoch+1}, train {train_loss.item():.4f}, val {val_loss.item():.4f}")
検証lossが一定エポック(patience)にわたって改善しなくなった時点で学習を打ち切る、Early Stoppingを自作で実装するレシピです。過学習が進む前に学習を止めることで、無駄な計算時間を削減しつつ汎化性能の高い時点のモデルを選ぶ際に使います。ポイントはpatienceの考え方で、改善が止まった瞬間にすぐ止めるのではなく、指定した回数だけ猶予を与えてから停止する点です。もう1つ欠かせないのが、最良だった時点の重みを控えておくことです。停止した時点の重みは、猶予として回した数エポック分だけ悪化した状態なので、そのまま使うと早期終了の目的を果たせません。
import copy
import torch
import torch.nn as nn
torch.manual_seed(0)
model = nn.Sequential(nn.Linear(4, 8), nn.ReLU(), nn.Linear(8, 1))
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
criterion = nn.MSELoss()
X_train, y_train = torch.randn(80, 4), torch.randn(80, 1)
X_val, y_val = torch.randn(20, 4), torch.randn(20, 1)
best_val_loss = float("inf")
best_state = None # 最良だった時点の重みを控えておく
patience = 5 # 改善が止まってから許容するエポック数
patience_counter = 0
for epoch in range(100):
optimizer.zero_grad()
loss = criterion(model(X_train), y_train)
loss.backward()
optimizer.step()
model.eval()
with torch.no_grad():
val_loss = criterion(model(X_val), y_val).item()
model.train()
if val_loss < best_val_loss:
best_val_loss = val_loss
best_state = copy.deepcopy(model.state_dict()) # 最良時点の重みを保持
patience_counter = 0 # 改善したのでカウンタをリセット
else:
patience_counter += 1
if patience_counter >= patience:
print(f"epoch {epoch+1}で早期終了(best_val_loss={best_val_loss:.4f})")
break
# 停止した時点ではなく、検証lossが最良だった時点の重みに戻す
if best_state is not None:
model.load_state_dict(best_state)

nn.Dropoutを使い、訓練時にランダムにユニットを無効化することで過学習を抑制する手法をまとめるレシピです。全結合層が多い、あるいは学習データが比較的少ないモデルで過学習が疑われる場面でよく使われます。ポイントは、model.train()時はDropoutが確率pでユニットを無効化しつつ残りを \( 1/(1-p) \) 倍にスケーリングする一方、model.eval()時は何もせずそのまま出力するという、訓練時と推論時の挙動の違いです。
import torch
import torch.nn as nn
torch.manual_seed(0)
dropout = nn.Dropout(p=0.5)
x = torch.ones(1, 10)
dropout.train() # 訓練モード
print("train:", dropout(x)) # 約半分が0になり、残りは1/(1-p)倍にスケーリングされる
dropout.eval() # 推論モード
print("eval :", dropout(x)) # 何も変化させずそのまま出力する
# 実際のモデルへの組み込み例
model = nn.Sequential(
nn.Linear(20, 64),
nn.ReLU(),
nn.Dropout(p=0.3), # 活性化関数の後に挿入するのが定番
nn.Linear(64, 32),
nn.ReLU(),
nn.Dropout(p=0.3),
nn.Linear(32, 1),
)
print(model)
nn.BatchNorm1d(全結合層向け)とnn.BatchNorm2d(畳み込み層向け)を使い、各層の出力を正規化して学習を安定させるバッチ正規化のレシピです。層を深く重ねたモデルで学習が不安定になったり収束が遅かったりする場合に、Dropoutと並んでまず検討する定番の対策です。ポイントは、入れる位置が「全結合層・畳み込み層の直後、活性化関数の前」が基本形であることと、正規化の対象次元(BatchNorm1dなら特徴量数、BatchNorm2dならチャネル数)を直前の層の出力次元に一致させることです。
import torch
import torch.nn as nn
torch.manual_seed(0)
# 全結合層向け: BatchNorm1d
mlp = nn.Sequential(
nn.Linear(20, 64),
nn.BatchNorm1d(64), # 全結合層の直後・活性化関数の前
nn.ReLU(),
nn.Linear(64, 1),
)
# 畳み込み層向け: BatchNorm2d
cnn = nn.Sequential(
nn.Conv2d(3, 16, kernel_size=3, padding=1),
nn.BatchNorm2d(16), # チャネル数16に一致させる
nn.ReLU(),
nn.MaxPool2d(2),
)
x_tabular = torch.randn(8, 20)
x_image = torch.randn(8, 3, 32, 32)
print(mlp(x_tabular).shape) # torch.Size([8, 1])
print(cnn(x_image).shape) # torch.Size([8, 16, 16, 16])
学習済みモデルの重みをstate_dictとして保存し、別環境で読み込むための定番手順をまとめるレシピです。モデル全体ではなくstate_dict(パラメータの辞書)だけを保存するのが推奨される方法で、モデルクラスのコードと重みファイルを分けて管理できます。ポイントは、GPUで学習した重みをCPU環境で読み込む場合など、torch.load時にmap_locationを指定してデバイスの読み替えを行うことです。
import torch
import torch.nn as nn
torch.manual_seed(0)
model = nn.Sequential(nn.Linear(4, 8), nn.ReLU(), nn.Linear(8, 1))
# 保存: state_dict(パラメータの辞書)のみを保存する
torch.save(model.state_dict(), "model_weights.pth")
# 読み込み: 同じ構造のモデルを用意してstate_dictをロードする
loaded_model = nn.Sequential(nn.Linear(4, 8), nn.ReLU(), nn.Linear(8, 1))
# GPUで保存した重みをCPU環境で読み込む場合はmap_locationを指定する
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# weights_only=True にすると、重み以外のオブジェクトを復元しないので安全に読める
state_dict = torch.load("model_weights.pth", map_location=device, weights_only=True)
loaded_model.load_state_dict(state_dict)
loaded_model.eval()
print("読み込み完了:", loaded_model)
各エポックの訓練lossと検証lossをリストに記録し、matplotlibで学習曲線としてプロットするレシピです。過学習の兆候(訓練lossは下がり続けるのに検証lossが途中から上がり始める現象)を目視で確認する際の基本ツールとして使います。ポイントは、学習ループの中でtrain_losses.append(...)のように毎エポックの値を蓄積しておき、ループ終了後にまとめてプロットする構造です。
import torch
import torch.nn as nn
import matplotlib.pyplot as plt
torch.manual_seed(0)
model = nn.Sequential(nn.Linear(4, 8), nn.ReLU(), nn.Linear(8, 1))
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
criterion = nn.MSELoss()
X_train, y_train = torch.randn(80, 4), torch.randn(80, 1)
X_val, y_val = torch.randn(20, 4), torch.randn(20, 1)
train_losses, val_losses = [], []
for epoch in range(50):
optimizer.zero_grad()
train_loss = criterion(model(X_train), y_train)
train_loss.backward()
optimizer.step()
train_losses.append(train_loss.item())
model.eval()
with torch.no_grad():
val_loss = criterion(model(X_val), y_val)
model.train()
val_losses.append(val_loss.item())
plt.plot(train_losses, label="train loss")
plt.plot(val_losses, label="val loss")
plt.xlabel("epoch")
plt.ylabel("loss")
plt.legend()
plt.savefig("learning_curve.png", dpi=150, bbox_inches="tight")

合成した表形式データに対し、前処理(標準化・train/test分割)からモデル学習、評価までを1本のコードで通しで行うレシピです。実務でPyTorchによる回帰を最初に組む際のひな形として使える構成にしています。40行以内に収めるため、モデルは最小構成の2層MLP、ミニバッチ処理は省略し全データを一括で学習させる形にしています。
import torch
import torch.nn as nn
from sklearn.datasets import make_regression
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
torch.manual_seed(0)
# 1. データ生成と前処理
X, y = make_regression(n_samples=500, n_features=6, noise=10.0, random_state=0)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=0)
scaler = StandardScaler()
X_train = scaler.fit_transform(X_train)
X_test = scaler.transform(X_test)
X_train = torch.tensor(X_train, dtype=torch.float32)
y_train = torch.tensor(y_train, dtype=torch.float32).view(-1, 1)
X_test = torch.tensor(X_test, dtype=torch.float32)
y_test = torch.tensor(y_test, dtype=torch.float32).view(-1, 1)
# 2. モデル定義
model = nn.Sequential(nn.Linear(6, 32), nn.ReLU(), nn.Linear(32, 1))
criterion = nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
# 3. 学習
for epoch in range(200):
optimizer.zero_grad()
loss = criterion(model(X_train), y_train)
loss.backward()
optimizer.step()
# 4. 評価
model.eval()
with torch.no_grad():
test_loss = criterion(model(X_test), y_test)
print(f"テストMSE: {test_loss.item():.2f}")
レシピ25と同じ構成で、多クラス分類版の通しコードをまとめるレシピです。回帰との違いは、目的変数をクラスラベル(整数)として扱うこと、出力層のユニット数をクラス数に合わせること、損失関数にCrossEntropyLossを使うことの3点で、この対応関係を押さえておくと回帰と分類の切り替えが機械的に行えます。評価はargmaxで予測クラスを求め正解率で確認しています。
import torch
import torch.nn as nn
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
torch.manual_seed(0)
# 1. データ生成と前処理
X, y = make_classification(n_samples=500, n_features=8, n_classes=3,
n_informative=5, random_state=0)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=0)
scaler = StandardScaler()
X_train = scaler.fit_transform(X_train)
X_test = scaler.transform(X_test)
X_train = torch.tensor(X_train, dtype=torch.float32)
y_train = torch.tensor(y_train, dtype=torch.long)
X_test = torch.tensor(X_test, dtype=torch.float32)
y_test = torch.tensor(y_test, dtype=torch.long)
# 2. モデル定義(出力層はクラス数分のロジット、Softmaxは不要)
model = nn.Sequential(nn.Linear(8, 32), nn.ReLU(), nn.Linear(32, 3))
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
# 3. 学習
for epoch in range(200):
optimizer.zero_grad()
loss = criterion(model(X_train), y_train)
loss.backward()
optimizer.step()
# 4. 評価
model.eval()
with torch.no_grad():
pred_labels = model(X_test).argmax(dim=1)
accuracy = (pred_labels == y_test).float().mean()
print(f"テスト精度: {accuracy.item():.3f}")
レシピ25と同じ回帰タスクを、TensorFlow/KerasのSequential APIで実装し、PyTorchとの対応関係を整理するレシピです。生成AIにコードを書かせる際や、チーム内にKeras経験者とPyTorch経験者が混在する場面で、両者の語彙を対応づけて理解しておくと意思疎通がスムーズになります。ポイントは、PyTorchでは明示的に書く学習ループ(forward→loss→backward→step)が、Kerasではmodel.compile()とmodel.fit()に集約される点です。
import tensorflow as tf
from sklearn.datasets import make_regression
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
tf.random.set_seed(0)
# 1. データ生成と前処理(レシピ25と同一)
X, y = make_regression(n_samples=500, n_features=6, noise=10.0, random_state=0)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=0)
scaler = StandardScaler()
X_train = scaler.fit_transform(X_train)
X_test = scaler.transform(X_test)
# 2. モデル定義(nn.Sequentialに相当)
model = tf.keras.Sequential([
tf.keras.layers.Input(shape=(6,)),
tf.keras.layers.Dense(32, activation="relu"),
tf.keras.layers.Dense(1),
])
# 3. compile: 損失関数・最適化アルゴリズムをまとめて指定する
model.compile(optimizer=tf.keras.optimizers.Adam(learning_rate=0.01), loss="mse")
# 4. fit: forward/backward/stepの学習ループを内部で自動実行する
model.fit(X_train, y_train, epochs=200, verbose=0)
# 5. 評価
test_loss = model.evaluate(X_test, y_test, verbose=0)
print(f"テストMSE: {test_loss:.2f}")
対応関係を整理すると、PyTorchのnn.Sequential(層の並び)はKerasのtf.keras.Sequentialに、forwardメソッドはmodel.call()(Sequentialの場合は暗黙的に実行)に、optimizer.zero_grad()→loss.backward()→optimizer.step()という3行はmodel.compile()とmodel.fit()の内部処理に、DataLoaderによるミニバッチ処理はmodel.fit()のbatch_size引数に、それぞれ対応します。PyTorchが学習ループを自分で制御する分だけ細部を書き換えやすく、Kerasは定型的な学習であれば少ないコード量で済む、という違いとして捉えておくとよいと思います。

『PyTorch実践入門 ディープラーニングの基礎から実装へ』(Eli Stevens ほか、マイナビ出版):nn.Moduleによるモデル定義から学習ループ、損失関数や最適化アルゴリズムの選び方まで、本セクションで扱った基礎項目を体系的にカバーした定番書です。実装コードを追いながらテンソル操作や自動微分の仕組みを理解できる構成になっており、本セクションの内容を一通り終えたあとに基礎を固め直したい方に向いています。
画像認識(画像分類)は、1枚の画像に対して1つのラベルを割り当てるタスクです。良品か不良品か、どの製品カテゴリか、届いた書類がどの様式かといった判定が該当し、画像を扱う業務のAI化で最初に検討されることの多い形です。出力がラベル1つなので、業務フローへのつなぎ込みが単純で済むという利点もあります。
このセクションでは、畳み込みニューラルネットワーク(CNN)を自分で組むところから始めて、実務でほぼ必ず使うことになる転移学習、データ拡張、学習済みモデルの評価と誤りの分析、そして判断根拠の可視化までを15本のレシピで扱います。実務では、ゼロから学習するより、大規模なデータで事前学習されたモデルを自社のデータで微調整するほうが、少ない枚数で高い精度に届きます。レシピ34以降がその中心です。
精度の数字だけを見て判断しないための道具も、このセクションに入れました。クラスごとの内訳を見る混同行列(レシピ38)、外した画像を実際に並べて眺める誤分類の可視化(レシピ39)、モデルが画像のどこを見て判断したかを示すGrad-CAM(レシピ40)の3つです。学習が終わったあとに必ず通す手順として位置づけています。
画像認識のすべてのレシピは、画像ファイルをテンソルに変換するところから始まります。PILで画像を読み込み、torchvision.transformsでリサイズ・テンソル化・正規化までを一続きの処理としてまとめるのが定番の書き方です。正規化にはImageNet(1,000種類のカテゴリを含む大規模な画像データセットで、多くの学習済みモデルがこれで事前学習されています)の統計量、つまりその画像群から求めた色ごとの平均と標準偏差を使うことが多く、学習済みモデルを使う場合はこの値を揃えておくことが精度に直結します。
from PIL import Image
from torchvision import transforms
# 画像を読み込んでテンソル化する一連の流れ
img = Image.open("sample.jpg").convert("RGB") # PILで読み込み、RGBに統一
# 学習済みモデル(ImageNet)向けの標準的な前処理
preprocess = transforms.Compose([
transforms.Resize(256), # 短辺を256pxにリサイズ
transforms.CenterCrop(224), # 中央224x224を切り出し
transforms.ToTensor(), # PIL画像をTensorに変換し、値を[0,1]にスケーリング
transforms.Normalize( # ImageNetの平均・標準偏差で正規化
mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225],
),
])
tensor = preprocess(img) # shape: (3, 224, 224)
batch = tensor.unsqueeze(0) # shape: (1, 3, 224, 224) バッチ次元を追加
print(tensor.shape, tensor.dtype)
print(tensor.min().item(), tensor.max().item()) # 正規化後は0付近を中心とした分布になる
正規化は「見た目を変える処理」ではなく、モデルが学習時に見たピクセル値の分布に合わせる処理です。この平均・標準偏差がモデル側の想定とずれていると、学習済みモデルの性能が発揮されません。
MNISTやCIFAR-10のような定番データセットは、torchvision.datasetsからダウンロードとテンソル化を一度に済ませられます。初回実行時はdownload=Trueによりインターネットからファイルが取得されるため、実行環境によっては数十MBから数百MBの通信が発生する点に注意が必要です。取得したデータセットはDataLoaderに渡してミニバッチ単位の学習に使います。
import torchvision
from torchvision import transforms
from torch.utils.data import DataLoader
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,)), # MNIST全体の平均・標準偏差
])
# 初回実行時はdownload=Trueによりデータを自動ダウンロード(数十MB程度)
train_set = torchvision.datasets.MNIST(
root="./data", train=True, download=True, transform=transform
)
test_set = torchvision.datasets.MNIST(
root="./data", train=False, download=True, transform=transform
)
train_loader = DataLoader(train_set, batch_size=64, shuffle=True)
test_loader = DataLoader(test_set, batch_size=64, shuffle=False)
images, labels = next(iter(train_loader))
print(images.shape, labels.shape) # (64, 1, 28, 28), (64,)
# CIFAR-10も同じ書き方でダウンロード可能(初回はカラー画像で約170MB)
cifar_set = torchvision.datasets.CIFAR10(
root="./data", train=True, download=True, transform=transforms.ToTensor()
)
畳み込みニューラルネットワーク(CNN)の基本構造は、Conv2d(特徴抽出)、ReLU(非線形変換)、MaxPool2d(縮小)を繰り返し、最後に全結合層でクラスに分類するという流れです。各層でテンソルの形がどう変わるかを把握しておくと、実装時のサイズ不一致エラーを未然に防げます。
import torch.nn as nn
class SimpleCNN(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.conv1 = nn.Conv2d(1, 16, kernel_size=3, padding=1) # (B,1,28,28) -> (B,16,28,28)
self.conv2 = nn.Conv2d(16, 32, kernel_size=3, padding=1) # (B,16,14,14) -> (B,32,14,14)
self.pool = nn.MaxPool2d(kernel_size=2) # 高さ・幅をそれぞれ半分に縮小
self.relu = nn.ReLU()
self.fc = nn.Linear(32 * 7 * 7, num_classes) # 28 -> 14 -> 7 と2回poolした結果
def forward(self, x):
x = self.pool(self.relu(self.conv1(x))) # (B,1,28,28) -> (B,16,14,14)
x = self.pool(self.relu(self.conv2(x))) # (B,16,14,14) -> (B,32,7,7)
x = x.view(x.size(0), -1) # (B,32,7,7) -> (B, 1568)
x = self.fc(x) # (B, 1568) -> (B, num_classes)
return x
model = SimpleCNN()
print(model)

ここまでのデータセット準備とモデル定義を組み合わせ、学習から評価までを通しで実行します。実務のコードはこの骨格にログ出力や学習率調整、保存処理などが加わっていく形になるため、まずはこの最小構成を手元で一度動かしておくと見通しが良くなります。
import torch
import torch.nn as nn
import torch.optim as optim
# 前提: レシピ29のtrain_loader・test_loaderと、レシピ30のSimpleCNNクラスを定義済みとします
device = "cuda" if torch.cuda.is_available() else "cpu"
model = SimpleCNN().to(device)
optimizer = optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss()
# 学習(わかりやすさのため1エポックのみ)
model.train()
for images, labels in train_loader:
images, labels = images.to(device), labels.to(device)
optimizer.zero_grad()
outputs = model(images)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
# 評価
model.eval()
correct, total = 0, 0
with torch.no_grad():
for images, labels in test_loader:
images, labels = images.to(device), labels.to(device)
outputs = model(images)
preds = outputs.argmax(dim=1)
correct += (preds == labels).sum().item()
total += labels.size(0)
print(f"Test Accuracy: {correct / total:.4f}")
データ拡張(Data Augmentation)は、手元の画像に多様なランダム変換を加えることで、実質的なデータ量を増やし過学習を抑える手法です。RandomCrop・RandomHorizontalFlip・ColorJitterは定番の組み合わせで、近年はRandAugmentのように複数の変換を自動で組み合わせる手法も広く使われます。検証・推論用のデータには水増しをかけないのが原則です。
from torchvision import transforms
train_transform = transforms.Compose([
transforms.RandomCrop(32, padding=4), # ランダムな位置で切り出し、周囲をパディング
transforms.RandomHorizontalFlip(p=0.5), # 左右反転(自然画像で有効。文字画像等は要注意)
transforms.ColorJitter( # 明るさ・コントラスト・彩度をランダムに変化
brightness=0.2, contrast=0.2, saturation=0.2
),
transforms.RandAugment(num_ops=2, magnitude=9), # 複数の変換を自動選択・自動適用する手法
transforms.ToTensor(),
transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)),
])
# 検証用データには水増しをかけず、決定的な前処理のみを適用する
val_transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)),
])
torchvisionの現行APIでは、学習済み重みをweights引数に列挙型で渡します(pretrained=Trueは非推奨のため使用を避けます)。ResNet50にImageNetの学習済み重みを読み込ませ、1枚の画像がどのクラスに分類されるかを推論する最小例です。初回実行時は重みファイル(約100MB)が自動でダウンロードされます。モデルが出す生の値(ロジット)は大小の比較しかできない数値なので、コードではsoftmaxを通しています。softmaxは、複数の値をすべて0から1の範囲に収め、合計が1になるように変換する処理で、これによりクラスごとの確からしさを比べやすい形になります。
import torch
from torchvision.models import resnet50, ResNet50_Weights
from PIL import Image
# weights引数に列挙型を渡す現行API(pretrained=Trueは非推奨)
weights = ResNet50_Weights.DEFAULT # 初回はImageNet学習済み重みを自動ダウンロード(約100MB)
model = resnet50(weights=weights)
model.eval()
preprocess = weights.transforms() # このモデル専用の前処理を取得
categories = weights.meta["categories"] # ImageNetの1000クラス名一覧
img = Image.open("sample.jpg").convert("RGB")
batch = preprocess(img).unsqueeze(0)
with torch.no_grad():
logits = model(batch)
probs = logits.softmax(dim=1)
top5_prob, top5_idx = probs.topk(5)
for prob, idx in zip(top5_prob[0], top5_idx[0]):
print(f"{categories[idx]}: {prob.item():.3f}")
転移学習は、ImageNetなど大規模データで学習済みのモデルの大部分をそのまま使い、最終の全結合層だけを自社のクラス数に合わせて差し替える手法です。少ない自社データでも、ゼロから学習するより短時間で実用的な精度に到達しやすくなります。下のコードは、差し替え後に全層を更新する形(全層のファインチューニング)です。差し替えた層以外を固定して最終層だけを学習させる方法は、続くレシピ35で扱います。
import torch
import torch.nn as nn
from torchvision.models import resnet18, ResNet18_Weights
weights = ResNet18_Weights.DEFAULT
model = resnet18(weights=weights)
num_classes = 5 # 自社の分類クラス数(例: 製品カテゴリ5種)
model.fc = nn.Linear(model.fc.in_features, num_classes) # 最終全結合層のみ差し替え
device = "cuda" if torch.cuda.is_available() else "cpu"
model = model.to(device)
# 差し替えた層以外の重みは、ImageNetで学習済みのものを初期値として使う。
# この書き方では全層が更新対象になる(=全層のファインチューニング)。
# 最終層だけを学習させたい場合はレシピ35の凍結を使う
optimizer = torch.optim.Adam(model.parameters(), lr=1e-4)
差し替えた最終層は重みが初期状態のため、学習の最初のうちは大きな勾配が発生します。それがそのまま前の層まで流れると、事前学習で得られた特徴表現(画像から輪郭や模様といった手がかりを取り出す働き)を壊してしまうことがあります。そこで、まず全層を凍結(requires_grad=False)して最終層だけを学習させ、精度が安定したら後段の層から順に解凍していくという段階的な進め方が定石です。解凍した層には小さめの学習率を設定します。
# 前提: レシピ34で作ったmodel(fcを差し替えたResNet18)とtorchのimportを引き継ぎます
# 段階1: 全層を凍結し、最終層だけを学習させる
for param in model.parameters():
param.requires_grad = False
for param in model.fc.parameters():
param.requires_grad = True
optimizer = torch.optim.Adam(model.fc.parameters(), lr=1e-3)
# ここで数エポック学習し、最終層をデータに合わせて安定させる
# 段階2: 精度が頭打ちになったら、後段の層から順に解凍する
for param in model.layer4.parameters():
param.requires_grad = True
# 解凍した層は学習率を小さくするのが定石(既存の特徴表現を壊さないため)
optimizer = torch.optim.Adam([
{"params": model.layer4.parameters(), "lr": 1e-5},
{"params": model.fc.parameters(), "lr": 1e-4},
])
自社で撮影・収集した画像を学習に使う場合、フォルダ名をそのままクラスラベルとして扱うImageFolderが便利です。クラスごとにサブフォルダへ画像を分けて配置するだけで、ラベル付きデータセットとして読み込めます。train用とval用のフォルダを別に用意する方法と、random_splitで1つのフォルダから分割する方法があります。前処理は、リサイズとテンソル化だけでなく正規化まで学習済みモデル側の前提に揃える必要があります。この点はレシピ37で改めて扱います。
from torchvision import datasets, transforms
from torch.utils.data import DataLoader, random_split
# フォルダ構成の約束事:
# data/
# class_a/xxx.jpg, yyy.jpg, ...
# class_b/zzz.jpg, ...
# サブフォルダ名がそのままクラスラベルになる
# 学習済みモデルにつなぐ場合は、その重みが前提にしている前処理に合わせる。
# torchvisionの重みは weights.transforms() で正しい前処理一式を取り出せる
# from torchvision.models import ResNet18_Weights
# transform = ResNet18_Weights.DEFAULT.transforms()
# 自分で書く場合も、リサイズとテンソル化だけでなく正規化まで揃える
transform = transforms.Compose([
transforms.Resize((224, 224)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], # ImageNetの統計量
std=[0.229, 0.224, 0.225]),
])
full_dataset = datasets.ImageFolder(root="./data", transform=transform)
print(full_dataset.classes) # ['class_a', 'class_b', ...] アルファベット順に並ぶ
# train/valに8:2で分割
n_val = int(len(full_dataset) * 0.2)
n_train = len(full_dataset) - n_val
train_set, val_set = random_split(full_dataset, [n_train, n_val])
train_loader = DataLoader(train_set, batch_size=32, shuffle=True)
val_loader = DataLoader(val_set, batch_size=32, shuffle=False)
推論時の前処理が学習時とわずかにでもずれていると、モデルの精度は大きく落ちます。リサイズの補間方式が違う、正規化の平均・標準偏差が違う、といった食い違いは、実務でたびたび発生する定番の事故です。weights.transforms()を使えば、そのモデルが学習時に使った前処理をそのまま取得できるため、手書きでの再現よりも安全です。
from torchvision.models import resnet18, ResNet18_Weights
from PIL import Image
weights = ResNet18_Weights.DEFAULT
model = resnet18(weights=weights)
preprocess = weights.transforms() # 学習時と同じリサイズ・正規化を再現する
print(preprocess)
# Resize(256) -> CenterCrop(224) -> ToTensor -> Normalize(mean, std) 等が
# モデルごとに適切な値で自動設定されている
# 独自に前処理を書き直すと、リサイズの補間方式や正規化の値がずれて
# 精度が大きく落ちる事故が起きやすい(学習時はBICUBIC、推論時はデフォルトのBILINEARだった、等)
img = Image.open("sample.jpg").convert("RGB")
batch = preprocess(img).unsqueeze(0) # 学習時と同じ前処理をそのまま利用する
全体の正解率だけでは、特定のクラスだけ精度が低いといった問題を見落とします。sklearnのconfusion_matrixとclassification_reportを使うと、どのクラスとどのクラスを混同しやすいか、クラスごとの適合率(precision)・再現率(recall)・F1がどうなっているかを一度に確認できます。適合率は「そのクラスだと予測したもののうち、実際に正しかった割合」、再現率は「実際にそのクラスだったもののうち、正しく拾えた割合」で、F1は両者の調和平均です。見逃しを減らしたいなら再現率、誤検知を減らしたいなら適合率を重く見ます。
import torch
from sklearn.metrics import confusion_matrix, classification_report
# 前提: レシピ34で学習したmodel・deviceと、レシピ36のval_loader・full_datasetを使います
model.eval()
all_preds, all_labels = [], []
with torch.no_grad():
for images, labels in val_loader:
images = images.to(device)
outputs = model(images)
preds = outputs.argmax(dim=1).cpu()
all_preds.extend(preds.tolist())
all_labels.extend(labels.tolist())
# 検証側に1件も現れなかったクラスがあってもズレないよう、ラベルを明示する
labels = list(range(len(full_dataset.classes)))
cm = confusion_matrix(all_labels, all_preds, labels=labels)
print(cm) # 行:正解クラス、列:予測クラス
# クラスごとのprecision/recall/f1をまとめて確認する
print(classification_report(all_labels, all_preds, labels=labels,
target_names=full_dataset.classes, zero_division=0))
数値上の評価指標だけでなく、実際に間違えた画像を並べて目で確認する作業は、モデル改善の手がかりを見つける実務の定番の型です。特定の角度や照明条件でだけ誤分類が集中している、といった傾向が見えてくることがあります。
import matplotlib.pyplot as plt
import torch
# 前提: レシピ38で計算したall_preds・all_labelsと、レシピ36の検証用データセットを使います
MEAN = torch.tensor([0.485, 0.456, 0.406]).view(3, 1, 1)
STD = torch.tensor([0.229, 0.224, 0.225]).view(3, 1, 1)
wrong_indices = [i for i, (p, l) in enumerate(zip(all_preds, all_labels)) if p != l]
fig, axes = plt.subplots(2, 4, figsize=(12, 6))
for ax, idx in zip(axes.flatten(), wrong_indices[:8]):
image, label = val_set[idx]
# レシピ36で正規化しているので、表示前に元のスケールへ戻す
image = (image * STD + MEAN).clamp(0, 1)
ax.imshow(image.permute(1, 2, 0).numpy()) # (C,H,W) -> (H,W,C)に並べ替え
true_name = full_dataset.classes[label]
pred_name = full_dataset.classes[all_preds[idx]]
ax.set_title(f"true:{true_name}\npred:{pred_name}")
ax.axis("off")
plt.tight_layout()
plt.savefig("misclassified.png")

Grad-CAMは、CNNが画像のどの領域に注目して分類判断を下したかをヒートマップとして可視化する手法です。pytorch-grad-camライブラリを使うと、最終畳み込みブロックを指定するだけで数行のコードから可視化を実行できます。判断根拠の可視化は、モデルが背景ではなく対象物を正しく見ているかを検査する用途で活用されます。ここでは手順そのものを確かめるため、レシピ33で読み込んだ汎用の学習済みモデルを対象にしています。自社データで学習したモデル(レシピ34以降)に対しても、可視化する層の指定を差し替えるだけで同じ手順が使えます。Grad-CAMの理論的な背景や他のXAI手法との比較については、既刊の説明可能AI(XAI)実践ガイドで詳しく取り上げています。
pip install grad-cam
import numpy as np
from pytorch_grad_cam import GradCAM
from pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget
from pytorch_grad_cam.utils.image import show_cam_on_image
# 前提: レシピ33のResNet(model)と、前処理済みテンソルbatch・元画像img(レシピ33参照)を使います
model.eval()
target_layers = [model.layer4[-1]] # 最終の畳み込みブロックを可視化対象に指定
cam = GradCAM(model=model, target_layers=target_layers)
input_tensor = batch # 前処理済みの(1,3,224,224)テンソル
predicted_class_idx = int(model(input_tensor).argmax(dim=1)[0])
targets = [ClassifierOutputTarget(predicted_class_idx)] # 可視化したいクラスを指定
grayscale_cam = cam(input_tensor=input_tensor, targets=targets)[0] # (224,224)のヒートマップ
rgb_img = np.array(img.resize((224, 224))) / 255.0 # 0〜1に正規化した元画像
visualization = show_cam_on_image(rgb_img, grayscale_cam, use_rgb=True)

本番環境がエッジ端末やモバイル、あるいは大量画像のリアルタイム処理を要件とする場合、精度最優先のモデルではなくMobileNetやEfficientNetのような軽量モデルの採用を検討することになります。パラメータ数と推論速度、精度のトレードオフを把握したうえで選定することが実務では重要です。
from torchvision.models import (
mobilenet_v3_small, MobileNet_V3_Small_Weights,
efficientnet_b0, EfficientNet_B0_Weights,
resnet50, ResNet50_Weights,
)
candidates = {
"MobileNetV3-Small": mobilenet_v3_small(weights=MobileNet_V3_Small_Weights.DEFAULT),
"EfficientNet-B0": efficientnet_b0(weights=EfficientNet_B0_Weights.DEFAULT),
"ResNet50": resnet50(weights=ResNet50_Weights.DEFAULT),
}
for name, m in candidates.items():
n_params = sum(p.numel() for p in m.parameters())
print(f"{name}: {n_params / 1e6:.1f}M params")
# 目安: MobileNetV3-Small(約2.5M params)はエッジ端末・リアルタイム処理向け
# EfficientNet-B0(約5.3M params)は精度と速度のバランス型
# ResNet50(約25.6M params)は精度優先だがGPU推論を前提とすることが多い
本番運用では、モデルの予測をすべて自動採用するのではなく、確信度(softmaxの最大確率)が一定の閾値を下回った画像だけを人間の確認キューに回す設計が定番です。モデルが自信を持てない難しいケースだけを人手でカバーすることで、誤った自動判定が業務に流れるリスクを下げられます。ただし、softmaxの最大確率は「その予測が当たる確率」そのものではありません。深層学習のモデルは実際の的中率より高い確信度を出しがちで、0.9という値が的中率9割を意味するとは限らないためです。閾値は理屈で決めるのではなく、検証データで確信度と実際の的中率の関係を確かめたうえで、人手で確認できる件数と見逃しの許容度から決めてください。
import torch
# 前提: レシピ34で学習したmodel・device・full_datasetと、推論対象のDataLoader(inference_loader)を用意済みとします
CONFIDENCE_THRESHOLD = 0.8
model.eval()
results = []
with torch.no_grad():
# inference_loaderは (画像テンソル, ファイルパス) を返す想定。
# ImageFolderは (画像, ラベル) を返すので、パスも欲しい場合は
# __getitem__ で self.samples[idx][0] を一緒に返すDatasetを自作する
for images, paths in inference_loader:
images = images.to(device)
probs = model(images).softmax(dim=1)
confidences, preds = probs.max(dim=1)
for path, pred, conf in zip(paths, preds.tolist(), confidences.tolist()):
results.append({
"path": path,
"pred_class": full_dataset.classes[pred],
"confidence": conf,
"needs_human_review": conf < CONFIDENCE_THRESHOLD,
})
# 確信度が閾値未満の画像だけを人間の確認キューに回す業務設計
review_queue = [r for r in results if r["needs_human_review"]]
print(f"要確認: {len(review_queue)} / {len(results)} 件")
『画像認識』(原田達也、講談社 機械学習プロフェッショナルシリーズ):CNNによる画像分類・物体検出・セグメンテーションの理論を体系的に解説した専門書です。本セクションで扱ったCNNの基本構造や転移学習の背景にある理論を、数式レベルで深く理解したい場合に参照すると理解が深まります。
セグメンテーションは、画像を1枚単位や矩形単位で扱う画像分類・物体検出とは異なり、画素(ピクセル)ひとつひとつに「どのクラスに属するか」というラベルを割り当てるタスクです。分類は画像全体に1つのラベルを与え、物体検出は対象を矩形(バウンディングボックス)で囲みますが、セグメンテーションは対象の輪郭に沿った領域そのものを出力できるため、位置と形状の情報が精密に必要な場面で使われます。代表的な用途としては、製造ラインでの外観検査における欠陥領域の抽出、医療画像における臓器や病変領域の抽出、衛星画像における土地被覆(森林・農地・建物など)の分類が挙げられます。このセクションでは、マスクの扱い方から学習済みモデルの利用、U-Netの自作、評価指標、そして基盤モデルによるプロンプトベースのセグメンテーションまでを扱います。
セグメンテーションの出力である「マスク配列」が、元画像と同じ縦横サイズを持つ2次元配列で、各画素にクラスIDが格納されているだけの単純なデータであることを確認するレシピです。外観検査であれば「背景」「対象領域」「欠陥領域」のようにクラスを割り当て、結果を元画像に重ねて表示することで、どこが検出されたのかを直感的に確認できます。コードでは各クラスIDに固定の色を割り当てたカラーマスクを作成し、imshowを2回重ねてalphaで半透明にすることでオーバーレイ表示を実現しています。

import numpy as np
import matplotlib.pyplot as plt
# ダミーの元画像(64x64のグレースケール画像を疑似生成)
np.random.seed(0)
image = np.random.randint(80, 180, size=(64, 64), dtype=np.uint8)
# ダミーのセグメンテーションマスク(0:背景, 1:対象領域, 2:欠陥領域)
mask = np.zeros((64, 64), dtype=np.uint8)
mask[10:50, 15:45] = 1
mask[30:35, 30:38] = 2
# クラスごとに色を割り当てたカラーマスクを作成
color_map = {0: (0, 0, 0), 1: (0, 128, 255), 2: (255, 0, 0)}
color_mask = np.zeros((64, 64, 3), dtype=np.uint8)
for class_id, color in color_map.items():
color_mask[mask == class_id] = color
# 元画像にマスクを半透明で重ねて表示
fig, axes = plt.subplots(1, 2, figsize=(8, 4))
axes[0].imshow(image, cmap='gray')
axes[0].set_title('元画像')
axes[1].imshow(image, cmap='gray')
axes[1].imshow(color_mask, alpha=0.5)
axes[1].set_title('マスクのオーバーレイ')
for ax in axes:
ax.axis('off')
plt.tight_layout()
plt.show()
マスク配列は元画像と同じ(高さ, 幅)のサイズを持つ整数配列であり、値そのものがクラスIDを表しています。オーバーレイの透明度はalphaパラメータで調整でき、値を小さくするほど元画像の情報が透けて見えます。
torchvisionが提供する学習済みのDeepLabV3を使い、モデルを1から学習させることなくセグメンテーションを試すレシピです。汎用的な物体(人・車・動物など)を含む画像に対する大まかな領域分割のベースラインとして、実務のプロトタイピング段階でよく利用されます。コードではpretrained引数を使う旧APIではなく、weights引数に列挙型を渡す現行APIを使用しており、モデルに付属するpreprocessをそのまま前処理に使える点がポイントです。
import torch
from torchvision.models.segmentation import deeplabv3_resnet50, DeepLabV3_ResNet50_Weights
from PIL import Image
import numpy as np
# 学習済み重みとモデルの読み込み(現行のweights APIを使用)
weights = DeepLabV3_ResNet50_Weights.DEFAULT
model = deeplabv3_resnet50(weights=weights)
model.eval()
# 前処理は重みに付属するtransformsをそのまま利用
preprocess = weights.transforms()
# ダミー画像を用意(実務ではImage.open('path.jpg')で実画像を読み込む)
dummy_image = Image.fromarray(
(np.random.rand(320, 480, 3) * 255).astype(np.uint8)
)
input_tensor = preprocess(dummy_image).unsqueeze(0)
# 推論の実行
with torch.no_grad():
output = model(input_tensor)['out']
# 各画素についてクラススコアが最大のクラスIDを採用
predicted_mask = output.argmax(1).squeeze(0).numpy()
print(f"マスクの形状: {predicted_mask.shape}")
print(f"検出されたクラスID: {np.unique(predicted_mask)}")
クラスIDとクラス名の対応はweights.meta["categories"]から取得でき、PASCAL VOC(物体認識の評価によく使われる標準データセット)の21クラス(背景含む)が定義されています。なおpreprocessは画像をリサイズするため、得られるマスクの縦横は元画像ではなく前処理後のサイズになります。元画像に重ねて表示する用途では、マスクを元画像のサイズへ戻す処理(最近傍補間でのリサイズ)を挟んでください。
推論で得られたクラスIDの配列を、クラスごとに固定色で塗り分けたうえで凡例を添えて表示するレシピです。複数クラスが混在する結果を報告資料に載せる際、色だけでは判別しづらいクラスをラベル付きで示す用途に向いています。コードではmatplotlib.patchesを使って色と対応するクラス名の凡例ハンドルを作成し、ax.legendに渡している点が読みどころです。

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
# 44で得られる推論結果に相当するダミーマスクを用意
np.random.seed(1)
predicted_mask = np.random.choice([0, 8, 12, 15], size=(64, 64), p=[0.6, 0.15, 0.1, 0.15])
class_names = {0: '背景', 8: '猫', 12: '犬', 15: '人'}
# クラスIDごとに固定の色を割り当て
palette = {0: (0, 0, 0), 8: (255, 165, 0), 12: (0, 200, 0), 15: (0, 100, 255)}
color_image = np.zeros((*predicted_mask.shape, 3), dtype=np.uint8)
for class_id, color in palette.items():
color_image[predicted_mask == class_id] = color
# 塗り分け画像と凡例を表示
fig, ax = plt.subplots(figsize=(6, 6))
ax.imshow(color_image)
ax.axis('off')
ax.set_title('クラス別セグメンテーション結果')
legend_patches = [
mpatches.Patch(color=np.array(color) / 255, label=class_names[class_id])
for class_id, color in palette.items()
]
ax.legend(handles=legend_patches, bbox_to_anchor=(1.3, 1), loc='upper right')
plt.tight_layout()
plt.show()
セグメンテーションの代表的なアーキテクチャであるU-Netを、エンコーダ・デコーダ構造とスキップ接続に絞った最小構成で組んだレシピです。エンコーダは画像を段階的に小さくしながら特徴を絞り込む前半部分、デコーダはそれを元の解像度まで戻していく後半部分で、スキップ接続は前半の各段階の情報を後半の同じ解像度の段階へ直接渡す横のつながりです。縮小の途中で失われる細かい位置の情報を、この横のつながりで補います。実務でも医療画像や工業画像のセグメンテーションモデルの土台としてよく採用される形であり、まずこの骨格を理解しておくと市販の実装や論文の派生モデルも読みやすくなります。コードではpoolで解像度を落としながら特徴を抽出するエンコーダ側と、ConvTranspose2dで解像度を戻しながらエンコーダ側の特徴をtorch.catで連結するデコーダ側(スキップ接続)を対にして構成しています。
import torch
import torch.nn as nn
def conv_block(in_ch, out_ch):
return nn.Sequential(
nn.Conv2d(in_ch, out_ch, 3, padding=1),
nn.ReLU(inplace=True),
nn.Conv2d(out_ch, out_ch, 3, padding=1),
nn.ReLU(inplace=True),
)
class MiniUNet(nn.Module):
def __init__(self, in_channels=3, num_classes=3):
super().__init__()
self.enc1 = conv_block(in_channels, 32)
self.enc2 = conv_block(32, 64)
self.pool = nn.MaxPool2d(2)
self.bottleneck = conv_block(64, 128)
self.up2 = nn.ConvTranspose2d(128, 64, 2, stride=2)
self.dec2 = conv_block(128, 64)
self.up1 = nn.ConvTranspose2d(64, 32, 2, stride=2)
self.dec1 = conv_block(64, 32)
self.out_conv = nn.Conv2d(32, num_classes, 1)
def forward(self, x):
e1 = self.enc1(x)
e2 = self.enc2(self.pool(e1))
b = self.bottleneck(self.pool(e2))
d2 = self.dec2(torch.cat([self.up2(b), e2], dim=1))
d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1))
return self.out_conv(d1)
# 動作確認(バッチ1枚・3チャンネル・64x64画像を通す)
model = MiniUNet(in_channels=3, num_classes=3)
dummy_input = torch.randn(1, 3, 64, 64)
output = model(dummy_input)
print(f"出力形状: {output.shape}")
画像とマスクをペアで返す、セグメンテーション学習用の自作Datasetクラスを作るレシピです。分類タスク用のDatasetと異なり、データ拡張(反転やクロップなど)を画像とマスクの両方に同一の内容で適用しないと、画像とラベルの対応がずれてしまう点に注意が必要です。コードでは1回の乱数判定の結果を画像・マスク双方のTF.hflip呼び出しに共通で使うことで、拡張のずれを防いでいます。
import torch
import numpy as np
from torch.utils.data import Dataset
import torchvision.transforms.functional as TF
import random
class SegmentationDataset(Dataset):
def __init__(self, images, masks, train=True):
self.images = images # (N, H, W, 3) のnumpy配列
self.masks = masks # (N, H, W) のnumpy配列(クラスID)
self.train = train
def __len__(self):
return len(self.images)
def __getitem__(self, idx):
image = TF.to_pil_image(self.images[idx])
mask = TF.to_pil_image(self.masks[idx].astype(np.uint8))
if self.train and random.random() > 0.5:
# 画像とマスクに必ず同じ変換を適用する(左右反転)
image = TF.hflip(image)
mask = TF.hflip(mask)
image_tensor = TF.to_tensor(image)
mask_tensor = torch.as_tensor(np.array(mask), dtype=torch.long)
return image_tensor, mask_tensor
# 動作確認用のダミーデータ
dummy_images = np.random.randint(0, 255, (4, 64, 64, 3), dtype=np.uint8)
dummy_masks = np.random.randint(0, 3, (4, 64, 64), dtype=np.uint8)
dataset = SegmentationDataset(dummy_images, dummy_masks)
img, msk = dataset[0]
print(f"画像テンソル形状: {img.shape}, マスクテンソル形状: {msk.shape}")
セグメンテーションで標準的に使われるCrossEntropy損失と、領域の重なり具合を直接最適化するDice損失を比較するレシピです。CrossEntropy損失は画素ごとに独立してクラスを判定するため、背景が大半を占め欠陥領域がごく一部しかないような不均衡なデータでは、背景クラスの正解が学習を支配してしまいがちです。Dice損失は予測領域と正解領域の重なりの大きさ(次のレシピ49で扱うIoUに近い考え方です)を最大化するように働くため、対象領域が小さいデータでも学習が崩れにくいという特性があります。コードではDice損失をsoftmax後の確率とone-hot化した正解ラベルから自作しています。
import torch
import torch.nn as nn
import torch.nn.functional as F
class DiceLoss(nn.Module):
def __init__(self, num_classes, smooth=1e-6):
super().__init__()
self.num_classes = num_classes
self.smooth = smooth
def forward(self, logits, targets):
probs = F.softmax(logits, dim=1)
targets_onehot = F.one_hot(targets, self.num_classes).permute(0, 3, 1, 2).float()
dims = (0, 2, 3)
intersection = torch.sum(probs * targets_onehot, dims)
union = torch.sum(probs + targets_onehot, dims)
dice_per_class = (2 * intersection + self.smooth) / (union + self.smooth)
return 1 - dice_per_class.mean()
# ダミーの出力とラベルで両損失を比較
logits = torch.randn(2, 3, 32, 32)
targets = torch.randint(0, 3, (2, 32, 32))
ce_loss_fn = nn.CrossEntropyLoss()
dice_loss_fn = DiceLoss(num_classes=3)
ce_loss = ce_loss_fn(logits, targets)
dice_loss = dice_loss_fn(logits, targets)
print(f"CrossEntropy損失: {ce_loss.item():.4f}")
print(f"Dice損失: {dice_loss.item():.4f}")
実務では両者を組み合わせて(CrossEntropy損失 + Dice損失)のように加重和を取り、双方の長所を活かす構成もよく採用されています。
セグメンテーションの評価指標として広く使われるIoU(Intersection over Union、Jaccard係数)を、クラスごとに計算したうえで平均を取ったmIoUを求めるレシピです。正解率のような画素単位の一致率だけでは、面積の小さい対象クラスの精度低下が見えにくくなるため、クラスごとの重なり具合を個別に確認できるIoUが実務の評価では重視されます。コードでは各クラスについて予測領域と正解領域の論理積(intersection)と論理和(union)から比率を求め、対象クラスが画像中に存在しない場合はnanとして平均から除外しています。
import numpy as np
def compute_iou(pred_mask, true_mask, num_classes):
ious = []
for class_id in range(num_classes):
pred_area = (pred_mask == class_id)
true_area = (true_mask == class_id)
intersection = np.logical_and(pred_area, true_area).sum()
union = np.logical_or(pred_area, true_area).sum()
if union == 0:
ious.append(np.nan) # そのクラスが画像中に存在しない場合
else:
ious.append(intersection / union)
return ious
# ダミーの予測マスクと正解マスクで検証
np.random.seed(2)
pred_mask = np.random.randint(0, 3, (64, 64))
true_mask = np.random.randint(0, 3, (64, 64))
class_names = ['背景', '対象領域', '欠陥領域']
ious = compute_iou(pred_mask, true_mask, num_classes=3)
for name, iou in zip(class_names, ious):
print(f"{name}のIoU: {iou:.4f}")
mean_iou = np.nanmean(ious)
print(f"mIoU: {mean_iou:.4f}")
46で組んだMiniUNetを使い、合成データによる最小限の学習ループを通すレシピです。実データを用意する前段階でモデルの入出力形状や損失計算に不具合がないかを確認する、いわゆる動作確認(スモークテスト)の位置づけで使われます。コードでは中央に矩形領域を配置した簡単なマスクを教師データとして与え、5エポックの学習で損失が下がっていく様子を確認しています。
import torch
import torch.nn as nn
import torch.optim as optim
# 前提: レシピ46のMiniUNetクラスを定義済みとします(以下は合成データでの最小学習ループ)
torch.manual_seed(0)
model = MiniUNet(in_channels=3, num_classes=3)
optimizer = optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss()
# 合成データ(中央に対象クラスを配置したダミー画像とマスク)を10枚生成
images = torch.randn(10, 3, 64, 64)
masks = torch.zeros(10, 64, 64, dtype=torch.long)
masks[:, 20:44, 20:44] = 1 # 中央に対象領域を配置
images[:, :, 20:44, 20:44] += 2.0 # 画像側にも対応する信号を載せる(学習できる形にする)
# 5エポックの学習ループ
for epoch in range(5):
optimizer.zero_grad()
outputs = model(images)
loss = criterion(outputs, masks)
loss.backward()
optimizer.step()
print(f"epoch {epoch + 1}: loss = {loss.item():.4f}")
モデルの予測マスクに含まれがちな孤立したノイズ画素や、実務上意味を持たない小さな連結領域を除去し、後段の集計や表示に耐えられる品質に整えるレシピです。外観検査で1〜2画素だけの誤検出が大量に混ざるようなケースで、モルフォロジー演算による整形が有効です。コードではオープニング処理(収縮の後に膨張を行う演算)で孤立ノイズを除去し、続けて面積の小さい連結領域をremove_small_objectsでまとめて除去しています。

import numpy as np
from scipy import ndimage
from skimage.morphology import remove_small_objects, binary_opening, disk
# ノイズを含むダミーの二値予測マスクを用意(小さな誤検出領域を含む)
np.random.seed(3)
pred_mask = np.zeros((100, 100), dtype=bool)
pred_mask[30:70, 30:70] = True
noise_coords = np.random.randint(0, 100, size=(15, 2))
for y, x in noise_coords:
pred_mask[y, x] = True # 孤立したノイズ画素
# モルフォロジー演算(オープニング)で孤立ノイズを除去
opened_mask = binary_opening(pred_mask, footprint=disk(2))
# さらに面積の小さい連結領域を除去
cleaned_mask = remove_small_objects(opened_mask, min_size=50)
print(f"後処理前の有効画素数: {pred_mask.sum()}")
print(f"オープニング後の有効画素数: {opened_mask.sum()}")
print(f"小領域除去後の有効画素数: {cleaned_mask.sum()}")
# 穴埋め処理(領域内部の欠損を補完したい場合)
filled_mask = ndimage.binary_fill_holes(cleaned_mask)
print(f"穴埋め後の有効画素数: {filled_mask.sum()}")
SAM(Segment Anything Model)のような基盤モデルは、対象物をクリックした点や囲んだ矩形といった「プロンプト」を与えるだけで領域のマスクを生成させることができ、モデルを都度学習させる手間なくセグメンテーションを試せる選択肢として実務でも活用が広がっています。未知のドメインの画像に対する初期アノテーション作業の効率化などが典型的な使いどころです。ここではsegment-anythingライブラリを使った最小限の呼び出し方を紹介します。チェックポイントファイルはモデルサイズによって375MBから2.4GB程度と大きく、ダウンロードには相応の時間と十分なディスク容量が必要になる点に注意してください。
# Meta公式の実装はGitHubから直接入れる
pip install git+https://github.com/facebookresearch/segment-anything.git
# チェックポイント(例: ViT-B、約375MB)は事前に手動でダウンロードしておく
# https://github.com/facebookresearch/segment-anything#model-checkpoints
import numpy as np
from segment_anything import sam_model_registry, SamPredictor
# ダウンロード済みのチェックポイントパスを指定してモデルを構築
sam = sam_model_registry["vit_b"](checkpoint="sam_vit_b_01ec64.pth")
predictor = SamPredictor(sam)
# ダミー画像(実務では読み込んだ実画像を使用)
# set_imageはRGBの並びを前提にしている。cv2.imreadはBGRで返すので、
# cv2.cvtColor(img, cv2.COLOR_BGR2RGB) で並べ替えてから渡すこと
dummy_image = np.random.randint(0, 255, (480, 640, 3), dtype=np.uint8)
predictor.set_image(dummy_image)
# 対象物の中心付近を1点指定してプロンプトベースでマスクを生成
input_point = np.array([[320, 240]])
input_label = np.array([1]) # 1は前景(対象)を意味するラベル
masks, scores, logits = predictor.predict(
point_coords=input_point,
point_labels=input_label,
multimask_output=True,
)
print(f"生成されたマスク数: {masks.shape[0]}")
print(f"各マスクの信頼度スコア: {scores}")

multimask_output=Trueにすると、曖昧なプロンプトに対して複数候補のマスクとスコアが返されるため、実務ではスコアが最も高いものを採用するといった使い方をします。
『つくりながら学ぶ!PyTorchによる発展ディープラーニング』(小川雄太郎、マイナビ出版):物体検出やセグメンテーションを含む代表的なタスクをPyTorchでゼロから実装しながら学べる書籍で、本セクションで扱ったU-Netやスキップ接続の考え方をより体系的に補強したい場合の橋渡しとして参考になります。
物体検出は、画像に写っているものが「どこに」「何が」あるかを同時に求めるタスクです。画像全体に1つのラベルを付ける画像認識(分類)や、画素ごとにクラスを塗り分けるセグメンテーションとは異なり、検出はインスタンス単位で矩形の位置(バウンディングボックス)とクラス、そして確信度(スコア)を出力します。防犯カメラでの不審物・不審者の検知、店舗の在庫棚での欠品チェック、製造ラインでの検品、駅やイベント会場の人流計測など、「個数を数える」「位置を把握する」業務との相性がよく、実務でも導入例の多い技術です。このセクションでは、torchvisionの学習済みモデルによる推論から、ultralyticsのYOLOによる高速な推論・独自データでの再学習、評価の考え方、業務集計や動画処理への応用までを、コピーしてすぐ試せるコードで通しでご紹介します。
物体検出のバウンディングボックスは、多くのライブラリで左上の座標\((x1, y1)\)と右下の座標\((x2, y2)\)の組で表現されます。画像の原点は左上で、右方向が\(x\)、下方向が\(y\)の正方向です。まずはこの座標の約束事を押さえたうえで、PillowのImageDrawを使って画像に矩形を描画する最小コードを確認します。
from PIL import Image, ImageDraw
img = Image.open("sample.jpg").convert("RGB")
draw = ImageDraw.Draw(img)
# (x1, y1, x2, y2) = 左上と右下の座標
box = (120, 80, 340, 260)
draw.rectangle(box, outline="red", width=3)
img.save("sample_with_box.jpg")
print("box width:", box[2] - box[0], "box height:", box[3] - box[1])
幅は\(x2 – x1\)、高さは\(y2 – y1\)で求まります。ライブラリによっては\((x_{center}, y_{center}, w, h)\)の中心座標形式を使うものもあり(後述のYOLOのアノテーション形式など)、混同すると座標がずれた矩形が描かれるため、扱っている形式を都度確認する習慣が有効です。
torchvisionにはCOCOデータセット(人・車・椅子など日常的な80種類の物体に、位置と種類の正解が付けられた標準的なデータセットです)で学習済みの物体検出モデルが同梱されており、追加学習なしにそのまま推論できます。ここではFaster R-CNNを読み込みます。モデル名に付いているResNet50-FPN v2は、画像から特徴を取り出す土台部分(バックボーンと呼びます)の種類を表しており、FPNは大小さまざまな大きさの物体を捉えるために解像度の異なる特徴を組み合わせる仕組みです。読み込んだあとは、画像をテンソルに変換して推論し、スコアの低い検出をしきい値で除外する流れを実装します。初回実行時は学習済み重み(約160MB)がインターネットからダウンロードされます。
import torch
from torchvision.io import read_image
from torchvision.models.detection import (
fasterrcnn_resnet50_fpn_v2,
FasterRCNN_ResNet50_FPN_V2_Weights,
)
weights = FasterRCNN_ResNet50_FPN_V2_Weights.DEFAULT
model = fasterrcnn_resnet50_fpn_v2(weights=weights, box_score_thresh=0.5)
model.eval()
preprocess = weights.transforms()
img = read_image("sample.jpg") # uint8 の Tensor (C, H, W)
batch = [preprocess(img)]
with torch.no_grad():
outputs = model(batch)
result = outputs[0]
score_threshold = 0.7
keep = result["scores"] >= score_threshold
boxes = result["boxes"][keep]
labels = result["labels"][keep]
scores = result["scores"][keep]
print(f"検出数: {len(boxes)}")
for box, label, score in zip(boxes, labels, scores):
print(box.tolist(), int(label), float(score))
コンストラクタ引数のbox_score_threshはモデル内部で使われる足切りライン、後段のscore_thresholdは用途に応じて後から調整するための二段構えです。誤検知を厳しく減らしたい業務では0.7〜0.9程度、見逃しを避けたい業務では0.3〜0.5程度に設定することが多いです。
検出したクラスIDは数値のままでは意味が分からないため、COCOのクラス名一覧と対応付けて表示します。学習済み重みのメタデータにはcategoriesとしてクラス名のリストが同梱されており、クラスIDをそのままインデックスとして参照できます。torchvisionのdraw_bounding_boxesを使うと、ラベルとスコアを添えた矩形を一括で描画できます。
import torch
from torchvision.io import read_image
from torchvision.utils import draw_bounding_boxes
from torchvision.transforms.functional import to_pil_image
from torchvision.models.detection import (
fasterrcnn_resnet50_fpn_v2,
FasterRCNN_ResNet50_FPN_V2_Weights,
)
weights = FasterRCNN_ResNet50_FPN_V2_Weights.DEFAULT
categories = weights.meta["categories"] # COCOのクラス名リスト(背景含む)
model = fasterrcnn_resnet50_fpn_v2(weights=weights)
model.eval()
img = read_image("sample.jpg")
batch = [weights.transforms()(img)]
with torch.no_grad():
result = model(batch)[0]
keep = result["scores"] >= 0.7
boxes = result["boxes"][keep]
labels = result["labels"][keep]
scores = result["scores"][keep]
label_texts = [
f"{categories[label]}: {score:.2f}"
for label, score in zip(labels, scores)
]
annotated = draw_bounding_boxes(
img, boxes=boxes, labels=label_texts, colors="lime", width=3, font_size=20
)
to_pil_image(annotated).save("sample_annotated.jpg")
クラス名一覧には\(N\)個ではなく背景クラス(”__background__”)が0番に含まれる点に注意が必要です。モデルが出力するラベルIDはこの並びをそのまま参照する前提で設計されているため、独自にクラス名リストを作り直すとズレが生じます。

ultralytics社のYOLOは、pipインストール後にわずか数行で推論まで実行できる手軽さが特長で、実務のプロトタイピングでよく使われます。ここでは軽量モデルyolo11n.pt(初回実行時に自動ダウンロード)を使い、画像に対する推論と結果オブジェクトの読み方を確認します。
pip install ultralytics
from ultralytics import YOLO
model = YOLO("yolo11n.pt") # 初回はモデルを自動ダウンロード
results = model("sample.jpg", conf=0.5)
result = results[0]
print("検出数:", len(result.boxes))
for box in result.boxes:
xyxy = box.xyxy[0].tolist() # [x1, y1, x2, y2]
cls_id = int(box.cls[0])
cls_name = result.names[cls_id] # COCOのクラス名
conf = float(box.conf[0])
print(cls_name, conf, xyxy)
# ラベル・矩形を描き込んだ画像を保存
result.save(filename="sample_yolo.jpg")
結果オブジェクトresult.boxesには座標(xyxy/xywh)・クラスID(cls)・確信度(conf)がテンソルとしてまとまっており、result.namesでクラスIDからクラス名の辞書を引けます。conf引数で検出のスコアしきい値をその場で指定できるため、55番のように後から絞り込むコードを書かずに済む手軽さがあります。
自社の商品や設備などCOCOに含まれない対象を検出したい場合は、独自にアノテーションしたデータでYOLOを再学習させます。学習にはデータセットの構成を定義したYAMLファイルと、画像・ラベルを分けたフォルダ構成が必要です。ラベルはYOLO形式(先頭にクラスIDを整数のまま置き、続けて中心座標\((x_{center}, y_{center})\)、幅\(w\)、高さ\(h\)を0〜1に正規化して並べたテキスト)で1画像1ファイルに保存します。正規化するのは座標と大きさだけで、クラスIDは正規化しません。
from pathlib import Path
# フォルダ構成の例
# dataset/
# images/train/xxx.jpg
# images/val/yyy.jpg
# labels/train/xxx.txt # 1行 = 1物体: class cx cy w h (正規化座標)
# labels/val/yyy.txt
# data.yaml
# data.yaml の中身の例
data_yaml = """
path: dataset
train: images/train
val: images/val
names:
0: box
1: pallet
2: forklift
"""
Path("dataset").mkdir(parents=True, exist_ok=True) # 無いと書き込みで落ちる
with open("dataset/data.yaml", "w", encoding="utf-8") as f:
f.write(data_yaml)
from ultralytics import YOLO
model = YOLO("yolo11n.pt") # 学習済み重みから転移学習
model.train(
data="dataset/data.yaml",
epochs=100,
imgsz=640,
batch=16,
project="runs_custom",
name="warehouse_v1",
)
ゼロからアノテーションを行う際は、LabelImgやCVAT、Label Studio、Roboflowといった無償・有償のアノテーションツールを使うと、矩形の描画からYOLO形式へのエクスポートまでを一貫して行えます。学習後はruns_custom/warehouse_v1/weights/best.ptが生成され、56番と同じ流れで独自クラスの推論に利用できます。
物体検出モデルは同じ物体に対して複数の重複したボックスを出力することがよくあります。NMS(Non-Maximum Suppression、非最大抑制)は、スコアの高いボックスを残しながら、それと大きく重なる(IoUが高い)ボックスを間引くことで、1物体1ボックスに整理する後処理です。torchvisionのops.nmsを使うと、この処理を1行で実演できます。
import torch
from torchvision.ops import nms
# 同じ物体に対する重複ボックスを想定したダミーデータ
# nmsはboxesを浮動小数テンソルとして扱うので、dtypeを明示する
boxes = torch.tensor([
[100, 100, 210, 210], # ほぼ同じ位置のボックスA
[105, 102, 215, 208], # ボックスAとほぼ重複
[400, 300, 480, 380], # 別の物体のボックスB
], dtype=torch.float32)
scores = torch.tensor([0.95, 0.80, 0.88])
keep_idx = nms(boxes, scores, iou_threshold=0.5)
print("残るボックスのインデックス:", keep_idx.tolist())
print("残るボックス:", boxes[keep_idx])
この例では、AとBの2つの物体があるにもかかわらず3つのボックスが出力されていますが、NMSによりスコア0.80のボックスがスコア0.95のボックスと高いIoUを持つとして間引かれ、2つのボックスに整理されます。iou_thresholdを小さくするほど間引きが厳しくなり、近接した複数物体を1つに潰してしまうリスクが上がるため、対象物体の密集度合いに応じた調整が必要です。torchvisionやultralyticsの検出モデルは、通常この処理を内部で自動的に行っています。
検出の正解・不正解は、予測ボックスと正解ボックスの重なり度合いを表すIoU(Intersection over Union、\(\frac{\text{重なり面積}}{\text{和集合の面積}}\))が一定のしきい値(代表的には0.5)を超えるかどうかで判定します。この判定を全クラス・全検出について行い、クラスごとに適合率と再現率のトレードオフを1本の曲線として描きます。これをPR曲線と呼び、その曲線の下側の面積を求めたものがAP(Average Precision)です。APを全クラスで平均したものがmAP(mean Average Precision)になります。ここでいう適合率は「検出したもののうち正しかった割合」、再現率は「実在するもののうち検出できた割合」で、いずれも全体の正解率(accuracy)とは別の指標です。ultralyticsでは検証データに対してval()を実行するだけで、この指標をまとめて算出できます。
from ultralytics import YOLO
model = YOLO("runs_custom/warehouse_v1/weights/best.pt")
metrics = model.val(data="dataset/data.yaml")
print("mAP50:", metrics.box.map50) # IoU閾値0.5でのmAP
print("mAP50-95:", metrics.box.map) # IoU 0.5〜0.95を平均したmAP
print("クラスごとのmAP50-95:", metrics.box.maps)
mAP50はIoU閾値0.5だけで判定した比較的緩やかな指標、mAP50-95はIoU閾値を0.5から0.95まで段階的に変えて平均した厳しめの指標で、COCOの標準的な評価指標として広く使われています。「検出はできているがボックスの位置がやや粗い」場合はmAP50は高いままmAP50-95だけが下がる、といった読み方をすると改善点を切り分けやすくなります。
検出結果は座標とクラスの羅列にすぎないため、業務に使うには「フレーム内に何人写っているか」「特定エリアに何個の物体があるか」といった集計ロジックに変換する必要があります。ここでは人物クラスの個数カウントと、ボックスの足元座標が指定した矩形エリア内にあるかどうかで滞在を判定する例を実装します。
from ultralytics import YOLO
model = YOLO("yolo11n.pt")
result = model("store_floor.jpg", conf=0.5)[0]
PERSON_CLASS_ID = 0 # UltralyticsのCOCOクラスでは0番が"person"
# torchvisionの検出モデルは0番が背景でpersonは1番。混同しやすい
# 滞在判定エリア(x1, y1, x2, y2): 例としてレジ前の矩形を定義
zone = (300, 200, 700, 480)
person_count = 0
in_zone_count = 0
for box in result.boxes:
if int(box.cls[0]) != PERSON_CLASS_ID:
continue
person_count += 1
x1, y1, x2, y2 = box.xyxy[0].tolist()
foot_x = (x1 + x2) / 2 # 足元のx座標は矩形の中心
foot_y = y2 # 足元のy座標は矩形の下端
if zone[0] <= foot_x <= zone[2] and zone[1] <= foot_y <= zone[3]:
in_zone_count += 1
print(f"フレーム内の人数: {person_count}")
print(f"レジ前エリア内の人数: {in_zone_count}")
人物の位置は矩形の中心ではなく、地面に接する足元(矩形下端の中央)で判定するのが定石です。矩形の中心で判定すると、身長差やカメラ角度によって実際にはエリア外に立っている人物を誤ってエリア内と判定しやすくなります。滞在時間の計測まで行う場合は、後述のフレーム処理と組み合わせ、同一人物の追跡(トラッキング)IDごとにエリア内にいたフレーム数を積算する設計になります。
動画に対する検出は、OpenCVでフレームを1枚ずつ取得し、そのフレームに検出をかけ、結果を描画した映像として書き出すループが基本形になります。防犯カメラ映像や店舗の定点カメラ映像を処理する際の最小構成として押さえておくと応用が利きます。
import cv2
from ultralytics import YOLO
model = YOLO("yolo11n.pt")
cap = cv2.VideoCapture("input_video.mp4")
fps = cap.get(cv2.CAP_PROP_FPS)
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
writer = cv2.VideoWriter("output_video.mp4", fourcc, fps, (width, height))
while True:
ok, frame = cap.read()
if not ok:
break # 動画の終端
result = model(frame, conf=0.5, verbose=False)[0]
annotated_frame = result.plot() # 検出結果を描き込んだフレーム(BGR)
writer.write(annotated_frame)
cap.release()
writer.release()
print("書き出し完了: output_video.mp4")
result.plot()はOpenCVのBGR形式で描画済みのフレームをそのまま返すため、VideoWriterにそのまま渡せます。フレーム数が多い動画では1フレームずつの推論に時間がかかるため、リアルタイム性が必要な場合は数フレームおきに間引いて推論する、GPUを使う、軽量モデルに切り替えるといった調整を組み合わせます。

物体検出モデルには精度・速度・利用条件の異なる選択肢が複数存在し、要件に応じた使い分けが必要です。エッジ端末(カメラ内蔵チップやRaspberry Piなど)で動かすのか、サーバー側でまとめて処理するのかによっても最適な選択は変わります。以下に代表的なモデルの傾向と、導入前に確認すべきライセンスの注意点をまとめます。
| モデル系統 | 精度の傾向 | 速度の傾向 | 主な配置先 | ライセンス上の注意 |
|---|---|---|---|---|
| YOLO(ultralytics, v8〜v11) | 中〜高 | 非常に高速 | エッジ・サーバー両対応 | AGPL-3.0が既定。商用で公開せず使う場合はEnterprise Licenseの要否を要確認 |
| Faster R-CNN(torchvision) | 高い | 中程度(YOLOより低速) | 主にサーバー | BSD-3-Clauseで商用利用の制約は少ない(利用前に最新ライセンス文を要確認) |
| SSD(torchvision) | 中程度 | 高速 | エッジ・サーバー | BSD-3-Clauseで商用利用の制約は少ない(利用前に最新ライセンス文を要確認) |
| RT-DETR/DETR系 | 高い | 中程度 | 主にサーバー | 実装元のライセンス(Apache-2.0等)を要確認。NMS不要というアーキテクチャ上の特徴あり |
一般に、YOLO系は速度と手軽さを優先する現場向き、Faster R-CNNのような2段階方式は多少の速度低下と引き換えに高精度を狙える現場向き、という傾向があります。ただしYOLOはAGPL-3.0ライセンスを採用しているため、自社サービスに組み込んで外部に提供する形態によってはソースコード公開義務が生じる可能性があり、商用での利用形態は導入前に必ず確認することが重要です。エッジ端末で動かす場合はモデルサイズ(パラメータ数)と推論速度、サーバー側で動かす場合は精度とスループットを優先軸にする、といった判断基準を持っておくと選定がぶれにくくなります。
『Pythonで学ぶ画像認識』(田村雅人・中村克行、インプレス):ディープラーニングによる画像認識の基礎から、分類・検出・セグメンテーションまでを実装ベースで学べる書籍です。本セクションで扱った検出の内部構造(特徴を取り出す土台であるバックボーンや、候補の矩形をあらかじめ用意しておくアンカーといった検出モデル特有の考え方)を体系的に補いたい場合の参考になります。

このセクションでは、Hugging Face社が公開しているTransformersライブラリを軸に、感情分析・要約・翻訳・固有表現抽出といった代表的な自然言語処理タスクを実務目線で扱っていきます。Transformersは事前学習済みモデルをpipeline関数で数行呼び出すだけで使える手軽さが特徴で、モデルの差し替えやファインチューニングにも同じAPIで対応できる点が実務での採用しやすさにつながっています。あわせて、日本語処理特有の注意点にも触れます。英語はスペース区切りである程度単語の境界が分かりますが、日本語は文中に区切りがないため、モデルごとに専用のトークナイザ(形態素解析ベース、あるいはSentencePieceなどのサブワード分割ベース)を内蔵しており、英語用モデルをそのまま日本語テキストに使うと正しく処理できません。日本語を扱う際は、必ず日本語データで事前学習・調整されたモデルとトークナイザの組み合わせを選ぶ必要があります。
このセクションは前半と後半で性格が変わります。前半(レシピ63〜71)は、公開されている学習済みモデルをそのまま呼び出して目的別に使い分ける話です。後半(レシピ72〜77)は、自社のデータでモデルを作り直す話で、データの準備、ファインチューニング、保存と再利用、評価指標の計算、長い文章への対処、そして従来型の軽量な手法との使い分けまでを順に扱います。「まず既製のモデルで試し、精度が足りなければ自社データで調整する」という実務の進め方に沿った並びにしています。
Transformersはpipeline関数を使うことで、モデルの読み込みからトークナイズ、推論、後処理までを1行でまとめて実行できます。感情分析(sentiment-analysis)やテキスト分類など多くのタスクがこの共通インターフェースで呼び出せるため、まず全体像をつかむための最初の一歩として最適です。初回実行時には指定したタスクのデフォルトモデルが自動的にダウンロードされ、ローカルにキャッシュされます。
pip install transformers torch
from transformers import pipeline
# タスク名だけ指定すればデフォルトモデルが自動でダウンロードされる
classifier = pipeline("sentiment-analysis")
results = classifier([
"This product is amazing, I love it!",
"The service was terrible and slow."
])
for text, result in zip(
["This product is amazing, I love it!", "The service was terrible and slow."],
results
):
print(f"{text} -> {result['label']} ({result['score']:.3f})")
デフォルトモデルは英語向けのため、日本語テキストをそのまま渡すと精度が大きく落ちます。本番運用ではmodel引数にタスクと言語に合ったモデル名を明示的に指定する運用にしておくと、後からモデルが差し替わって挙動が変わる事故を避けられます。
AutoTokenizerは、テキストをモデルが理解できるID列に変換する前処理を担います。tokenizeは文字列をトークンのリストに、encodeはさらにID列に変換し、decodeは逆にID列を文字列へ戻す処理です。英語モデルの多くはサブワード分割を採用しており、未知語や複合語を「##」などの接頭辞付きの断片に分けて表現している様子を実際に確認できます。
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
text = "Unhappiness is not the opposite of happiness."
tokens = tokenizer.tokenize(text)
print("トークン:", tokens)
encoded = tokenizer.encode(text)
print("ID列:", encoded)
decoded = tokenizer.decode(encoded)
print("デコード結果:", decoded)
# 実際にモデルへ渡す形(attention_maskなど)まで確認する
inputs = tokenizer(text, return_tensors="pt", padding=True, truncation=True)
print(inputs)
出力される「Unhappiness」の分割結果を見ると、語彙に登録されていない単語が「un」に続く複数の断片へ細かく割られていることが分かります。どこで区切られるかは語彙表次第なので、実際の分割は必ず出力で確かめてください。サブワード分割は、こうして既知の断片の組み合わせで未知語を表現する仕組みです。tokenizer()を直接呼び出すと、input_idsに加えてattention_mask(有効なトークンの範囲を示す)も同時に得られ、モデルへの入力形式がそのまま整います。
日本語で感情分析を行う場合は、日本語コーパスで事前学習・調整された専用モデルをmodelとtokenizerの両方に指定します。ここでは日本語BERTをポジティブ・ネガティブの二値分類用にファインチューニングした公開モデルを例に、問い合わせ文の感情判定を行う流れを示します。なお日本語BERT系のトークナイザは形態素解析器などの追加依存が必要になるため、pip install "transformers[ja]"のように日本語用の追加パッケージを含めて導入しておくと確実です。公開モデルは提供者の都合で非公開になったり、利用に承認が必要な設定へ変わったりすることがあります。読み込みで認証を求められた場合は、Hugging Face Hub上でそのモデルの現在の公開状態を確認してください。
from transformers import pipeline
sentiment_analyzer = pipeline(
"sentiment-analysis",
model="llm-book/bert-base-japanese-v3-marc_ja",
tokenizer="llm-book/bert-base-japanese-v3-marc_ja"
)
texts = [
"この製品はとても使いやすく、満足しています。",
"対応が遅く、サポート品質にも不満が残りました。"
]
for text in texts:
result = sentiment_analyzer(text)[0]
print(f"{text} -> {result['label']} ({result['score']:.3f})")
modelとtokenizerに同じモデル名を指定しているのは、学習時に使われたトークナイズ方式とモデルの語彙が一致していないと精度が大きく落ちるためです。社内システムに組み込む際は、想定する文体(口コミ、問い合わせメール、SNS投稿など)に近いデータで事前に精度を検証しておくことをおすすめします。

zero-shot-classificationは、分類先のラベルをあらかじめ学習させなくても、候補となるカテゴリ名を文章として与えるだけで分類できるタスクです。カスタマーサポートの問い合わせ振り分けのように、カテゴリの追加・変更が頻繁に発生する場面で、モデルを再学習せずに運用できる点が実務上のメリットになります。ここでは多言語対応モデルを使い、日本語の問い合わせ文をカテゴリ分けしています。
from transformers import pipeline
classifier = pipeline(
"zero-shot-classification",
model="MoritzLaurer/mDeBERTa-v3-base-mnli-xnli"
)
inquiry = "注文した商品が届かないのですが、配送状況を教えてください。"
candidate_labels = [
"配送に関する問い合わせ",
"返品・交換の相談",
"料金・請求に関する質問",
"製品の使い方の質問"
]
# 仮説文の雛形も日本語にしておく(既定は英語なので日英が混ざる)
result = classifier(
inquiry,
candidate_labels,
hypothesis_template="この文章は{}に関するものです。",
)
for label, score in zip(result["labels"], result["scores"]):
print(f"{label}: {score:.3f}")
候補ラベルの文言を工夫するだけで分類の切り口を変えられるため、新しい問い合わせカテゴリが増えた場合もリストに文言を追加するだけで対応できます。ただし専用にファインチューニングしたモデルと比べると精度は劣る傾向があるため、件数が多く重要度の高い分類には別途評価を行うことをおすすめします。
固有表現抽出(Named Entity Recognition)は、文章中の人名・組織名・地名・日付などを自動的に検出するタスクです。契約書や議事録から関係者名や取引先企業名を抜き出す用途などに使われます。aggregation_strategy=”simple”を指定すると、サブワードに分割されたトークンを1つの単語としてまとめ直してくれるため、そのまま扱いやすい形式で結果を得られます。
from transformers import pipeline
ner = pipeline(
"ner",
model="tsmatz/xlm-roberta-ner-japanese",
aggregation_strategy="simple"
)
# 動作確認用の架空の文(実在の企業名は使わない)
text = "田中太郎さんは、サンプル商事株式会社の大阪支社で働いています。"
entities = ner(text)
for entity in entities:
print(f"{entity['word']} ({entity['entity_group']}): {entity['score']:.3f}")
aggregation_strategyを指定しない場合、1つの固有表現が複数のサブワードに割れたまま、断片ごとにラベルが返ってくるため後処理が煩雑になります。断片の表記のしかたはモデルが使うトークナイザの方式によって異なります(レシピ64で扱ったBERT系は「##」を付ける方式、ここで使う多言語モデルは別の方式です)。実務で使う際はsimpleやaverageなどの集約方式を必ず指定し、抽出結果の後段処理を単純化しておくとよいでしょう。
要約モデルは、長文の要点をまとめた短い文章を新たに生成します(原文から文をそのまま抜き出すのではありません)。モデルには入力できるトークン数の上限があるため、レポートや議事録のような長文をそのまま渡すと途中で切り捨てられてしまいます。そのため、長文はあらかじめ一定の長さで分割してから個別に要約し、それらをつなぎ合わせる処理が必要になります。
なお要約と翻訳は、かつてはpipeline("summarization")・pipeline("translation")という専用のパイプラインで呼び出せましたが、これらのタスクはTransformersの新しい系列では登録されていません。バージョンによらず動く形として、ここではモデルとトークナイザを直接読み込んでgenerateを呼ぶ書き方を示します。
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
model_name = "tsmatz/mt5_summarize_japanese"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSeq2SeqLM.from_pretrained(model_name)
long_text = "ここに要約したい長文のニュース記事やレポートのテキストが入ります。" * 5
def summarize(text, max_new_tokens=100):
inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=512)
output_ids = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
min_new_tokens=20,
do_sample=False, # 毎回同じ結果にする
)
return tokenizer.decode(output_ids[0], skip_special_tokens=True)
def summarize_long_text(text, max_chunk_chars=800):
chunks = [text[i:i + max_chunk_chars] for i in range(0, len(text), max_chunk_chars)]
return " ".join(summarize(chunk) for chunk in chunks)
print(summarize_long_text(long_text))
文字数での分割は簡便ですが、トークン数と文字数は厳密には一致しないため、余裕を持ったmax_chunk_charsを設定するか、tokenizerで実際のトークン数を確認しながら分割することをおすすめします。分割位置が文の途中になると要約の質が落ちやすいため、可能であれば段落や文の区切りで分けるようにします。
言語ペアごとに用意された翻訳モデルを使うと、日英・英日といった翻訳を実行できます。社内文書の下訳や、海外拠点とのメールの一次翻訳など、人手による翻訳前の下地作りとして活用しやすいタスクです。要約と同じく、モデルとトークナイザを直接読み込む書き方にしています。
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
def load_translator(model_name):
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSeq2SeqLM.from_pretrained(model_name)
return tokenizer, model
def translate(text, tokenizer, model):
inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=512)
output_ids = model.generate(**inputs, max_new_tokens=256)
return tokenizer.decode(output_ids[0], skip_special_tokens=True)
ja_en_tok, ja_en_model = load_translator("Helsinki-NLP/opus-mt-ja-en")
en_ja_tok, en_ja_model = load_translator("Helsinki-NLP/opus-mt-en-jap")
japanese_text = "この度は弊社製品をご利用いただき、誠にありがとうございます。"
print(translate(japanese_text, ja_en_tok, ja_en_model))
english_text = "Thank you for choosing our product."
print(translate(english_text, en_ja_tok, en_ja_model))
翻訳モデルは文の長さや専門用語の有無によって精度が変動しやすいため、契約書や技術文書など正確性が求められる翻訳では、この結果をそのまま採用せず人によるチェックを挟む運用が現実的です。
sentence-transformersは文章全体を固定長のベクトルに変換するライブラリで、文の意味的な近さをコサイン類似度で比較できます。キーワードが完全一致しなくても意味が近い文章を探せるため、FAQ検索やチャットボットの一次回答候補の抽出に向いています。多言語対応モデルを使えば、日本語のFAQデータもそのまま扱えます。
pip install sentence-transformers
from sentence_transformers import SentenceTransformer, util
model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
faq_questions = [
"パスワードを忘れた場合はどうすればよいですか。",
"支払い方法にはどのようなものがありますか。",
"解約の手続き方法を教えてください。"
]
faq_embeddings = model.encode(faq_questions, convert_to_tensor=True)
query = "ログインパスワードがわからなくなりました。"
query_embedding = model.encode(query, convert_to_tensor=True)
scores = util.cos_sim(query_embedding, faq_embeddings)[0]
best_idx = scores.argmax().item()
print(f"最も近いFAQ: {faq_questions[best_idx]} (類似度: {scores[best_idx]:.3f})")

文埋め込みとk-meansクラスタリングを組み合わせると、事前にカテゴリを定義しなくても似た内容の文章同士を自動的にグループ化できます。大量に溜まった問い合わせログをまず機械的に俯瞰したい、といった探索的な分析の初期段階で役立ちます。
from sentence_transformers import SentenceTransformer
from sklearn.cluster import KMeans
model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
inquiries = [
"商品の配送が遅れています。",
"注文した商品がまだ届きません。",
"料金の請求内容について確認したいです。",
"請求書の金額が違う気がします。",
"サイトのログインができません。",
"パスワードの再設定がうまくいきません。"
]
embeddings = model.encode(inquiries)
kmeans = KMeans(n_clusters=3, random_state=42, n_init=10)
labels = kmeans.fit_predict(embeddings)
for label in sorted(set(labels)):
print(f"--- クラスタ{label} ---")
for inquiry, l in zip(inquiries, labels):
if l == label:
print(f" {inquiry}")
クラスタ数(n_clusters)は業務側で想定するカテゴリ数の見当を付けて指定する必要があり、最適な数を機械的に決めたい場合はエルボー法やシルエットスコアなどで別途検討します。
datasetsライブラリは、公開データセットの読み込みから前処理、分割までを効率的に行うためのライブラリです。load_datasetで元データを取得し、mapで各サンプルに前処理関数を適用し、train_test_splitで学習用と検証用に分割するという一連の流れが、大規模データでもメモリを圧迫しにくい形で実行できます。
pip install datasets
from datasets import load_dataset
# 英語の映画レビューデータセットを例に処理の流れを示す
dataset = load_dataset("imdb")
def add_length(example):
example["text_length"] = len(example["text"])
return example
dataset = dataset.map(add_length)
small_train = dataset["train"].shuffle(seed=42).select(range(2000))
split = small_train.train_test_split(test_size=0.2, seed=42)
print(split)
print(split["train"][0]["text"][:200])
日本語データで同様の流れを試したい場合は、load_datasetに日本語のニュース記事分類データセットなどを指定すれば、mapやtrain_test_splitの使い方はそのまま流用できます。
Trainer APIを使うと、学習ループを自前で書かずに事前学習済みモデルのファインチューニングを行えます。TrainingArgumentsに学習率やバッチサイズ、評価タイミングなどをまとめて渡し、Trainerにモデル・データセット・評価関数を渡すだけで学習が実行できる構成です。ここでは最小限の設定でテキスト分類モデルを調整する流れを示します。
pip install transformers torch datasets evaluate accelerate scikit-learn
from transformers import AutoTokenizer, AutoModelForSequenceClassification
from transformers import TrainingArguments, Trainer
from datasets import load_dataset
import numpy as np
import evaluate
dataset = load_dataset("imdb")
small_train = dataset["train"].shuffle(seed=42).select(range(1000))
small_eval = dataset["test"].shuffle(seed=42).select(range(200))
model_name = "distilbert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(model_name)
def tokenize_fn(batch):
return tokenizer(batch["text"], padding="max_length", truncation=True, max_length=256)
train_tokenized = small_train.map(tokenize_fn, batched=True)
eval_tokenized = small_eval.map(tokenize_fn, batched=True)
model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=2)
accuracy = evaluate.load("accuracy")
def compute_metrics(eval_pred):
logits, labels = eval_pred
predictions = np.argmax(logits, axis=-1)
return accuracy.compute(predictions=predictions, references=labels)
training_args = TrainingArguments(
output_dir="./results",
num_train_epochs=1,
per_device_train_batch_size=8,
per_device_eval_batch_size=8,
eval_strategy="epoch",
save_strategy="epoch",
logging_steps=50
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=train_tokenized,
eval_dataset=eval_tokenized,
compute_metrics=compute_metrics
)
trainer.train()
TrainingArgumentsの引数名はバージョンによって変わることがあるため、実際に使うtransformersのバージョンのドキュメントで最新の引数名を確認してから設定することをおすすめします。ここではeval_strategyとsave_strategyを揃えて指定し、エポックごとに評価と保存を行う最小構成にしています。

ファインチューニングしたモデルはsave_pretrainedでローカルに保存し、from_pretrainedで読み込み直すことで再利用できます。保存したモデルとトークナイザをpipelineに載せ替えれば、学習時と同じインターフェースで推論を実行できるようになり、他のタスク用モデルと同じ運用フローに組み込めます。
# 前提: レシピ73で学習したtrainerとtokenizerをそのまま引き継ぎます
trainer.save_model("./my_sentiment_model")
tokenizer.save_pretrained("./my_sentiment_model")
from transformers import AutoModelForSequenceClassification, AutoTokenizer, pipeline
loaded_model = AutoModelForSequenceClassification.from_pretrained("./my_sentiment_model")
loaded_tokenizer = AutoTokenizer.from_pretrained("./my_sentiment_model")
classifier = pipeline(
"text-classification",
model=loaded_model,
tokenizer=loaded_tokenizer
)
print(classifier("This movie was absolutely wonderful."))
保存したフォルダにはモデルの重みだけでなく設定ファイルやトークナイザの語彙ファイルも含まれるため、そのままZIPで圧縮して別サーバーに配置すれば、同じモデルとトークナイザをそのまま再利用できます。ただしライブラリのバージョンまでは含まれないため、実行環境を丸ごと同じにしたい場合は依存関係の指定を別途持ち運ぶ必要があります。
evaluateライブラリは、精度(accuracy)やF1スコアといった代表的な評価指標を統一されたインターフェースで計算できるライブラリです。Trainerのcompute_metrics内で使うだけでなく、単体でも予測値と正解ラベルの配列を渡すだけで指標を計算できるため、モデルの比較や検証に手軽に利用できます。
# f1 は内部で scikit-learn を使うので一緒に入れる
pip install evaluate scikit-learn
import evaluate
import numpy as np
accuracy_metric = evaluate.load("accuracy")
f1_metric = evaluate.load("f1")
predictions = np.array([1, 0, 1, 1, 0, 1, 0, 0])
references = np.array([1, 0, 0, 1, 0, 1, 1, 0])
accuracy_result = accuracy_metric.compute(predictions=predictions, references=references)
f1_result = f1_metric.compute(predictions=predictions, references=references, average="binary")
print("Accuracy:", accuracy_result)
print("F1:", f1_result)
クラスの件数に偏りがあるデータでは、accuracyだけを見ると多数派クラスを当てるだけで高く見えてしまうことがあるため、F1スコアや適合率・再現率もあわせて確認する習慣が実務上は重要です。
多くのモデルには一度に処理できるトークン数の上限があり、それを超える部分はtruncationで単純に切り捨てられてしまいます。契約書や長文レポートの分類のように文章全体の情報が重要な場合は、テキストを重複を持たせながら分割し、それぞれの分割区間で推論した結果を平均するスライディングウィンドウの考え方が有効です。
from transformers import AutoTokenizer, AutoModelForSequenceClassification
import torch
model_name = "distilbert-base-uncased-finetuned-sst-2-english"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSequenceClassification.from_pretrained(model_name)
def classify_long_text(text, max_length=512, stride=50):
encodings = tokenizer(
text,
max_length=max_length,
truncation=True,
stride=stride,
return_overflowing_tokens=True,
padding="max_length", # 最後の区間だけ短くなるとテンソルにまとめられない
return_tensors="pt"
)
all_probs = []
for i in range(encodings["input_ids"].shape[0]):
inputs = {
"input_ids": encodings["input_ids"][i].unsqueeze(0),
"attention_mask": encodings["attention_mask"][i].unsqueeze(0)
}
with torch.no_grad():
outputs = model(**inputs)
# 区間ごとに確率へ直してから平均する(生のロジットのまま平均しない)
all_probs.append(torch.softmax(outputs.logits, dim=-1))
averaged_probs = torch.mean(torch.cat(all_probs, dim=0), dim=0)
predicted_id = torch.argmax(averaged_probs).item()
return model.config.id2label[predicted_id], averaged_probs
long_review = "This is a very long product review. " * 100
label, probs = classify_long_text(long_review)
print(f"予測ラベル: {label} 確率: {probs.tolist()}")
strideの値を大きくすると分割区間同士の重複が増え、文の切れ目付近の情報が失われにくくなる一方、処理する区間数が増えて計算コストも上がるため、対象文書の長さに応じてバランスを調整させる必要があります。
BERTのような事前学習済みモデルは精度面で有利なことが多い一方、計算コストや推論速度、モデルサイズの面ではTF-IDFとロジスティック回帰の組み合わせのような従来型の手法にも根強い利点があります。TF-IDFは、文書に出てくる語の頻度を数えたうえで、どの文書にも出てくるありふれた語の重みを下げ、その文書を特徴づける語を目立たせる方法です。語の並び順は見ませんが、計算が軽く、どの語が効いたかを後から確かめられます。データ量が少ない、リアルタイム性が求められる、サーバーリソースが限られているといった条件では、あえて軽量な手法を選ぶ判断も実務上は有効です。
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
texts = [
"この商品は素晴らしいです。",
"対応が悪くて残念でした。",
"とても満足しています。",
"二度と利用したくありません。",
"期待どおりの品質で満足です。",
"説明と違っていて不満です。",
"梱包も丁寧で好印象でした。",
"初期不良があり困りました。"
]
labels = [1, 0, 1, 0, 1, 0, 1, 0]
# stratifyを付けないと、分割した学習側が片方のクラスだけになり学習できないことがある
X_train, X_test, y_train, y_test = train_test_split(
texts, labels, test_size=0.5, random_state=42, stratify=labels
)
# 日本語は空白で単語が区切られないため、既定の単語分割では特徴が粗くなる。
# 簡易にはこのように文字N-gramを使い、本格的にはMeCab等で分かち書きしてから渡す
vectorizer = TfidfVectorizer(analyzer="char", ngram_range=(2, 3))
X_train_vec = vectorizer.fit_transform(X_train)
X_test_vec = vectorizer.transform(X_test)
clf = LogisticRegression()
clf.fit(X_train_vec, y_train)
predictions = clf.predict(X_test_vec)
print("予測:", predictions)
print("正解:", y_test)
| 観点 | TF-IDF+ロジスティック回帰 | BERTなど事前学習済みモデル |
|---|---|---|
| 学習・推論コスト | 非常に低い。CPUのみでも十分実用的 | GPUがあると望ましく、モデルサイズも大きい |
| 推論速度 | 高速。大量データのバッチ処理に向く | 相対的に遅く、レイテンシ要件次第で工夫が必要 |
| 必要な学習データ量 | 少量データでも一定の精度を出しやすい | ファインチューニングにもある程度の件数が望ましい |
| 文脈理解・精度 | 単語の出現頻度ベースで、語順や文脈は捉えにくい | 文脈や語順を踏まえた高い精度が期待できる |
| 解釈のしやすさ | 寄与度の高い単語を確認しやすい | 判断根拠の説明にはXAI等の別途の工夫が必要 |
| 向いている場面 | 大量データの一次スクリーニング、軽量なバッチ処理 | 精度が最優先で計算資源を確保できる場面 |
両者は競合するものではなく、まずTF-IDFベースの軽量モデルで大まかな分類やベースラインの精度を確認し、精度が不足する部分だけBERT系モデルに置き換える、といった段階的な使い分けも実務ではよく行われます。
『BERTによる自然言語処理入門 Transformersを使った実践プログラミング』(ストックマーク株式会社編、オーム社):Transformersライブラリの基本的な使い方からBERTの仕組み、ファインチューニングの実装までを一冊で通して学べる書籍です。本セクションで扱ったpipelineやTrainer APIの背景にある考え方をより深く理解したい場合の参考になります。
大規模言語モデル(LLM)を業務システムに組み込む際、最初に選ぶべき道は大きく2つあります。1つは、transformersなどのライブラリを使ってモデル自体を手元の環境やGPUサーバーにロードして動かす「ローカルLLM」の道です。もう1つは、OpenAIやAzure OpenAI、あるいは社内で構築した推論サーバーが公開するAPIを呼び出す「API利用」の道です。ローカルLLMは、契約書や顧客情報といった社内データを外部に送信せずに処理できる点が強みですが、GPUメモリの確保や量子化といった運用の手間がかかります。API利用は導入が速く常に最新モデルを利用できる一方、入力したテキストが外部のサーバーに送信されるため、機密情報を扱う際は各サービスのデータ保持ポリシーの確認が欠かせません。加えてAPIは呼び出し回数やトークン数に応じた従量課金となるため、バッチ処理では想定外のコスト増にも注意が必要です。このセクションでは、この2つの道の基本操作から、両者を組み合わせたRAG(検索拡張生成)の実装、LoRAによる軽量なファインチューニング、生成結果の自動評価までを扱います。
transformersのtext-generationパイプラインを使い、小型のLLMを手元の環境でそのまま動かすレシピです。API連携を組む前の動作確認や、外部送信が難しい検証環境での試作に向いています。コードではモデル名を環境変数から取得し、パイプライン1つで入力文から続きの文章を生成する最小構成を実装しました。数億パラメータ規模のモデルであればCPUでも動作しますが、応答速度を求める場合はGPU環境を用意してください。
import os
from transformers import pipeline
# モデル名は環境変数で切り替えられるようにしておく
MODEL_NAME = os.environ.get("LOCAL_LLM_MODEL", "Qwen/Qwen2.5-0.5B-Instruct")
generator = pipeline(
"text-generation",
model=MODEL_NAME,
dtype="auto",
device_map="auto", # GPUがあれば自動的に使用
)
prompt = "在庫管理を効率化するアイデアを3つ挙げてください。"
outputs = generator(
prompt,
max_new_tokens=200,
do_sample=True,
temperature=0.7,
)
print(outputs[0]["generated_text"])
初回実行時はモデルの重み(数億パラメータ規模で1〜2GB程度)が自動でダウンロードされます。社内ネットワークで外部通信が制限されている場合は、事前にモデルをダウンロードしてローカルパスを指定する運用に切り替えてください。

apply_chat_templateを使い、system(役割設定)・user(質問)・assistant(応答)という会話の役割をモデルが理解できる形式に変換するレシピです。素の文字列を渡すだけでは指示追従型のモデルの性能を引き出せないため、実務で対話的な用途に使う場合はほぼ必須の処理になります。コードではトークナイザに役割付きのメッセージリストを渡し、生成用のプロンプトを自動組み立てさせています。
import os
from transformers import AutoTokenizer, AutoModelForCausalLM
# 小型の指示応答モデルを例に使用(環境変数で差し替え可能)
MODEL_NAME = os.environ.get("LOCAL_LLM_MODEL", "Qwen/Qwen2.5-0.5B-Instruct")
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME,
dtype="auto",
device_map="auto",
)
messages = [
{"role": "system", "content": "あなたは社内ヘルプデスクのアシスタントです。丁寧な日本語で回答してください。"},
{"role": "user", "content": "経費精算システムのパスワードを忘れました。どうすればよいですか。"},
]
input_ids = tokenizer.apply_chat_template(
messages,
tokenize=True,
add_generation_prompt=True,
return_tensors="pt",
).to(model.device)
output_ids = model.generate(input_ids, max_new_tokens=150)
response = tokenizer.decode(
output_ids[0][input_ids.shape[-1]:],
skip_special_tokens=True,
)
print(response)
モデルごとにテンプレートの書式(役割の区切り方など)が異なりますが、apply_chat_templateを使えばモデルに合わせた書式を意識せずに済みます。
generateメソッドに渡すtemperature・top_p・max_new_tokens・repetition_penaltyの効果を確認するレシピです。temperatureは出力のランダム性、top_pは候補となる単語の絞り込み幅、max_new_tokensは生成する最大トークン数、repetition_penaltyは同じ表現の繰り返しを抑える強さを、それぞれ制御します。コードでは同じ質問文に対してtemperatureだけを変えて生成し、値による出力の違いを比較できるようにしました。
# 前提: レシピ79で読み込んだtokenizerとmodelを使います
def generate(prompt, temperature=0.7, top_p=0.9, max_new_tokens=200, repetition_penalty=1.1):
input_ids = tokenizer.apply_chat_template(
[{"role": "user", "content": prompt}],
tokenize=True,
add_generation_prompt=True,
return_tensors="pt",
).to(model.device)
output_ids = model.generate(
input_ids,
do_sample=True,
temperature=temperature,
top_p=top_p,
max_new_tokens=max_new_tokens,
repetition_penalty=repetition_penalty,
)
return tokenizer.decode(
output_ids[0][input_ids.shape[-1]:],
skip_special_tokens=True,
)
# temperatureを変えて挙動を比較する
for temp in [0.2, 0.7, 1.2]:
print(f"--- temperature={temp} ---")
print(generate("新製品のキャッチコピーを1つ考えてください。", temperature=temp))
temperatureを低めにすると安定した定型文向けの出力に、高めにすると発想の幅を広げたい場面向けの出力になる傾向があります。repetition_penaltyは1.0を超える値に設定すると、同じ語句の繰り返しを抑えられます。
bitsandbytesによる4bit量子化を使い、通常はGPUメモリに収まらない大きめのモデルをロードするレシピです。パラメータを4bit精度に圧縮することで、メモリ使用量をfp16のおよそ3分の1程度まで削減できます。コードではBitsAndBytesConfigで量子化の設定をまとめ、from_pretrained時に渡すだけで量子化ロードを実現しています。
import os
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
MODEL_NAME_LARGE = os.environ.get("LARGE_LLM_MODEL", "elyza/Llama-3-ELYZA-JP-8B")
quant_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME_LARGE)
model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME_LARGE,
quantization_config=quant_config,
device_map="auto",
)
print(f"モデル: {MODEL_NAME_LARGE}")
print(f"確保されたGPUメモリ: {torch.cuda.memory_allocated() / 1024**3:.1f} GB")
bitsandbytesはNVIDIA製GPU(CUDA)を前提としたライブラリです。80億パラメータ規模のモデルであれば、fp16では約16GB必要なところ、4bit量子化で約5〜6GB程度まで削減できる場合が多く、業務用の1枚挿しGPUでも動かせる可能性が広がります。Windows環境では対応状況の変化が早いため、公式リポジトリで最新のインストール手順を確認してください。
OpenAI互換のchat completions APIを呼び出す最小構成のレシピです。OpenAI本体のほか、社内に立てた推論サーバーなどOpenAI互換のインターフェースを持つ提供先であれば、base_urlを差し替えるだけで同じコードを流用できます。APIキーはコードに直接書き込まず、環境変数から読み込む形にしています。
なおAzure OpenAIは互換とはいえ接続の指定が異なり、base_urlの差し替えだけでは動きません。専用のクライアント(AzureOpenAI)を使い、エンドポイント・APIバージョン・デプロイ名を渡す形になります。移行を検討する場合は提供元の案内を確認してください。
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ["OPENAI_API_KEY"],
base_url=os.environ.get("OPENAI_BASE_URL"), # 未設定ならOpenAI本体を利用
)
response = client.chat.completions.create(
model=os.environ["OPENAI_MODEL"],
messages=[
{"role": "system", "content": "あなたは丁寧な日本語で回答するアシスタントです。"},
{"role": "user", "content": "在庫回転率を改善する施策を3つ挙げてください。"},
],
temperature=0.3,
)
print(response.choices[0].message.content)
顧客情報や未公開の社内資料をプロンプトに含める場合は、契約しているプランのデータ利用ポリシー(学習利用の有無など)を必ず事前に確認してください。
JSONスキーマを指定して、モデルの出力を決まった形式のJSONに固定するレシピです。自由記述の回答をそのまま業務システムに渡すのは難しいため、感情分析やキーワード抽出のような表形式データを扱う処理では構造化出力を使うのが定石になっています。コードでは出力されたJSONをそのままpandasのDataFrameに変換しています。
import json
import os
import pandas as pd
# 前提: レシピ82で作成したclientを使います
response = client.chat.completions.create(
model=os.environ["OPENAI_MODEL"], # 利用するモデル名は環境変数で指定
messages=[
{"role": "system", "content": "商品レビューを分析し、指定したJSON形式のみで出力してください。"},
{"role": "user", "content": "とても使いやすいが、価格がやや高いと感じた。"},
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "review_analysis",
"strict": True,
"schema": {
"type": "object",
"properties": {
"sentiment": {"type": "string", "enum": ["positive", "negative", "neutral"]},
"keywords": {"type": "array", "items": {"type": "string"}},
},
"required": ["sentiment", "keywords"],
"additionalProperties": False,
},
},
},
)
choice = response.choices[0]
if getattr(choice.message, "refusal", None) or choice.finish_reason == "length":
raise RuntimeError("構造化出力を取得できませんでした(拒否または出力打ち切り)")
result = json.loads(choice.message.content)
df = pd.DataFrame([result])
print(df)
スキーマのpropertiesに列名を定義しておけば、複数件のレビューをループ処理してDataFrameに追記していくだけで、簡易的な集計基盤を作れます。なおjson_schemaによる厳密な形式指定は、対応しているモデルでのみ使えます。OPENAI_MODELに指定するモデルが構造化出力に対応しているかを、提供元の一覧で先に確認してください。対応していないモデルではエラーになります。
大量のテキストを分類・要約する際に、ループとリトライ処理を組み合わせて安定的に回すレシピです。API呼び出しはネットワークの一時的な不調やレート制限で失敗することがあるため、失敗時に待機時間を伸ばしながら再試行する仕組み(指数バックオフ)を入れておくと実務での安定性が上がります。コードでは問い合わせ内容を3種類のカテゴリに分類する処理を、DataFrameのapplyで一括実行しています。
import time
import pandas as pd
# 前提: レシピ82のclient(とos.environ["OPENAI_MODEL"])を使います
def classify_with_retry(text, max_retries=3):
for attempt in range(max_retries):
try:
response = client.chat.completions.create(
model=os.environ["OPENAI_MODEL"],
messages=[
{"role": "system", "content": "顧客からの問い合わせを『請求』『技術』『その他』のいずれかに分類してください。分類名のみ出力すること。"},
{"role": "user", "content": text},
],
temperature=0,
)
return response.choices[0].message.content.strip()
except Exception as e:
wait = 2 ** attempt
print(f"エラーが発生したため{wait}秒後に再試行します: {e}")
time.sleep(wait)
return "分類失敗"
df_inquiries = pd.DataFrame({
"text": ["請求書の金額が間違っています", "アプリが起動しません", "営業時間を教えてください"],
})
df_inquiries["category"] = df_inquiries["text"].apply(classify_with_retry)
print(df_inquiries)
件数が数千件を超える場合は、APIのレート制限を考慮して、同時に走らせる本数に上限を設ける仕組み(セマフォなど)を併用してください。
埋め込みモデルを使って文章をベクトル化し、後段の検索や分類で使えるように保存するレシピです。埋め込みは文章の意味的な近さを数値ベクトルの距離として扱えるようにする処理で、次に紹介するRAGの土台になります。コードでは複数の文章をまとめてベクトル化し、numpy形式で保存しています。
import numpy as np
# 前提: レシピ82のclientを使います(埋め込み取得のAPIを呼び出すため)
texts = [
"在庫管理システムの導入事例",
"需要予測モデルの精度改善",
"配送ルートの最適化",
]
response = client.embeddings.create(
model=os.environ.get("EMBEDDING_MODEL", "text-embedding-3-small"),
input=texts,
)
embeddings = np.array([item.embedding for item in response.data])
np.save("document_embeddings.npy", embeddings)
print(embeddings.shape)
埋め込みモデルもAPI利用とローカル実行(sentence-transformers等)の両方が選べます。社内文書を扱う場合は、埋め込みの取得自体も外部送信を伴う処理であることを忘れないようにしてください。
検索拡張生成(RAG)の前半部分にあたる、文書を一定の長さに分割(チャンク化)してベクトル検索の対象にするレシピです。長い文書をそのまま埋め込みにすると意味がぼやけてしまうため、数百文字程度の単位に区切ってから埋め込みを取得するのが基本の型になります。コードでは数十行程度の最小構成で、チャンク分割からコサイン類似度(ベクトルどうしの向きがどれだけ近いかを、マイナス1から1までの範囲で表した値です。値が大きいほど意味が近いとみなします)による検索までの一連の流れを実装しました。
import numpy as np
# 前提: レシピ82のclientと、レシピ85と同様の埋め込み取得を使います
def chunk_text(text, chunk_size=200, overlap=50):
chunks = []
start = 0
while start < len(text):
chunks.append(text[start:start + chunk_size])
start += chunk_size - overlap
return chunks
documents = ["長い社内マニュアルの本文がここに入る想定です。実際には複数ファイルを読み込みます。"]
chunks = []
for doc in documents:
chunks.extend(chunk_text(doc))
chunk_embeddings = np.array([
item.embedding
for item in client.embeddings.create(
model=os.environ.get("EMBEDDING_MODEL", "text-embedding-3-small"),
input=chunks,
).data
])
def search(query, top_k=3):
query_embedding = np.array(
client.embeddings.create(
model=os.environ.get("EMBEDDING_MODEL", "text-embedding-3-small"),
input=[query],
).data[0].embedding
)
scores = chunk_embeddings @ query_embedding / (
np.linalg.norm(chunk_embeddings, axis=1) * np.linalg.norm(query_embedding)
)
top_indices = np.argsort(scores)[::-1][:top_k]
return [(chunks[i], scores[i]) for i in top_indices]
results = search("有給休暇の申請方法")
for chunk, score in results:
print(score, chunk[:50])
ここではnumpyだけで検索を実装していますが、対象文書が数万チャンクを超える規模になる場合は、faissなどの近似最近傍探索ライブラリやベクトルデータベースの導入を検討してください。

RAGの後半部分にあたる、検索で見つけた関連チャンクをプロンプトに埋め込んで回答を生成するレシピです。モデルに「渡した資料の範囲内でのみ回答する」よう指示し、根拠にした出典番号を回答に付記させることで、回答の裏付けを追跡できるようにしています。前のレシピのsearch関数をそのまま利用する構成です。
# 前提: レシピ86のsearch関数と、レシピ82のclientを使います
def answer_with_rag(query):
results = search(query, top_k=3)
context = "\n\n".join(
[f"[出典{i+1}] {chunk}" for i, (chunk, _) in enumerate(results)]
)
messages = [
{"role": "system", "content": "以下の資料の範囲内でのみ回答し、根拠にした出典番号を文末に付記してください。資料に記載のない内容は「資料からは分かりません」と答えてください。"},
{"role": "user", "content": f"資料:\n{context}\n\n質問: {query}"},
]
response = client.chat.completions.create(
model=os.environ["OPENAI_MODEL"],
messages=messages,
temperature=0,
)
return response.choices[0].message.content
print(answer_with_rag("有給休暇の申請方法を教えてください"))
「資料の範囲内でのみ回答する」という指示は、モデルが資料にない内容を作り出してしまう現象(ハルシネーション)を抑える効果があります。ただし完全には防げないため、出典の明示と合わせて、重要な業務では人による最終確認を組み合わせる運用が現実的です。
peftライブラリを使い、モデル全体ではなく一部の小さなアダプタ行列だけを学習するLoRA(Low-Rank Adaptation)の最小構成です。全パラメータを学習する通常のファインチューニングは、モデル本体と同規模のメモリと計算資源を必要としますが、LoRAは学習対象のパラメータを全体の1%未満に絞り込めるため、業務用GPU1枚でも社内文書の言い回しなどをモデルに覚え込ませやすくなります。コードではLoraConfigで学習対象の層を指定し、通常のTrainerに渡すだけで学習が進む構成にしました。
import os
from datasets import Dataset
from transformers import (AutoTokenizer, AutoModelForCausalLM,
TrainingArguments, Trainer, DataCollatorForLanguageModeling)
from peft import LoraConfig, TaskType, get_peft_model
MODEL_NAME = os.environ.get("LOCAL_LLM_MODEL", "Qwen/Qwen2.5-0.5B-Instruct")
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
base_model = AutoModelForCausalLM.from_pretrained(MODEL_NAME)
lora_config = LoraConfig(
r=8,
lora_alpha=16,
target_modules=["q_proj", "v_proj"],
lora_dropout=0.05,
task_type=TaskType.CAUSAL_LM,
)
model = get_peft_model(base_model, lora_config)
model.print_trainable_parameters() # 学習対象が全体のごく一部に絞られていることを確認できる
train_texts = [
"社内用語『KPI』とは重要業績評価指標のことです。",
"有給休暇の申請は人事システムから行います。",
]
dataset = Dataset.from_dict({"text": train_texts})
def tokenize(batch):
return tokenizer(batch["text"], truncation=True, padding="max_length", max_length=64)
tokenized_dataset = dataset.map(tokenize, batched=True)
training_args = TrainingArguments(
output_dir="./lora_output",
per_device_train_batch_size=2,
num_train_epochs=3,
learning_rate=2e-4,
logging_steps=10,
)
# 因果言語モデルの学習ではlabels(=input_ids)を作るデータコレータを渡す
collator = DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm=False)
trainer = Trainer(model=model, args=training_args,
train_dataset=tokenized_dataset, data_collator=collator)
trainer.train()
model.save_pretrained("./lora_adapter")
保存されるのはベースモデルではなくアダプタ部分のみのため、ファイルサイズも数十MB程度に収まることが多く、複数の業務用途ごとにアダプタを使い分ける運用がしやすくなります。
LLM自身に別のLLMの出力を採点させる「LLM-as-a-judge」の最小実装です。人手による評価をすべての出力に対して行うのは現実的ではないため、大量の生成結果を継続的にモニタリングする際の一次スクリーニングとして使われる手法です。コードでは質問と回答のペアをJSON形式で採点させる関数を実装しています。
import json
# 前提: レシピ82のclient(とos.environ["OPENAI_MODEL"])を使います
def judge_response(question, answer, criteria="正確性と分かりやすさ"):
messages = [
{
"role": "system",
"content": (
f"あなたは回答品質の評価者です。{criteria}の観点で1〜5点で採点し、"
"理由を1文で述べてください。"
'{"score": 整数, "reason": 文字列} という形式のJSONのみを出力してください。'
),
},
{"role": "user", "content": f"質問: {question}\n回答: {answer}"},
]
response = client.chat.completions.create(
model=os.environ["OPENAI_MODEL"],
messages=messages,
temperature=0,
)
return json.loads(response.choices[0].message.content)
result = judge_response(
"有給休暇の申請方法を教えてください",
"人事システムから申請書を提出し、上長の承認を得てください。",
)
print(result)
LLM-as-a-judgeには注意すべき限界もあります。評価に使うモデル自身が特定の言い回しや長い回答を好む傾向(自己選好バイアス)を持つこと、同じ入力でも実行のたびに点数がぶれることが知られています。重要な意思決定に使う場合は、複数回実行した平均を取る、評価基準を具体的に書き込む、一定割合を人手で抜き取り確認するといった対策を組み合わせてください。
transformersを直接使う以外にも、Ollamaのようにモデルのダウンロードから推論サーバーの起動までを簡単なコマンドでまとめて行えるツールがあります。手早く社内で試作環境を作りたい場合や、非エンジニアのメンバーにもローカルLLMを触ってもらいたい場合に選択肢になります。コードではOllamaが起動するローカルAPIに対してHTTPリクエストを送る例を示しました。
import requests
response = requests.post(
"http://localhost:11434/api/generate",
json={
"model": "llama3.2",
"prompt": "在庫管理を効率化するアイデアを3つ挙げてください。",
"stream": False,
},
timeout=120, # サーバー未起動のときに待ち続けないようにする
)
# モデル未取得などの失敗を、その場で例外として気づけるようにする
response.raise_for_status()
print(response.json()["response"])
モデル規模とGPUメモリの目安、およびローカルLLMとAPI利用の使い分けの考え方を、それぞれ表に整理しました。
| モデル規模 | 精度 | GPUメモリの目安 | 該当するモデル例 |
|---|---|---|---|
| 1B前後 | fp16 | 約2〜3GB | Qwen2.5-0.5B/1.5B等の小型モデル |
| 7〜8B | fp16 | 約14〜16GB | Llama 3.1 8B、ELYZA-JP-8B等 |
| 7〜8B | 4bit量子化 | 約5〜6GB | 同上をbitsandbytesで量子化 |
| 70B前後 | 4bit量子化 | 約35〜40GB(複数GPU構成が前提) | Llama 3.1 70B等 |
| 観点 | ローカルLLM | API利用 |
|---|---|---|
| 社内データの外部送信 | なし(環境内で完結) | あり(サービスごとのポリシー確認が必要) |
| 初期コスト | GPUサーバー等の設備投資が必要 | ほぼ不要 |
| ランニングコスト | 電気代・保守費が中心 | トークン数に応じた従量課金 |
| 利用できるモデルの性能 | 手元で動かせる規模のモデルに限られる | 最先端の大規模モデルを利用可能 |
| 運用の手間 | モデル管理・量子化・更新対応が必要 | 提供元のインフラに任せられる |
試作段階ではAPI利用で素早く要件を固め、機密性の高いデータを扱う本番運用の段階でローカルLLMへの切り替えを検討する、という段階的な進め方も現実的な選択肢です。

『大規模言語モデル入門』(山田育矢ほか、技術評論社):Transformerの仕組みから事前学習・ファインチューニング・応用までを体系的に解説した書籍です。本セクションで扱ったチャットテンプレートや量子化、LoRAといった個々のレシピの背景にある理論を、順を追って理解し直したい場合の参考になります。
ノートブックの中で一度動いたモデルと、日々の業務で安定して使われ続けるモデルとの間には、大きな距離があります。学習にかかる時間やメモリ使用量が現実的な範囲に収まっているか、同じコードを走らせれば同じ結果を再現できるか、そして本番投入後も性能が劣化していないかを継続的に把握できるか、といった観点が新たに加わるためです。本セクションでは、GPUメモリの管理や学習の高速化といった開発時の工夫から、モデルのエクスポート、API化、バッチ推論、劣化監視までを扱い、モデルを作って終わりにしないための実務上の型を整理します。
torch.cuda.memory_allocated()とmemory_reserved()で使用中・確保済みのGPUメモリを定期的に確認し、torch.cuda.empty_cache()でPyTorchが保持しているキャッシュを解放するレシピです。CUDA out of memory(OOM)エラーは学習中のバッチサイズや中間層のサイズが原因で発生することが多く、エラーメッセージ中のメモリ使用量とあわせて確認すると原因を切り分けやすくなります。典型的な対処としては、バッチサイズを下げる、複数バッチ分の勾配をためてから一度に更新する勾配蓄積に切り替える、推論時はtorch.no_grad()で計算グラフを保持しないようにする、といった方法が挙げられます。
import torch
def print_gpu_memory(tag: str = "") -> None:
"""現在のGPUメモリ使用状況を表示する"""
if not torch.cuda.is_available():
print("GPUが利用できないため、メモリ確認をスキップします")
return
allocated = torch.cuda.memory_allocated() / 1024**2
reserved = torch.cuda.memory_reserved() / 1024**2
print(f"[{tag}] 使用中: {allocated:.1f}MB / 確保済み: {reserved:.1f}MB")
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = torch.nn.Linear(1024, 1024).to(device)
print_gpu_memory("モデル配置後")
try:
# バッチサイズを大きくしすぎるとOOMになりやすい
x = torch.randn(65536, 1024, device=device)
y = model(x)
print_gpu_memory("順伝播後")
except torch.cuda.OutOfMemoryError:
print("OOMが発生しました。バッチサイズを下げて再試行します")
torch.cuda.empty_cache() # 未使用のキャッシュを解放
x_small = torch.randn(1024, 1024, device=device)
y = model(x_small)
print_gpu_memory("縮小後の順伝播後")
empty_cache()は、PyTorchが確保したまま未使用になっているキャッシュメモリを解放し、他のGPUアプリケーションやnvidia-smiから見える空き容量を増やす操作です。PyTorch自身が使えるメモリ量が増えるわけではないため、頻繁に呼び出しても学習速度は上がりません。OOMが常態化する場合は、バッチサイズやモデルサイズそのものを見直す方が確実です。
32bit(fp32)と16bit(fp16)の計算を自動的に使い分け、学習を高速化しつつメモリ使用量を削減する混合精度学習(Automatic Mixed Precision)のレシピです。autocastが順伝播中の演算精度を自動選択し、GradScalerが勾配のアンダーフロー(16bitでは表せないほど小さな値が0に潰れてしまい、学習が進まなくなる現象)を防ぐスケーリングを担当します。行列計算を専用に速く処理する回路(Tensorコア)を持つ世代のGPUでは、精度をほとんど落とさずに学習時間を大きく短縮できるケースが多く、追加のコード変更も学習ループ内の数行に収まります。
import torch
import torch.nn as nn
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = nn.Sequential(nn.Linear(512, 256), nn.ReLU(), nn.Linear(256, 10)).to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss()
# fp16用の勾配スケーラー(アンダーフロー防止)
scaler = torch.amp.GradScaler("cuda", enabled=torch.cuda.is_available())
X = torch.randn(256, 512, device=device)
y = torch.randint(0, 10, (256,), device=device)
for epoch in range(3):
optimizer.zero_grad()
# autocastの範囲内では演算ごとに最適な精度が自動選択される
with torch.amp.autocast("cuda", enabled=torch.cuda.is_available()):
outputs = model(X)
loss = criterion(outputs, y)
# スケールした損失で逆伝播し、アンダーフローを防ぐ
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()
print(f"epoch {epoch}: loss={loss.item():.4f}")
古いチュートリアルではtorch.cuda.amp.autocastやtorch.cuda.amp.GradScalerという書き方も見られますが、PyTorch 2系ではtorch.amp配下にdevice_typeを指定して呼び出す形に統一されています。CPUのみの環境ではenabled=Falseとして扱われるため、GPUの有無に関わらず同じコードを使い回せます。

DataLoaderのnum_workersとpin_memory引数を調整し、データ読み込みがGPUの計算速度に対してボトルネックにならないようにするレシピです。num_workersを1以上にするとデータの読み込みや前処理を別プロセスで並列に行えるため、画像の読み込みやデータ拡張のように重い前処理を含む場合に効果が大きくなります。pin_memory=Trueにするとデータがページロックされたメモリに配置され、CPUからGPUへの転送が速くなります。ただしワーカー数を増やしすぎるとプロセス起動のオーバーヘッドやメモリ使用量の増加で逆に遅くなることがあるため、実際の環境で数値を変えながら計測するのが確実です。
import time
import torch
from torch.utils.data import TensorDataset, DataLoader
def build_loader(num_workers: int, pin_memory: bool) -> DataLoader:
X = torch.randn(20000, 3, 64, 64)
y = torch.randint(0, 10, (20000,))
dataset = TensorDataset(X, y)
return DataLoader(
dataset,
batch_size=128,
shuffle=True,
num_workers=num_workers,
pin_memory=pin_memory,
persistent_workers=(num_workers > 0), # エポックごとの再起動を防ぐ
prefetch_factor=4 if num_workers > 0 else None, # 先読みするバッチ数
)
def measure_one_epoch(loader: DataLoader) -> float:
start = time.time()
for _ in loader:
pass
return time.time() - start
if __name__ == "__main__":
for workers in [0, 2, 4]:
loader = build_loader(num_workers=workers, pin_memory=True)
elapsed = measure_one_epoch(loader)
print(f"num_workers={workers}: {elapsed:.2f}秒")
Windows環境ではDataLoaderが内部でプロセスをspawnするため、num_workersを1以上にするコードはif __name__ == "__main__":のブロック内で実行する必要があります。この対応を忘れるとエラーになったり、プロセスが増殖し続けたりするため注意してください。
PyTorch 2系で導入されたtorch.compileを使い、モデルの計算グラフをコンパイルして実行を高速化するレシピです。compiled_model = torch.compile(model)という1行を追加するだけで、内部的には演算のフュージョンやカーネルの最適化が行われ、特に入力形状が毎回一定で、GPU上で大きめのモデルを繰り返し実行する場面(画像分類やTransformer系の学習)で効果が出やすい機能です。一方で、入力の形状やバッチサイズが頻繁に変わる場合や、モデル内にPythonの複雑な分岐処理が多い場合は再コンパイルが繰り返し発生し、かえって遅くなることがあります。初回実行時にはコンパイル自体に時間がかかる点も踏まえ、学習全体の実行時間で効果を判断する必要があります。なお、戻り値を元の変数に上書きせず別の名前で受けているのは、重みの保存やONNXへの書き出しをコンパイル前のモデルに対して行うためです。コンパイル後のモデルからstate_dictを取ると、キーの名前が変わって読み込み側と食い違います。
import time
import torch
import torch.nn as nn
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
class SimpleCNN(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Conv2d(3, 32, 3, padding=1), nn.ReLU(),
nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(),
nn.AdaptiveAvgPool2d(1), nn.Flatten(),
nn.Linear(64, 10),
)
def forward(self, x):
return self.net(x)
model = SimpleCNN().to(device)
compiled_model = torch.compile(model) # 追加はこの1行のみ
X = torch.randn(64, 3, 64, 64, device=device)
# 初回はコンパイルが走るため時間がかかる
start = time.time()
_ = compiled_model(X)
print(f"初回実行(コンパイル込み): {time.time() - start:.2f}秒")
# 2回目以降は同じ入力形状であればコンパイル済みの高速なパスが使われる
start = time.time()
for _ in range(10):
_ = compiled_model(X)
print(f"2回目以降10回の実行: {time.time() - start:.2f}秒")
入力形状が固定の学習ループでは有効に働くことが多い一方、可変長系列を扱う自然言語処理タスクなどでは効果が薄れることもあります。mode="reduce-overhead"のような指定も含め、実測しながらtorch.compileを使うかどうかを判断することをおすすめします。
学習済みモデルをフレームワーク非依存のONNX(Open Neural Network Exchange)形式に変換し、onnxruntimeで推論できることを確認するレシピです。ONNX化しておくと、PyTorchの実行環境がなくてもC++やJava、モバイル端末など別の環境でモデルを動かせるようになり、推論専用サーバーでは純粋なPyTorchより高速に動作する場合もあります。torch.onnx.exportには、ダミー入力とあわせて入出力の名前と、可変長バッチに対応するための指定を渡します。ここで注意が必要なのは、この関数が新旧2つの書き出し経路を持っている点です。現行のPyTorchでは新しい経路(dynamo=True)が既定で、可変長の指定はdynamic_shapesで行います。従来からのdynamic_axesは旧経路向けの引数なので、これを使う場合はdynamo=Falseを明示します。下のコードは、広く出回っている書き方に合わせて旧経路を明示的に選んでいます。
import torch
import torch.nn as nn
import numpy as np
import onnxruntime as ort
class SimpleNet(nn.Module):
def __init__(self):
super().__init__()
self.fc = nn.Sequential(nn.Linear(10, 32), nn.ReLU(), nn.Linear(32, 1))
def forward(self, x):
return self.fc(x)
model = SimpleNet().eval()
dummy_input = torch.randn(1, 10)
# ONNX形式で書き出す(バッチサイズを可変にする設定も含む)
torch.onnx.export(
model,
dummy_input,
"simple_net.onnx",
input_names=["input"],
output_names=["output"],
dynamo=False, # dynamic_axes は旧経路の引数。新経路では dynamic_shapes を使う
dynamic_axes={"input": {0: "batch_size"}, "output": {0: "batch_size"}},
opset_version=17,
)
# onnxruntimeで推論し、PyTorchの出力と一致するか確認する
session = ort.InferenceSession("simple_net.onnx", providers=["CPUExecutionProvider"])
test_input = np.random.randn(4, 10).astype(np.float32)
with torch.no_grad():
torch_output = model(torch.from_numpy(test_input)).numpy()
onnx_output = session.run(["output"], {"input": test_input})[0]
print("最大誤差:", np.abs(torch_output - onnx_output).max())
最大誤差が1e-5程度に収まっていれば、変換によるずれは実用上ほぼ無視できる範囲です。GPU上で高速に推論したい場合はonnxruntime-gpuパッケージを導入し、CUDAExecutionProviderを指定します。
TensorBoardのSummaryWriterを使い、学習中のlossや評価指標をエポックごとに記録・可視化するレシピです。複数のハイパーパラメータやモデル構造を試す際、ログをターミナルに流すだけでは比較が難しくなるため、add_scalarで数値を記録しグラフとして見比べられるようにしておくと、どの設定が有効だったかを振り返りやすくなります。より多くの実験を組織的に管理したい場合は、パラメータやモデルそのものをまとめて記録できるMLflowのようなツールも存在し、チームでの実験管理基盤として使われることがあります。
import torch
import torch.nn as nn
from torch.utils.tensorboard import SummaryWriter
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = nn.Sequential(nn.Linear(20, 32), nn.ReLU(), nn.Linear(32, 2)).to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss()
# ログの出力先ディレクトリ(実験ごとにサブフォルダを分けるのが定番)
writer = SummaryWriter(log_dir="runs/experiment_001")
X = torch.randn(500, 20, device=device)
y = torch.randint(0, 2, (500,), device=device)
for epoch in range(20):
optimizer.zero_grad()
outputs = model(X)
loss = criterion(outputs, y)
loss.backward()
optimizer.step()
acc = (outputs.argmax(dim=1) == y).float().mean().item()
# スカラー値をタグ付きで記録する
writer.add_scalar("Loss/train", loss.item(), epoch)
writer.add_scalar("Accuracy/train", acc, epoch)
writer.close()
print("記録完了。'tensorboard --logdir runs' で確認できます")
tensorboard --logdir runs
TensorBoardは1回の学習の推移を可視化するのに向いており、実験の条件(ハイパーパラメータやデータのバージョン、モデルの重みそのもの)をまとめて記録・比較したい場合は、MLflowのようなツールを併用する構成もよく使われます。
FastAPIを使い、学習済みモデルをHTTP経由で呼び出せる推論APIにする最小構成のレシピです。モデルはサーバー起動時に一度だけ読み込んでグローバルに保持しておき、リクエストのたびに読み込み直さないようにするのがポイントです。リクエストとレスポンスの型をpydanticのBaseModelで定義しておくと、入力値のバリデーションとAPI仕様書の自動生成が同時に得られます。仕様書はSwagger UIというブラウザ上の画面として提供され、そこから実際にリクエストを送って試せます。
pip install fastapi "uvicorn[standard]"
# main.py
import torch
import torch.nn as nn
from fastapi import FastAPI
from pydantic import BaseModel, Field
N_FEATURES = 4
class SimpleNet(nn.Module):
def __init__(self):
super().__init__()
self.fc = nn.Sequential(nn.Linear(N_FEATURES, 16), nn.ReLU(), nn.Linear(16, 1))
def forward(self, x):
return self.fc(x)
# サーバー起動時に一度だけモデルを読み込む
model = SimpleNet()
model.load_state_dict(torch.load("model.pt", map_location="cpu", weights_only=True))
model.eval()
app = FastAPI(title="推論API")
class PredictRequest(BaseModel):
# 長さを検証しておかないと、要素数が違う入力で500エラーになる
features: list[float] = Field(min_length=N_FEATURES, max_length=N_FEATURES)
class PredictResponse(BaseModel):
prediction: float
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
@app.post("/predict", response_model=PredictResponse)
def predict(request: PredictRequest) -> PredictResponse:
x = torch.tensor([request.features], dtype=torch.float32)
with torch.no_grad():
y = model(x)
return PredictResponse(prediction=y.item())
uvicorn main:app --host 0.0.0.0 --port 8000
起動後にhttp://localhost:8000/docsへアクセスすると、自動生成されたSwagger UIから実際にリクエストを送って動作確認ができます。モデルの入れ替えを想定する場合は、読み込み処理をFastAPIのlifespanイベント(サーバーの起動時と停止時に一度だけ実行される処理を登録する仕組み)として切り出しておくと管理しやすくなります。

夜間バッチなどで大量のデータをまとめて推論する際の設計パターンを扱うレシピです。全件を一度にメモリへ載せると91番と同様のメモリ問題が起きるため、一定件数ごとのチャンクに分けて読み込み・推論・書き出しを行うのが定石です。また、途中の1件でエラーが起きてもバッチ全体を止めずに該当件だけリトライまたはスキップして処理を続行し、失敗した件数や内容をログに残しておくと、翌朝の運用チェックで問題箇所を特定しやすくなります。書き出しもチャンクごとに追記していきます。最後にまとめて書くと、結局は全件をメモリに抱えることになるためです。あわせて、失敗したチャンクも欠測として同じ行数だけ書き出しておきます。飛ばしてしまうと入力の行と出力の行の対応が崩れ、どの入力に対する予測なのかを後から突き合わせられなくなります。
import time
import pandas as pd
import torch
def load_model():
model = torch.nn.Linear(10, 1)
model.eval()
return model
def predict_chunk(model, chunk: pd.DataFrame) -> list:
x = torch.tensor(chunk.values, dtype=torch.float32)
with torch.no_grad():
return model(x).squeeze(-1).tolist()
def run_batch_inference(input_path: str, output_path: str, chunk_size: int = 1000, max_retries: int = 2) -> None:
model = load_model()
failed_chunks = []
wrote_header = False
reader = pd.read_csv(input_path, chunksize=chunk_size)
for i, chunk in enumerate(reader):
preds = None
for attempt in range(1, max_retries + 1):
try:
preds = predict_chunk(model, chunk)
print(f"チャンク{i}: {len(chunk)}件 完了")
break
except Exception as e:
print(f"チャンク{i}: 失敗({attempt}回目) {e}")
if attempt == max_retries:
failed_chunks.append(i)
time.sleep(1)
# 失敗したチャンクも欠測として同じ行数だけ書き出す。
# 飛ばしてしまうと入力の行と出力の行の対応が崩れ、後から突き合わせられなくなる
if preds is None:
preds = [None] * len(chunk)
# チャンクごとに追記する。全件をメモリに溜めない
out = pd.DataFrame({"row_id": chunk.index, "prediction": preds})
out.to_csv(output_path, mode="w" if not wrote_header else "a",
header=not wrote_header, index=False)
wrote_header = True
print(f"完了。失敗チャンク数: {len(failed_chunks)} {failed_chunks}")
進捗と失敗件数をログや別ファイルに残しておくと、翌朝の運用担当者が処理結果を素早く確認できます。件数が非常に多い場合は、multiprocessingや分散処理基盤への切り替えも検討対象になります。
本番投入後の入力データの分布が学習時から変化していないかを、簡単な統計量の定点観測によって監視するレシピです。具体的には、学習時点の特徴量ごとの平均・標準偏差・分位点をベースラインとして保存しておき、運用中に届いた入力バッチと比較します。下のコードで実際に判定に使うのは平均値のずれだけで、標準偏差は「ずれの大きさを測る物差し」として、分位点は目視で分布の形を確かめるために保存しています。まずはこの平均値の簡易監視だけでも、明らかな入力分布の変化には気づける場合が多いです。より踏み込むなら、分布の差の大きさを表す指標であるPopulation Stability Index(PSI)や、2つの分布が同じと言えるかを調べるKolmogorov-Smirnov検定へ発展させます。前者は指標、後者は統計的検定で、性格が異なる点に注意してください。
import numpy as np
import pandas as pd
def compute_baseline_stats(df: pd.DataFrame, columns: list) -> dict:
"""学習データの基準統計量を保存しておく"""
stats = {}
for col in columns:
stats[col] = {
"mean": df[col].mean(),
"std": df[col].std(),
"q10": df[col].quantile(0.1),
"q90": df[col].quantile(0.9),
}
return stats
def check_drift(current_df: pd.DataFrame, baseline_stats: dict, threshold: float = 0.3) -> list:
"""基準統計量からの乖離が大きい列を検出する"""
alerts = []
for col, base in baseline_stats.items():
current_mean = current_df[col].mean()
# 標準偏差を単位にした乖離幅で判定する
diff_ratio = abs(current_mean - base["mean"]) / (base["std"] + 1e-8)
if diff_ratio > threshold:
alerts.append({
"column": col,
"baseline_mean": base["mean"],
"current_mean": current_mean,
"diff_ratio": round(diff_ratio, 2),
})
return alerts
# 学習時に保存しておいた基準統計量(実運用ではファイルに保存・読み込みする)
baseline_df = pd.DataFrame({
"気温": np.random.normal(20, 5, 1000),
"湿度": np.random.normal(50, 10, 1000),
})
baseline_stats = compute_baseline_stats(baseline_df, ["気温", "湿度"])
# 本番投入後、ある日届いた入力データ(分布がシフトしたケースを想定)
current_df = pd.DataFrame({
"気温": np.random.normal(28, 5, 500),
"湿度": np.random.normal(50, 10, 500),
})
alerts = check_drift(current_df, baseline_stats, threshold=0.5)
for alert in alerts:
print("ドリフト検知:", alert)
しきい値を厳しくしすぎると通常の季節変動などでも頻繁にアラートが鳴ってしまうため、まずは緩めの設定で運用しながら、実際の再学習判断に使えるレベルまで調整していく進め方が現実的です。

これまでのレシピを1つの流れに組み合わせ、画像分類モデルを学習してから本番で使い続けるまでのロードマップを示すレシピです。個々の工程は既存のレシピの組み合わせであり、ここでは全体の順序と、各ステップで参照すべきレシピ番号を疑似コードのコメントとして明記します。実際のプロジェクトでは、チームの体制やインフラに応じて一部の工程(実験管理基盤やAPIのホスティング先など)を差し替えることになります。
# これは全体の順序を示す疑似コードです。build_dataloaders などの関数名は、
# 前のレシピで書いた処理をひとまとめにした呼び名で、このままでは実行できません。
# ステップ1: データ準備
# - Dataset/DataLoaderを構築し、num_workers・pin_memoryで読み込みを高速化する(レシピ6, 7, 93)
train_loader, val_loader = build_dataloaders()
# ステップ2: モデル定義と学習
# - CNNまたは転移学習でモデルを構築する(セクション3、レシピ28〜42)
# - 再現性のため乱数シードを固定する(レシピ8)
# - AMPで学習を高速化・省メモリ化する(レシピ92)
# - 入力形状が固定ならtorch.compileで高速化する(レシピ94)
model = build_model()
set_seed(42)
# コンパイル前のモデルを必ず手元に残す。
# 保存とONNX化はコンパイル前のモデルに対して行う(compile後のstate_dictはキーが変わる)
compiled_model = torch.compile(model)
# - 学習ループ中はGPUメモリを定期的に確認する(レシピ91)
# - lossと評価指標をTensorBoard(必要に応じてMLflow)に記録する(レシピ96)
for epoch in range(num_epochs):
train_one_epoch(compiled_model, train_loader) # 学習はコンパイル済みで速く回す
evaluate(compiled_model, val_loader)
log_metrics_to_tensorboard(epoch)
print_gpu_memory(tag=f"epoch{epoch}")
# ステップ3: モデルの保存(重みは共有されているので、元のmodelから保存すればよい)
torch.save(model.state_dict(), "model.pt")
# ステップ4: 推論形式への変換とAPI化
# - 必要に応じてONNXにエクスポートする(レシピ95)
# - FastAPIで推論APIとして公開する(レシピ97)
export_to_onnx(model, "model.onnx")
# 用意したmain.pyをuvicorn main:app --host 0.0.0.0 --port 8000 で起動する
# ステップ5: 運用
# - 夜間バッチで大量データを推論する場合はチャンク処理とリトライを組み込む(レシピ98)
# - 入力分布の変化を定点観測し、劣化の兆候を検知する(レシピ99)
run_batch_inference("new_data.csv", "predictions.csv")
alerts = check_drift(current_batch_df, baseline_stats)
if alerts:
notify_team_and_consider_retraining(alerts)
学習から監視までを1つのパイプラインとして眺めると、モデルそのものの精度向上と同じくらい、こうした周辺の仕組みづくりに工数がかかることが分かります。プロジェクトの初期段階からこれらの工程を見据えて設計しておくと、後になって作り直す手戻りを減らせます。
『AIエンジニアのための機械学習システムデザインパターン』(澁井雄介、翔泳社):モデルの学習から推論API化、監視、運用に至るまでの設計パターンを、本セクションで扱った内容よりもさらに広い範囲で体系的に整理した一冊です。バッチ推論やモデルの劣化監視の設計を本格的に検討する際の実践的な参考になります。
100のレシピを通じて、テンソルの基礎からニューラルネットワークの学習、画像認識・セグメンテーション・物体検出、自然言語処理とLLM、そして本番運用までを見てきました。最後に、これらのレシピを実務の成果につなげるための視点を3つお伝えします。
第一に、モデルを作る前に「出口」を決めることです。検品AIなら、検出した不良品を誰がどう処理するのか。文書分類なら、分類結果がどの業務フローに流れるのか。ディープラーニングは強力な道具ですが、出力が人の行動とお金の動きにつながる設計がなければ成果にはなりません。この設計の考え方は、当社の他のコラムでも繰り返し扱っているテーマです。
第二に、いきなり深層学習に行かないことです。テーブルデータなら、まず勾配ブースティング(決定木をいくつも重ねて精度を上げる、ニューラルネットワークとは別系統の手法です。既刊の「機械学習の仕組みと使いどころ」で扱っています)などの古典的な機械学習を、強いベースラインとして検討します。文書分類ならTF-IDFのベースライン(レシピ77)がまず基準になります。ディープラーニングの出番は、画像・音声・自然言語といった非構造化データや、事前学習済みモデルの恩恵を受けられる場面です。適材適所の判断こそが、レシピを使いこなす前提になります。
第三に、学習済みモデルと生成AIを「ゼロから作らない」選択肢として常に持つことです。転移学習(レシピ34)、Hugging Faceのパイプライン(レシピ63)、LLMのAPI活用(レシピ82)は、いずれも少ないデータと工数で実用水準に到達する近道です。自前で学習するのは、その近道で足りないと確かめてからでも遅くありません。
コードをコピーして動かすところまでは進んだのに、自社のデータに差し替えた途端に止まる、あるいは精度が出ないという相談は少なくありません。本コラムの範囲でとくに起きやすいものを3つ挙げておきます。
1つめは、テンソルの形とデバイスの食い違いです。エラーメッセージは食い違った形の数字を並べて示してくれるので、どの層で崩れたのかは追えます。処理の途中でshapeを表示して確かめる習慣(レシピ2)が、結局のところいちばん速い解決策になります。デバイスの食い違いも同様で、モデルとデータの両方を同じデバイスへ送っているかを1箇所で確認できるようコードを組んでおくと、環境を移したときの手戻りが減ります。
2つめは、学習時と推論時で前処理を揃え損ねることです。学習時はImageNetの統計量で正規化しておきながら推論時には省く、学習時と違うリサイズ方法を使う、といった食い違いは、エラーにならずに精度だけが落ちるため気づきにくい種類の不具合です。前処理の手順をモデルと一緒に保存し、推論側から同じものを読み込む形にしておけば防げます(レシピ37)。
3つめは、評価指標の選び方です。不良品が全体の1パーセントしかない検品データでは、すべて良品と答えるだけで正解率は99パーセントに届きます。クラスの偏りがあるデータでは、全体の正解率ではなく、クラスごとの内訳と、見逃しと過検知のどちらがより高くつくかという業務側の事情に合わせた指標で見る必要があります(レシピ38)。この判断は技術ではなく業務の側にあるため、モデルを作り始める前に、現場と一緒に決めておくのが確実です。
ディープラーニングの実務は「巨大な1つの正解コード」ではなく、目的の明確な部品の組み合わせでできています。本コラムの100本が、その部品箱として長く使われることを願っています。
体系的な理解を深めたい方は、既刊の「機械学習の仕組みと使いどころ」(共通の土台)、「説明可能AI(XAI)の実践ガイド」(判断根拠の可視化)、「データ分析と機械学習のレシピ100選」(前処理と古典的機械学習)も併せてご利用ください。
Anagraftでは、AIプロジェクトの構想・課題設計から、データ分析・機械学習モデルの開発、AI人材の育成まで一貫したご支援を行っています。会社概要・ご支援内容の詳細は、以下の資料からご覧いただけます。
序章と各セクション末でご紹介した参考書籍の一覧です。学習の段階に合わせてお選びください。
本コラムのレシピで使用したライブラリ・サービスの公式ドキュメントです。深層学習まわりはAPIの変更が比較的速いため、本文の記述と食い違う場合は、以下の一次情報を優先してご確認ください。リンクはいずれも2026年9月1日時点で到達を確認しています。