File size: 14,067 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 242 | """Several objectives at once: MOG-DFM's cone and pCoMole's rollout.
A peptide is scored on binding, solubility and half-life together, and no dataset labels one
sequence for all of them, so the single value function of Discrete Guidance is out of reach.
MOG-DFM drops the ratio form and builds a tilt out of a rank inside each objective and a direction
against a weight vector, with a cone that removes the candidates pointing the wrong way. pCoMole
keeps the ratio and replaces the property likelihood by an exponentiated worst-objective utility,
and its value at a candidate is the average terminal reward over rollouts of the base flow.
Chapter 5 runs t = 0 at the source and t = 1 at the data, and writes the probability velocity
u_t(y, x) for the same quantity Chapter 4 writes as the rate R_t(x, y).
Chapter 5, Sections 5.30 to 5.32. The single-objective tilt is in ``lecture_5/guidance.py``. The
rollout runs on the synthetic DNA of ``cis6270.data``, scored by GC fraction against AT fraction.
Prints the Pareto front, the chapter's MOG-DFM step with its cone, the chapter's pCoMole rollout,
and a rollout on DNA whose value reverses the base rates.
python lecture_5/multi_objective.py # 600 rollouts per candidate on DNA
python lecture_5/multi_objective.py --steps 4000 # a tighter estimate of the second value
python lecture_5/multi_objective.py --steps 20 --quiet # twenty rollouts, for a first look
"""
# %% 01. Imports and configuration
import math
import torch
from cis6270.data import DNA, DNA_SIZE, MOTIF, decode_dna, dna_splits
from cis6270.runner import common_parser, print_report, save_run, set_seed
MASK = DNA_SIZE # Index 4 is the mask symbol m, still held by a candidate off its own position.
OMEGA = torch.tensor([0.5, 0.5], dtype=torch.double) # Equal weight on the two objectives of the DNA rollout.
REFERENCE = torch.tensor([0.4, 0.3], dtype=torch.double) # The levels r_k the objectives sit above.
BETA = 2.0 # The inverse temperature of the terminal reward.
# %% 02. Several objectives at once
#
# With several objectives no single sequence is best. A candidate is on the Pareto front when no
# other candidate matches it everywhere and beats it somewhere, and a weight vector picks one point
# of that front by collapsing the scores to a single weighted number.
def dominated(scores, row):
"""True when some other candidate is at least as good everywhere and better somewhere."""
others = torch.cat([scores[:row], scores[row + 1:]]) # Every candidate except this one.
weak = (others >= scores[row]).all(dim=1) # At least as good on every objective.
strict = (others > scores[row]).any(dim=1) # Strictly better on at least one.
return bool((weak & strict).any())
def pareto_front(scores):
"""The indices of the candidates no other candidate dominates. scores: [M, N] -> list."""
return [row for row in range(len(scores)) if not dominated(scores, row)]
# %% 03. MOG-DFM: rank, direction and a cone
#
# Without a joint likelihood we have no ratio to compute, so we score every candidate letter twice.
# A rank inside each objective removes the units, the inner product with the weight vector measures
# direction, and the two z-scored terms exponentiate into a tilt on the pretrained rate.
def rank_scores(changes):
"""I_n(y^i, x), the rank of each candidate's change within each objective, over the alphabet size."""
order = changes.argsort(dim=0).argsort(dim=0) + 1 # Rank one for the smallest change.
return order.to(changes.dtype) / changes.shape[0] # [K, N] in {1/K, ..., 1}.
def z_score(values):
"""Norm[.], the z-score across the candidate letters at one position."""
spread = values.std(unbiased=False).clamp(min=1e-12) # The population deviation, held off zero.
return (values - values.mean()) / spread # Zero everywhere when no candidate stands out.
def combined_score(changes, importance, weights, lam=1.0):
"""Delta S = Norm[mean_n i_n I_n] + lambda Norm[Delta s . omega]. changes: [K, N] -> [K]."""
ranks = (rank_scores(changes) * importance).mean(dim=1) # Importance-weighted average of ranks.
direction = changes @ weights # [K] the directional score of each candidate.
return z_score(ranks) + lam * z_score(direction), ranks, direction
# %% 04. Accepting a move only inside a cone
#
# A z-scored score puts about half the candidates above zero, so the tilt alone promotes the least
# bad letter at a position where every letter moves away from the requested trade-off. The angle
# between a candidate's score change and omega removes those, and a controller on the logarithm of
# the angle holds the surviving fraction near one minus the target rejection rate.
def cone_angles(changes, weights):
"""alpha^i = arccos(Delta s . omega / (||Delta s|| ||omega||)), in degrees."""
cosine = (changes @ weights) / (changes.norm(dim=1) * weights.norm()) # [K].
return torch.rad2deg(torch.arccos(cosine.clamp(-1.0, 1.0))), cosine
def update_cone(phi, rejected, target=0.2, gain=0.5, low=30.0, high=90.0):
"""Phi_{t+h} = clip(Phi_t exp(eta (r - tau)), Phi_min, Phi_max), proportional control on log Phi."""
return min(max(phi * math.exp(gain * (rejected - target)), low), high) # Wider when it rejects more.
# %% 05. pCoMole: a reward in place of a likelihood
#
# If we score a sequence by its weakest objective, the sampler cannot trade one property away, and
# a small multiple of the sum breaks ties. The exponentiated utility times a feasibility indicator
# is the terminal reward, and the value of a candidate is the average reward over rollouts of the
# base flow started from it.
def utility(scores, weights, reference, rho=0.1):
"""U(x) = min_k omega_k (f_k(x) - r_k) + rho sum_k omega_k (f_k(x) - r_k)."""
gaps = weights * (scores - reference) # [N] the weighted gap on each objective.
return float(gaps.min() + rho * gaps.sum()) # The minimum, with the tie-breaking correction.
def terminal_reward(scores, weights, reference, beta, feasible, rho=0.1):
"""G(x) = exp(beta U(x)) 1[x in F], zero for an infeasible sequence."""
return math.exp(beta * utility(scores, weights, reference, rho)) * float(feasible)
def rollout_value(rollouts, feasible, weights, reference, beta, rho=0.1):
"""hat h_t(y_c), the average terminal reward over R continuations of the base flow."""
rewards = [terminal_reward(s, weights, reference, beta, f, rho) for s, f in zip(rollouts, feasible)]
return sum(rewards) / len(rewards), rewards # A candidate with no feasible ending scores zero.
# %% 06. A rollout on the synthetic DNA
#
# The base flow of a rollout is the mask-source sampler of this chapter with the letter frequency of
# the data as its denoiser, so a continuation costs no network call. The GC fraction and the AT
# fraction of the finished sequence pull against each other. The feasible set is the sequences that
# carry the planted motif.
def continuations(partial, frequency, rollouts, generator=None):
"""Fill every masked position of a candidate from the base flow. partial: [L] -> [R, L]."""
grid = partial[None, :].repeat(rollouts, 1) # [R, L] one copy of the candidate per rollout.
masked = grid == MASK # [R, L] the positions the flow still owes a letter.
draws = torch.multinomial(frequency, int(masked.sum()), replacement=True, generator=generator)
return grid.masked_scatter(masked, draws) # [R, L] the terminal sequences of the rollouts.
def dna_objectives(sequences):
"""The GC fraction and the AT fraction of each sequence. The two sum to one. [R, L] -> [R, 2]."""
gc = ((sequences == 1) | (sequences == 2)).to(torch.get_default_dtype()).mean(dim=1) # C and G.
return torch.stack([gc, 1.0 - gc], dim=1) # [R, 2] raising one lowers the other.
def carries_motif(sequences):
"""One per sequence that holds the planted motif, the hard constraint here. [R] ints."""
return [int(MOTIF in sequence) for sequence in decode_dna(sequences)]
def dna_rollout_value(partial, frequency, rollouts, weights, reference, beta, generator=None):
"""hat h_t at one candidate, from ``rollouts`` continuations of the base flow. Returns a float."""
finished = continuations(partial, frequency, rollouts, generator) # [R, L].
scores = dna_objectives(finished) # [R, 2] the two objectives at the end of each rollout.
value, _ = rollout_value(list(scores), carries_motif(finished), weights, reference, beta)
return value # Zero when no continuation of this candidate is feasible.
# %% 07. The report
def main():
parser = common_parser(__doc__.split("\n")[0])
args = parser.parse_args()
set_seed(args.seed)
# Section 5.30: four candidates on two objectives, and the two weight vectors.
candidates = torch.tensor([[0.8, 0.3], [0.6, 0.7], [0.5, 0.2], [0.6, 0.3]], dtype=torch.double)
front = pareto_front(candidates)
report = {
"pareto_front": front, # Candidates 1 and 2; 3 and 4 are dominated.
"weighted_at_0.6_0.4": [round(float(s), 3) for s in candidates @ torch.tensor(
[0.6, 0.4], dtype=torch.double)], # 0.60 for the first and 0.64 for the second.
"weighted_at_0.8_0.2": [round(float(s), 3) for s in candidates @ torch.tensor(
[0.8, 0.2], dtype=torch.double)], # 0.70 and 0.62, a reversal of the order.
}
# Section 5.31.2: one MOG-DFM step at a position holding A, with three candidate letters.
changes = torch.tensor([[0.0, 0.0], [0.20, -0.05], [0.05, 0.15], [-0.10, 0.08]], dtype=torch.double)
importance = torch.tensor([1.2, 0.8], dtype=torch.double)
weights = torch.tensor([0.6, 0.4], dtype=torch.double)
delta_s, ranks, direction = combined_score(changes, importance, weights)
report["rank_average"] = [round(float(r), 3) for r in ranks] # (0.500, 0.700, 0.850, 0.450).
report["directional_scores"] = [round(float(d), 3) for d in direction] # (0, 0.100, 0.090, -0.028).
report["combined_score"] = [round(float(s), 3) for s in delta_s] # (-1.511, 1.541, 2.297, -2.327).
posterior = torch.tensor([0.3, 0.3, 0.3, 0.1], dtype=torch.double) # The denoiser at t = 0.7.
base = posterior[1:] / (1.0 - 0.7) # The pretrained rates out of A toward C, G and T.
report["base_rates"] = [round(float(r), 4) for r in base] # (1.000, 1.000, 0.333).
tilted = base * delta_s[1:].exp()
report["guided_rates"] = [round(float(r), 3) for r in tilted] # (4.667, 9.948, 0.033).
angles, cosines = cone_angles(changes[1:], weights)
report["cone_cosines"] = [round(float(c), 4) for c in cosines] # (0.6727, 0.7894, -0.3032).
report["cone_angles_deg"] = [round(float(a), 1) for a in angles] # (47.7, 37.9, 107.7).
inside = angles <= 60.0 # The cone test at Phi = 60 degrees.
survivors = tilted[inside]
report["after_cone"] = [round(float(r), 3) for r in survivors / survivors.sum()] # (0.319, 0.681).
rejected = float((~inside).to(torch.get_default_dtype()).mean()) # One candidate of three.
report["next_cone_angle"] = round(update_cone(60.0, rejected), 1) # 64.1.
# Section 5.32.2: three candidate edits, each rolled out four times.
omega, reference = torch.tensor([0.5, 0.5], dtype=torch.double), torch.zeros(2, dtype=torch.double)
edits = [
([(0.8, 0.4), (0.6, 0.6), (0.9, 0.1), (0.5, 0.5)], [1, 1, 0, 1], 0.6),
([(1.0, 0.0), (0.9, 0.1), (0.95, 0.05), (1.0, 0.1)], [1, 1, 1, 1], 1.2),
([(0.4, 0.4)] * 4, [0, 0, 0, 0], 2.0),
]
values = [rollout_value([torch.tensor(s, dtype=torch.double) for s in rollouts],
feasible, omega, reference, 2.0)[0] for rollouts, feasible, _ in edits]
base_rates = torch.tensor([rate for _, _, rate in edits], dtype=torch.double)
products = torch.tensor(values) * base_rates # The base rate times the continuation value.
report["rollout_values"] = [round(v, 3) for v in values] # (1.390, 1.181, 0).
report["rollout_products"] = [round(float(p), 3) for p in products] # (0.834, 1.417, 0).
report["rollout_selection"] = [round(float(p), 3) for p in products / products.sum()]
report["rollout_base_selection"] = [round(float(p), 3) for p in base_rates / base_rates.sum()]
# The same rollout on the shared DNA, from a partly revealed sequence with ACG already in place.
splits = dna_splits(num_sequences=512, length=args.length, seed=args.seed)
counts = torch.bincount(splits["train_tokens"].flatten(), minlength=DNA_SIZE).double()
frequency = counts / counts.sum() # [4] the denoiser of the base flow, GC-rich like the data.
# The base rates below are kappa_dot/(1 - kappa) times that denoiser, read at t = 0.3.
revealed = torch.full((args.length,), MASK, dtype=torch.long)
revealed[2:5] = torch.tensor([0, 1, 2]) # Positions 3, 4 and 5 hold A, C and G.
finish = {letter: revealed.clone() for letter in "TC"}
for letter, candidate in finish.items():
candidate[5] = DNA.index(letter) # Position 6 takes T, completing ACGT, or C, and C does not.
seen = {letter: dna_rollout_value(candidate, frequency, args.steps, OMEGA, REFERENCE, BETA)
for letter, candidate in finish.items()}
rates = torch.tensor([float(frequency[DNA.index(letter)]) for letter in finish]) / (1.0 - 0.3)
guided = rates * torch.tensor([seen[letter] for letter in finish]) # The base rate times hat h.
report["dna_rollout_value_T"] = round(seen["T"], 4) # Every continuation carries the motif.
report["dna_rollout_value_C"] = round(seen["C"], 4) # Only the ones that plant it elsewhere.
report["dna_base_rates_T_C"] = [round(float(r), 4) for r in rates] # C has the higher base rate.
report["dna_base_selection"] = [round(float(r), 4) for r in rates / rates.sum()]
report["dna_guided_selection"] = [round(float(r), 4) for r in guided / guided.sum()]
print_report("several objectives at once", report)
save_run(args.out, config=vars(args), report=report)
if __name__ == "__main__":
main()
|