Skip to main content

[LLM 4/10] DPO: When a Language Model Becomes Its Own Reward Model

· 20 min read
Kobkrit Viriyayudhakorn
CEO, iApp Technology

In the last chapter we did RLHF with PPO, and you saw how many moving parts it has — a separate reward model to train, four models resident in VRAM at once, a dozen-plus PPO hyperparameters to tune, and if the reward model is off, the policy finds a shortcut that games the score. In this chapter we'll accomplish the same thing with an ordinary supervised training loop — no reward model, no RL. And crucially, this is not an approximation. We'll prove algebraically that those two pieces genuinely cancel out.

Open in Colab04_dpo.ipynb

1. The Problem

Say you want your AI assistant to "always answer in Thai" — sounds simple. Now try writing that as a loss function. You can't.

This is the central problem of alignment: answer quality cannot be written as an equation. "More polite," "more natural," "doesn't drift into English" — there is no single correct answer key. There is only comparison: show a human two answers and ask which one they prefer. So the data comes in threes: a prompt xx, a preferred answer ywy_w (chosen), and a dispreferred one yly_l (rejected).

PPO-style RLHF solves this by taking a two-step detour:

  1. Train a reward model rϕ(x,y)r_\phi(x,y) to imitate human preferences
  2. Use RL to push the policy toward high scores from that reward model

The detour has a price:

Problem with RLHF/PPOWhat it costs you in practice
An extra model to trainMore steps, more ways to fail, more time
Four models loaded at oncepolicy + ref + reward + value — VRAM explodes
Reward hackingThe model finds a loophole that scores well without humans liking it any more
PPO is hyperparameter-sensitiveTwo runs with different seeds can tell completely different stories

So this chapter asks one short question: can we skip steps 1 and 2 entirely?

2. What We're Going to Do

Yes, we can — and the reason is beautiful.

The starting point is the observation that the KL-constrained RLHF objective has a closed-form solution. We already know exactly what the optimal policy looks like, without running a single step of RL. Knowing that, we invert the equation — instead of asking "what policy does this reward produce?" we ask "what reward does this policy imply?"

The core idea of this chapter

Once you invert the equation, the language model already is a reward model, implicitly. The reward model and the RL loop aren't "approximated away" — they cancel out algebraically. What's left is an ordinary supervised loss function you can train with a single Trainer.

This is DPO (Direct Preference Optimization), proposed by Rafailov et al. (2023). The "Direct" comes from optimizing on preference data directly, with no intermediary.

3. The Equations

3.1 Setting up: the RLHF objective

maxπ ExD,yπ(x)[r(x,y)]βDKL[π(yx)πref(yx)]\max_{\pi}\ \mathbb{E}_{x\sim\mathcal{D},\,y\sim\pi(\cdot|x)}\big[r(x,y)\big] - \beta\,\mathbb{D}_{\text{KL}}\big[\pi(y|x)\,\|\,\pi_{\text{ref}}(y|x)\big]

In plain language: "maximize the reward, but don't wander too far from where you started."

  • π\pi = the policy, i.e. the model we're training
  • πref\pi_{\text{ref}} = the reference policy, i.e. the starting model (here, the post-SFT model from chapter 2)
  • r(x,y)r(x,y) = the reward for answer yy given prompt xx
  • β\beta = how tight the leash is; higher values pull harder back toward πref\pi_{\text{ref}}

The KL term is not decoration. Without it the model runs off to wherever reward is highest and the language falls apart.

3.2 Step 1 — the closed-form solution

That problem can be solved by hand (it amounts to finding the distribution that minimizes KL against a target distribution), giving

π(yx)=1Z(x)πref(yx)exp(1βr(x,y))\pi^*(y|x) = \frac{1}{Z(x)}\pi_{\text{ref}}(y|x)\exp\left(\frac{1}{\beta}r(x,y)\right)
  • Z(x)=yπref(yx)exp ⁣(r(x,y)/β)Z(x) = \sum_{y}\pi_{\text{ref}}(y|x)\exp\!\big(r(x,y)/\beta\big) is the partition function, the denominator that makes everything sum to 1
  • Note that Z(x)Z(x) depends only on xx, not on yy — hold on to that sentence, it's about to become the hero of the story

The intuition: the optimal policy is the original model, reweighted by exp(r/β)\exp(r/\beta). High-reward answers get their probability amplified, low-reward answers get pushed down, but everything starts from the original shape of πref\pi_{\text{ref}}.

In practice we can't compute Z(x)Z(x), because it requires summing over every possible answer in the universe. That's why people reach for RL — and it's exactly why DPO doesn't have to.

3.3 Step 2 — invert the equation to get the reward

Take the log of both sides and rearrange:

r(x,y)=βlogπ(yx)πref(yx)+βlogZ(x)r(x,y) = \beta\log\frac{\pi^*(y|x)}{\pi_{\text{ref}}(y|x)} + \beta\log Z(x)

This line is the crux: any reward function can be rewritten in terms of the optimal policy and the starting policy. Which means that if we have two models, we can compute their implicit reward immediately, without ever training a reward model.

3.4 Step 3 — substitute into Bradley-Terry and Z(x)Z(x) vanishes

The standard model of preference is Bradley-Terry: the probability a human picks ywy_w over yly_l is

p(ywylx)=σ(r(x,yw)r(x,yl))p(y_w \succ y_l \mid x) = \sigma\big(r(x,y_w) - r(x,y_l)\big)

where σ\sigma is the sigmoid. Notice that reward appears in this equation only as a difference. Substitute equation 3.3 — βlogZ(x)\beta\log Z(x) appears identically on both sides because it's the same xx — so it cancels out.

LDPO(θ)=E(x,yw,yl)[logσ(βlogπθ(ywx)πref(ywx)βlogπθ(ylx)πref(ylx))]\mathcal{L}_{\text{DPO}}(\theta) = -\mathbb{E}_{(x,y_w,y_l)}\left[\log\sigma\left(\beta\log\frac{\pi_\theta(y_w|x)}{\pi_{\text{ref}}(y_w|x)} - \beta\log\frac{\pi_\theta(y_l|x)}{\pi_{\text{ref}}(y_l|x)}\right)\right]
This is the sentence the whole article exists to say

The uncomputable thing (Z(x)Z(x)) disappears, because Bradley-Terry only cares about the difference of rewards. What remains is the log-probability of two models on text we already have, obtainable with an ordinary forward pass. No sampling of answers, no rollouts, no value function — DPO is supervised learning, all the way down.

3.5 The gradient — where the intuition lives

θLDPO=βE[σ(r^lr^w)(θlogπθ(ywx)θlogπθ(ylx))]\nabla_\theta\mathcal{L}_{\text{DPO}} = -\beta\,\mathbb{E}\left[\sigma(\hat r_l - \hat r_w)\left(\nabla_\theta\log\pi_\theta(y_w|x) - \nabla_\theta\log\pi_\theta(y_l|x)\right)\right]

where r^=βlog(πθ/πref)\hat r = \beta\log\big(\pi_\theta/\pi_{\text{ref}}\big) is called the implicit reward.

Read it piece by piece:

  • The right-hand parenthesis = the direction: push the log-prob of ywy_w up and the log-prob of yly_l down, simultaneously
  • σ(r^lr^w)\sigma(\hat r_l - \hat r_w) = the weight: "how badly is the model ranking this pair?"

That weight is the most important teaching point here. If the model already ranks the pair correctly (r^w\hat r_w clearly greater than r^l\hat r_l), then σ(r^lr^w)\sigma(\hat r_l - \hat r_w) approaches zero and the pair contributes almost no gradient. DPO therefore focuses on its own mistakes automatically, with nobody curating the data for it.

4. Seeing the Equations

Bradley-Terry: from a reward difference to a probability

A sigmoid curve over the reward difference between chosen and rejected, with the regions where the model agrees and disagrees with the human shadedA sigmoid curve over the reward difference between chosen and rejected, with the regions where the model agrees and disagrees with the human shaded

Figure 4.1Bradley-Terry turns a reward difference into the probability a human picks chosen — the model never needs absolute reward values, only the difference

Where the difference is zero the probability is exactly 0.5 — "the model has no opinion." And because the curve only cares about the difference, adding a constant to both rewards changes nothing at all. That is the geometric reason Z(x)Z(x) can cancel.

Loss and the gradient weight

Let Δ=logπθ(ywx)πref(ywx)logπθ(ylx)πref(ylx)\Delta = \log\frac{\pi_\theta(y_w|x)}{\pi_{\text{ref}}(y_w|x)} - \log\frac{\pi_\theta(y_l|x)}{\pi_{\text{ref}}(y_l|x)}. Then the loss is logσ(βΔ)-\log\sigma(\beta\Delta) and the gradient weight is σ(βΔ)\sigma(-\beta\Delta).

Two panels showing DPO loss decreasing as margin grows, and the gradient weight converging to zero once the model ranks a pair correctly, compared across three beta valuesTwo panels showing DPO loss decreasing as margin grows, and the gradient weight converging to zero once the model ranks a pair correctly, compared across three beta values

Figure 4.2Left: DPO loss against margin — Right: the weight a pair receives in the gradient, for β = 0.1, 0.3, 1.0

The right panel is the one to study. Once the margin is strongly positive, the weight converges to zero — that pair has "graduated." And the higher β\beta is, the steeper the curve: the model both learns fast and "stops learning" fast. At β=1.0\beta = 1.0, pairs with a margin past 4 have essentially no gradient left. At β=0.1\beta = 0.1 the curve is far flatter, so the model keeps collecting gradient from every pair — slower, but steadier.

β\beta controls how far the model can drift from where it started

Bar chart of the probabilities of 5 candidate answers at different beta values, against a dashed line for the starting policyBar chart of the probabilities of 5 candidate answers at different beta values, against a dashed line for the starting policy

Figure 4.3The equation π* ∝ π_ref · exp(r/β) on a toy example with 5 answers — a small β collapses everything onto the highest-reward answer, a large β restores the shape of π_ref

Read this one right to left: at β=0.1\beta = 0.1 nearly all the probability mass collapses onto y5y_5, the highest-reward answer. That is mode collapse — a great score with all diversity wiped out. At β=10\beta = 10 the bars nearly overlap the dashed line, meaning almost nothing was learned. β\beta isn't a hyperparameter you "tune for lowest loss." It is a choice of where to sit on the trade-off between following preferences and keeping your original identity.

Drag the slider yourself and watch how loss and gradient change shape:

Loss family
Positive means the model already prefers the chosen response.
-6-4-20246reward margin Δlossgradient weight

Drag anywhere on the plot, or use the Δ slider with the arrow keys.

Δ2.00βΔ = 0.200
Loss0.5981
Gradient weight0.045069.7% of maximum
σ(−βΔ)0.450245.02%

This pair carries real signal.σ(−βΔ) is 45.02%: the model is still wrong or unsure about this pair, so it dominates the batch gradient. Drag Δ to the right and watch that weight collapse.

5. Setting Up the Environment

Open Colab and pick Runtime → Change runtime type → T4 GPU (the free tier suffices).

The series-wide warning worth re-reading every chapter

The Colab T4 is Turing architecture (SM 7.5), which does not support bfloat16 and does not support FlashAttention-2.

But Qwen3-0.6B's config.json declares torch_dtype: bfloat16. So torch_dtype="auto" is a trap — your code will crash or run bizarrely slowly without telling you why.

torch_dtype=torch.float16      # not bfloat16
attn_implementation="sdpa" # not flash_attention_2
fp16=True # in DPOConfig (not bf16=True)
cap = torch.cuda.get_device_capability(0)
print("compute capability:", cap) # T4 = (7, 5)
print("native bf16:", cap[0] >= 8) # T4 -> False
print("torch says :", torch.cuda.is_bf16_supported()) # T4 -> True (counts emulation!)
is_bf16_supported() lies on a T4

Recent torch returns True on a T4 because it counts emulation as support — which is far slower than fp16. Gate on compute capability ≥ 8.0 (Ampere and up) instead. This was a real bug, caught only by running the notebook on Colab.

A reference model that costs zero extra VRAM

DPO needs both πθ\pi_\theta and πref\pi_{\text{ref}}, which sounds like loading two models. But we're building on the LoRA adapter from chapter 2, which lets both share the same base weights.

from peft import PeftModel

base = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen3-0.6B",
torch_dtype=torch.float16,
attn_implementation="sdpa",
).cuda()

policy = PeftModel.from_pretrained(base, "kobkrit/qwen3-0.6b-th-sft-lora", is_trainable=True)

policy is base + adapter, while πref\pi_{\text{ref}} is that same base with the adapter switched off. Just call it inside the policy.disable_adapter() context manager — nothing extra to load.

This is the payoff from chapter 2

Had we done full fine-tuning back in chapter 2, we'd now need two complete copies of the model in memory. Choosing LoRA from the start makes the reference model cost zero additional bytes of VRAM. That's an architectural reason, not merely a way to save memory during training.

6. Preparing the Data

DPO data needs exactly three columns: prompt, chosen, rejected.

Set 1 — iapp/dpo_thai_tutorial (100 pairs, Apache-2.0) A dataset I built myself for this series and released under Apache-2.0 so anyone can reuse it. Hand-curated Thai preference pairs, emphasizing politeness and natural-sounding language.

Set 2 — roughly 400 pairs built from scratch out of airesearch/wangchanx-seed-free-synthetic-instruct-thai-120k. The construction is about as direct as it gets:

  • chosen = the Thai reference answer shipped with the dataset
  • rejected = the base model's own output under greedy decoding
def build_rejected(prompt):
with torch.no_grad(), policy.disable_adapter():
out = policy.generate(**tok(prompt, return_tensors="pt").to("cuda"),
max_new_tokens=192, do_sample=False)
return tok.decode(out[0], skip_special_tokens=True)[len(prompt):]
Why self-generated rejected answers beat sourced ones

Qwen3-0.6B's greedy answers to Thai prompts frequently drift into English mid-sentence. That is a real defect of this specific model, not a defect we invented.

Using it as rejected points the gradient precisely at the behavior we want to fix, and lines up exactly with the th_ratio metric we'll measure in section 8. If you pull rejected answers from a different model, you're teaching your model "don't be that other model," which isn't what you want.

That gives roughly 500 pairs, with 15% held out for evaluation and never touched during training.

7. The Main Code

The point of this section isn't calling a library. It's writing the DPO loss by hand and then proving it matches the real thing. If the equations in section 3 didn't convince you, this code will.

7.1 Twenty-five lines, written from scratch

import torch, torch.nn.functional as F

def seq_logp(model, input_ids, attention_mask, labels):
"""Sum of log-probs over the ANSWER ONLY (prompt tokens are masked to -100)"""
logits = model(input_ids=input_ids, attention_mask=attention_mask).logits
logits = logits[:, :-1, :] # position t predicts the token at t+1
target = labels[:, 1:] # so the targets shift by 1
mask = target.ne(-100) # count answer tokens only
target = target.masked_fill(~mask, 0) # keep gather from breaking on -100
logp = torch.log_softmax(logits.float(), dim=-1)
tokp = logp.gather(-1, target.unsqueeze(-1)).squeeze(-1)
return (tokp * mask).sum(-1) # [B] — a sum, not a mean

def dpo_loss(policy, batch, beta=0.1):
pi_w = seq_logp(policy, batch["chosen_ids"], batch["chosen_mask"], batch["chosen_labels"])
pi_l = seq_logp(policy, batch["rejected_ids"], batch["rejected_mask"], batch["rejected_labels"])

with torch.no_grad(), policy.disable_adapter(): # reference: adapter off + no gradient
ref_w = seq_logp(policy, batch["chosen_ids"], batch["chosen_mask"], batch["chosen_labels"])
ref_l = seq_logp(policy, batch["rejected_ids"], batch["rejected_mask"], batch["rejected_labels"])

delta = (pi_w - ref_w) - (pi_l - ref_l) # the Δ from section 4
loss = -F.logsigmoid(beta * delta).mean() # equation 3.4, literally

r_w = beta * (pi_w - ref_w).detach() # implicit reward of chosen
r_l = beta * (pi_l - ref_l).detach() # implicit reward of rejected
return loss, r_w, r_l

All of DPO is right there. Nothing else is hiding. The delta line is equation 3.4 transcribed one-to-one, and -F.logsigmoid(beta * delta) is the entire loss.

7.2 Proving it matches TRL

from trl import DPOTrainer, DPOConfig

cfg = DPOConfig(
output_dir="dpo-out",
beta=0.1,
loss_type="sigmoid", # must match the formula we wrote by hand
label_smoothing=0.0, # anything nonzero and it stops being equation 3.4
per_device_train_batch_size=2,
gradient_accumulation_steps=8, # effective batch = 16
num_train_epochs=2,
learning_rate=5e-6, # much lower than SFT — see the warning below
lr_scheduler_type="cosine",
warmup_ratio=0.1,
max_length=768,
max_prompt_length=256,
fp16=True, # the T4 has no bf16
logging_steps=5,
)

trainer = DPOTrainer(model=policy, args=cfg, train_dataset=train_ds, processing_class=tok)

# Critical: a freshly created LoRA has lora_B = 0, so the policy is exactly the
# reference. The margin is zero and BOTH implementations return ln 2 — even if the
# formula is wrong. Perturb the weights first or the assert proves nothing.
with torch.no_grad():
for name, p in policy.named_parameters():
if "lora_B" in name:
p.add_(torch.randn_like(p) * 0.01)

loss_manual, _, _ = dpo_loss(policy, batch, beta=0.1)
loss_trl = trainer.compute_loss(policy, trl_batch)

assert torch.allclose(loss_manual, loss_trl, atol=1e-4)
print("match:", loss_manual.item(), loss_trl.item())
This is the moment the article proves itself instead of asking you to take its word

Most tutorials stop at "call DPOTrainer and it works." That assert above says the equation we derived across all of section 3 produces the same value as the library the whole world uses. If the assert passes, you understand DPO well enough to implement it — not just well enough to call it.

If the assert fails, don't blame your own code yet

The usual causes, in order of frequency: label_smoothing isn't zero, loss_type isn't "sigmoid", the two sides weren't fed the same example, or the padding/masking doesn't line up. All four are mismatched definitions, not bugs — and hunting them down is the best lesson in this section.

DPO needs a much lower learning rate than SFT

SFT with LoRA is perfectly happy at 2e-4, but DPO at 2e-4 will send the policy running away from the reference within a few dozen steps, and the language will degrade to the point of being unreadable.

Start at 5e-6, because DPO isn't teaching new content. It's merely tilting a probability distribution that already exists, which takes far less force.

Total training time on a T4 is around 9 minutes for 500 pairs over 2 epochs.

8. Results

The notebook measures three things and writes them to results.json:

  1. Held-out preference accuracy — the fraction of pairs where the implicit reward of chosen exceeds rejected, with a Wilson 95% CI
  2. The distribution of implicit reward margins (r^wr^l\hat r_w - \hat r_l) — the whole distribution, not just the mean
  3. th_ratio — the fraction of Thai characters in the model's generated answers, this series' standing metric for catching silent drift into English
Why you look at the whole distribution, not just the mean

A beautiful average margin can come from a handful of pairs with enormous margins while most pairs still sit near zero. A histogram tells you that; a single number conceals it. And accuracy without a CI still isn't an experimental result, as we've said since chapter 1.

Prompt
Promptอธิบายว่าทำไมท้องฟ้าถึงเป็นสีฟ้า แบบสั้น ๆ

base

Thai 18%41 tokens
The sky appears blue because of Rayleigh scattering. ท้องฟ้า is blue เพราะ light scatter ครับ. Shorter wavelengths scatter more than longer ones.

sft

Thai 99%78 tokens
ท้องฟ้าเป็นสีฟ้าเพราะแสงอาทิตย์กระทบกับโมเลกุลของอากาศแล้วเกิดการกระเจิงแบบเรย์ลี ซึ่งแสงสีน้ำเงินที่มีความยาวคลื่นสั้นกว่าจะกระเจิงได้มากกว่าแสงสีแดง เราจึงมองเห็นท้องฟ้าเป็นสีฟ้าครับ

Showing the built-in sample.

The thing that will alarm you the first time you read the logs

During training you'll see rewards/chosen and rewards/rejected in TRL's logs, and what happens nearly every time is that both values drift negative together while rewards/margins keeps widening.

This is normal, not a failure

Remember the definition: r^=βlog(πθ/πref)\hat r = \beta\log(\pi_\theta/\pi_{\text{ref}}), so a negative r^\hat r means the policy assigns that text less probability than the reference does.

DPO was never told to "make chosen more probable." It was told only to "widen the gap." Pushing rejected down hard while pushing chosen down gently satisfies that just as well — and is often the easier path.

So what you watch is the margin and the held-out accuracy, not the absolute level of the rewards. That said, if rewards/chosen plunges very deep (below −10, say), that starts to signal the model is abandoning the reference — lower the LR or raise β\beta.

9. Comparison

ModelPref. accMean marginth_ratioTH-INSTRTraining time
starting point (before DPO)~50% (chance)00.9373.3%
DPO, β = 0.183.8%+0.940.9376.7%8.3 min
Measured on a Colab T4 — iapp/dpo_thai_tutorial (Kobkrit's own dataset), peak VRAM 12.65 GB. Every number from results.json.
The self-proving moment — it passed on a real T4

assert torch.allclose(loss_manual, loss_trl, atol=1e-4) passed: the ~25-line hand-written DPO loss gave 1.46599 against TRL's 1.46602 (2×10⁻⁵ apart), and crucially on a margin of −12.0 that is not zero — with a zero margin both formulas return ln 2 even if the formula is wrong, making the check meaningless.

The DPO curves match theory: rewards/chosen +0.74, rewards/rejected −0.20, margins +0.94, accuracies (fraction ranked correctly) = 83.8%.

What is not yet conclusive, and what we did not measure

th_ratio did not move (0.93 → 0.93) because the baseline was already high — on this dataset DPO improves other things, not Thai-ness. TH-INSTR rose 73.3% → 76.7% but McNemar gives p = 1.0 (n=30 is too small to conclude); the solid evidence is the 83.8% preference accuracy on held-out pairs. We did not run a full β = 0.5 comparison (free-Colab budget), so that row is dropped — we report only what was measured.

10. Summary

  • DPO doesn't approximate RLHF — it solves the same equation in closed form; the reward model and RL loop cancel algebraically
  • Z(x)Z(x) disappears because Bradley-Terry only cares about the difference of rewards — this is the key to the whole chapter
  • A language model is its own reward model, via the implicit reward r^=βlog(πθ/πref)\hat r = \beta\log(\pi_\theta/\pi_{\text{ref}})
  • The gradient is weighted by the model's own error — pairs it already ranks correctly contribute almost nothing
  • β\beta is the trade-off dial between indulging preferences and preserving existing capability
  • A very low LR (5e-6), because we're tilting a distribution, not teaching new knowledge
  • Both rewards drifting down together is normal — watch the margin, not the absolute level
  • Always measure answer length, because length bias impersonates quality remarkably well
Limitations of this experiment

DPO is strictly offline. It learns only from the answer pairs already sitting in the file. What it can do is re-rank behaviors the model was already capable of sampling. It can never discover a way of answering that the base model never produced, because nobody ever put that way into the chosen column.

That gap is exactly why chapter 5 (GRPO) has to exist — for when the model must sample its own answers to learn from, rather than merely ranking what someone prepared for it.

And one more thing: 500 pairs is a demonstration of the mechanism, not real alignment. Production alignment work uses preference pairs in the tens to hundreds of thousands — several orders of magnitude more. What you get from this chapter is an understanding of how the equations work and what each dial does, and that transfers to real scale. But don't cite these results as evidence that you got a better Thai model.

Next chapter: GRPO — when ranking what already exists stops being enough, and we let the model sample several of its own answers and compare them against each other, with no PPO-style value function.

References

  1. Rafailov et al. (2023). Direct Preference Optimization: Your Language Model is Secretly a Reward Model — the original DPO paper -- the derivation in section 3
  2. Bradley & Terry (1952). Rank Analysis of Incomplete Block Designs: I. The Method of Paired Comparisons — the Bradley-Terry model every reward model rests on
  3. Azar et al. (2023). A General Theoretical Paradigm to Understand Learning from Human Preferences — IPO: identifies DPO's overfitting-to-preferences weakness
  4. Ethayarajh et al. (2024). KTO: Model Alignment as Prospect Theoretic Optimization — KTO: an alternative that needs no chosen/rejected pairs
  5. Park et al. (2024). Disentangling Length from Quality in Direct Preference Optimization — the DPO length bias that section 9 warns about
  6. Tang et al. (2024). Understanding the performance gap between online and offline alignment algorithms — why offline (DPO) trails online (PPO/GRPO)
  7. Ouyang et al. (2022). Training language models to follow instructions with human feedback — InstructGPT: the origin of the whole SFT -> RM -> PPO pipeline

The writing, code and notebooks in this series are licensed under CC BY-NC-SA 4.0 — reuse and adapt them freely with attribution, for non-commercial purposes, and share your adaptations under the same terms. Third-party models and datasets referenced here keep their own licences.