Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Coupling flow

Coupling Flow with Mixture-CDF Transform

The first notebook trained a Gaussianization flow by stacking unconditional marginal layers (mixture CDF per dim) and rotations. The marginal layers can only Gaussianize each dimension independently; cross-dimensional structure is left entirely to the rotations.

A coupling layer (Real NVP, 2016) replaces that with a conditional transform. Given a mask m∈{0,1}dm \in \{0, 1\}^d:

  1. Split x=(xa,xb)x = (x_a, x_b) with xax_a the identity half (mask True) and xbx_b the transformed half (mask False).
  2. A conditioner network θ(xa)\theta(x_a) outputs the mixture parameters (π,μ,log⁡σ)(\pi, \mu, \log\sigma) for each dimension in xbx_b.
  3. Apply the mixture-CDF Gaussianization to xbx_b using those per-example parameters: zb=Φ−1(F(xb;θ(xa)))z_b = \Phi^{-1}(F(x_b; \theta(x_a))).
  4. Leave za=xaz_a = x_a unchanged.

Since the Jacobian is lower triangular, the log-det is just the sum of per-b-dim diagonal contributions — same form as the unconditional marginal layer, but now the distribution of zbz_b depends on xax_a, so cross-dimensional structure is learnt directly. We pair two coupling layers with swapped masks inside each block so both halves get transformed.

Mathematical structure

Notation and shapes

Throughout: batch dimension NN, input dimensionality dd, mixture components KK.

  • Mask m∈{0,1}dm \in \{0, 1\}^d. With default “half” for d=2d = 2 we have m=[1,0]m = [1, 0], so da=1d_a = 1 dim passes through, db=1d_b = 1 dim gets transformed. The mask=True set is the identity side aa; mask=False is the transformed side bb.
  • Split: x∈RN×dx \in \mathbb{R}^{N \times d} splits into xa∈RN×dax_a \in \mathbb{R}^{N \times d_a} and xb∈RN×dbx_b \in \mathbb{R}^{N \times d_b}.

The bijector — GMM per b-dim

Each transformed dimension i∈bi \in b has its own per-example mixture of KK Gaussians,

Fi(xb,i; πi,μi,σi)  =  ∑k=1Kπi,k Φ ⁣((xb,i−μi,k)/σi,k),F_i(x_{b,i};\,\pi_i, \mu_i, \sigma_i) \;=\; \sum_{k=1}^{K} \pi_{i,k}\,\Phi\!\big((x_{b,i} - \mu_{i,k})/\sigma_{i,k}\big),

and the per-b-dim forward is exactly the mixture-CDF Gaussianization from notebook 01 — the same primitive, only difference is that (πi,μi,σi)(\pi_i, \mu_i, \sigma_i) now depend on xax_a:

zb,i  =  Φ−1 ⁣(Fi(xb,i; θi(xa))).z_{b,i} \;=\; \Phi^{-1}\!\big(F_i(x_{b,i};\,\theta_i(x_a))\big).

The per-example parameter tensors have shape (N,db,K)(N, d_b, K).

The conditioner — a residual MLP with a zero-init head

The conditioner is any keras.Layer taking xa∈Rdax_a \in \mathbb{R}^{d_a} and producing 3⋅db⋅K3 \cdot d_b \cdot K floats (packed logits, means, raw log-scales). We use two concrete builders:

  • make_mlp_conditioner(d_b, K, hidden=(64, 64)) — plain MLP with a zero-initialised final Dense so the flow starts at identity.
  • make_shared_mlp_conditioner(d_b, K, hidden=(64, 64)) — one (3⋅K)(3 \cdot K)-wide head tiled across all dbd_b b-dims. Cheaper, less expressive.

Section 9 shows a residual variant — one hidden block with a skip connection,

ϕ(xa)  =  tanh⁡ ⁣(W2tanh⁡(W1xa+b1)+b2)  +  tanh⁡(W1xa+b1),θ(xa)  =  Wout ϕ(xa)+c.\phi(x_a) \;=\; \tanh\!\big(W_2 \tanh(W_1 x_a + b_1) + b_2\big) \;+\; \tanh(W_1 x_a + b_1), \quad \theta(x_a) \;=\; W_{\text{out}}\,\phi(x_a) + c.

All three builders plug into MixtureCDFCoupling interchangeably because the coupling layer only cares about the output width (=3⋅db⋅K= 3 \cdot d_b \cdot K, enforced by a build-time check).

Log-scale clamp

Raw conditioner outputs for log⁡σ\log \sigma are passed through a bounded map before being exponentiated,

log⁡σi,k  =  B tanh⁡(log⁡σ~i,k),\log \sigma_{i,k} \;=\; B\,\tanh(\tilde{\log\sigma}_{i,k}),

with default B=3B = 3 so σ∈[e−3,e3]≈[0.05,20]\sigma \in [e^{-3}, e^3] \approx [0.05, 20]. This keeps scales from blowing up or collapsing during training, especially when the conditioner is still warming up.

Jacobian of the coupling layer

With za=xaz_a = x_a and zb=Φ−1(F(xb; θ(xa)))z_b = \Phi^{-1}(F(x_b;\,\theta(x_a))), the Jacobian is block-triangular:

J  =  (Ida0∗diag(∂zb,i/∂xb,i)),log⁡∣det⁡J∣  =  ∑i∈blog⁡∣∂zb,i/∂xb,i∣.J \;=\; \begin{pmatrix} I_{d_a} & 0 \\ \ast & \mathrm{diag}(\partial z_{b,i} / \partial x_{b,i}) \end{pmatrix}, \qquad \log\lvert\det J\rvert \;=\; \sum_{i \in b} \log\lvert \partial z_{b,i} / \partial x_{b,i} \rvert.

The lower-left off-diagonal depends on ∂θ(xa)/∂xa\partial \theta(x_a)/\partial x_a (the conditioner Jacobian) but that block doesn’t enter the determinant — only the diagonal of the bottom-right matters. Same diagonal-log-det structure as the unconditional layer.

Comparison to classical Real NVP (affine coupling)

Real NVP / Glow use an affine coupling,

zb  =  xb⊙exp⁡ ⁣(s(xa))+t(xa).z_b \;=\; x_b \odot \exp\!\big(s(x_a)\big) + t(x_a).

Our layer uses a mixture-CDF coupling — strictly more expressive per-b-dim (can handle multimodal conditional distributions), same diagonal-Jacobian structure, same cost.

Mask-swap pair per block

A single coupling layer only transforms the bb-dim half. One block therefore uses two coupling layers with swapped masks [m, ~m] so both halves get touched. The rotation sitting at the start of the block mixes information across dims before the pair fires.

WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
I0000 00:00:1776869571.422244  410267 cpu_feature_guard.cc:227] This TensorFlow binary is optimized to use available CPU instructions in performance-critical operations.
To enable the following instructions: AVX2 AVX512F FMA, in other operations, rebuild TensorFlow with the appropriate compiler flags.

1. Dataset

Same two-moons dataset as in notebook 01 so the comparison is clean.

/home/azureuser/.cache/uv/archive-v0/aYSeLZUlluhRY4DCBNG7F/lib/python3.13/site-packages/seaborn/axisgrid.py:1766: UserWarning: The figure layout has changed to tight
  f.tight_layout()
<Figure size 825x825 with 3 Axes>

2. Build a coupling flow

Each block is [Householder, MixtureCDFCoupling(mask=m), MixtureCDFCoupling(mask=~m)]. The two coupling layers with complementary masks together transform all dimensions; the Householder rotation mixes the already-transformed state before the next block.

With d = 2, mask = [True, False] means the first coupling layer conditions on dim 0 and transforms dim 1, and the second coupling layer (with swapped mask) conditions on dim 1 and transforms dim 0. The conditioner is a zero-initialised MLP, so the flow starts out as the identity and training is stable from step 0.

E0000 00:00:1776869576.712001  410267 cuda_platform.cc:52] failed call to cuInit: INTERNAL: CUDA error: Failed call to cuInit: CUDA_ERROR_NO_DEVICE: no CUDA-capable device is detected
bijectors          : 12  (4 rotations, 8 coupling)
trainable params   : 46800

3. Train

Same training recipe as the marginal-only flow (Adam, NLL loss, 200 epochs), so differences in the final density/pushforward are attributable to the coupling architecture.

<Figure size 1870x715 with 2 Axes>
final loss: +1.2587   (min: +1.2130)

4. Pushforward and density

The pushforward z=f(x)z = f(x) should be approximately N(0,I)\mathcal{N}(0, I). A jointplot puts the 2-D scatter next to the per-dim marginals; the black reference curves are φ(z)\varphi(z).

Unlike a marginal-only flow, here the conditioner can express the cross-dim dependence exactly (each zbz_b is conditional on xax_a) — the density contour in xx-space should trace the two moons sharply.

pushforward diagnostics
  mean:      [+0.086, -0.090]   target = [0, 0]
  cov[0,0]:  0.948                  target = 1
  cov[1,1]:  1.150                  target = 1
  cov[0,1]:  +0.127                  target = 0
/home/azureuser/.cache/uv/archive-v0/aYSeLZUlluhRY4DCBNG7F/lib/python3.13/site-packages/seaborn/axisgrid.py:1766: UserWarning: The figure layout has changed to tight
  f.tight_layout()
<Figure size 825x825 with 3 Axes>
<Figure size 1870x825 with 4 Axes>

5. Sampling from the flow

z∼N(0,I)↦x=f−1(z)z \sim \mathcal{N}(0, I) \mapsto x = f^{-1}(z). Each coupling layer’s inverse bisects F(xb;θ(za))=Φ(zb)F(x_b; \theta(z_a)) = \Phi(z_b) per b-dim, with za=xaz_a = x_a preserved through the layer so the conditioner sees the same input in both directions.

/home/azureuser/.cache/uv/archive-v0/aYSeLZUlluhRY4DCBNG7F/lib/python3.13/site-packages/seaborn/axisgrid.py:1766: UserWarning: The figure layout has changed to tight
  f.tight_layout()
<Figure size 825x825 with 3 Axes>
/home/azureuser/.cache/uv/archive-v0/aYSeLZUlluhRY4DCBNG7F/lib/python3.13/site-packages/seaborn/axisgrid.py:1766: UserWarning: The figure layout has changed to tight
  f.tight_layout()
<Figure size 825x825 with 3 Axes>

6. Round-trip inversion

Each coupling layer is a bijection by construction, so f−1(f(x))f^{-1}(f(x)) should recover xx to numerical precision.

round-trip L2 error  ||f^{-1}(f(x)) - x||_2
  median : 1.55e-06
  95th%  : 7.06e-05
  max    : 1.20e-01
<Figure size 1100x605 with 1 Axes>

7. Layer-by-layer view

States x, f1(x), …, zx,\, f_1(x),\, \ldots,\, z. Each coupling layer transforms one half while keeping the other intact (you can see the passthrough half pinned, the other half morphing). Rotations remix the state between coupling pairs.

<Figure size 1980x1980 with 16 Axes>

8. Shared-mixture conditioner

The per-dim conditioner emits 3⋅db⋅K3 \cdot d_b \cdot K floats — one mixture per transformed dimension. A shared-mixture conditioner emits only 3⋅K3 \cdot K floats and reuses (tiles) those parameters across all dbd_b dims. It is cheaper but strictly less expressive: every b-dim sees the same mixture given the same xax_a.

For two-moons (d=2d = 2, db=1d_b = 1) the two are equivalent (only one b-dim per mask anyway). To see the difference we’d want d≥3d \geq 3; here we just verify it trains and recovers the same density to similar fidelity.

shared-mixture flow trainable params : 46800  (per-dim reference : 46800)
<Figure size 1870x715 with 2 Axes>
<Figure size 935x770 with 2 Axes>
/home/azureuser/.cache/uv/archive-v0/aYSeLZUlluhRY4DCBNG7F/lib/python3.13/site-packages/seaborn/axisgrid.py:1766: UserWarning: The figure layout has changed to tight
  f.tight_layout()
<Figure size 825x825 with 3 Axes>

9. Custom conditioner — bring your own (residual network)

The coupling layer accepts any Keras layer/model whose output width is exactly 3⋅db⋅K3 \cdot d_b \cdot K. The build-time correctness check enforces this and raises a descriptive error otherwise. Below is the residual conditioner from the math section: one tanh hidden block with a skip connection, plus a zero-initialised output head so the layer starts at identity.

<Figure size 935x770 with 2 Axes>

Recap

  • Coupling = one half conditions, the other is transformed. Mask-swap in the same block so both halves get touched.
  • The same MixtureOfGaussians primitive drives both the unconditional marginal layer (notebook 01) and this layer — only difference is that the params now come from a network output instead of a learned per-dim lookup.
  • Zero-initialising the conditioner’s final Dense makes training stable from step 0 (the flow starts as the identity).
  • make_mlp_conditioner (per-dim) vs make_shared_mlp_conditioner (tiled) trade off capacity against parameter count; custom architectures plug in cleanly as long as they output 3⋅db⋅K3 \cdot d_b \cdot K floats.