File size: 10,984 Bytes
0ba9d09 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 | """SEDD on DNA, a network that predicts the ratios the reverse rates are built from.
The reverse rate at a position is a forward rate times the ratio p_t(z with position i set to a)
divided by p_t(z). The two models before this one reached that ratio through a denoiser over
clean tokens and a sum against the forward kernel. Here the network outputs the ratio itself, and
the rate error becomes score entropy, with its minimum over the prediction at the true ratio.
Chapter 4, "The ratio we have to learn" and "Predicting the ratios directly", on the uniform member
of the pi family. Time runs from t = 0 clean to t = 1 noise, the survival probability is
alpha_t = exp(-beta t), and the rates R_t(x, y) act on probability rows, p_dot = p R. Trained on the
synthetic DNA of cis6270.data. Prints the worked score entropy at three predictions and the sample
statistics.
python lecture_4/sedd.py # train and generate
python lecture_4/sedd.py --steps 20 # a few seconds, for a first look
"""
# %% 01. Imports and configuration
import math # The worked score entropies are logarithms.
import torch # Tensors, gradients and the categorical draws.
from cis6270.data import DNA_SIZE, decode_dna, dna_splits # ACGT, K = 4 and the shared sequences.
from cis6270.metrics import gc_fraction, motif_fraction, token_entropy, unique_fraction
from cis6270.nets import SequenceDenoiser, count_parameters # Reused as L heads of K outputs.
from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
BETA = 4.0 # The constant noise rate, so alpha_t = exp(-4t) and the forward rate is BETA/K.
TIME_FLOOR = 2e-2 # The true ratios grow like 1/(1 - alpha_t), so we stop just above zero.
MAX_RATIO = 1e4 # The predicted ratios are exponentials, kept finite for the sampler.
# %% 02. The concrete score
#
# s_t(z)_{i,a} = p_t(z^{i <- a}) / p_t(z) holds L(K - 1) numbers per sequence, one per position and
# per alternative token, exactly the shape of a network with L heads of K logits.
def concrete_score(model: torch.nn.Module, z: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
"""s_theta(z)_{i,a}, positive and with no normalization constraint. Returns [B, L, K]."""
return model(z, t).clamp(max=math.log(MAX_RATIO)).exp() # [B, L, K] exponentiate the logits.
def kernel_ratio(x0: torch.Tensor, z: torch.Tensor, alpha: torch.Tensor) -> torch.Tensor:
"""The conditional target r = q_t(a | x_0) / q_t(z^i | x_0), one per destination. [B, L, K]."""
uniform = (1.0 - alpha) / DNA_SIZE # [B, 1] the redrawn share at this noise level.
numerator = uniform[..., None] + alpha[..., None] * torch.nn.functional.one_hot(
x0, DNA_SIZE) # [B, L, K] q_t(a | x_0) at every destination a.
denominator = numerator.gather(2, z[:, :, None]) # [B, L, 1] the same kernel at the current token.
return numerator / denominator # [B, L, K] the ratio the network has to reproduce.
# %% 03. Denoising score entropy
#
# If we write the target rate as lambda r and the model rate as lambda s, the rate error becomes
# lambda [s - r log s + r(log r - 1)]. The first two terms hold every dependence on the prediction,
# a linear term against a logarithmic one, so the minimum over s at fixed r sits at s = r.
def score_entropy(target: torch.Tensor, predicted: torch.Tensor,
forward_rate: float = 1.0) -> torch.Tensor:
"""l(lambda r, lambda s), elementwise, and it vanishes at s = r."""
constant = target * (target.clamp(min=1e-20).log() - 1.0) # r(log r - 1), free of the prediction.
return forward_rate * (predicted - target * predicted.clamp(min=1e-20).log() + constant)
def sedd_loss(model: torch.nn.Module, x0: torch.Tensor) -> torch.Tensor:
"""One Monte Carlo sample of the score entropy, summed over destinations and positions."""
t = torch.rand(x0.shape[0], 1, device=x0.device).clamp(min=TIME_FLOOR) # [B, 1] one time each.
alpha = torch.exp(-BETA * t) # [B, 1] the survival probability at that time.
keep = torch.rand(x0.shape, device=x0.device) < alpha # [B, L] survival of the original base.
fresh = torch.randint(0, DNA_SIZE, x0.shape, device=x0.device) # [B, L] a draw from pi.
z = torch.where(keep, x0, fresh) # [B, L] uniform replacement, with no marker of the damage.
target = kernel_ratio(x0, z, alpha) # [B, L, K] the ratio attached to this clean sequence.
predicted = concrete_score(model, z, t[:, 0]) # [B, L, K] the network's own ratios.
current = torch.nn.functional.one_hot(z, DNA_SIZE).bool() # [B, L, K] the token already in place.
error = score_entropy(target, predicted, BETA / DNA_SIZE) # [B, L, K] one term per jump.
return error.masked_fill(current, 0.0).sum((1, 2)).mean() # Nats per sequence.
# %% 04. Sampling from the predicted ratios
#
# The reverse rate is the forward rate of the jump that would undo it, times the predicted ratio,
# and tau-leaping turns each rate into a jump probability over a step of length h.
@torch.no_grad() # Generation needs no gradient tracking.
def sample(model: torch.nn.Module, batch: int, length: int, steps: int = 64) -> torch.Tensor:
"""Generate from uniformly random tokens down to clean DNA. Returns [B, L] over ACGT."""
device = next(model.parameters()).device
z = torch.randint(0, DNA_SIZE, (batch, length), device=device) # [B, L] a draw from pi.
ts = torch.linspace(1.0, TIME_FLOOR, steps + 1, device=device) # The reverse grid.
for t, s in zip(ts[:-1], ts[1:]):
rates = (BETA / DNA_SIZE) * concrete_score(model, z, t.expand(batch)) # [B, L, K].
jump = (t - s) * rates # [B, L, K] the tau-leaping probability of each jump.
jump.scatter_(2, z[:, :, None], 0.0) # Only a change of base counts as a jump.
jump = jump / jump.sum(-1, keepdim=True).clamp(min=1.0) # Rescale an oversized step.
stay = (1.0 - jump.sum(-1, keepdim=True)).clamp(min=0.0) # [B, L, 1] the chance of staying.
choice = torch.distributions.Categorical(
probs=torch.cat([jump, stay], dim=-1)).sample() # [B, L] destination, or K for staying.
z = torch.where(choice == DNA_SIZE, z, choice.clamp(max=DNA_SIZE - 1)) # [B, L].
return z # [B, L] clean DNA, since uniform replacement never leaves the alphabet.
# %% 05. The report
def main() -> None:
parser = common_parser(__doc__.split("\n")[0])
# The ratios need a longer budget than the worked numbers do, so the default trains for 1000
# steps and measures on 64 sequences, and that keeps the run inside about a minute.
parser.set_defaults(steps=1000, sample_steps=64, samples=64)
args = parser.parse_args()
set_seed(args.seed)
device = pick_device(args.device)
talk = (lambda *a: None) if args.quiet else print # --quiet keeps only the report block.
# Section 02, the target ratio of the running example, reached through the denoiser route.
posterior = torch.tensor([0.375, 0.625, 0.0, 0.0]) # p(x_0 | X_t = C) at alpha_t = 1/2.
per_clean = torch.tensor([5.0, 0.2, 1.0, 1.0]) # q_t(A | b) / q_t(C | b) for b over ACGT.
target_ratio = float(posterior @ per_clean) # The ratio in the A direction, and it comes out 2.
# Section 03, the score entropy at three predictions of that ratio.
errors = {s: float(score_entropy(torch.tensor(target_ratio), torch.tensor(s)))
for s in (1.8, 1.0, 3.0)} # A ten percent error against two larger ones.
talk(f"target ratio {target_ratio:.1f} from 0.375(5) + 0.625(0.2)")
at_target = float(score_entropy(torch.tensor(target_ratio), torch.tensor(target_ratio)))
talk("score entropy " + " ".join(f"s={s}: {v:.4f}" for s, v in errors.items()) +
f" at s = r it is {at_target:.1e}")
# Train the network to output the ratios.
splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
train_tokens = splits["train_tokens"].to(device) # [N, L] clean sequences over ACGT.
valid_tokens = splits["valid_tokens"].to(device) # [M, L] held out for the loss below.
model = SequenceDenoiser(DNA_SIZE, args.length, width=args.width).to(device)
talk(f"device={device} parameters={count_parameters(model):,} ratios per sequence "
f"{args.length * (DNA_SIZE - 1)}")
def step_loss(step: int) -> torch.Tensor:
rows = torch.randint(0, len(train_tokens), (args.batch_size,), device=device)
return sedd_loss(model, train_tokens[rows]) # One clean batch into one scalar.
losses = train(model, step_loss, steps=args.steps, lr=args.lr, quiet=args.quiet)
with torch.no_grad():
held_out = sum(float(sedd_loss(model, valid_tokens)) for _ in range(4)) / 4.0
# The ratios the trained network predicts at a half-corrupted batch, against their targets.
with torch.no_grad():
check_t = torch.full((len(valid_tokens), 1), math.log(2.0) / BETA, device=device)
check_alpha = torch.exp(-BETA * check_t) # alpha_t = 1/2 at this time.
keep = torch.rand(valid_tokens.shape, device=device) < check_alpha
noisy = torch.where(keep, valid_tokens,
torch.randint(0, DNA_SIZE, valid_tokens.shape, device=device))
predicted = concrete_score(model, noisy, check_t[:, 0]) # [M, L, K] the predicted ratios.
exact = kernel_ratio(valid_tokens, noisy, check_alpha) # [M, L, K] the conditional targets.
toward_clean = exact > 1.0 # The jumps that point back at the clean base.
mean_predicted = float(predicted[toward_clean].mean()) # Should sit below the target 5.
torch.manual_seed(args.seed + 1) # Fix the jump draws, so the samples repeat.
samples = sample(model, args.samples, args.length, args.sample_steps) # [B, L] over ACGT.
report = {
"target ratio at C toward A": target_ratio,
"score entropy at s = 1.8": errors[1.8],
"score entropy at s = 1.0": errors[1.0],
"score entropy at s = 3.0": errors[3.0],
"training score entropy, last 50": sum(losses[-50:]) / len(losses[-50:]),
"validation score entropy": held_out,
"mean predicted ratio toward clean": mean_predicted,
"conditional target toward clean": float(exact[toward_clean].mean()),
"GC fraction": gc_fraction(samples),
"GC fraction in the data": gc_fraction(train_tokens),
"motif fraction": motif_fraction(samples),
"motif fraction in the data": motif_fraction(train_tokens),
"distinct samples": unique_fraction(samples),
"letter entropy, nats": token_entropy(samples, DNA_SIZE),
}
print_report("sedd: score entropy on DNA", report, decode_dna(samples))
save_run(args.out, model=model, config=vars(args), report=report,
losses=losses, samples=decode_dna(samples))
# %% 06. Run the script
if __name__ == "__main__": # Execute when launched directly.
main() # Work the score entropy, train the ratios, and generate.
|