生成モデルの全体像
生成モデルは、データを分類するのではなく「データそのものを作る」ためのモデルです。画像生成、文章生成、音声生成だけでなく、欠損補完、異常検知、シミュレーションの近似などにも使われます。
生成モデルは「正解を当てる」のでなく、「ありそうなものを出せる」ように学ぶ
分類モデルでは境界を覚えれば足りますが、生成モデルではデータ分布そのものを近似しなければいけません。だから最初に見るべきは、個々のサンプルではなく「どんな頻度で、どんな組み合わせが現れるか」です。
このノートでは、まず極小の 0/1 データで「分布を学ぶ」とは何かを具体化し、そのあとで単峰近似の限界、潜在変数の役割、自己回帰・VAE・GAN・フロー・拡散の違いへ進みます。狙いは各手法の暗記ではなく、どんな分布をどう近似したいときに何が必要になるかをつかむことです。
読み筋は「独立では足りない」から始まる
最初の独立ベルヌーイは、生成モデルの出発点としては分かりやすい一方で、相関をほとんど持てません。そこから混合、潜在変数、逐次生成、ノイズ除去へ広がっていく流れをつかむと、この節全体の地図が見えやすくなります。
まずは 4 ピクセルの世界で、分布を持つとは何かを見る
高次元画像や文章に入る前に、極小データで頻度の違いを観察します。どの並びが多く、どの並びがほとんど出ないかを見られると、「生成モデルが学習しているもの」が急に具体的になります。
手法の違いは、結局どこで依存関係を表すかに出る
混合モデルは複数の山を持ち込み、潜在変数モデルは見えない原因を導入し、自己回帰は順番に条件づけ、拡散はノイズから戻します。後半ではこの違いを、尤度・品質・多様性・速度の観点で並べます。
この章では、手法の系譜を先に見る
ここで比較しているのは最終的な SOTA 競争ではなく、どんな仮定を置くとどんな生成器が生まれるか、という設計の入口です。実データでの高品質生成は後続の各 notebook で個別に見ていきます。
まずは極小データの頻度を眺める
最初の節では、生成モデルが扱う対象をできるだけ小さくして、頻出パターンと稀なパターンの差を観察します。分布モデリングの最小例です。
import math
import random
from collections import Counter, defaultdict
from statistics import mean
random.seed(42)
生成モデルで最初に見えにくいのは、「何を当てているのか」ではなく「どんな分布に近づけたいのか」です。ここから先は、その見えにくい対象をできるだけ小さなデータで可視化していきます。
1. 分布を学ぶとはどういうことか
まずは最小の例として、0/1からなる長さ4のベクトルを考えます。これは「白黒4ピクセルの超小型画像」とみなせます。
toy_data = [
(1, 1, 1, 0),
(1, 1, 0, 0),
(1, 1, 1, 0),
(1, 0, 0, 0),
(1, 1, 0, 0),
(1, 1, 1, 0),
(0, 0, 0, 1),
(0, 0, 1, 1),
]
count = Counter(toy_data)
print('observed patterns and frequencies:')
for pattern, c in count.items():
print(pattern, c)
print('most frequent pattern =', count.most_common(1)[0])
ここでの目的は、1つ1つのサンプルを暗記することではありません。頻出パターン、稀なパターン、あり得ないパターンの違いをモデル化して、新しいサンプルを引けるようにすることです。これが生成モデルの出発点です。
2. 最も基本的な生成モデル: 多次元ベルヌーイ
各次元を独立な0/1確率で近似すると、最尤推定は「各次元の1の割合」を取るだけで求まります。これは簡単ですが、依存関係を捨てているので表現力に限界があります。
def bernoulli_mle(dataset):
n = len(dataset)
d = len(dataset[0])
probs = []
for j in range(d):
probs.append(sum(x[j] for x in dataset) / n)
return probs
def sample_bernoulli(probs, n_samples=5):
out = []
for _ in range(n_samples):
out.append(tuple(1 if random.random() < p else 0 for p in probs))
return out
p_hat = bernoulli_mle(toy_data)
print('estimated pixel-wise probabilities =', [round(p, 3) for p in p_hat])
print('samples from model =', sample_bernoulli(p_hat, n_samples=8))
このモデルは「どの位置が1になりやすいか」は学べますが、「この2つは同時に1になりやすい」のような相関を強く表現できません。ここで混合モデルや潜在変数モデルが必要になります。
3. 単峰性の限界と混合分布の必要性
次に2次元連続データを考えます。データが2つの塊を持つとき、単一ガウスで近似すると中間に質量を置きすぎる問題が起きます。
def sample_gaussian_2d(mu_x, mu_y, sigma, n):
return [(random.gauss(mu_x, sigma), random.gauss(mu_y, sigma)) for _ in range(n)]
cluster_a = sample_gaussian_2d(-2.0, -1.5, 0.45, 120)
cluster_b = sample_gaussian_2d(2.5, 2.0, 0.55, 120)
data_2d = cluster_a + cluster_b
mx = mean(x for x, _ in data_2d)
my = mean(y for _, y in data_2d)
print('single Gaussian mean estimate =', (round(mx, 3), round(my, 3)))
print('example points near cluster centers:')
print('A sample:', cluster_a[0], 'B sample:', cluster_b[0])
独立仮定で何が表せて何が落ちるかを見る
独立ベルヌーイは簡単ですが、位置同士の結び付きは弱くなります。ここでは、出てきたサンプルを見ながらその限界を確認します。
# 真のクラスタラベルを使った理想的な2成分近似(教育用)
mx_a = mean(x for x, _ in cluster_a)
my_a = mean(y for _, y in cluster_a)
mx_b = mean(x for x, _ in cluster_b)
my_b = mean(y for _, y in cluster_b)
print('component means (oracle split) =')
print('component A:', (round(mx_a, 3), round(my_a, 3)))
print('component B:', (round(mx_b, 3), round(my_b, 3)))
# 単一平均との距離比較
single_to_a = math.dist((mx, my), (mx_a, my_a))
single_to_b = math.dist((mx, my), (mx_b, my_b))
print('distance(single_mean, compA)=', round(single_to_a, 3))
print('distance(single_mean, compB)=', round(single_to_b, 3))
単一"ガウス"では多峰性を表しにくいため、混合モデルや潜在変数モデルが登場します。ここで言いたい本質は「単峰近似では足りない状況がある」という点です。分布族を変えれば単一分布でも多峰性を表現できる場合はありますが、実務では混合・潜在表現で扱うことが多いです。
なお、上の2成分平均はクラスタラベルを知っている教育用の oracle split です。実際にはラベルは未知なので、EM法や変分推論で潜在変数を同時推定します。
4. 潜在変数モデルの直感
潜在変数 z は「観測されない説明変数」です。生成では z -> x の写像を学び、サンプリングでは z を振って x を得ます。
def decoder_toy(z1, z2):
# 非線形な簡易デコーダ
x1 = 1.4 * z1 + 0.3 * z2
x2 = -0.8 * z1 + 1.2 * z2
x3 = 0.5 * (z1 ** 2) - 0.2 * z2
return (x1, x2, x3)
latent_points = [(random.gauss(0, 1), random.gauss(0, 1)) for _ in range(5)]
outputs = [decoder_toy(z1, z2) for z1, z2 in latent_points]
print('latent points:')
for z in latent_points:
print(tuple(round(v, 3) for v in z))
print('decoded outputs:')
for x in outputs:
print(tuple(round(v, 3) for v in x))
潜在空間を学べると、補間(interpolation)ができるようになります。これは「2つのサンプルの間を連続的に生成する」能力で、生成モデルが概念をどれだけ滑らかに表現しているかの手がかりになります。
ただし、線形補間が常に意味的に自然とは限りません。潜在空間の幾何が歪んでいると、途中サンプルの品質が落ちることがあります。
def interpolate(z_a, z_b, steps=5):
out = []
for t in range(steps):
alpha = t / (steps - 1)
z = (1 - alpha) * z_a[0] + alpha * z_b[0], (1 - alpha) * z_a[1] + alpha * z_b[1]
out.append(z)
return out
z_a = (-1.2, 0.4)
z_b = (1.1, -0.7)
for z in interpolate(z_a, z_b, steps=6):
x = decoder_toy(*z)
print('z=', tuple(round(v, 2) for v in z), '-> x=', tuple(round(v, 2) for v in x))
5. 自己回帰モデルの見方
自己回帰モデルは、同時に全部を生成せず、左から順に次トークンを予測します。言語モデルがこの系統です。Teacher Forcingで学習しやすい利点がある一方、逐次生成なので長い系列では遅くなりやすく、学習と推論の分布ずれ(露出バイアス)が課題になりやすいというトレードオフがあります。
token_sequences = [
['A', 'B', 'C', 'EOS'],
['A', 'B', 'D', 'EOS'],
['A', 'C', 'C', 'EOS'],
['B', 'D', 'EOS'],
]
bigram = defaultdict(Counter)
for seq in token_sequences:
prev = 'BOS'
for tok in seq:
bigram[prev][tok] += 1
prev = tok
def next_token_probs(prev_tok):
c = bigram[prev_tok]
total = sum(c.values())
return {k: v / total for k, v in c.items()}
for prev in ['BOS', 'A', 'B']:
print(prev, '->', {k: round(v, 3) for k, v in next_token_probs(prev).items()})
6. 拡散モデルの直感
拡散モデルは、前向き過程でデータに少しずつノイズを足し、逆向き過程でノイズを除去します。学習時には「どのノイズが足されたか」を予測するネットワーク eps_theta(x_t, t) を訓練し、その予測を使って逆向き更新します。
次のコードは「仕組み理解のための1次元トイ例」です。実際のDDPM/score-basedの更新式を省略した近似デモであり、実運用実装をそのまま表してはいません。
x0 = 2.0
noise_schedule = [0.1, 0.2, 0.35, 0.5]
x = x0
trajectory = [x0]
used_eps = []
for s in noise_schedule:
eps = random.gauss(0, 1)
used_eps.append(eps)
x = math.sqrt(1 - s) * x + math.sqrt(s) * eps
trajectory.append(x)
print('forward noising trajectory:')
print([round(v, 3) for v in trajectory])
# トイ逆過程: 本物のepsを知っている理想条件なら戻せる
x_rev_oracle = trajectory[-1]
for s, eps in zip(reversed(noise_schedule), reversed(used_eps)):
x_rev_oracle = (x_rev_oracle - math.sqrt(s) * eps) / max(math.sqrt(1 - s), 1e-6)
# 現実には eps は未知なので、学習した eps_theta が必要
x_rev_naive = trajectory[-1]
for s in reversed(noise_schedule):
x_rev_naive = x_rev_naive / max(math.sqrt(1 - s), 1e-6)
print('oracle reverse result =', round(x_rev_oracle, 3), '(target:', x0, ')')
print('naive reverse result =', round(x_rev_naive, 3), '(without eps prediction)')
7. 評価の基本: 尤度・品質・多様性
生成モデル評価は1つの数字で終わりません。尤度が高くても見た目品質が悪いことがあり、品質が高くても多様性が低い(モード崩壊)ことがあります。だから、用途ごとに複数指標を併用します。
def neg_log_likelihood_bernoulli(x, probs):
# x: tuple of 0/1
# probs: each dimension probability of 1
out = 0.0
for xi, p in zip(x, probs):
p = min(max(p, 1e-9), 1 - 1e-9)
out -= math.log(p if xi == 1 else (1 - p))
return out
nll_values = [neg_log_likelihood_bernoulli(x, p_hat) for x in toy_data]
print('mean NLL =', round(mean(nll_values), 4))
print('max NLL =', round(max(nll_values), 4), '(outlier-like sample indicator)')
多様性が落ちたときの見え方を比べる
最後は、同じようなサンプルばかり出す生成器をあえて作って、多様性低下が指標上どう見えるかを確かめます。
# 多様性の簡易指標: 生成サンプル中のユニークパターン率
# 注意: 離散空間サイズ(ここでは最大16パターン)に強く依存する
samples = sample_bernoulli(p_hat, n_samples=200)
unique_ratio = len(set(samples)) / len(samples)
coverage = len(set(samples)) / 16
print('diversity proxy (unique ratio) =', round(unique_ratio, 3))
print('space coverage proxy =', round(coverage, 3), '(max patterns=16)')
print('unique count =', len(set(samples)), 'out of', len(samples))
モード崩壊の危険を可視化するために、意図的に「同じサンプルしか出さない生成器」を作って比較します。
def collapsed_generator(mode, n=50):
return [mode for _ in range(n)]
collapsed = collapsed_generator((1, 1, 1, 0), n=200)
collapsed_unique_ratio = len(set(collapsed)) / len(collapsed)
print('collapsed diversity proxy =', collapsed_unique_ratio)
print('normal model diversity proxy =', round(unique_ratio, 3))
8. 生成モデルファミリーをどう使い分けるか
ここまでの話を実務判断に落とすと、次の軸で選ぶと整理しやすくなります。
- 尤度を明示的に評価したいか
- サンプリング速度を最優先するか
- 潜在空間の制御性が重要か
- 品質最優先か、運用コスト最優先か
重要なのは「流行モデルを選ぶ」より「制約に合う設計を選ぶ」ことです。以下の関数は厳密な最適化ではなく、選定議論を始めるためのヒューリスティックです。
def choose_generative_family(
need_explicit_likelihood: bool,
need_fast_sampling: bool,
need_latent_control: bool,
quality_priority: str,
compute_budget: str,
):
# 厳密解ではなく議論開始用のルール
if need_explicit_likelihood:
return 'autoregressive / flow-based'
if need_latent_control and compute_budget in {'low', 'medium'}:
return 'VAE-family'
if quality_priority == 'very_high' and not need_fast_sampling and compute_budget != 'low':
return 'diffusion-family'
if need_fast_sampling and quality_priority in {'high', 'very_high'}:
return 'GAN-family or distilled diffusion'
return 'hybrid approach (task-specific)'
cases = [
(True, False, False, 'high', 'medium'),
(False, False, True, 'high', 'low'),
(False, False, False, 'very_high', 'high'),
(False, True, False, 'high', 'medium'),
]
for c in cases:
print(c, '->', choose_generative_family(*c))
生成モデルの全体像を一言でまとめると、「データ分布をどう近似し、どうサンプリングするか」の設計問題です。
このあと各ノートで、潜在変数モデル、VAE、GAN、フロー、エネルギーベース、拡散へ進みます。全体像としては、どの手法も目的は同じで、トレードオフの置き方が違うだけだと捉えると迷いにくくなります。