「画像や音声を生成するAI」と聞くと、GANや拡散モデルを思い浮かべる人が多いかもしれません。でも、これらのモデルには共通の弱点があります。「この画像が出てくる確率はどれくらい?」という問いに、正確な数字で答えられないのです。GANは確率を計算できず、VAEや拡散モデルは近似値しか出せません。
正確な確率(尤度)を計算できると、何が嬉しいのでしょうか。例えば異常検知では「確率がすごく低いデータ=異常」と判定できます。データ圧縮では確率の高いデータに短い符号を割り当てられます。こうした応用のために「生成もできて、確率も正確に計算できる」モデルが欲しい——その答えが正規化フロー(Normalizing Flow) であり、その心臓部が今回の主役、RealNVP のアフィンカップリング層(affine coupling layer) です。

アフィンカップリング層は、一見すると地味な「入力を半分に分けて、片方だけを変換する」という操作です。でもこの単純なトリックには、変換が必ず元に戻せる(可逆)、しかも確率計算で一番重い部分(ヤコビアン行列式)が一瞬で求まる、という2つの「うますぎる」性質が同時に成り立っています。本記事では、なぜこの2つが両立するのかを図と数式で一歩ずつ解きほぐし、最後に PyTorch でゼロから実装して two moons という2次元データの密度推定とサンプリングを動かします。
本記事の内容
- なぜ「可逆な変換」と「軽いヤコビアン」が必要なのか(変数変換公式の復習)
- アフィンカップリング層のアイデア:入力を半分に分けてスケール&シフトする
- なぜこの変換が必ず元に戻せるのか(逆変換の導出)
- なぜヤコビアン行列式が対角の積だけで済むのか(下三角構造)
- マスクを反転して層を積む工夫
- PyTorch によるスクラッチ実装(密度推定・サンプリング・可逆性の確認)
前提知識
この記事を読む前に、以下の記事を読んでおくと理解が深まります。難しければ、本記事の最初の2セクション(復習)だけでも追えるように書いています。
まず復習:確率の「変数変換公式」
アフィンカップリング層の話に入る前に、土台となる考え方を1つだけ押さえます。それは「変数を変換すると、確率密度はどう変わるか」という話です。
身近なイメージから始めましょう。粘土のかたまりがあって、その密度(混み具合)が場所ごとに決まっているとします。この粘土を引き伸ばすと、伸ばした部分は薄く(密度が下がり)、縮めた部分は濃く(密度が上がり)なります。粘土の総量(=確率の合計=1)は変わりません。「どれだけ伸ばしたか・縮めたか」さえ分かれば、変換後の密度を正確に計算できる——これが変数変換の核心です。
数式で書くと、変数 $\bm{z}$ を可逆な関数 $\bm{x} = \bm{g}(\bm{z})$ で別の変数 $\bm{x}$ に変換したとき、それぞれの確率密度には次の関係が成り立ちます。
$$ \begin{equation} p_X(\bm{x}) = p_Z(\bm{z}) \left| \det \frac{\partial \bm{z}}{\partial \bm{x}} \right| \end{equation} $$
右辺の $\left| \det \dfrac{\partial \bm{z}}{\partial \bm{x}} \right|$ が「伸び縮みの度合い」を表す量で、ヤコビアン行列式(の絶対値) と呼びます。粘土を2倍に伸ばしたら密度は $1/2$ になる、という話を多次元に一般化したものです。
正規化フローでは、$\bm{z}$ を標準正規分布(まんまるな粘土)から出発させ、可逆な変換を何段も重ねて複雑なデータ分布 $\bm{x}$ を作ります。学習ではデータ $\bm{x}$ の対数尤度を最大化したいので、両辺の対数を取って次を計算します。
$$ \begin{equation} \log p_X(\bm{x}) = \log p_Z(\bm{z}) + \log \left| \det \frac{\partial \bm{z}}{\partial \bm{x}} \right| \end{equation} $$
第1項は「標準正規分布での密度」なので簡単です。問題は第2項、ヤコビアン行列式です。一般に $D$ 次元の行列式の計算には $O(D^3)$ の計算量がかかります。画像のように $D$ が数千〜数百万になると、これは絶望的に重い。
ここで自然な疑問が生まれます。「可逆で、しかもヤコビアン行列式が軽く計算できる変換を、うまく設計できないだろうか?」——その答えこそがアフィンカップリング層です。
アフィンカップリング層のアイデア:半分だけ変換する
発想は驚くほどシンプルです。「全部を一度に変換しようとするから難しい。半分はそのまま残して、もう半分だけを変換しよう」というものです。
入力ベクトル $\bm{x}$ を前半 $\bm{x}_1$ と後半 $\bm{x}_2$ の2つに分けます。そして次のように変換します。
$$ \begin{equation} \begin{aligned} \bm{y}_1 &= \bm{x}_1 \quad (\text{そのまま}) \\ \bm{y}_2 &= \bm{x}_2 \odot \exp\big(s(\bm{x}_1)\big) + t(\bm{x}_1) \end{aligned} \end{equation} $$
ここで $\odot$ は要素ごとの掛け算です。$s(\cdot)$ と $t(\cdot)$ はニューラルネットワークで、それぞれ「スケール(拡大率)」と「シフト(平行移動量)」を出力します。「アフィン」とは「スケール&シフト($ax+b$ の形)」という意味で、$\bm{x}_2$ を $\exp(s)$ 倍して $t$ だけずらしているわけです。
冒頭の概念図をもう一度見てください。ポイントは2つあります。
- $\bm{x}_1$ には一切手を加えず、そのまま $\bm{y}_1$ として出力する。
- $\bm{x}_2$ を変換するための $s, t$ は、$\bm{x}_2$ ではなく $\bm{x}_1$ だけから計算する。
この「変換される側($\bm{x}_2$)を、変換のパラメータ計算に使わない」という一点が、後で効いてくる魔法の鍵です。なぜわざわざこんな回りくどいことをするのか——それは次節の「逆変換」を見ると一気に腑に落ちます。
なお、$s, t$ を作るニューラルネット自体はどんなに複雑でも構いません。多層パーセプトロンでも畳み込みでも何でもよく、可逆である必要すらありません。複雑さはすべてこのネットワークに押し込み、変換の骨組みだけを単純に保つ——これがアフィンカップリング層の設計思想です。
なぜ必ず元に戻せるのか(逆変換)
正規化フローでは、データから潜在変数への変換(順変換 $f$)と、潜在変数からデータを生成する変換(逆変換 $f^{-1}$)の両方が必要です。アフィンカップリング層が偉いのは、逆変換が紙とペンで一瞬で書けることです。

逆変換を求めましょう。出力 $\bm{y}_1, \bm{y}_2$ から入力 $\bm{x}_1, \bm{x}_2$ を復元したい。まず前半は自明です。
$$ \bm{x}_1 = \bm{y}_1 $$
そのまま通しただけなので、$\bm{y}_1$ がそっくりそのまま $\bm{x}_1$ です。ここで決定的に重要なことが起きます。$\bm{x}_1$ が手に入った瞬間、$s(\bm{x}_1)$ と $t(\bm{x}_1)$ を順変換のときとまったく同じ値で計算し直せるのです。
スケールとシフトが分かれば、後半の式 $\bm{y}_2 = \bm{x}_2 \odot \exp(s) + t$ を $\bm{x}_2$ について解くだけです。$t$ を引いて、$\exp(s)$ で割ります。
$$ \begin{equation} \bm{x}_2 = \big(\bm{y}_2 – t(\bm{x}_1)\big) \odot \exp\big(-s(\bm{x}_1)\big) \end{equation} $$
ここがアフィンカップリング層の最大のうまみです。後半の変換は $\bm{x}_1$ から決まる $s, t$ を使ったただの1次関数なので、逆向きにたどるのも引き算と割り算だけ。$\exp(s)$ は必ず正なのでゼロ割りも起きません。ニューラルネット $s, t$ が中でどんなに複雑な計算をしていても、その逆関数を求める必要はまったくない——これが「変換される側をパラメータ計算に使わない」設計の御利益です。もし $s, t$ が $\bm{x}_2$ にも依存していたら、$\bm{x}_2$ を求めるのに $\bm{x}_2$ が必要になり、堂々巡りで逆算できなくなります。
可逆性が確認できたので、次はもう1つの宿題——「ヤコビアン行列式が本当に軽いのか」を確かめます。
なぜヤコビアン行列式が一瞬で求まるのか(下三角構造)
復習したとおり、対数尤度の計算には $\log\left|\det \dfrac{\partial \bm{y}}{\partial \bm{x}}\right|$ が必要です。一般には $O(D^3)$ の重い計算ですが、アフィンカップリング層ではこれが足し算だけで済みます。理由は、ヤコビアン行列が「下三角行列」になるからです。
ヤコビアン行列は「出力の各成分を、入力の各成分で偏微分したもの」を並べた行列です。アフィンカップリング層の出力を、$\bm{x}_1$ と $\bm{x}_2$ で偏微分してみましょう。
$$ \frac{\partial \bm{y}}{\partial \bm{x}} = \begin{pmatrix} \dfrac{\partial \bm{y}_1}{\partial \bm{x}_1} & \dfrac{\partial \bm{y}_1}{\partial \bm{x}_2} \\[2mm] \dfrac{\partial \bm{y}_2}{\partial \bm{x}_1} & \dfrac{\partial \bm{y}_2}{\partial \bm{x}_2} \end{pmatrix} = \begin{pmatrix} \bm{I} & \bm{0} \\[1mm] \dfrac{\partial \bm{y}_2}{\partial \bm{x}_1} & \mathrm{diag}\big(\exp(s)\big) \end{pmatrix} $$
ブロックごとに見ていきます。左上 $\dfrac{\partial \bm{y}_1}{\partial \bm{x}_1}$ は $\bm{y}_1 = \bm{x}_1$ なので単位行列 $\bm{I}$。右上 $\dfrac{\partial \bm{y}_1}{\partial \bm{x}_2}$ は $\bm{y}_1$ が $\bm{x}_2$ をまったく含まないのでゼロ行列 $\bm{0}$。これが効きます。右下 $\dfrac{\partial \bm{y}_2}{\partial \bm{x}_2}$ は、$\bm{y}_2 = \bm{x}_2 \odot \exp(s) + t$ を $\bm{x}_2$ で微分するので、各成分が $\exp(s_i)$ の対角行列になります($s, t$ は $\bm{x}_1$ だけの関数なので $\bm{x}_2$ で微分すると消えます)。
左下のブロック $\dfrac{\partial \bm{y}_2}{\partial \bm{x}_1}$ は複雑な値が入りますが、行列式の計算には一切関係しません。下の図のように、右上がゼロのブロック三角行列の行列式は、対角ブロックの行列式の積だけで決まるからです。

図は具体的な数値例(前半3次元・後半3次元)のヤコビアンです。右上が完全にゼロ、左下に値が散らばり、対角に $\exp(s_i)$ が並ぶ「下三角」の形になっています。三角行列の行列式は対角成分の積なので、
$$ \begin{equation} \det \frac{\partial \bm{y}}{\partial \bm{x}} = \det(\bm{I}) \cdot \det\big(\mathrm{diag}(\exp(s))\big) = \prod_i \exp(s_i) \end{equation} $$
対数を取れば、積は和に変わります。
$$ \begin{equation} \log \left| \det \frac{\partial \bm{y}}{\partial \bm{x}} \right| = \sum_i s_i \end{equation} $$
なんと、ヤコビアンの対数行列式は「スケールを出力したニューラルネットの値 $s_i$ をぜんぶ足すだけ」になりました。$O(D^3)$ だった計算が $O(D)$ の単純な足し算に化けたのです。これがアフィンカップリング層が実用になる理由です。
スケール $\exp(s)$ が密度を伸び縮みさせる様子を、1次元で可視化してみます。

左の図は $y = e^s x + t$ という変換そのもので、傾きが $e^s$、切片が $t$ の直線です。右の図が要点で、$e^s$ 倍に引き伸ばすと、その分だけ確率密度が $1/e^s$ に薄まっています。粘土を伸ばすと薄くなる、というあの話そのものです。ヤコビアン項 $\sum_i s_i$ は、この「薄まり」を全次元ぶん帳尻合わせするための補正項なのだ、と理解できます。
ここまでで、アフィンカップリング層が「可逆」かつ「ヤコビアンが軽い」という2つの性質を満たすことが分かりました。でも、よく見ると1つ問題が残っています。
マスクを反転して層を積む
アフィンカップリング層は、前半 $\bm{x}_1$ をまったく変換しません。1層だけでは、入力の半分が手つかずのまま出てきてしまう。これでは複雑な分布は作れません。
解決策はシンプルです。層を重ねるたびに、固定する側と変換する側を入れ替えるのです。1層目で後半を変換したら、2層目では前半を変換する。これを交互に繰り返せば、すべての次元がまんべんなく変換されます。

実装上は「どの次元を固定し、どの次元を変換するか」を 0/1 のマスク $\bm{b}$ で表します。1つの層を一般的に書くと次のようになります。
$$ \begin{aligned} \bm{y} &= \bm{b} \odot \bm{x} + (1-\bm{b}) \odot \Big( \bm{x} \odot \exp\big(s(\bm{b}\odot\bm{x})\big) + t(\bm{b}\odot\bm{x}) \Big) \end{aligned} $$
$\bm{b}=1$ の次元はそのまま通り、$\bm{b}=0$ の次元だけがスケール&シフトされます。$s, t$ の入力は $\bm{b}\odot\bm{x}$(固定側だけ)なので、先ほどの可逆性とヤコビアンの議論はそっくりそのまま成り立ちます。層ごとにマスク $\bm{b}$ を反転させれば、全次元が更新されるというわけです。
画像のような構造を持つデータでは、マスクの作り方に「チェッカーボード(市松模様)」や「チャンネル分割」といった工夫が使われますが、本質は同じ「半分を固定して交互に入れ替える」です。
これで理論の道具はすべて揃いました。あとは実際にコードに落として、本当に分布を学習できるのかを確かめましょう。
PyTorch で実装する
ここからは PyTorch で RealNVP をゼロから組み立てます。題材は two moons(2つの三日月が絡み合った2次元データ)です。単純な正規分布では絶対に表せない、ねじれた分布を相手にします。

まず、アフィンカップリング層を1つのクラスとして実装します。forward が順変換(データ→潜在)、inverse が逆変換(潜在→データ)です。
import numpy as np
import torch
import torch.nn as nn
class AffineCoupling(nn.Module):
def __init__(self, dim, mask, hidden=64):
super().__init__()
self.register_buffer("mask", mask) # 0/1 のマスク(固定する次元=1)
# s と t を一度に出すネットワーク(出力は dim*2)
self.net = nn.Sequential(
nn.Linear(dim, hidden), nn.ReLU(),
nn.Linear(hidden, hidden), nn.ReLU(),
nn.Linear(hidden, dim * 2),
)
def forward(self, x):
xm = x * self.mask # 固定する半分だけを残す
s, t = self.net(xm).chunk(2, dim=1) # スケールとシフトに分割
s = torch.tanh(s) * (1 - self.mask) # 更新側だけに適用(tanhで安定化)
t = t * (1 - self.mask)
y = xm + (1 - self.mask) * (x * torch.exp(s) + t)
log_det = s.sum(dim=1) # log|det J| = Σ s
return y, log_det
def inverse(self, y):
ym = y * self.mask # 固定側は y からそのまま得られる
s, t = self.net(ym).chunk(2, dim=1) # 同じ s, t を再計算
s = torch.tanh(s) * (1 - self.mask)
t = t * (1 - self.mask)
x = ym + (1 - self.mask) * ((y - t) * torch.exp(-s))
return x
forward では、まず x * mask で固定側だけを取り出し、それをネットワークに通して s, t を得ます。更新側(1 - mask)にだけスケール&シフトを適用し、固定側はそのまま足し戻します。log_det は理論どおり s.sum() だけ。inverse では、固定側 y * mask から同じ s, t を計算し直し、$\bm{x}_2 = (\bm{y}_2 – t)\exp(-s)$ の式で復元しています。順変換と逆変換でネットワークの呼び出し方が同じなのが、可逆性が保証される証拠です。なお、スケール s に tanh をかけているのは、$\exp(s)$ が爆発して学習が不安定になるのを防ぐ実用上の工夫です。
次に、この層を複数積み重ねて RealNVP 本体を作ります。
class RealNVP(nn.Module):
def __init__(self, dim=2, n_layers=6, hidden=64):
super().__init__()
masks = []
for i in range(n_layers):
# 層ごとにマスクを反転([1,0] と [0,1] を交互に)
m = torch.tensor([1.0, 0.0] if i % 2 == 0 else [0.0, 1.0])
masks.append(m)
self.layers = nn.ModuleList(
[AffineCoupling(dim, m, hidden) for m in masks])
def forward(self, x):
# データ x を潜在 z へ。log|det| を全層ぶん足し上げる
log_det = torch.zeros(x.size(0), device=x.device)
z = x
for layer in self.layers:
z, ld = layer(z)
log_det = log_det + ld
return z, log_det
def inverse(self, z):
# 潜在 z をデータ x へ(層を逆順にたどる)
x = z
for layer in reversed(self.layers):
x = layer.inverse(x)
return x
def log_prob(self, x):
# 変数変換公式:log p(x) = log N(z) + Σ log|det J|
z, log_det = self.forward(x)
base = -0.5 * (z**2 + np.log(2 * np.pi)) # 標準正規分布の対数密度
return base.sum(dim=1) + log_det
def sample(self, n):
# 標準正規からサンプルし、逆変換でデータ空間へ
z = torch.randn(n, 2)
return self.inverse(z)
forward は層を順番に通しながら log_det を足し上げ、inverse は層を逆順にたどります(順変換の逆をたどるので順番も逆)。log_prob が変数変換公式そのもので、潜在 z での標準正規分布の対数密度に、ヤコビアン項 log_det を足しているだけです。生成は sample で、標準正規分布から点を引いて inverse でデータ空間に流すだけ。学習も生成も同じネットワークで、向きを変えるだけという正規化フローの美しさが、このコードに凝縮されています。
学習は、データの負の対数尤度(NLL)を最小化するだけです。
from sklearn.datasets import make_moons
torch.manual_seed(0)
np.random.seed(0)
X, _ = make_moons(n_samples=2000, noise=0.07, random_state=0)
X = (X - X.mean(0)) / X.std(0) # 標準化
Xt = torch.tensor(X, dtype=torch.float32)
model = RealNVP(dim=2, n_layers=6, hidden=64)
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
losses = []
for epoch in range(2000):
opt.zero_grad()
loss = -model.log_prob(Xt).mean() # 負の対数尤度を最小化
loss.backward()
opt.step()
losses.append(loss.item())
print("final NLL:", losses[-1]) # → 約 1.42
損失(NLL)の推移を見てみましょう。

NLL が単調に下がり、約 1.42 で落ち着いています。NLL が下がるとは「データの対数尤度が上がる」、つまりモデルが two moons の形をだんだん上手に説明できるようになっている、ということです。最初の数百エポックで急降下し、その後ゆるやかに改善する典型的なカーブです。
学習した密度を見る
正規化フローの真骨頂は、任意の点での確率密度 $p(\bm{x})$ を厳密に計算できることです。格子状に点を並べて密度を評価し、ヒートマップにします。
import matplotlib.pyplot as plt
g = np.linspace(-2.6, 2.6, 200)
xx, yy = np.meshgrid(g, g)
pts = torch.tensor(np.c_[xx.ravel(), yy.ravel()], dtype=torch.float32)
with torch.no_grad():
dens = np.exp(model.log_prob(pts).numpy()).reshape(xx.shape)
plt.figure(figsize=(5.5, 5))
plt.contourf(xx, yy, dens, levels=30, cmap="magma")
plt.title("学習した確率密度 p(x)")
plt.show()

左がデータ点、右がモデルが学習した密度です。2つの三日月がはっきりと高密度領域(明るい色)として浮かび上がり、その間の隙間は低密度(暗い色)になっています。単純な正規分布では絶対に表せないこの曲がった分布を、アフィンカップリング層の積み重ねだけで正確に捉えられているのが分かります。
新しいデータを生成する
密度が学習できたなら、生成もできます。標準正規分布から点をサンプリングし、inverse でデータ空間に流すだけです。
with torch.no_grad():
samples = model.sample(2000).numpy()
plt.figure(figsize=(5, 5))
plt.scatter(samples[:, 0], samples[:, 1], s=6, alpha=0.5)
plt.title("生成サンプル")
plt.show()

生成された点(オレンジ)が、元データ(グレー)の三日月の形にきれいに乗っています。標準正規分布という「まんまるな雲」から出発した点が、逆変換を通って三日月の形に変形されたわけです。学習で得た変換が、生成にもそのまま使えるのが正規化フローの強みです。
厳密な尤度が計算できることのありがたみ
ここで、冒頭の問いに立ち返りましょう。「この点が出てくる確率は?」に正確に答えられると、何が嬉しいのか。上のヒートマップで計算した $p(\bm{x})$ は、近似でも下界でもなく厳密な値です。これは GAN(確率を出せない)や VAE(変分下界しか出せない)にはない、正規化フローならではの長所です。
具体的なご利益を2つ挙げます。1つ目は異常検知です。学習した密度のもとで $-\log p(\bm{x})$ が極端に大きい点は「正常データではめったに現れない点」、すなわち異常の候補です。再構成誤差のような間接的な指標ではなく、確率そのものをスコアにできるので、しきい値の意味が明確になります。2つ目はデータ圧縮です。情報理論によれば、確率 $p(\bm{x})$ の事象は理想的には $-\log_2 p(\bm{x})$ ビットで符号化できます。厳密な尤度が手に入るということは、最適な符号長が直接分かるということです。
two moons のような単純な例では当たり前に見えますが、この「厳密さ」が画像や音声のような高次元データでも $O(D)$ で保たれるところに、アフィンカップリング層の設計の価値があります。
順変換でデータが「ほどける」様子
逆に、データを順変換 $f$ に通すと何が起きるでしょうか。複雑な三日月が、単純な標準正規分布へと「ほどけて」いくはずです。
with torch.no_grad():
z, _ = model.forward(Xt)
z = z.numpy()
fig, ax = plt.subplots(1, 2, figsize=(11, 5))
ax[0].scatter(Xt[:, 0], Xt[:, 1], s=6, alpha=0.5)
ax[0].set_title("データ空間 x")
ax[1].scatter(z[:, 0], z[:, 1], s=6, alpha=0.5)
ax[1].set_title("潜在空間 z = f(x)")
plt.show()

左の三日月が、右ではまんまるなガウス分布(点線は半径1と2の円)に変換されています。正規化フローの学習とは、「複雑なデータを単純なガウスにほどく変換 $f$ を見つけること」 なのだと、この図から直感的に理解できます。生成はその逆をたどっているだけです。
層を1つずつ通したときの中間表現を見ると、この「ほどけ」が段階的に進む様子がよく分かります。

0層(生データ)の三日月が、2層・4層と通過するごとに少しずつ引き伸ばされ、6層通過後にはほぼ等方的なガウスになっています。1枚の層ができる変形は控えめでも、マスクを反転しながら積み重ねることで、複雑な分布を扱えるだけの表現力が生まれているわけです。
可逆性をコードで確認する
最後に、理論で導いた可逆性が数値的にも成り立つかを確認します。データを順変換して潜在に飛ばし、すぐ逆変換で戻したとき、元の点に一致するはずです。
with torch.no_grad():
z, _ = model.forward(Xt)
x_rec = model.inverse(z)
err = (Xt - x_rec).abs().max().item()
print("最大復元誤差:", err) # → 約 6.6e-06(丸め誤差レベル)

最大誤差は $10^{-6}$ 程度——浮動小数点の丸め誤差レベルで、実質ゼロです。$f^{-1}(f(\bm{x})) = \bm{x}$ が数値的にもきっちり成り立っていることが確認できました。VAE の再構成誤差のような「近似的に似ている」ではなく、完全に元に戻るのがアフィンカップリング層の特徴です。
まとめ
本記事では、RealNVP のアフィンカップリング層を初心者向けに掘り下げました。
- アイデア:入力を半分に分け、片方($\bm{x}_1$)はそのまま通し、もう片方($\bm{x}_2$)を $\bm{x}_1$ から計算したスケール&シフトで変換する。
- 可逆性:$\bm{x}_1$ をそのまま残すので、逆変換でも同じ $s, t$ を再計算でき、$\bm{x}_2 = (\bm{y}_2 – t)\exp(-s)$ と引き算・割り算だけで元に戻せる。ニューラルネットの逆関数は不要。
- 軽いヤコビアン:ヤコビアンが下三角になり、対数行列式が $\sum_i s_i$(スケールの総和)だけで求まる。$O(D^3)$ が $O(D)$ に。
- 層の積み重ね:マスクを反転しながら層を重ね、全次元を変換して表現力を確保する。
- 実装:PyTorch で two moons の密度推定・生成・可逆性確認まで、同じネットワークの向きを変えるだけで実現できた。
アフィンカップリング層は「複雑さをニューラルネットに押し込み、変換の骨組みは単純に保つ」という設計のお手本です。この考え方は GLOW(1×1 可逆畳み込みでマスクを学習)や、より表現力の高い変換(スプラインフローなど)へと発展していきます。また、条件 $\bm{c}$ を $s, t$ の入力に加えれば「条件付き正規化フロー」になり、異常検知などに応用できます。
次のステップとして、以下の記事も参考にしてください。