Skip to content

Sinh ảnh (2): Diffusion, Stable Diffusion, LoRA

This content is not available in your language yet.

Sau bài này, bạn sẽ:

  • Hiểu ý tưởng của mô hình khuếch tán (diffusion model): làm hỏng ảnh bằng nhiễu từng chút một, rồi dạy một mạng gỡ nhiễu ngược lại.
  • Tự dẫn được dạng đóng xt=αˉt x0+1−αˉt ϵ\mathbf{x}_t = \sqrt{\bar\alpha_t}\,\mathbf{x}_0 + \sqrt{1-\bar\alpha_t}\,\boldsymbol\epsilon của DDPM, và hiểu vì sao mạng chỉ cần dự đoán nhiễu bằng một hàm mất mát MSE.
  • Giải thích được ba mảnh của Stable Diffusion: latent diffusion (khử nhiễu trong không gian nén của VAE), điều kiện văn bản qua cross-attention, và classifier-free guidance.
  • Đếm được số tham số khi gắn LoRA vào attention của U-Net, và cài được một DDPM tí hon bằng PyTorch.

Nhỏ một giọt mực vào cốc nước. Lúc đầu bạn còn thấy rõ hình giọt mực. Vài giây sau nó loang ra. Vài phút sau, cả cốc chỉ còn một màu xám đều. Hiện tượng đó gọi là khuếch tán (diffusion), và nó chỉ đi một chiều: không ai thấy cốc nước xám tự gom lại thành giọt mực.

Mô hình khuếch tán làm điều tương tự với ảnh. Lấy một ảnh, cộng thêm một chút nhiễu ngẫu nhiên. Lặp lại vài trăm lần. Cuối cùng ảnh chỉ còn là nhiễu thuần, không ai đoán được ảnh gốc.

Phần thú vị là đi ngược lại. Mỗi bước thêm nhiễu chỉ làm hỏng ảnh một chút. Nhìn một ảnh hơi nhiễu, việc đoán “phần nào là nhiễu vừa thêm vào” không quá khó. Nếu dạy được một mạng làm việc nhỏ đó, ta cho nó chạy ngược từ nhiễu thuần: mỗi bước gỡ một chút nhiễu, và sau đủ số bước, một ảnh mới hiện ra.

GAN và VAE sinh ảnh trong một lần chạy mạng: từ z\mathbf{z} ra thẳng ảnh. Đó là một bước nhảy rất xa, từ nhiễu tới một ảnh hoàn chỉnh. GAN học bước nhảy đó bằng một cuộc đấu khó giữ cân bằng; VAE học bằng một hàm mất mát tái tạo nên ảnh dễ bị mờ.

Diffusion chia bước nhảy xa thành nghìn bước ngắn. Mỗi bước là một bài toán hồi quy bình thường: nhìn ảnh nhiễu, đoán nhiễu. Huấn luyện ổn định như học một mạng hồi quy, không có cuộc đấu nào. Đổi lại, sinh một ảnh phải chạy mạng nhiều lần.

Từ chữ số tới “phi hành gia cưỡi ngựa”

Phần tiêu đề “Từ chữ số tới “phi hành gia cưỡi ngựa””

Trên chữ số 8×88 \times 8, ý tưởng này chạy được ngay. Nhưng ảnh 512×512512 \times 512 màu có gần 800 nghìn số, và chạy mạng nghìn lần trên ngần ấy số rất tốn. Ta còn muốn điều khiển ảnh bằng một câu lệnh, như “một phi hành gia cưỡi ngựa”. Và muốn dạy mô hình một phong cách mới mà không huấn luyện lại gần một tỉ tham số.

Ba mong muốn đó dẫn tới ba mảnh của Stable Diffusion trong bài: khử nhiễu trong không gian nén, điều kiện bằng chữ, và LoRA.

Bài toán của bài này: làm sao dạy một mạng đảo ngược quá trình thêm nhiễu, để đi từ nhiễu thuần tới một ảnh mới; làm việc đó thế nào cho rẻ trên ảnh lớn, theo đúng một câu lệnh, và tinh chỉnh với rất ít tham số?

Ảnh gốc là x0\mathbf{x}_0, ảnh sau tt bước thêm nhiễu là xt\mathbf{x}_t, với t=1,…,Tt = 1, \dots, T. Ở bài này TT là số bước khuếch tán, không phải nhiệt độ TT khi lấy mẫu token ở phần LLM. Ở mỗi bước có một số nhỏ βt\beta_t gọi là phương sai nhiễu của bước đó; dãy β1,…,βT\beta_1, \dots, \beta_T gọi là lịch nhiễu (noise schedule). Ta đặt thêm:

αt=1−βt,αˉt=∏s=1tαs=α1α2⋯αt\alpha_t = 1 - \beta_t, \qquad \bar\alpha_t = \prod_{s=1}^{t} \alpha_s = \alpha_1 \alpha_2 \cdots \alpha_t

ϵ∼N(0,I)\boldsymbol\epsilon \sim \mathcal{N}(\mathbf{0}, \mathbf{I}) là nhiễu Gauss chuẩn cùng cỡ với ảnh: mỗi phần tử độc lập, trung bình 0, phương sai 1. DDPM (Ho, Jain, Abbeel, 2020) đưa điểm ảnh về đoạn [−1,1][-1, 1] trước, để ảnh và nhiễu có thang đo tương đương.

Một bước thêm nhiễu của DDPM là:

xt=αt xt−1+1−αt ϵt−1\mathbf{x}_t = \sqrt{\alpha_t}\,\mathbf{x}_{t-1} + \sqrt{1 - \alpha_t}\,\boldsymbol\epsilon_{t-1}

trong đó ϵt−1\boldsymbol\epsilon_{t-1} là một nhiễu chuẩn mới, độc lập với mọi bước trước. Đây là quá trình thuận (forward process). Nó không có gì để học: chỉ là công thức cố định.

Vì sao nhân ảnh với αt\sqrt{\alpha_t} chứ không giữ nguyên? Nếu mỗi phần tử của xt−1\mathbf{x}_{t-1} có phương sai 1, thì phần ảnh đóng góp phương sai αt\alpha_t, phần nhiễu đóng góp 1−αt1 - \alpha_t, cộng lại vẫn là 1. Thang đo của ảnh giữ nguyên qua mọi bước, không phình ra. Chỉ có tỉ lệ giữa “ảnh” và “nhiễu” thay đổi.

DDPM dùng T=1000T = 1000 và lịch tuyến tính: βt\beta_t tăng đều từ β1=10−4\beta_1 = 10^{-4} tới βT=0.02\beta_T = 0.02. Bước đầu thêm rất ít nhiễu, bước cuối thêm nhiều hơn.

Muốn có x500\mathbf{x}_{500}, phải thêm nhiễu 500 lần sao? Không cần. Ta dẫn một công thức đi thẳng từ x0\mathbf{x}_0.

Bước 1: thay một bước vào bước kế. Viết xt−1\mathbf{x}_{t-1} theo xt−2\mathbf{x}_{t-2} bằng chính công thức trên, rồi thay vào công thức của xt\mathbf{x}_t:

xt=αt(αt−1 xt−2+1−αt−1 ϵt−2)+1−αt ϵt−1=αtαt−1 xt−2+αt(1−αt−1) ϵt−2+1−αt ϵt−1\begin{aligned} \mathbf{x}_t &= \sqrt{\alpha_t}\Big(\sqrt{\alpha_{t-1}}\,\mathbf{x}_{t-2} + \sqrt{1-\alpha_{t-1}}\,\boldsymbol\epsilon_{t-2}\Big) + \sqrt{1-\alpha_t}\,\boldsymbol\epsilon_{t-1} \\ &= \sqrt{\alpha_t\alpha_{t-1}}\,\mathbf{x}_{t-2} + \sqrt{\alpha_t(1-\alpha_{t-1})}\,\boldsymbol\epsilon_{t-2} + \sqrt{1-\alpha_t}\,\boldsymbol\epsilon_{t-1} \end{aligned}

Dòng thứ hai chỉ là nhân αt\sqrt{\alpha_t} vào trong ngoặc.

Bước 2: gộp hai nhiễu thành một. Hai số hạng cuối là hai nhiễu Gauss độc lập, trung bình 0, phương sai lần lượt αt(1−αt−1)\alpha_t(1-\alpha_{t-1}) và 1−αt1-\alpha_t. Tổng của hai biến Gauss độc lập vẫn là Gauss, và phương sai của tổng bằng tổng phương sai:

αt(1−αt−1)+(1−αt)=1−αtαt−1\alpha_t(1-\alpha_{t-1}) + (1-\alpha_t) = 1 - \alpha_t\alpha_{t-1}

Vậy hai số hạng đó có cùng phân phối với 1−αtαt−1 ϵˉ\sqrt{1-\alpha_t\alpha_{t-1}}\,\bar{\boldsymbol\epsilon}, với ϵˉ\bar{\boldsymbol\epsilon} là một nhiễu chuẩn duy nhất:

xt=αtαt−1 xt−2+1−αtαt−1 ϵˉ\mathbf{x}_t = \sqrt{\alpha_t\alpha_{t-1}}\,\mathbf{x}_{t-2} + \sqrt{1-\alpha_t\alpha_{t-1}}\,\bar{\boldsymbol\epsilon}

Bước 3: lặp lại tới x0\mathbf{x}_0. Công thức vừa có giống hệt công thức một bước, chỉ thay αt\alpha_t bằng tích αtαt−1\alpha_t\alpha_{t-1}. Thay tiếp xt−2\mathbf{x}_{t-2}, rồi xt−3\mathbf{x}_{t-3}, mỗi lần tích dài thêm một thừa số. Tới x0\mathbf{x}_0, tích là αˉt\bar\alpha_t:

xt=αˉt x0+1−αˉt ϵ,ϵ∼N(0,I)\mathbf{x}_t = \sqrt{\bar\alpha_t}\,\mathbf{x}_0 + \sqrt{1-\bar\alpha_t}\,\boldsymbol\epsilon, \qquad \boldsymbol\epsilon \sim \mathcal{N}(\mathbf{0}, \mathbf{I})

Đây là kết quả chính của quá trình thuận. Với lịch của DDPM (số lấy từ ô NumPy bên dưới):

ttαˉt\sqrt{\bar\alpha_t} (phần ảnh)1−αˉt\sqrt{1-\bar\alpha_t} (phần nhiễu)
1000.94710.3209
2500.72390.6899
5000.28030.9599
10000.00641.0000

Ở t=1000t = 1000, ảnh chỉ còn hệ số 0.0064: xT\mathbf{x}_T gần như là nhiễu chuẩn thuần. Đó là lý do việc sinh ảnh có thể bắt đầu từ một nhiễu chuẩn bất kỳ.

Nhìn lại dạng đóng. Nếu biết xt\mathbf{x}_t, biết tt, và biết đúng nhiễu ϵ\boldsymbol\epsilon đã cộng vào, ta giải ra được ảnh gốc:

x0=xt−1−αˉt ϵαˉt\mathbf{x}_0 = \frac{\mathbf{x}_t - \sqrt{1-\bar\alpha_t}\,\boldsymbol\epsilon}{\sqrt{\bar\alpha_t}}

(chuyển số hạng nhiễu sang vế trái rồi chia cho αˉt\sqrt{\bar\alpha_t}). Vậy “đoán nhiễu” và “đoán ảnh sạch” là hai cách nói của cùng một việc. DDPM chọn cho mạng đoán nhiễu. Gọi mạng đó là ϵθ(xt,t)\boldsymbol\epsilon_\theta(\mathbf{x}_t, t): nhận ảnh nhiễu và số bước, trả ra một tensor cùng cỡ ảnh. Mạng biết tt nên biết ảnh đang nhiễu nhiều hay ít.

Ho và cộng sự dẫn hàm mất mát từ một cận dưới của log-likelihood (mục 3 của paper), rồi thấy dạng rút gọn, bỏ các trọng số, cho ảnh đẹp hơn. Dạng rút gọn đó chỉ là MSE giữa nhiễu thật và nhiễu đoán:

Lsimple=Ex0, t, ϵ[∥ϵ−ϵθ(αˉt x0+1−αˉt ϵ,  t)∥2]\mathcal{L}_{\text{simple}} = \mathbb{E}_{\mathbf{x}_0,\, t,\, \boldsymbol\epsilon}\Big[\big\lVert \boldsymbol\epsilon - \boldsymbol\epsilon_\theta\big(\sqrt{\bar\alpha_t}\,\mathbf{x}_0 + \sqrt{1-\bar\alpha_t}\,\boldsymbol\epsilon,\; t\big)\big\rVert^2\Big]

Một bước huấn luyện (Thuật toán 1 của paper) vì thế rất gọn: lấy một ảnh x0\mathbf{x}_0, chọn tt ngẫu nhiên đều trong 1,…,T1, \dots, T, sinh một ϵ\boldsymbol\epsilon, tạo xt\mathbf{x}_t bằng dạng đóng, rồi đi một bước gradient descent trên ∥ϵ−ϵθ(xt,t)∥2\lVert \boldsymbol\epsilon - \boldsymbol\epsilon_\theta(\mathbf{x}_t, t)\rVert^2. Dạng đóng là thứ làm việc này rẻ: mỗi mẫu chỉ cần một phép cộng, dù t=900t = 900.

Trong DDPM, ϵθ\boldsymbol\epsilon_\theta là một U-Net: mạng tích chập có nhánh co và nhánh giãn đối xứng, đầu ra cùng cỡ đầu vào (xem bài Semantic segmentation). Số bước tt được đổi thành một vectơ kiểu embedding vị trí hình sin của Transformer rồi cộng vào từng khối của mạng. Một mạng dùng chung cho mọi tt.

Đã có ϵθ\boldsymbol\epsilon_\theta, ta sinh ảnh mới theo Thuật toán 2 của paper. Bắt đầu từ xT∼N(0,I)\mathbf{x}_T \sim \mathcal{N}(\mathbf{0}, \mathbf{I}), rồi với t=T,T−1,…,1t = T, T-1, \dots, 1:

xt−1=1αt(xt−βt1−αˉt ϵθ(xt,t))+σt z\mathbf{x}_{t-1} = \frac{1}{\sqrt{\alpha_t}}\left(\mathbf{x}_t - \frac{\beta_t}{\sqrt{1-\bar\alpha_t}}\,\boldsymbol\epsilon_\theta(\mathbf{x}_t, t)\right) + \sigma_t\,\mathbf{z}

với z∼N(0,I)\mathbf{z} \sim \mathcal{N}(\mathbf{0}, \mathbf{I}) khi t>1t > 1 và z=0\mathbf{z} = \mathbf{0} ở bước cuối. Đọc công thức từ trong ra ngoài:

  • Trừ đi một phần nhiễu dự đoán. Mạng đoán toàn bộ nhiễu tích lũy ϵ\boldsymbol\epsilon; bước này chỉ gỡ phần ứng với một bước, nên nhân với hệ số nhỏ βt/1−αˉt\beta_t/\sqrt{1-\bar\alpha_t}.
  • Chia cho αt\sqrt{\alpha_t}. Quá trình thuận đã nhân ảnh với αt\sqrt{\alpha_t}; chia ra là trả lại thang đo cũ.
  • Cộng thêm một chút nhiễu mới σtz\sigma_t\mathbf{z}. Từ một xt\mathbf{x}_t có nhiều xt−1\mathbf{x}_{t-1} hợp lý, không chỉ một. Thêm nhiễu là lấy mẫu một trong số đó, giữ cho ảnh đa dạng. Paper thử σt2=βt\sigma_t^2 = \beta_t và một lựa chọn khác, thấy kết quả tương tự.

Phần trong ngoặc là trung bình của bước ngược mà paper dẫn ra (phương trình 11), bài này không dẫn lại chi tiết. Điều cần nhớ: mỗi bước gọi mạng một lần, nên lấy mẫu DDPM tốn T=1000T = 1000 lần chạy U-Net cho một ảnh.

DDIM: ít bước hơn, không cần huấn luyện lại

Phần tiêu đề “DDIM: ít bước hơn, không cần huấn luyện lại”

1000 lần chạy mạng là chậm. DDIM (Song, Meng, Ermon, 2021) giữ nguyên mạng ϵθ\boldsymbol\epsilon_\theta đã huấn luyện, chỉ đổi cách đi ngược. Ở bước tt, nó ước lượng ảnh sạch bằng công thức giải ngược ở trên:

x^0=xt−1−αˉt ϵθ(xt,t)αˉt\hat{\mathbf{x}}_0 = \frac{\mathbf{x}_t - \sqrt{1-\bar\alpha_t}\,\boldsymbol\epsilon_\theta(\mathbf{x}_t, t)}{\sqrt{\bar\alpha_t}}

rồi dùng lại chính dạng đóng để nhảy tới một bước t′<tt' < t bất kỳ, với nhiễu là nhiễu vừa đoán:

xt′=αˉt′ x^0+1−αˉt′ ϵθ(xt,t)\mathbf{x}_{t'} = \sqrt{\bar\alpha_{t'}}\,\hat{\mathbf{x}}_0 + \sqrt{1-\bar\alpha_{t'}}\,\boldsymbol\epsilon_\theta(\mathbf{x}_t, t)

Đây là bản tất định của DDIM: không cộng nhiễu mới. Vì t′t' có thể cách tt hàng chục bước, chỉ cần khoảng 50 bước thay vì 1000. Paper báo cáo DDIM cho ảnh chất lượng cao nhanh hơn DDPM 10 tới 50 lần tính theo thời gian chạy.

Latent diffusion: khử nhiễu trong không gian nén

Phần tiêu đề “Latent diffusion: khử nhiễu trong không gian nén”

Ảnh 512×512×3512 \times 512 \times 3 có 786 432786\,432 số. Chạy U-Net hàng chục lần trên ngần ấy số, cho mỗi ảnh, là đắt; huấn luyện còn đắt hơn. Phần lớn số đó mô tả chi tiết nhỏ mà mắt khó nhận ra.

Rombach và cộng sự (2022) đề xuất latent diffusion: tách việc thành hai phần.

  1. Một autoencoder, huấn luyện một lần. Bộ mã hóa E\mathcal{E} nén ảnh x\mathbf{x} thành latent z=E(x)\mathbf{z} = \mathcal{E}(\mathbf{x}) nhỏ hơn nhiều, bộ giải mã D\mathcal{D} dựng lại ảnh. Autoencoder chính của paper có một phạt KL nhẹ về phía phân phối chuẩn, họ hàng gần của VAE ở bài trước.
  2. Diffusion chạy trên latent. Mọi công thức ở trên giữ nguyên, chỉ thay ảnh x0\mathbf{x}_0 bằng latent z0\mathbf{z}_0. Hàm mất mát là E[∥ϵ−ϵθ(zt,t)∥2]\mathbb{E}\big[\lVert \boldsymbol\epsilon - \boldsymbol\epsilon_\theta(\mathbf{z}_t, t)\rVert^2\big]. Sinh xong latent thì cho qua D\mathcal{D} một lần để ra ảnh.

Ở Stable Diffusion v1, autoencoder có hệ số thu nhỏ f=8f = 8 và latent 4 kênh: ảnh H×W×3H \times W \times 3 thành latent H/8×W/8×4H/8 \times W/8 \times 4. Với ảnh 512×512×3512 \times 512 \times 3:

512⋅512⋅364⋅64⋅4=786 43216 384=48\frac{512 \cdot 512 \cdot 3}{64 \cdot 64 \cdot 4} = \frac{786\,432}{16\,384} = 48

Diffusion làm việc trên một tensor nhỏ hơn 48 lần (tự tính). Paper thử ff từ 1 tới 32 và thấy f=4f = 4 và f=8f = 8 cân bằng tốt nhất giữa chất lượng và chi phí; ff quá lớn làm mất chi tiết.

Để ảnh theo câu lệnh yy, latent diffusion cho câu lệnh qua một bộ mã hóa chữ τθ\tau_\theta, rồi chèn kết quả vào U-Net bằng cross-attention. Gọi φ(zt)\boldsymbol\varphi(\mathbf{z}_t) là đặc trưng của U-Net ở một tầng, trải phẳng thành dãy vị trí. Một lớp cross-attention tính:

Q=φ(zt) WQ,K=τθ(y) WK,V=τθ(y) WV\mathbf{Q} = \boldsymbol\varphi(\mathbf{z}_t)\,\mathbf{W}_Q, \quad \mathbf{K} = \tau_\theta(y)\,\mathbf{W}_K, \quad \mathbf{V} = \tau_\theta(y)\,\mathbf{W}_V

rồi softmax(QK⊤/d) V\mathrm{softmax}(\mathbf{Q}\mathbf{K}^\top/\sqrt{d})\,\mathbf{V} như mọi attention. Truy vấn đến từ ảnh, khóa và giá trị đến từ chữ: mỗi vị trí trong latent “hỏi” các token của câu lệnh xem mình nên vẽ gì. Paper viết các tích này theo cột (WQ φ\mathbf{W}_Q\,\boldsymbol\varphi); bài viết theo hàng cho khớp với bài Multimodal.

Trong paper, mô hình chữ-ra-ảnh dùng một Transformer học cùng U-Net làm τθ\tau_\theta. Stable Diffusion v1 (2022), xây trên latent diffusion, thay nó bằng bộ mã hóa chữ của CLIP ViT-L/14 và đóng băng bộ mã hóa này. Theo README chính thức, mô hình có U-Net 860 triệu tham số và bộ mã hóa chữ 123 triệu tham số, huấn luyện trên một tập con của LAION-5B, trước ở 256×256256 \times 256 rồi tinh chỉnh ở 512×512512 \times 512. Vì CLIP đã học ghép ảnh với chữ (bài CLIP, BLIP, GLIP), đặc trưng chữ của nó vốn mang nghĩa “trông như thế nào”.

Mô hình có điều kiện thường “nghe lời” chưa đủ: ảnh hợp lý, nhưng không bám sát câu lệnh. Classifier-free guidance (Ho và Salimans, 2022) khuếch đại ảnh hưởng của câu lệnh mà không cần thêm mạng nào.

Khi huấn luyện: thỉnh thoảng thay câu lệnh bằng một câu lệnh rỗng ∅\varnothing (bản Stable Diffusion v1-4 bỏ điều kiện 10% số lần). Nhờ vậy một mạng học được hai việc: đoán nhiễu khi có câu lệnh, ϵθ(xt,c)\boldsymbol\epsilon_\theta(\mathbf{x}_t, \mathbf{c}), và khi không có, ϵθ(xt,∅)\boldsymbol\epsilon_\theta(\mathbf{x}_t, \varnothing).

Khi lấy mẫu: mỗi bước chạy mạng hai lần rồi trộn. Paper viết với trọng số ww:

ϵ~=(1+w) ϵθ(xt,c)−w ϵθ(xt,∅)\tilde{\boldsymbol\epsilon} = (1+w)\,\boldsymbol\epsilon_\theta(\mathbf{x}_t, \mathbf{c}) - w\,\boldsymbol\epsilon_\theta(\mathbf{x}_t, \varnothing)

Thêm rồi bớt ϵθ(xt,∅)\boldsymbol\epsilon_\theta(\mathbf{x}_t, \varnothing), rồi gom theo (1+w)(1+w), ta được dạng mà thư viện hay dùng, với hệ số guidance s=1+ws = 1 + w:

ϵ~=ϵθ(xt,∅)+(1+w) ϵθ(xt,c)−(1+w) ϵθ(xt,∅)=ϵθ(xt,∅)+s (ϵθ(xt,c)−ϵθ(xt,∅))\begin{aligned} \tilde{\boldsymbol\epsilon} &= \boldsymbol\epsilon_\theta(\mathbf{x}_t, \varnothing) + (1+w)\,\boldsymbol\epsilon_\theta(\mathbf{x}_t, \mathbf{c}) - (1+w)\,\boldsymbol\epsilon_\theta(\mathbf{x}_t, \varnothing) \\ &= \boldsymbol\epsilon_\theta(\mathbf{x}_t, \varnothing) + s\,\big(\boldsymbol\epsilon_\theta(\mathbf{x}_t, \mathbf{c}) - \boldsymbol\epsilon_\theta(\mathbf{x}_t, \varnothing)\big) \end{aligned}

Hiệu trong ngoặc là “hướng mà câu lệnh kéo ảnh đi”. s=0s = 0 là sinh không điều kiện, s=1s = 1 là dùng đúng dự đoán có điều kiện, s>1s > 1 đi xa hơn theo hướng đó. Script lấy mẫu của Stable Diffusion v1 mặc định s=7.5s = 7.5. Paper cho thấy tăng guidance đổi độ đa dạng lấy độ bám và độ rõ của từng ảnh: quá mạnh thì ảnh kém đa dạng.

Bạn muốn mô hình vẽ theo phong cách tranh của mình, hay vẽ đúng con mèo nhà bạn. Fine-tune cả U-Net 860 triệu tham số thì tốn bộ nhớ, và mỗi phong cách là một bản sao vài GB.

LoRA (Hu và cộng sự, 2022) ra đời cho LLM, đã dẫn chi tiết ở bài Đánh giá mô hình và PEFT. Nhắc lại: với một ma trận đóng băng W0∈Rdout×din\mathbf{W}_0 \in \mathbb{R}^{d_{\text{out}} \times d_{\text{in}}}, LoRA học thêm B∈Rdout×r\mathbf{B} \in \mathbb{R}^{d_{\text{out}} \times r} và A∈Rr×din\mathbf{A} \in \mathbb{R}^{r \times d_{\text{in}}} hạng rr nhỏ:

h=W0x+αr BAx\mathbf{h} = \mathbf{W}_0\mathbf{x} + \frac{\alpha}{r}\,\mathbf{B}\mathbf{A}\mathbf{x}

mỗi ma trận học r(din+dout)r(d_{\text{in}} + d_{\text{out}}) số, và B=0\mathbf{B} = \mathbf{0} lúc đầu nên mô hình xuất phát đúng từ mô hình gốc.

Với Stable Diffusion, chỗ tự nhiên để gắn LoRA là attention của U-Net: bốn phép chiếu truy vấn, khóa, giá trị và đầu ra (to_q, to_k, to_v, to_out) của mọi lớp self-attention và cross-attention. Script huấn luyện LoRA của thư viện diffusers làm đúng như vậy, hạng mặc định r=4r = 4. Cross-attention là nơi chữ gặp ảnh, nên chỉnh nó là chỉnh cách mô hình hiểu câu lệnh thành hình.

Đếm thử một lớp cross-attention ở tầng d=320d = 320 kênh, đặc trưng chữ 768 chiều, r=4r = 4: to_q và to_out là 320→320320 \to 320, mỗi cái 4⋅640=2 5604 \cdot 640 = 2\,560 số; to_k và to_v là 768→320768 \to 320, mỗi cái 4⋅1 088=4 3524 \cdot 1\,088 = 4\,352 số. Cộng lại 13 82413\,824 số. Trên cả 32 lớp attention của U-Net, ô PyTorch bên dưới đếm được 797 184797\,184 số, khoảng 0.09% của U-Net. Lưu ở 32 bit là khoảng 3.2 MB (tự tính), thay vì vài GB.

DreamBooth (Ruiz và cộng sự, 2023) là một cách tinh chỉnh khác: dạy mô hình một đối tượng cụ thể từ vài ảnh, gắn nó với một từ định danh hiếm trong câu lệnh. DreamBooth gốc tinh chỉnh cả mô hình; trong thực tế người ta hay ghép nó với LoRA để rẻ hơn.

  • Lấy mẫu chậm. Mỗi bước một lần chạy mạng, hai lần nếu có guidance. DDIM giảm còn khoảng 50 bước; còn nhiều cách nhanh hơn nữa ngoài phạm vi bài.
  • Lịch nhiễu không phải luật cố định. Stable Diffusion v1 dùng lịch khác lịch 10−4→0.0210^{-4} \to 0.02 của DDPM (hộp “Lỗi hay gặp” bên dưới); ô PyTorch của bài cũng phải đổi lịch vì TT nhỏ.
  • Chất lượng phụ thuộc autoencoder. Chi tiết bị mất khi nén thì diffusion không lấy lại được, ví dụ chữ nhỏ hay khuôn mặt xa.

Hình dưới cho thấy dạng đóng của quá trình thuận trên một ảnh nhỏ. Thanh trượt chọn bước tt; ảnh xt\mathbf{x}_t được trộn từ ảnh gốc và một nhiễu cố định, nên bạn thấy chính ảnh đó chìm dần vào cùng một lớp nhiễu. Đồ thị nhỏ vẽ hai hệ số αˉt\sqrt{\bar\alpha_t} và 1−αˉt\sqrt{1-\bar\alpha_t} theo tt, có dấu ở bước hiện tại. Hãy thử:

  1. Đặt t=100t = 100: ảnh vẫn rõ, hệ số ảnh là 0.9471, hệ số nhiễu 0.3209.
  2. Kéo tới t=250t = 250: hai hệ số gần bằng nhau (0.7239 và 0.6899), hai đường trên đồ thị sắp cắt nhau. Ảnh vẫn nhận ra được nhưng đã lốm đốm.
  3. Kéo tới t=500t = 500: hệ số ảnh chỉ còn 0.2803. Nhìn kỹ mới thấy bóng của hình.
  4. Kéo hết tới t=1000t = 1000: hệ số ảnh 0.0064, ảnh là nhiễu thuần. Đây là điểm xuất phát khi sinh ảnh.

Bảng dưới đặt ba họ mô hình sinh ảnh cạnh nhau. Hãy nhìn hai dòng “Huấn luyện” và “Sinh một ảnh”: diffusion đổi tốc độ sinh lấy sự ổn định khi học.

Tiêu chíVAEGANDiffusion
Ý tưởngNén ảnh thành z\mathbf{z} rồi giải mãGG sinh ảnh giả, DD phân biệt thật giảThêm nhiễu từng bước, học gỡ nhiễu ngược lại
Huấn luyệnỔn định: tái tạo cộng KLKhó giữ cân bằng giữa GG và DDỔn định: MSE trên nhiễu
Sinh một ảnhMột lần chạy bộ giải mãMột lần chạy GGHàng chục tới 1000 lần chạy mạng
Chất lượng ảnhThường hơi mờSắc nétSắc nét
Đa dạngTốtDễ sụp mode (mode collapse)Tốt
Vai trò trong Stable DiffusionAutoencoder nén ảnh thành latentKhông dùngU-Net khử nhiễu trên latent

Code trong bài chạy thử lần cuối tháng 11/2026: ô NumPy với Python 3.14, NumPy 2.4, scikit-learn 1.8 và matplotlib (bản có trong Pyodide); hai ô PyTorch với Python 3.14, PyTorch 2.15 và scikit-learn 1.9 trên CPU. Ô NumPy chạy ngay trên trang, hai ô PyTorch chạy trên máy bạn. Các ô độc lập, không dùng biến của nhau. Ô NumPy lần đầu chạy lâu hơn, vì trình duyệt phải tải thêm scikit-learn và matplotlib.

Ô dưới đây lấy chữ số đầu tiên của load_digits (một số 0, ảnh 8×88 \times 8), đưa về [−1,1][-1, 1], rồi dùng dạng đóng với lịch tuyến tính của DDPM để tạo xt\mathbf{x}_t ở t=0,100,250,500,1000t = 0, 100, 250, 500, 1000, cùng một nhiễu ϵ\boldsymbol\epsilon. Phần cuối kiểm lại phép dẫn: thêm nhiễu từng bước 500 lần cho 20 000 bản sao của một điểm ảnh x0=1x_0 = 1, rồi so trung bình và độ lệch chuẩn với dạng đóng.

Thêm nhiễu theo dạng đóng của DDPM

import matplotlib.pyplot as plt
import numpy as np
from sklearn.datasets import load_digits
# Ảnh x_0: chữ số đầu tiên (số 0), đưa từ [0, 16] về [-1, 1] như DDPM
x0 = load_digits().images[0] / 8.0 - 1.0
# Lịch tuyến tính của DDPM: beta_1 = 1e-4 tới beta_T = 0.02, T = 1000
T = 1000
betas = np.linspace(1e-4, 0.02, T)
alpha_bar = np.cumprod(1.0 - betas) # alpha_bar[t - 1] ứng với bước t
rng = np.random.default_rng(0)
eps = rng.standard_normal(x0.shape) # một nhiễu chung cho mọi t
ts = [0, 100, 250, 500, 1000]
fig, axes = plt.subplots(1, len(ts), figsize=(10, 2.4))
for ax, t in zip(axes, ts):
ab = 1.0 if t == 0 else alpha_bar[t - 1] # t = 0: ảnh gốc
xt = np.sqrt(ab) * x0 + np.sqrt(1 - ab) * eps # dạng đóng
print(f"t={t:4d} sqrt(ab)={np.sqrt(ab):.4f} "
f"sqrt(1-ab)={np.sqrt(1 - ab):.4f}")
ax.imshow(xt, cmap="gray", vmin=-2.5, vmax=2.5)
ax.set_title(f"t = {t}")
ax.axis("off")
plt.show()
# Kiểm dạng đóng: thêm nhiễu từng bước, 20000 lần, cho một pixel x0 = 1
x = np.ones(20000)
for b in betas[:500]:
x = np.sqrt(1 - b) * x + np.sqrt(b) * rng.standard_normal(x.size)
print(f"step by step, t=500: mean={x.mean():.4f} std={x.std():.4f}")
print(f"closed form, t=500: mean={np.sqrt(alpha_bar[499]):.4f} "
f"std={np.sqrt(1 - alpha_bar[499]):.4f}")

The first run downloads Python, about 10 MB.

Đọc kết quả:

  • Năm dòng đầu là bảng ở mục Lý thuyết. Hai hệ số cắt nhau ngay sau t=250t = 250; tới t=1000t = 1000 phần ảnh chỉ còn 0.0064.
  • Hình: cùng một nhiễu, nên từ trái sang phải bạn thấy đúng một chữ số chìm dần, như trong VIZ.
  • Hai dòng cuối: thêm nhiễu từng bước cho trung bình 0.2742 và độ lệch chuẩn 0.9624; dạng đóng cho 0.2803 và 0.9599. Chênh lệch nằm trong sai số của 20 000 mẫu (độ lệch chuẩn của trung bình mẫu khoảng 0.96/20 000≈0.0070.96/\sqrt{20\,000} \approx 0.007). 500 bước nhỏ thật sự gộp được thành một bước.

Ảnh thật cần U-Net và GPU. Để thấy cả vòng huấn luyện và lấy mẫu chạy trên CPU, file dưới đây thay ảnh bằng điểm hai chiều: 2000 điểm hình hai nửa vầng trăng (make_moons), chuẩn hóa về trung bình 0, độ lệch chuẩn 1. Mạng ϵθ\boldsymbol\epsilon_\theta là một MLP nhận (x,y,t/T)(x, y, t/T) và trả về 2 số. Vòng huấn luyện là Thuật toán 1, vòng lấy mẫu là Thuật toán 2 với σt2=βt\sigma_t^2 = \beta_t, đúng các công thức ở trên.

Vì chỉ có T=100T = 100 bước, lịch được nhân 10 lần: βt\beta_t từ 10−310^{-3} tới 0.20.2. Giữ lịch gốc với T=100T = 100 thì αˉT\bar\alpha_T chỉ khoảng 0.36, xT\mathbf{x}_T còn rất nhiều dữ liệu, và việc bắt đầu lấy mẫu từ nhiễu chuẩn sẽ sai. Cách cài PyTorch bản CPU có ở bài Xây dựng LLM đầu tiên.

Run on your machine

DDPM tí hon: huấn luyện và lấy mẫu trên hai nửa vầng trăng

Install
pip install torch scikit-learn
Hardware
CPU là đủ, không cần GPU. Khoảng 2 phút trên máy phát triển, có thể lâu hơn trên máy chậm hoặc đang bận
import torch
from sklearn.datasets import make_moons
from torch import nn
torch.manual_seed(0)
# Dữ liệu thật: 2000 điểm hình hai nửa vầng trăng, chuẩn hóa về trung bình 0
x, _ = make_moons(2000, noise=0.05, random_state=0)
data = torch.tensor(x, dtype=torch.float32)
data = (data - data.mean(0)) / data.std(0)
# T = 100 bước nên beta lớn hơn DDPM gốc 10 lần: 1e-3 tới 0.2
T = 100
betas = torch.linspace(1e-3, 0.2, T)
alphas = 1 - betas
abar = torch.cumprod(alphas, 0)
# eps_theta(x_t, t): MLP nhận (x, y, t / T), trả về nhiễu dự đoán (2 số)
net = nn.Sequential(nn.Linear(3, 128), nn.ReLU(), nn.Linear(128, 128),
nn.ReLU(), nn.Linear(128, 128), nn.ReLU(),
nn.Linear(128, 2))
eps_theta = lambda xt, t: net(torch.cat([xt, t[:, None] / T], 1))
opt = torch.optim.Adam(net.parameters(), lr=1e-3)
for step in range(5001): # Thuật toán 1 của DDPM
x0 = data[torch.randint(0, len(data), (256,))]
t = torch.randint(0, T, (256,)) # chỉ số 0..T-1 ứng với bước 1..T
eps = torch.randn_like(x0)
a = abar[t][:, None]
xt = a.sqrt() * x0 + (1 - a).sqrt() * eps
loss = ((eps - eps_theta(xt, t)) ** 2).mean() # L_simple
opt.zero_grad()
loss.backward()
opt.step()
if step % 1000 == 0:
print(f"step {step:4d} loss {loss.item():.3f}")
with torch.no_grad(): # Thuật toán 2: đi ngược từ nhiễu thuần
noise = torch.randn(1000, 2)
x = noise.clone()
for i in reversed(range(T)):
t = torch.full((1000,), i)
e = eps_theta(x, t)
x = (x - betas[i] / (1 - abar[i]).sqrt() * e) / alphas[i].sqrt()
if i > 0:
x = x + betas[i].sqrt() * torch.randn_like(x)
# Khoảng cách từ mỗi điểm tới điểm thật gần nhất
gap = lambda p: torch.cdist(p, data).min(1).values
for name, p in [("noise x_T", noise), ("samples x_0", x)]:
d = gap(p)
print(f"{name}: mean dist {d.mean():.3f}, "
f"within 0.1: {(d < 0.1).float().mean():.0%}")

Sample output

From one real run. A model gives a different answer each time.

step 0 loss 0.895
step 1000 loss 0.192
step 2000 loss 0.214
step 3000 loss 0.236
step 4000 loss 0.197
step 5000 loss 0.243
noise x_T: mean dist 0.262, within 0.1: 35%
samples x_0: mean dist 0.042, within 0.1: 90%

Đọc kết quả:

  • Loss giảm nhanh từ 0.895 xuống quanh 0.2 rồi dao động. Loss không về 0, và điều đó là bình thường: ở tt lớn, xt\mathbf{x}_t gần như toàn nhiễu, không mạng nào đoán đúng được chính xác nhiễu nào đã cộng vào. Mỗi lô lấy tt ngẫu nhiên, nên loss nhảy lên xuống theo lô.
  • Hai dòng cuối đo khoảng cách từ mỗi điểm tới điểm thật gần nhất. 1000 điểm nhiễu ban đầu xT\mathbf{x}_T cách trung bình 0.262, chỉ 35% nằm trong bán kính 0.1 của dữ liệu thật (con số này không nhỏ vì hai nửa vầng trăng phủ khá rộng vùng giữa). Sau 100 bước đi ngược, khoảng cách còn 0.042 và 90% điểm nằm sát dữ liệu: các điểm đã gom lại thành hai nửa vầng trăng.

Số của bạn có thể lệch một chút so với kết quả mẫu, tùy phiên bản PyTorch và CPU.

File thứ hai tự viết một lớp LoRALinear đúng công thức W0x+αrBAx\mathbf{W}_0\mathbf{x} + \frac{\alpha}{r}\mathbf{B}\mathbf{A}\mathbf{x}, gắn vào bốn phép chiếu của một lớp attention có cỡ như U-Net của Stable Diffusion v1, rồi đếm tham số cho cả 32 lớp attention (16 khối Transformer, mỗi khối một self-attention và một cross-attention). Không tải mô hình nào, chỉ dựng các lớp tuyến tính đúng cỡ.

Run on your machine

Tham số LoRA trong attention của U-Net

Install
pip install torch
Hardware
CPU là đủ. Vài giây, phần lớn là thời gian nạp PyTorch
import torch
from torch import nn
torch.manual_seed(0)
class LoRALinear(nn.Module):
"""h = W0 x + (alpha / r) B A x, W0 đóng băng."""
def __init__(self, base, r=4, alpha=4):
super().__init__()
self.base = base.requires_grad_(False)
self.A = nn.Parameter(torch.randn(r, base.in_features) * 0.01)
self.B = nn.Parameter(torch.zeros(base.out_features, r)) # B = 0
self.scale = alpha / r
def forward(self, x):
return self.base(x) + self.scale * (x @ self.A.T) @ self.B.T
def attention(d, ctx):
# Bốn phép chiếu của một lớp attention trong U-Net của Stable Diffusion
# v1: q từ ảnh (d chiều), k và v từ chữ (ctx chiều), out về lại d chiều
return nn.ModuleDict({"to_q": nn.Linear(d, d, bias=False),
"to_k": nn.Linear(ctx, d, bias=False),
"to_v": nn.Linear(ctx, d, bias=False),
"to_out": nn.Linear(d, d)})
def count(d, ctx):
att = attention(d, ctx)
full = sum(p.numel() for p in att.parameters())
for name in list(att.keys()):
att[name] = LoRALinear(att[name])
lora = sum(p.numel() for p in att.parameters() if p.requires_grad)
return att, full, lora
att, full, lora = count(320, 768) # cross-attention ở tầng 320 kênh
print(f"cross-attn d=320: full {full}, LoRA r=4: {lora}")
x = torch.randn(2, 4096, 320) # 64 x 64 vị trí của latent, 2 ảnh
print("same output at init:",
torch.equal(att["to_q"](x), att["to_q"].base(x)))
# U-Net SD v1 có 16 khối Transformer, mỗi khối 1 self-attn (ctx = d)
# và 1 cross-attn (ctx = 768). Số kênh d của 16 khối:
widths = [320] * 2 + [640] * 2 + [1280] * 2 + [1280] + \
[1280] * 3 + [640] * 3 + [320] * 3
total = sum(count(d, d)[2] + count(d, 768)[2] for d in widths)
print(f"LoRA r=4 on all 32 attention layers: {total}")
print(f"share of an 860M U-Net: {total / 860e6:.3%}")

Sample output

From one real run.

cross-attn d=320: full 696640, LoRA r=4: 13824
same output at init: True
LoRA r=4 on all 32 attention layers: 797184
share of an 860M U-Net: 0.093%

Đọc kết quả:

  • full 696640, LoRA r=4: 13824: lớp cross-attention có gần 700 nghìn tham số (nhiều nhất ở to_k, to_v vì nhận 768 chiều), LoRA chỉ học 13 824, khớp phép đếm tay ở mục Lý thuyết.
  • same output at init: True: B=0\mathbf{B} = \mathbf{0} nên lúc đầu lớp LoRA cho đúng đầu ra của lớp gốc, trên một latent 64×6464 \times 64 (4 096 vị trí).
  • 797184 và 0.093%: cả U-Net chỉ học khoảng 0.8 triệu số. Con số này trùng với số tham số mà thư viện peft báo khi gắn LoRA r=4r = 4 vào U-Net dựng theo cấu hình Stable Diffusion v1 trong diffusers (kiểm riêng, không nằm trong bài).
Question 1 of 5 Khi huấn luyện DDPM với T=1000T = 1000, một lô cần ảnh nhiễu x700\mathbf{x}_{700} của mỗi ảnh gốc. Cách làm đúng và rẻ nhất là gì?
Question 2 of 5 Autoencoder của Stable Diffusion v1 có hệ số thu nhỏ f=8f = 8 và latent 4 kênh. Bạn sinh ảnh 768×768×3768 \times 768 \times 3. Latent lớn bao nhiêu, và nhỏ hơn ảnh bao nhiêu lần?
Question 3 of 5 Với classifier-free guidance ϵ~=ϵθ(xt,∅)+s (ϵθ(xt,c)−ϵθ(xt,∅))\tilde{\boldsymbol\epsilon} = \boldsymbol\epsilon_\theta(\mathbf{x}_t, \varnothing) + s\,\big(\boldsymbol\epsilon_\theta(\mathbf{x}_t, \mathbf{c}) - \boldsymbol\epsilon_\theta(\mathbf{x}_t, \varnothing)\big), đặt s=1s = 1 thì sao?
Question 4 of 5 Một lớp cross-attention trong U-Net có d=640d = 640 kênh, đặc trưng chữ có 768 chiều. Bạn gắn LoRA hạng r=4r = 4 chỉ vào hai phép chiếu to_k và to_v (mỗi phép biến 768 chiều thành 640 chiều). Có bao nhiêu tham số được huấn luyện?
Question 5 of 5 Bạn đã huấn luyện xong một DDPM với T=1000T = 1000. Muốn sinh ảnh bằng DDIM chỉ 50 bước, bạn cần gì?

Ý chính:

  • Diffusion thêm nhiễu Gauss vào ảnh qua TT bước nhỏ (ở đây TT là số bước, không phải nhiệt độ), rồi học đi ngược. Nhờ tổng các Gauss độc lập vẫn là Gauss, ta nhảy thẳng tới bước bất kỳ: xt=αˉt x0+1−αˉt ϵ\mathbf{x}_t = \sqrt{\bar\alpha_t}\,\mathbf{x}_0 + \sqrt{1-\bar\alpha_t}\,\boldsymbol\epsilon.
  • DDPM huấn luyện mạng ϵθ(xt,t)\boldsymbol\epsilon_\theta(\mathbf{x}_t, t) đoán nhiễu bằng MSE, rồi sinh ảnh bằng 1000 bước ngược từ nhiễu thuần. DDIM dùng cùng mạng, nhảy qua nhiều bước, chỉ cần khoảng 50.
  • Stable Diffusion chạy diffusion trên latent của một autoencoder (ảnh 512×512×3512 \times 512 \times 3 thành latent 64×64×464 \times 64 \times 4, nhỏ hơn 48 lần), nhận câu lệnh qua bộ mã hóa chữ CLIP và cross-attention, và dùng classifier-free guidance ϵ∅+s(ϵc−ϵ∅)\boldsymbol\epsilon_\varnothing + s(\boldsymbol\epsilon_{\mathbf{c}} - \boldsymbol\epsilon_\varnothing) để bám câu lệnh.
  • LoRA gắn vào các phép chiếu attention của U-Net: với r=4r = 4 chỉ khoảng 0.8 triệu tham số, chưa tới 0.1% U-Net, đủ để dạy một phong cách hay một đối tượng.

Đọc thêm:

Content licensed under CC BY-NC-SA 4.0. Code under the MIT license.About