File size: 15,138 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 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 | """Uniform replacement on DNA, the member of the pi family with no mask.
Corruption redraws a base from the uniform distribution over ACGT, so a noisy base may be the
original or a replacement, and no marker of the damage appears in the sequence. Every pair of
bases is then joined by a reverse rate, and the loss keeps the staying term that masking
cancelled. A sampler can revise a base it has already written.
Chapter 4, "A rate matrix with one free distribution", "The posterior average of conditional reverse
rates" and "Uniform replacement and the rates it produces". 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
posterior average of 2, the worked rate error, and the sample statistics.
python lecture_4/uniform.py # train and generate
python lecture_4/uniform.py --steps 20 # a few seconds, for a first look
"""
# %% 01. Imports and configuration
import math # The worked rate errors 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 # The denoiser d_theta(. | z, t).
from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
BETA = 4.0 # The constant noise rate of the chapter's worked example, so alpha_t = exp(-4t).
TIME_FLOOR = 2e-2 # The reverse rates grow like 1/(1 - alpha_t), so we stop just above zero.
# %% 02. The uniform kernel and the rates that produce it
#
# Under R_pi = 1 pi^T - I with pi uniform, every pair of distinct bases is joined at rate beta/K.
# Its matrix exponential is the kernel q_t(x | x_0) = alpha_t delta(x, x_0) + (1 - alpha_t)/K.
def survival(t: torch.Tensor | float) -> torch.Tensor | float:
"""alpha_t = exp(-tau(t)) for the constant schedule beta(t) = BETA, so tau(t) = BETA t."""
return torch.exp(-BETA * t) if torch.is_tensor(t) else math.exp(-BETA * t)
def kernel(alpha: float | torch.Tensor) -> torch.Tensor:
"""Q(alpha) over (A, C, G, T), with (x_0, x) entry q_t(x | x_0). Returns [K, K]."""
uniform = (1.0 - alpha) / DNA_SIZE # The share of the redrawn mass that lands on each base.
return alpha * torch.eye(DNA_SIZE, dtype=torch.float64) + uniform # [K, K] rows sum to one.
def corrupt(x0: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
"""Redraw each position from the uniform distribution with probability 1 - alpha_t."""
keep = torch.rand(x0.shape, device=x0.device) < survival(t) # [B, L] survival of the original.
fresh = torch.randint(0, DNA_SIZE, x0.shape, device=x0.device) # [B, L] a draw from pi.
return torch.where(keep, x0, fresh) # [B, L] nothing marks which positions were redrawn.
# %% 03. The conditional reverse rates and their three cases
#
# If we divide the conditional reverse rate by the noise rate beta/K we are left with one factor.
# It is 1 + K alpha_t/(1 - alpha_t) toward the clean token, its reciprocal away from it, and one
# between two tokens that are both wrong.
def clean_factor(alpha: float) -> float:
"""1 + K alpha_t / (1 - alpha_t), the factor 5 of the worked example at alpha_t = 1/2."""
return 1.0 + DNA_SIZE * alpha / (1.0 - alpha) # The ratio q_t(x_0 | x_0) / q_t(x | x_0).
def conditional_rates(alpha: float, current: int, clean: int) -> torch.Tensor:
"""R_hat_t(c, a | x_0) for every destination a, in units where the forward rate is one."""
Q = kernel(alpha) # [K, K] the forward kernel at this survival probability.
return Q[clean] / Q[clean, current] # [K] q_t(a | x_0) divided by q_t(c | x_0).
# %% 04. A worked posterior average on one base
#
# With a clean base A at probability 0.75 and C at 0.25, an observed C at alpha_t = 1/2 has
# posterior 0.375 and 0.625, and the two conditional rates 5 and 0.2 average to the marginal rate.
def posterior_over_clean(alpha: float, observed: int, prior: torch.Tensor) -> torch.Tensor:
"""p(x_0 = b | X_t = observed), Bayes' rule with the forward kernel as the likelihood."""
weights = prior * kernel(alpha)[:, observed] # [K] prior times q_t(observed | b).
return weights / weights.sum() # [K] normalized over the candidate clean bases.
def marginal_reverse_rate(alpha: float, observed: int, destination: int,
prior: torch.Tensor) -> float:
"""The posterior average of the conditional reverse rates, the marginal reverse rate."""
posterior = posterior_over_clean(alpha, observed, prior) # [K] the weights of the average.
targets = kernel(alpha)[:, destination] / kernel(alpha)[:, observed] # [K] one target per clean b.
return float(posterior @ targets) # The two together form the rate the sampler applies.
# %% 05. The rate error and the staying term
#
# l(A, B) = A log(A/B) - A + B is the Bregman divergence the path KL divergence produces. The term
# -A + B comes from the probability of staying put, and under uniform noise it does not cancel.
def rate_error(target: torch.Tensor, model_rate: torch.Tensor) -> torch.Tensor:
"""Elementwise l(A, B), nonnegative and zero exactly at B = A."""
logarithmic = torch.where(target > 0, target * (target.clamp(min=1e-12) / model_rate).log(),
torch.zeros_like(target)) # A log(A/B), with l(0, B) = B.
return logarithmic - target + model_rate # The staying term is the -A + B part.
def ratio_table(current: torch.Tensor, alpha: torch.Tensor) -> torch.Tensor:
"""q_t(a | b) / q_t(c | b) for every clean base b and destination a. Returns [B, L, K, K]."""
uniform = (1.0 - alpha) / DNA_SIZE # [B, 1] the redrawn share at this noise level.
Q = (alpha[..., None, None] * torch.eye(DNA_SIZE, device=current.device)
+ uniform[..., None, None]) # [B, 1, K, K] the kernel q_t(a | b) at each sequence's time.
denominator = Q.expand(-1, current.shape[1], -1, -1).gather(
3, current[:, :, None, None].expand(-1, -1, DNA_SIZE, 1)) # [B, L, K, 1] q_t(c | b).
return Q / denominator # [B, L, K, K] the only quantity the rates need from the kernel.
def model_rates(denoiser: torch.Tensor, ratios: torch.Tensor) -> torch.Tensor:
"""R_hat^theta_t(c, a) = (beta/K) sum_b d_theta(b) q_t(a | b) / q_t(c | b). Returns [B, L, K]."""
averaged = torch.einsum("nlb,nlba->nla", denoiser, ratios) # [B, L, K] averaged over clean bases.
return (BETA / DNA_SIZE) * averaged # [B, L, K] the forward rate times the posterior average.
def conditional_targets(x0: torch.Tensor, ratios: torch.Tensor) -> torch.Tensor:
"""R_hat_t(c, a | x_0), the same formula with the posterior collapsed onto the clean token."""
rows = x0[:, :, None, None].expand(-1, -1, 1, DNA_SIZE) # [B, L, 1, K] select the row b = x_0.
return (BETA / DNA_SIZE) * ratios.gather(2, rows).squeeze(2) # [B, L, K] one target per jump.
def rate_loss(model: torch.nn.Module, x0: torch.Tensor) -> torch.Tensor:
"""One Monte Carlo sample of L_cond, 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 = survival(t) # [B, 1] the survival probability at that time.
zt = corrupt(x0, t) # [B, L] the noisy sequence, with no marker of which positions moved.
ratios = ratio_table(zt, alpha) # [B, L, K, K] the kernel ratios at this noise level.
denoiser = model(zt, t[:, 0]).softmax(-1) # [B, L, K] the posterior over clean tokens.
predicted = model_rates(denoiser, ratios).clamp(min=1e-8) # [B, L, K] nonnegative by construction.
target = conditional_targets(x0, ratios) # [B, L, K] the rate attached to this clean sequence.
staying = torch.nn.functional.one_hot(zt, DNA_SIZE).bool() # [B, L, K] the destination c itself.
return rate_error(target, predicted).masked_fill(staying, 0.0).sum((1, 2)).mean()
# %% 06. Sampling by tau-leaping
#
# Over a step of length h a position moves to a with probability h times its reverse rate, and the
# remaining probability keeps it where it is. The step is valid while h times the total rate is one.
@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:]):
time_row = t.expand(batch, 1) # [B, 1] the shared time of this step.
denoiser = model(z, time_row[:, 0]).softmax(-1) # [B, L, K] the clean-token prediction.
rates = model_rates(denoiser, ratio_table(z, survival(time_row))) # [B, L, K] per destination.
jump = (t - s) * rates # [B, L, K] the tau-leaping probabilities of this step.
jump.scatter_(2, z[:, :, None], 0.0) # Only a change of base counts as a jump.
total = jump.sum(-1, keepdim=True).clamp(min=1.0) # Rescale when the step would exceed one.
jump = jump / total # [B, L, K] now a sub-probability vector at every position.
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].
final = model(z, ts[-1].expand(batch)).softmax(-1) # The exact posterior step down to t = 0.
return torch.distributions.Categorical(probs=final).sample() # [B, L] clean DNA.
# %% 07. The report
def main() -> None:
parser = common_parser(__doc__.split("\n")[0])
# The rate loss needs 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.
# Sections 03 and 04, the three cases and the worked posterior average on one base.
prior = torch.tensor([0.75, 0.25, 0.0, 0.0], dtype=torch.float64) # Clean A at 0.75, C at 0.25.
posterior = posterior_over_clean(0.5, 1, prior) # [4] after observing a C at alpha_t = 1/2.
targets = conditional_rates(0.5, current=1, clean=0) # [4] the rates attached to a clean A.
average = marginal_reverse_rate(0.5, 1, 0, prior) # The posterior average, and it comes out 2.
marginal = prior @ kernel(0.5) # [4] p_t over the four bases, or (0.5, 0.25, 0.125, 0.125).
talk(f"kernel at alpha=0.5 clean {float(kernel(0.5)[0, 0]):.3f} other "
f"{float(kernel(0.5)[0, 1]):.3f} clean factor {clean_factor(0.5):.0f}")
talk(f"posterior after C {[round(float(v), 3) for v in posterior]} conditional rates "
f"{float(targets[0]):.0f} under a clean A and "
f"{float(conditional_rates(0.5, 1, 1)[0]):.1f} under a clean C")
talk(f"posterior average {average:.1f} marginals {[round(float(v), 3) for v in marginal]} "
f"ratio {float(marginal[0] / marginal[1]):.1f} to G and T "
f"{marginal_reverse_rate(0.5, 1, 2, prior):.1f}")
# Section 05, the rate error on one base, where the staying term keeps the sum from collapsing.
worked_denoiser = torch.tensor([[[0.5, 0.3, 0.1, 0.1]]], dtype=torch.float64) # [1, 1, 4].
worked_current = torch.tensor([[1]]) # The current base is C.
worked_alpha = torch.tensor([[0.5]], dtype=torch.float64) # alpha_t = 1/2.
worked_ratios = ratio_table(worked_current, worked_alpha) # [1, 1, K, K] at alpha_t = 1/2.
predicted = model_rates(worked_denoiser, worked_ratios)[0, 0] / (BETA / DNA_SIZE)
target = conditional_rates(0.5, current=1, clean=0) # The clean base is A.
error = float(rate_error(target[[0, 2, 3]], predicted[[0, 2, 3]]).sum()) # Skip the current base.
talk(f"model rates from d {[round(float(v), 2) for v in predicted]} targets "
f"{[round(float(v), 1) for v in target]} rate error {error:.3f}")
# Train the denoiser against the conditional rate targets.
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):,} train={len(train_tokens)}")
def step_loss(step: int) -> torch.Tensor:
rows = torch.randint(0, len(train_tokens), (args.batch_size,), device=device)
return rate_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(rate_loss(model, valid_tokens)) for _ in range(4)) / 4.0
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 = {
"q_t(x_0 | x_0) at alpha=0.5": float(kernel(0.5)[0, 0]),
"clean factor at alpha=0.5": clean_factor(0.5),
"posterior on A after a C": float(posterior[0]),
"conditional rate under A": float(targets[0]),
"conditional rate under C": float(conditional_rates(0.5, 1, 1)[0]),
"posterior average rate": average,
"rate from the marginals": float(marginal[0] / marginal[1]),
"worked model rate to A": float(predicted[0]),
"worked rate error": error,
"training rate loss, last 50": sum(losses[-50:]) / len(losses[-50:]),
"validation rate loss": held_out,
"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("uniform: replacement diffusion on DNA", report, decode_dna(samples))
save_run(args.out, model=model, config=vars(args), report=report,
losses=losses, samples=decode_dna(samples))
# %% 08. Run the script
if __name__ == "__main__": # Execute when launched directly.
main() # Work the posterior average, train against the rate loss, and generate.
|