| import argparse |
| import csv |
| import json |
| import math |
| import os |
| import random |
| import sys |
| from dataclasses import dataclass, field |
| from pathlib import Path |
| from typing import Dict, List, Optional, Sequence, Tuple |
|
|
| import h5py |
| import pandas as pd |
| import torch |
| from torch import Tensor |
| from tqdm.auto import tqdm |
| from transformers import AutoTokenizer |
|
|
| from src.model import EsmConfig, EsmForMaskedLM |
| from src.utils import base_config, load_config, resume_model, validate_protein_dim_against_h5 |
|
|
|
|
| RNA_TOKEN_ALPHABET = {"A", "C", "G", "U"} |
|
|
|
|
| class _TeeIO: |
| """Mirror writes to two text streams (e.g. console + sample_run.log).""" |
|
|
| __slots__ = ("_a", "_b") |
|
|
| def __init__(self, a, b): |
| self._a = a |
| self._b = b |
|
|
| def write(self, data: str) -> int: |
| na = self._a.write(data) |
| self._b.write(data) |
| self._a.flush() |
| self._b.flush() |
| return na |
|
|
| def flush(self) -> None: |
| self._a.flush() |
| self._b.flush() |
|
|
| def isatty(self) -> bool: |
| return getattr(self._a, "isatty", lambda: False)() |
|
|
|
|
| @dataclass |
| class TokenizerRuntime: |
| tokenizer: object |
| id_to_token: List[str] |
| valid_content_token_mask: Tensor |
| mask_token_id: int |
| cls_token_id: int |
| pad_token_id: Optional[int] |
| eos_token_id: Optional[int] |
| special_token_ids: Sequence[int] |
|
|
|
|
| @dataclass |
| class SamplingConfig: |
| steps: int = 50 |
| temperature: float = 1.0 |
| top_p: float = 0.95 |
| top_k: int = 0 |
| eps: float = 1e-3 |
| alg: str = "maskgit_plus" |
| alg_temp: float = 0.8 |
| batch_size: int = 64 |
| num_sequences_per_rbp: int = 2048 |
| length_bp: int = 101 |
| token_length: int = 1 |
| seed: int = 42 |
| use_bf16: bool = True |
| variable_length: bool = False |
| min_rna_bp: Optional[int] = None |
| nucleotide_logit_bias: Optional[Dict[str, float]] = None |
| nucleotide_bias_vector: Optional[Tensor] = field(default=None, repr=False) |
| |
| maskgit_eos_late_bias: float = 0.0 |
| maskgit_eos_late_frac: float = 0.35 |
| |
| maskgit_eos_late_scope: str = "last_slot_if_variable_else_all_masked" |
|
|
| @property |
| def num_kmers(self) -> int: |
| if self.token_length <= 0: |
| raise ValueError( |
| f"`token_length` must be a positive integer, got {self.token_length}." |
| ) |
| if self.length_bp % self.token_length != 0: |
| raise ValueError( |
| f"`length_bp` must be a multiple of token_length={self.token_length}, got {self.length_bp}." |
| ) |
| return self.length_bp // self.token_length |
|
|
| @property |
| def num_prompt_content_tokens(self) -> int: |
| """Slots after <cls>: RNA (num_kmers) and optionally one extra for <eos>.""" |
| n = self.num_kmers |
| if self.variable_length: |
| return n + 1 |
| return n |
|
|
|
|
| class ProteinEmbeddingStore: |
| def __init__(self, h5_path: Path) -> None: |
| self.h5_path = h5_path |
| self._h5_file = None |
| self._embeddings_ds = None |
| with h5py.File(self.h5_path, "r") as f: |
| p_ids = [ |
| x.decode("utf-8") if isinstance(x, bytes) else str(x) |
| for x in f["p_ids"][:] |
| ] |
| starts = f["starts"][:] |
| lengths = f["lengths"][:] |
| self.protein_index = { |
| p_id: (int(start), int(length)) |
| for p_id, start, length in zip(p_ids, starts, lengths) |
| } |
|
|
| def _ensure_open(self) -> None: |
| if self._h5_file is None: |
| self._h5_file = h5py.File(self.h5_path, "r") |
| self._embeddings_ds = self._h5_file["embeddings"] |
|
|
| def get(self, p_id: str) -> Tensor: |
| if p_id not in self.protein_index: |
| raise KeyError(f"Protein id not found in H5 index: {p_id}") |
| self._ensure_open() |
| start, length = self.protein_index[p_id] |
| emb = self._embeddings_ds[start : start + length] |
| return torch.from_numpy(emb).float() |
|
|
| def close(self) -> None: |
| if self._h5_file is not None: |
| try: |
| self._h5_file.close() |
| except Exception: |
| pass |
| self._h5_file = None |
| self._embeddings_ds = None |
|
|
| def __del__(self) -> None: |
| self.close() |
|
|
| def resolve_path(raw_path: Optional[str], project_root: Path, config_dir: Optional[Path] = None) -> Optional[Path]: |
| if raw_path is None: |
| return None |
| path = Path(raw_path) |
| if path.is_absolute(): |
| return path |
| candidates = [] |
| if config_dir is not None: |
| candidates.append(config_dir / path) |
| candidates.append(project_root / path) |
| for candidate in candidates: |
| if candidate.exists(): |
| return candidate |
| if config_dir is not None: |
| return config_dir / path |
| return project_root / path |
|
|
|
|
| def is_valid_rna_token(token: str, token_length: int) -> bool: |
| return len(token) == token_length and set(token) <= RNA_TOKEN_ALPHABET |
|
|
|
|
| def build_tokenizer_runtime( |
| tokenizer_dir: Path, |
| device: torch.device, |
| token_length: int, |
| *, |
| allow_eos_in_content: bool = False, |
| ) -> TokenizerRuntime: |
| tokenizer = AutoTokenizer.from_pretrained( |
| str(tokenizer_dir), |
| trust_remote_code=True, |
| ) |
| vocab = tokenizer.get_vocab() |
| id_to_token = [""] * len(vocab) |
| for token, token_id in vocab.items(): |
| id_to_token[token_id] = token |
|
|
| valid_content_token_mask = torch.tensor( |
| [is_valid_rna_token(token, token_length=token_length) for token in id_to_token], |
| dtype=torch.bool, |
| device=device, |
| ) |
| eos_id = getattr(tokenizer, "eos_token_id", None) |
| if allow_eos_in_content and eos_id is not None and 0 <= eos_id < len(valid_content_token_mask): |
| valid_content_token_mask[eos_id] = True |
| if not valid_content_token_mask.any(): |
| raise ValueError( |
| f"Tokenizer vocab does not contain any valid RNA {token_length}-mer tokens." |
| ) |
| if tokenizer.mask_token_id is None: |
| raise ValueError("Tokenizer is missing `mask_token_id`.") |
| if tokenizer.cls_token_id is None: |
| raise ValueError( |
| "Tokenizer is missing `cls_token_id`. Training uses tokenizer-added <cls> at position 0, " |
| "so sampling requires it as a fixed prefix." |
| ) |
| return TokenizerRuntime( |
| tokenizer=tokenizer, |
| id_to_token=id_to_token, |
| valid_content_token_mask=valid_content_token_mask, |
| mask_token_id=tokenizer.mask_token_id, |
| cls_token_id=tokenizer.cls_token_id, |
| pad_token_id=tokenizer.pad_token_id, |
| eos_token_id=eos_id, |
| special_token_ids=tuple(tokenizer.all_special_ids), |
| ) |
|
|
|
|
| def build_nucleotide_bias_vector( |
| id_to_token: Sequence[str], |
| valid_content_token_mask: Tensor, |
| bias: Dict[str, float], |
| device: torch.device, |
| ) -> Tensor: |
| """Per-vocab additive bias for single-nucleotide RNA tokens (e.g. boost C).""" |
| vec = torch.zeros(len(id_to_token), device=device) |
| for token_id, token in enumerate(id_to_token): |
| if not bool(valid_content_token_mask[token_id].item()): |
| continue |
| if token in bias: |
| vec[token_id] = float(bias[token]) |
| return vec |
|
|
|
|
| def sequence_c_frac(rna: str) -> float: |
| bases = [c for c in rna.upper() if c in RNA_TOKEN_ALPHABET] |
| if not bases: |
| return 0.0 |
| return bases.count("C") / len(bases) |
|
|
|
|
| def resolve_composition_filter_for_target( |
| comp_cfg: dict, |
| *, |
| p_id: str, |
| project_root: Path, |
| config_dir: Optional[Path], |
| ) -> dict: |
| """Merge static composition_filter config with optional per-p_id reference from CSV.""" |
| if not comp_cfg.get("enabled", False): |
| return {"enabled": False} |
|
|
| out = dict(comp_cfg) |
| ref_csv = comp_cfg.get("reference_composition_csv") |
| if ref_csv: |
| ref_path = resolve_path(ref_csv, project_root, config_dir) |
| if ref_path is None or not ref_path.exists(): |
| raise FileNotFoundError(f"reference_composition_csv not found: {ref_csv}") |
| df = pd.read_csv(ref_path, low_memory=False) |
| if "p_id" not in df.columns or "rna" not in df.columns: |
| raise ValueError(f"reference_composition_csv needs p_id and rna: {ref_path}") |
| sub = df[df["p_id"].astype(str) == p_id] |
| if len(sub) == 0: |
| print( |
| f" [composition_filter] p_id={p_id} not in {ref_path.name}; " |
| "using static min_c_frac/max_c_frac only." |
| ) |
| else: |
| fracs = sub["rna"].astype(str).map(sequence_c_frac) |
| mean_c = float(fracs.mean()) |
| margin = float(comp_cfg.get("reference_margin_c_frac", 0.12)) |
| out["min_c_frac"] = max(0.0, mean_c - margin) |
| out["max_c_frac"] = min(1.0, mean_c + margin) |
| out["_reference_mean_c_frac"] = mean_c |
| return out |
|
|
|
|
| def passes_composition_filter(rna: str, filt: dict) -> bool: |
| if not filt.get("enabled", False): |
| return True |
| cf = sequence_c_frac(rna) |
| min_c = filt.get("min_c_frac") |
| max_c = filt.get("max_c_frac") |
| if min_c is not None and cf < float(min_c): |
| return False |
| if max_c is not None and cf > float(max_c): |
| return False |
| return True |
|
|
|
|
| def _top_p_logits(logits: Tensor, top_p: float) -> Tensor: |
| sorted_logits, sorted_indices = torch.sort(logits, descending=True) |
| cumulative_probs = torch.cumsum(torch.softmax(sorted_logits.float(), dim=-1), dim=-1) |
| sorted_indices_to_remove = cumulative_probs > top_p |
| sorted_indices_to_remove[..., 1:] = sorted_indices_to_remove[..., :-1].clone() |
| sorted_indices_to_remove[..., 0] = False |
| mask = torch.zeros_like(logits, dtype=torch.bool, device=logits.device) |
| mask.scatter_(-1, sorted_indices, sorted_indices_to_remove) |
| return logits.masked_fill(mask, torch.finfo(logits.dtype).min) |
|
|
|
|
| def _top_k_logits(logits: Tensor, top_k: int) -> Tensor: |
| if top_k is None or top_k <= 0: |
| return logits |
| top_k = min(top_k, logits.size(-1)) |
| threshold = torch.topk(logits, top_k)[0][..., -1, None] |
| return logits.masked_fill(logits < threshold, torch.finfo(logits.dtype).min) |
|
|
|
|
| def _sample_tokens( |
| logits: Tensor, |
| valid_token_mask: Tensor, |
| temperature: float = 0.0, |
| top_p: Optional[float] = None, |
| top_k: Optional[int] = None, |
| alg: str = "origin", |
| nucleotide_bias: Optional[Tensor] = None, |
| ) -> Tuple[Tensor, Tensor]: |
| logits = logits.clone() |
| logits = logits.masked_fill(~valid_token_mask.unsqueeze(0), torch.finfo(logits.dtype).min) |
| if nucleotide_bias is not None: |
| logits = logits + nucleotide_bias.unsqueeze(0) |
| if temperature > 0: |
| logits = logits / temperature |
| if top_p is not None and top_p < 1: |
| logits = _top_p_logits(logits, top_p) |
| if top_k is not None: |
| logits = _top_k_logits(logits, top_k) |
| probs = torch.softmax(logits.float(), dim=-1) |
| if temperature > 0: |
| x0 = torch.distributions.Categorical(probs=probs).sample() |
| else: |
| _, x0 = probs.max(dim=-1) |
| confidence = torch.gather(probs, -1, x0.unsqueeze(-1)).squeeze(-1) |
|
|
| if alg == "topk_margin": |
| sorted_probs, _ = torch.sort(probs, dim=-1, descending=True) |
| confidence = sorted_probs[..., 0] - sorted_probs[..., 1] |
| elif alg == "entropy": |
| log_probs = torch.log(probs.clamp(min=1e-10)) |
| confidence = (probs * log_probs).sum(dim=-1) |
|
|
| return confidence, x0 |
|
|
|
|
| def apply_late_eos_logit_bias_to_logits( |
| logits: Tensor, |
| mask_index: Tensor, |
| *, |
| eos_token_id: Optional[int], |
| variable_length: bool, |
| step_idx: int, |
| total_steps: int, |
| late_frac: float, |
| bias: float, |
| scope: str, |
| ) -> Tensor: |
| """ |
| 在扩散采样的后半段,对仍被 mask 的位置上 eos 维度的 logits 加常数偏置, |
| 使 <eos> 更容易进入高置信度解掩集合,减轻「eos 总被挤到最后」的现象。 |
| """ |
| if bias == 0.0 or eos_token_id is None or total_steps <= 0: |
| return logits |
| eid = int(eos_token_id) |
| B, L, V = logits.shape |
| if eid < 0 or eid >= V: |
| return logits |
| start = int(math.ceil((1.0 - float(late_frac)) * float(total_steps))) |
| if step_idx < start: |
| return logits |
|
|
| device = logits.device |
| pos = torch.arange(L, device=device).view(1, -1).expand(B, -1) |
| last = L - 1 |
| if scope == "last_slot_only": |
| sel = mask_index & (pos == last) |
| elif scope == "all_masked": |
| sel = mask_index |
| else: |
| |
| if variable_length: |
| sel = mask_index & (pos == last) |
| else: |
| sel = mask_index |
| if not sel.any(): |
| return logits |
| out = logits.clone() |
| delta = torch.zeros((B, L), device=device, dtype=out.dtype) |
| delta[sel] = float(bias) |
| out[:, :, eid] = out[:, :, eid] + delta |
| return out |
|
|
|
|
| @torch.no_grad() |
| def conditioned_diffusion_generate( |
| model: EsmForMaskedLM, |
| input_ids: Tensor, |
| protein_cond: Tensor, |
| protein_attention_mask: Tensor, |
| content_token_constraint: Tensor, |
| sampling_cfg: SamplingConfig, |
| mask_token_id: int, |
| eos_token_id: Optional[int] = None, |
| ) -> Tensor: |
| if input_ids.dim() != 2: |
| raise ValueError(f"`input_ids` must have shape [B, L], got {tuple(input_ids.shape)}.") |
| if protein_cond.dim() != 3: |
| raise ValueError( |
| f"`protein_cond` must have shape [B, Lp, Dp], got {tuple(protein_cond.shape)}." |
| ) |
| if protein_attention_mask.dim() != 2: |
| raise ValueError( |
| f"`protein_attention_mask` must have shape [B, Lp], got {tuple(protein_attention_mask.shape)}." |
| ) |
| if protein_cond.shape[:2] != protein_attention_mask.shape: |
| raise ValueError( |
| "Protein condition and attention mask shapes do not match: " |
| f"{tuple(protein_cond.shape)} vs {tuple(protein_attention_mask.shape)}." |
| ) |
|
|
| x = input_ids.clone() |
| fix_mask = x != mask_token_id |
| gen_attention_mask = torch.ones_like(x, dtype=torch.long) |
| timesteps = torch.linspace(1.0, sampling_cfg.eps, sampling_cfg.steps + 1, device=x.device) |
|
|
| autocast_ctx = ( |
| torch.autocast(device_type="cuda", dtype=torch.bfloat16) |
| if x.is_cuda and sampling_cfg.use_bf16 |
| else torch.autocast(device_type="cpu", enabled=False) |
| ) |
|
|
| for i in tqdm(range(sampling_cfg.steps), desc="Diffusion", leave=False): |
| mask_index = x == mask_token_id |
| if not mask_index.any(): |
| break |
|
|
| with autocast_ctx: |
| outputs = model( |
| input_ids=x, |
| attention_mask=gen_attention_mask, |
| protein_cond=protein_cond, |
| protein_attention_mask=protein_attention_mask, |
| ) |
| logits = outputs.logits |
| logits = apply_late_eos_logit_bias_to_logits( |
| logits, |
| mask_index, |
| eos_token_id=eos_token_id, |
| variable_length=sampling_cfg.variable_length, |
| step_idx=i, |
| total_steps=sampling_cfg.steps, |
| late_frac=sampling_cfg.maskgit_eos_late_frac, |
| bias=sampling_cfg.maskgit_eos_late_bias, |
| scope=sampling_cfg.maskgit_eos_late_scope, |
| ) |
| mask_logits = logits[mask_index] |
| t = timesteps[i] |
| s = timesteps[i + 1] |
|
|
| if sampling_cfg.alg == "origin": |
| p_transfer = 1 - s / t if i < sampling_cfg.steps - 1 else 1 |
| x0 = torch.full_like(x[mask_index], fill_value=mask_token_id, device=x.device, dtype=torch.long) |
| transfer_index = torch.rand(*x0.shape, device=x.device) < p_transfer |
| if transfer_index.any(): |
| _, sampled = _sample_tokens( |
| mask_logits[transfer_index], |
| valid_token_mask=content_token_constraint, |
| temperature=sampling_cfg.temperature, |
| top_p=sampling_cfg.top_p, |
| top_k=sampling_cfg.top_k, |
| alg=sampling_cfg.alg, |
| nucleotide_bias=sampling_cfg.nucleotide_bias_vector, |
| ) |
| x0[transfer_index] = sampled |
| x[mask_index] = x0 |
|
|
| elif sampling_cfg.alg == "p2": |
| kappa_t = (i + 1) / sampling_cfg.steps |
| confidence, x0 = _sample_tokens( |
| mask_logits, |
| valid_token_mask=content_token_constraint, |
| temperature=sampling_cfg.temperature, |
| top_p=sampling_cfg.top_p, |
| top_k=sampling_cfg.top_k, |
| alg=sampling_cfg.alg, |
| nucleotide_bias=sampling_cfg.nucleotide_bias_vector, |
| ) |
| full_conf = torch.full_like(x, float("inf"), dtype=confidence.dtype) |
| full_conf[mask_index] = confidence |
| full_conf[fix_mask] = float("inf") |
| num_positions = (~fix_mask).sum(dim=1, keepdim=True) |
| num_to_mask = (num_positions.float() * (1 - kappa_t)).long() |
| sorted_idx = torch.argsort(full_conf, dim=-1, descending=False) |
| max_mask = num_to_mask.max() |
| arange_mask = torch.arange(max_mask, device=x.device).unsqueeze(0) < num_to_mask |
| to_mask_idx = sorted_idx[:, :max_mask][arange_mask] |
| to_mask = torch.zeros_like(x, dtype=torch.bool) |
| batch_idx = ( |
| torch.arange(x.size(0), device=x.device) |
| .unsqueeze(1) |
| .expand(-1, max_mask)[arange_mask] |
| ) |
| to_mask[batch_idx, to_mask_idx] = True |
| x[to_mask] = mask_token_id |
| mask_candidates = mask_index & ~to_mask |
| x_proposals = torch.full_like(x, fill_value=mask_token_id) |
| x_proposals[mask_index] = x0 |
| x[mask_candidates] = x_proposals[mask_candidates] |
|
|
| elif sampling_cfg.alg in {"maskgit_plus", "entropy", "topk_margin"}: |
| confidence, x0 = _sample_tokens( |
| mask_logits, |
| valid_token_mask=content_token_constraint, |
| temperature=sampling_cfg.temperature, |
| top_p=sampling_cfg.top_p, |
| top_k=sampling_cfg.top_k, |
| alg=sampling_cfg.alg, |
| nucleotide_bias=sampling_cfg.nucleotide_bias_vector, |
| ) |
| confidence = confidence.to(mask_logits.dtype) |
| num_mask_tokens = mask_index.sum(dim=1) |
| if i < sampling_cfg.steps - 1: |
| n_transfer = (num_mask_tokens.float() * (1 - s / t)).long() |
| else: |
| n_transfer = num_mask_tokens |
| full_confidence = torch.full_like(x, -torch.inf, dtype=logits.dtype) |
| full_confidence[mask_index] = confidence |
| |
| seq_k = full_confidence.size(1) |
| if sampling_cfg.alg_temp == 0: |
| _, all_indices = torch.topk(full_confidence, seq_k, dim=1) |
| else: |
| scaled = full_confidence / sampling_cfg.alg_temp |
| uniform = torch.rand_like(scaled).clamp_(1e-20, 1 - 1e-20) |
| scores = scaled + (-torch.log(-torch.log(uniform))) |
| _, all_indices = torch.topk(scores, seq_k, dim=1) |
| valid_mask = ( |
| torch.arange(seq_k, device=x.device).unsqueeze(0) < n_transfer.unsqueeze(1) |
| ) |
| valid_indices = all_indices[valid_mask] |
| valid_batch = ( |
| torch.arange(x.size(0), device=x.device) |
| .unsqueeze(1) |
| .expand_as(all_indices)[valid_mask] |
| ) |
| x_ = torch.full_like(x, fill_value=mask_token_id) |
| x_[mask_index] = x0.clone() |
| x[valid_batch, valid_indices] = x_[valid_batch, valid_indices] |
|
|
| elif sampling_cfg.alg == "random": |
| _, x0 = _sample_tokens( |
| mask_logits, |
| valid_token_mask=content_token_constraint, |
| temperature=sampling_cfg.temperature, |
| top_p=sampling_cfg.top_p, |
| top_k=sampling_cfg.top_k, |
| alg=sampling_cfg.alg, |
| nucleotide_bias=sampling_cfg.nucleotide_bias_vector, |
| ) |
| num_mask_tokens = mask_index.sum(dim=1) |
| if i < sampling_cfg.steps - 1: |
| n_transfer = (num_mask_tokens.float() * (1 - s / t)).long() |
| else: |
| n_transfer = num_mask_tokens |
| if n_transfer.max().item() > 0: |
| x_ = torch.full_like(x, fill_value=mask_token_id) |
| x_[mask_index] = x0.clone() |
| for b in range(x.size(0)): |
| positions = mask_index[b].nonzero(as_tuple=True)[0] |
| n = min(int(n_transfer[b].item()), len(positions)) |
| if n > 0: |
| sel = torch.randperm(len(positions), device=x.device)[:n] |
| x[b, positions[sel]] = x_[b, positions[sel]] |
| else: |
| raise NotImplementedError(f"Unsupported diffusion sampling algorithm: {sampling_cfg.alg}") |
|
|
| return x |
|
|
|
|
| def _coalesce_rbp_name(row: Dict[str, object]) -> Optional[str]: |
| """TSV/CSV 行中的 RBP 展示名;兼容旧列名 tf_name。""" |
| for key in ("rbp_name", "tf_name"): |
| v = row.get(key) |
| if v is None: |
| continue |
| s = str(v).strip() |
| if s: |
| return s |
| return None |
|
|
|
|
| def load_targets_map(targets_tsv: Optional[Path]) -> Dict[str, Dict[str, str]]: |
| if targets_tsv is None or not targets_tsv.exists(): |
| return {} |
| targets_map: Dict[str, Dict[str, str]] = {} |
| with targets_tsv.open("r", encoding="utf-8") as f: |
| reader = csv.DictReader(f, delimiter="\t") |
| for row in reader: |
| p_id = row.get("p_id") |
| if p_id: |
| norm = dict(row) |
| name = _coalesce_rbp_name(norm) |
| if name: |
| norm["rbp_name"] = name |
| targets_map[p_id] = norm |
| return targets_map |
|
|
|
|
| def _csv_int(row: dict, *keys: str) -> Optional[int]: |
| """First non-empty CSV cell among keys; stripped string -> int.""" |
| for k in keys: |
| v = row.get(k) |
| if v is None: |
| continue |
| s = str(v).strip() |
| if not s: |
| continue |
| if s.lower() == "auto": |
| return None |
| return int(s) |
| return None |
|
|
|
|
| def _read_rlen_series(reference_csv: Path) -> pd.Series: |
| df = pd.read_csv(reference_csv, low_memory=False) |
| if "r_len" in df.columns: |
| return df["r_len"].astype(float) |
| if "rna" in df.columns: |
| return df["rna"].astype(str).str.len().astype(float) |
| raise ValueError(f"Reference CSV needs r_len or rna column: {reference_csv}") |
|
|
|
|
| def _quantize_rlen( |
| value: float, |
| *, |
| min_bp: int, |
| max_bp: int, |
| round_mode: str, |
| ) -> int: |
| if round_mode == "floor": |
| bp = int(math.floor(value)) |
| elif round_mode == "round": |
| bp = int(round(value)) |
| else: |
| bp = int(math.ceil(value)) |
| return max(min_bp, min(max_bp, bp)) |
|
|
|
|
| def compute_global_rlen_length_bp( |
| reference_csv: Path, |
| *, |
| percentile: float = 90.0, |
| min_bp: int = 12, |
| max_bp: int = 101, |
| round_mode: str = "ceil", |
| ) -> int: |
| """Global r_len percentile over all rows in reference CSV (e.g. entire train set).""" |
| lengths = _read_rlen_series(reference_csv) |
| if lengths.empty: |
| raise ValueError(f"No lengths in reference CSV: {reference_csv}") |
| val = float(lengths.quantile(percentile / 100.0)) |
| return _quantize_rlen(val, min_bp=min_bp, max_bp=max_bp, round_mode=round_mode) |
|
|
|
|
| def build_rlen_length_map( |
| reference_csv: Path, |
| *, |
| percentile: float = 90.0, |
| min_bp: int = 12, |
| max_bp: int = 101, |
| round_mode: str = "ceil", |
| ) -> Dict[str, int]: |
| """ |
| Per p_id, infer sampling length_bp from r_len (or len(rna)) in a reference CSV. |
| """ |
| df = pd.read_csv(reference_csv, low_memory=False) |
| if "p_id" not in df.columns: |
| raise ValueError(f"Reference CSV missing p_id: {reference_csv}") |
| if "r_len" in df.columns: |
| lengths = df["r_len"].astype(float) |
| elif "rna" in df.columns: |
| lengths = df["rna"].astype(str).str.len().astype(float) |
| else: |
| raise ValueError(f"Reference CSV needs r_len or rna column: {reference_csv}") |
|
|
| work = df.assign(_len=lengths).groupby("p_id", sort=False)["_len"] |
| out: Dict[str, int] = {} |
| for p_id, series in work: |
| if series.empty: |
| continue |
| val = float(series.quantile(percentile / 100.0)) |
| out[str(p_id)] = _quantize_rlen( |
| val, min_bp=min_bp, max_bp=max_bp, round_mode=round_mode |
| ) |
| return out |
|
|
|
|
| def apply_inferred_length_bp( |
| targets: List[Dict[str, object]], |
| *, |
| length_map: Dict[str, int], |
| default_length_bp: int, |
| percentile: float, |
| global_fallback_bp: Optional[int] = None, |
| ) -> None: |
| for target in targets: |
| p_id = str(target["p_id"]) |
| if target.get("length_bp") is not None: |
| target["length_bp_source"] = "sample_csv" |
| continue |
| inferred = length_map.get(p_id) |
| if inferred is not None: |
| target["length_bp"] = inferred |
| target["length_bp_source"] = f"train_rlen_per_pid_p{percentile:g}" |
| elif global_fallback_bp is not None: |
| target["length_bp"] = global_fallback_bp |
| target["length_bp_source"] = f"train_rlen_global_p{percentile:g}" |
| else: |
| target["length_bp"] = default_length_bp |
| target["length_bp_source"] = "default" |
|
|
|
|
| def resolve_sample_paths_from_config( |
| sd: dict, |
| project_root: Path, |
| config_dir: Path, |
| args: argparse.Namespace, |
| ) -> Tuple[Optional[Path], Optional[Path], Optional[Path], Optional[int]]: |
| """Optional sample_csv / checkpoint / output_dir / default length_bp from config.sample.""" |
| sample_csv = None |
| if sd.get("sample_csv"): |
| sample_csv = resolve_path(sd["sample_csv"], project_root, config_dir) |
| checkpoint = None |
| if sd.get("checkpoint"): |
| checkpoint = resolve_path(sd["checkpoint"], project_root, config_dir) |
| output_dir = None |
| if sd.get("output_dir"): |
| output_dir = resolve_path(sd["output_dir"], project_root, config_dir) |
| default_len = sd.get("length_bp") |
| if default_len is not None: |
| default_len = int(default_len) |
| return sample_csv, checkpoint, output_dir, default_len |
|
|
|
|
| def load_sampling_targets( |
| sample_csv: Path, |
| targets_map: Dict[str, Dict[str, str]], |
| default_num_sequences: int, |
| default_length_bp: int, |
| ) -> List[Dict[str, object]]: |
| if not sample_csv.exists(): |
| raise FileNotFoundError( |
| f"Sample target file not found: {sample_csv}. " |
| "Expected a CSV with at least a `p_id` column." |
| ) |
|
|
| with sample_csv.open("r", encoding="utf-8") as f: |
| reader = csv.DictReader(f) |
| fieldnames = reader.fieldnames or [] |
| if "p_id" not in fieldnames: |
| raise ValueError( |
| f"`sample.csv` must contain a `p_id` column. Current columns: {fieldnames}" |
| ) |
|
|
| seen = set() |
| targets: List[Dict[str, object]] = [] |
| for row in reader: |
| p_id = str(row["p_id"]).strip() |
| if not p_id or p_id in seen: |
| continue |
| seen.add(p_id) |
|
|
| meta = targets_map.get(p_id, {}) |
| filename = ( |
| row.get("filename") |
| or meta.get("filename") |
| or f"{p_id}.fasta" |
| ) |
| num_sequences = _csv_int(row, "num_sequences", "num_samples") |
| if num_sequences is None: |
| num_sequences = default_num_sequences |
|
|
| length_bp = None |
| length_bp_source = "pending" |
| for key in ("length_bp", "rna_length_bp", "dna_length_bp"): |
| raw = row.get(key) |
| if raw is None: |
| continue |
| s = str(raw).strip() |
| if not s: |
| continue |
| if s.lower() == "auto": |
| length_bp = None |
| length_bp_source = "pending" |
| break |
| length_bp = int(s) |
| length_bp_source = "sample_csv" |
| break |
| if length_bp is None and length_bp_source != "pending": |
| length_bp = default_length_bp |
| length_bp_source = "default" |
|
|
| rbp_name = _coalesce_rbp_name(row) or _coalesce_rbp_name(meta) |
| targets.append( |
| { |
| "p_id": p_id, |
| "filename": filename, |
| "split": row.get("split") or meta.get("split"), |
| "species": row.get("species") or meta.get("species"), |
| "rbp_name": rbp_name, |
| "num_sequences": num_sequences, |
| "length_bp": length_bp, |
| "length_bp_source": length_bp_source, |
| } |
| ) |
|
|
| if not targets: |
| raise ValueError(f"No valid targets found in {sample_csv}.") |
| return targets |
|
|
|
|
| def decode_sequence_ids_to_rna( |
| sequence_ids: Sequence[int], |
| id_to_token: Sequence[str], |
| special_token_ids: Sequence[int], |
| cls_token_id: int, |
| token_length: int, |
| *, |
| eos_token_id: Optional[int] = None, |
| pad_token_id: Optional[int] = None, |
| max_rna_nt: Optional[int] = None, |
| variable_length: bool = False, |
| ) -> Optional[str]: |
| pieces: List[str] = [] |
| special_token_ids = set(special_token_ids) |
| for idx, token_id in enumerate(sequence_ids): |
| if token_id < 0 or token_id >= len(id_to_token): |
| return None |
| if pad_token_id is not None and token_id == pad_token_id: |
| break |
| if eos_token_id is not None and token_id == eos_token_id: |
| break |
| token = id_to_token[token_id] |
| if is_valid_rna_token(token, token_length=token_length): |
| pieces.append(token) |
| continue |
| if token_id == cls_token_id and idx == 0: |
| continue |
| if token_id in special_token_ids: |
| return None |
| return None |
| rna = "".join(pieces) |
| if not rna or set(rna) - RNA_TOKEN_ALPHABET: |
| return None |
| if max_rna_nt is not None and len(rna) > max_rna_nt: |
| return None |
| if not variable_length and max_rna_nt is not None and len(rna) != max_rna_nt: |
| return None |
| return rna |
|
|
|
|
| def write_fasta(output_path: Path, sequences: Sequence[str]) -> None: |
| output_path.parent.mkdir(parents=True, exist_ok=True) |
| with output_path.open("w", encoding="utf-8") as f: |
| for idx, seq in enumerate(sequences, start=1): |
| f.write(f">sample_{idx:06d}\n") |
| f.write(seq + "\n") |
|
|
|
|
| def build_model_from_config( |
| config: dict, |
| device: torch.device, |
| tokenizer_runtime: TokenizerRuntime, |
| ) -> EsmForMaskedLM: |
| model_config = EsmConfig(**base_config) |
| model_config.vocab_size = len(tokenizer_runtime.id_to_token) |
| model_config.mask_token_id = tokenizer_runtime.mask_token_id |
| if tokenizer_runtime.pad_token_id is not None: |
| model_config.pad_token_id = tokenizer_runtime.pad_token_id |
| model_config.use_FiLM = config["model"]["use_FiLM"] |
| model_config.use_AdaLN = config["model"]["use_AdaLN"] |
| model_config.use_gated_bias = config["model"]["use_gated_bias"] |
| model_config.use_protein_conditioning_attention = config["model"]["use_protein_conditioning_attention"] |
| model_config.protein_dim = config["model"]["protein_dim"] |
| model = EsmForMaskedLM(model_config) |
| model.to(device) |
| model.eval() |
| return model |
|
|
|
|
| def make_prompt( |
| batch_size: int, |
| num_content_tokens: int, |
| cls_token_id: int, |
| mask_token_id: int, |
| device: torch.device, |
| ) -> Tensor: |
| prompt = torch.full( |
| (batch_size, num_content_tokens + 1), |
| mask_token_id, |
| dtype=torch.long, |
| device=device, |
| ) |
| prompt[:, 0] = cls_token_id |
| return prompt |
|
|
|
|
| def set_seed(seed: int) -> None: |
| random.seed(seed) |
| torch.manual_seed(seed) |
| if torch.cuda.is_available(): |
| torch.cuda.manual_seed_all(seed) |
|
|
|
|
| def parse_args() -> argparse.Namespace: |
| parser = argparse.ArgumentParser( |
| description="RBP-conditioned RNA 序列采样(蛋白嵌入 + ProRiboGen generation)" |
| ) |
| parser.add_argument("--config", default="config.json", help="Path to training config.json") |
| parser.add_argument("--checkpoint", default="checkpoints/latest.pt", help="Checkpoint to sample from") |
| parser.add_argument("--sample-csv", default="data/sample.csv", help="CSV with at least a p_id column") |
| parser.add_argument( |
| "--targets-tsv", |
| default=None, |
| help="可选:TSV(p_id 为主键)提供 filename、split、species、rbp_name 等;兼容旧列 tf_name", |
| ) |
| parser.add_argument("--output-dir", default="outputs", help="Directory for generated fasta files") |
| parser.add_argument( |
| "--protein-h5", |
| default=None, |
| help="Override config data.protein_h5 (e.g. custom embedding H5 for new proteins)", |
| ) |
| parser.add_argument( |
| "--num-sequences-per-rbp", |
| type=int, |
| default=2048, |
| dest="num_sequences_per_rbp", |
| help="CSV 未写 num_sequences 时,每个 RBP(p_id)生成的 RNA 条数", |
| ) |
| parser.add_argument( |
| "--length-bp", |
| type=int, |
| default=101, |
| help="Generated RNA length in nt. Must be a multiple of token-length.", |
| ) |
| parser.add_argument( |
| "--token-length", |
| type=int, |
| default=1, |
| help="Tokenizer content token length. Use 1 for RNA 1-mer tokenizer.", |
| ) |
| parser.add_argument( |
| "--steps", |
| type=int, |
| default=argparse.SUPPRESS, |
| help="扩散步数;省略则使用 config.json 的 sample.steps(缺省 50)", |
| ) |
| parser.add_argument( |
| "--temperature", |
| type=float, |
| default=argparse.SUPPRESS, |
| help="采样温度;省略则使用 config.json 的 sample.temperature(缺省 1.0)", |
| ) |
| parser.add_argument( |
| "--top-p", |
| type=float, |
| default=argparse.SUPPRESS, |
| help="nucleus top-p;省略则使用 config.json 的 sample.top_p(缺省 0.95)", |
| ) |
| parser.add_argument( |
| "--top-k", |
| type=int, |
| default=argparse.SUPPRESS, |
| help="top-k,0 表示关闭;省略则使用 config.json 的 sample.top_k", |
| ) |
| parser.add_argument( |
| "--alg", |
| default=argparse.SUPPRESS, |
| choices=["random", "origin", "p2", "maskgit_plus", "entropy", "topk_margin"], |
| help="解码调度算法;省略则使用 config.json 的 sample.alg", |
| ) |
| parser.add_argument( |
| "--alg-temp", |
| type=float, |
| default=argparse.SUPPRESS, |
| help="maskgit 系置信度排序温度;省略则使用 config.json 的 sample.alg_temp", |
| ) |
| parser.add_argument( |
| "--batch-size", |
| type=int, |
| default=64, |
| help="每轮前向并行采样的序列条数;显存占用低时可增大以提高 GPU 利用率", |
| ) |
| parser.add_argument("--seed", type=int, default=42, help="Random seed") |
| parser.add_argument("--device", default=None, help="Sampling device, e.g. cuda:0 or cpu") |
| parser.add_argument("--no-bf16", action="store_true", help="Disable bf16 autocast during sampling") |
| vlen = parser.add_mutually_exclusive_group() |
| vlen.add_argument( |
| "--variable-length", |
| dest="variable_length", |
| action="store_true", |
| default=argparse.SUPPRESS, |
| help="最长为 length_bp;多 1 个 content 位采样 <eos>,解码遇 <eos> 截断。省略则用 config.json sample.variable_length", |
| ) |
| vlen.add_argument( |
| "--fixed-length", |
| dest="variable_length", |
| action="store_false", |
| default=argparse.SUPPRESS, |
| help="与训练时一致:恰好 length_bp 个 RNA token,不采样 <eos> content 位", |
| ) |
| parser.add_argument( |
| "--sample-run-log", |
| default=None, |
| help="将 stdout/stderr 同步写入该文件;默认写入 output-dir/sample_run.log", |
| ) |
| parser.add_argument( |
| "--no-sample-run-log", |
| action="store_true", |
| help="不写 sample_run.log(仅终端输出)", |
| ) |
| parser.add_argument( |
| "--unconditional", |
| action="store_true", |
| help="无条件生成:将蛋白嵌入置零(保留原长度/mask),关闭蛋白条件信息", |
| ) |
| return parser.parse_args() |
|
|
|
|
| def main() -> None: |
| args = parse_args() |
| project_root = Path(__file__).resolve().parent |
| config_path = resolve_path(args.config, project_root) |
| config = load_config(str(config_path)) |
| config_dir = config_path.parent |
| sd = config.get("sample") or {} |
|
|
| if args.protein_h5: |
| config["data"]["protein_h5"] = args.protein_h5 |
|
|
| validate_protein_dim_against_h5(config, base_dir=str(project_root)) |
|
|
| cfg_sample_csv, cfg_checkpoint, cfg_output_dir, cfg_length_bp = resolve_sample_paths_from_config( |
| sd, project_root, config_dir, args |
| ) |
|
|
| tokenizer_dir = resolve_path(config["data"]["tokenizer_path"], project_root, config_dir) |
| protein_h5 = resolve_path(config["data"]["protein_h5"], project_root, config_dir) |
| checkpoint_path = cfg_checkpoint or resolve_path(args.checkpoint, project_root, config_dir) |
| |
| _default_sample_csv = resolve_path("data/sample.csv", project_root, config_dir) |
| _cli_sample_csv = resolve_path(args.sample_csv, project_root, config_dir) |
| if cfg_sample_csv is not None and _cli_sample_csv == _default_sample_csv: |
| sample_csv = cfg_sample_csv |
| else: |
| sample_csv = _cli_sample_csv |
| targets_tsv = ( |
| resolve_path(args.targets_tsv, project_root, config_dir) |
| if args.targets_tsv |
| else None |
| ) |
| _default_output_dir = resolve_path("outputs", project_root, config_dir) |
| _cli_output_dir = resolve_path(args.output_dir, project_root, config_dir) |
| if cfg_output_dir is not None and _cli_output_dir == _default_output_dir: |
| output_dir = cfg_output_dir |
| else: |
| output_dir = _cli_output_dir |
|
|
| device = torch.device( |
| args.device if args.device is not None else ("cuda:0" if torch.cuda.is_available() else "cpu") |
| ) |
| set_seed(args.seed) |
|
|
| max_generated_rna_bp = int(config.get("data", {}).get("max_generated_rna_bp", 101)) |
| cli_length_bp = int(args.length_bp) |
| if cfg_length_bp is not None: |
| cli_length_bp = min(cfg_length_bp, max_generated_rna_bp) |
| eff_length_bp = min(cli_length_bp, max_generated_rna_bp) |
|
|
| if sd.get("num_sequences_per_rbp") is not None: |
| args.num_sequences_per_rbp = int(sd["num_sequences_per_rbp"]) |
|
|
| def _pick(field: str, cfg_key: str, default): |
| if hasattr(args, field): |
| return getattr(args, field) |
| if cfg_key in sd: |
| return sd[cfg_key] |
| return default |
|
|
| sampling_cfg = SamplingConfig( |
| steps=int(_pick("steps", "steps", SamplingConfig.steps)), |
| temperature=float(_pick("temperature", "temperature", SamplingConfig.temperature)), |
| top_p=float(_pick("top_p", "top_p", SamplingConfig.top_p)), |
| top_k=int(_pick("top_k", "top_k", SamplingConfig.top_k)), |
| eps=float(config["train"].get("t_eps", 1e-3)), |
| alg=str(_pick("alg", "alg", SamplingConfig.alg)), |
| alg_temp=float(_pick("alg_temp", "alg_temp", SamplingConfig.alg_temp)), |
| batch_size=args.batch_size, |
| num_sequences_per_rbp=args.num_sequences_per_rbp, |
| length_bp=eff_length_bp, |
| token_length=args.token_length, |
| seed=args.seed, |
| use_bf16=(not args.no_bf16 and bool(config["train"].get("use_bf16", True))), |
| variable_length=bool(_pick("variable_length", "variable_length", SamplingConfig.variable_length)), |
| maskgit_eos_late_bias=float( |
| _pick("maskgit_eos_late_bias", "maskgit_eos_late_bias", SamplingConfig.maskgit_eos_late_bias) |
| ), |
| maskgit_eos_late_frac=float( |
| _pick("maskgit_eos_late_frac", "maskgit_eos_late_frac", SamplingConfig.maskgit_eos_late_frac) |
| ), |
| maskgit_eos_late_scope=str( |
| sd.get("maskgit_eos_late_scope", SamplingConfig.maskgit_eos_late_scope) |
| ), |
| ) |
| raw_nt_bias = sd.get("nucleotide_logit_bias") |
| if raw_nt_bias: |
| sampling_cfg.nucleotide_logit_bias = {str(k): float(v) for k, v in raw_nt_bias.items()} |
|
|
| tokenizer_runtime = build_tokenizer_runtime( |
| tokenizer_dir, |
| device=device, |
| token_length=sampling_cfg.token_length, |
| allow_eos_in_content=sampling_cfg.variable_length, |
| ) |
| if sampling_cfg.nucleotide_logit_bias: |
| sampling_cfg.nucleotide_bias_vector = build_nucleotide_bias_vector( |
| tokenizer_runtime.id_to_token, |
| tokenizer_runtime.valid_content_token_mask, |
| sampling_cfg.nucleotide_logit_bias, |
| device, |
| ) |
|
|
| model = build_model_from_config( |
| config, |
| device=device, |
| tokenizer_runtime=tokenizer_runtime, |
| ) |
| resume_model( |
| load_path=str(checkpoint_path), |
| model=model, |
| optimizer=None, |
| scheduler=None, |
| device=device, |
| strict=True, |
| ) |
| model.eval() |
|
|
| protein_store = ProteinEmbeddingStore(protein_h5) |
| if args.unconditional: |
| print("Unconditional mode: protein embeddings will be zeroed for every target.") |
| targets_map = load_targets_map(targets_tsv) |
| targets = load_sampling_targets( |
| sample_csv=sample_csv, |
| targets_map=targets_map, |
| default_num_sequences=sampling_cfg.num_sequences_per_rbp, |
| default_length_bp=sampling_cfg.length_bp, |
| ) |
|
|
| infer_cfg = sd.get("infer_length_from_rlen") or {} |
| if infer_cfg.get("enabled", False): |
| ref_csv = ( |
| infer_cfg.get("csv") |
| or config.get("data", {}).get("train_csv") |
| or config.get("data", {}).get("test_csv") |
| ) |
| if not ref_csv: |
| raise ValueError( |
| "sample.infer_length_from_rlen.enabled=true 但未提供 csv 或 data.train_csv" |
| ) |
| ref_path = resolve_path(ref_csv, project_root, config_dir) |
| percentile = float(infer_cfg.get("percentile", 90)) |
| min_bp = int(infer_cfg.get("min_bp", 12)) |
| round_mode = str(infer_cfg.get("round", "ceil")) |
| use_global_fallback = bool(infer_cfg.get("global_fallback", True)) |
| length_map = build_rlen_length_map( |
| ref_path, |
| percentile=percentile, |
| min_bp=min_bp, |
| max_bp=max_generated_rna_bp, |
| round_mode=round_mode, |
| ) |
| global_bp = None |
| if use_global_fallback: |
| global_bp = compute_global_rlen_length_bp( |
| ref_path, |
| percentile=percentile, |
| min_bp=min_bp, |
| max_bp=max_generated_rna_bp, |
| round_mode=round_mode, |
| ) |
| apply_inferred_length_bp( |
| targets, |
| length_map=length_map, |
| default_length_bp=eff_length_bp, |
| percentile=percentile, |
| global_fallback_bp=global_bp, |
| ) |
| print( |
| f"Inferred length_bp from train/reference CSV {ref_path} " |
| f"(p{percentile:g}, round={round_mode}); " |
| f"per-p_id map: {len(length_map)} proteins" |
| ) |
| if global_bp is not None: |
| print( |
| f" global train r_len fallback (unseen p_id): length_bp={global_bp} " |
| f"(train_rlen_global_p{percentile:g})" |
| ) |
| else: |
| for t in targets: |
| if t.get("length_bp") is None: |
| t["length_bp"] = eff_length_bp |
| t["length_bp_source"] = "default" |
|
|
| for t in targets: |
| t["length_bp"] = min(int(t["length_bp"]), max_generated_rna_bp) |
|
|
| min_rna_bp = sd.get("min_rna_bp") |
| if min_rna_bp is not None: |
| min_rna_bp = int(min_rna_bp) |
| elif sampling_cfg.variable_length and infer_cfg.get("enabled", False): |
| min_rna_bp = int(infer_cfg.get("min_bp", 12)) |
| else: |
| min_rna_bp = None |
| sampling_cfg.min_rna_bp = min_rna_bp |
|
|
| manifest_rows: List[Dict[str, object]] = [] |
| output_dir.mkdir(parents=True, exist_ok=True) |
|
|
| run_log_f = None |
| run_log_path: Optional[Path] = None |
| _saved_stdout, _saved_stderr = sys.stdout, sys.stderr |
| if not args.no_sample_run_log: |
| if args.sample_run_log: |
| run_log_path = resolve_path(args.sample_run_log, project_root, config_dir) |
| else: |
| run_log_path = output_dir / "sample_run.log" |
| run_log_path.parent.mkdir(parents=True, exist_ok=True) |
| run_log_f = run_log_path.open("w", encoding="utf-8", buffering=1) |
| sys.stdout = _TeeIO(_saved_stdout, run_log_f) |
| sys.stderr = _TeeIO(_saved_stderr, run_log_f) |
|
|
| if run_log_path is not None: |
| print(f"Tee: stdout/stderr -> {run_log_path}", flush=True) |
|
|
| try: |
| uniq_lens = sorted({int(t["length_bp"]) for t in targets}) |
| print(f"Loaded {len(targets)} RBP sampling targets from {sample_csv}") |
| for t in targets: |
| print( |
| f" {t['p_id']}: length_bp={t['length_bp']} ({t.get('length_bp_source', '?')}), " |
| f"n={t['num_sequences']}" |
| ) |
| print(f"Sampling from checkpoint: {checkpoint_path}") |
| print(f"Output directory: {output_dir}") |
| print( |
| "Note: each RBP's .fasta is written only after that RBP's target count of " |
| "valid sequences is collected (CSV num_sequences or default); " |
| "see sampling_progress.txt in the output dir while running." |
| ) |
| len_mode = ( |
| f"max RNA nt per sequence (variable_length); decode at <eos> or full cap {uniq_lens}" |
| if sampling_cfg.variable_length |
| else f"exact RNA nt per sequence (fixed); per-RBP length_bp values: {uniq_lens}" |
| ) |
| print( |
| f"RNA length: hard cap max_generated_rna_bp={max_generated_rna_bp} (config data); {len_mode}" |
| ) |
| print( |
| f"Sampling config: num_sequences_per_rbp={sampling_cfg.num_sequences_per_rbp}, " |
| f"length_bp={sampling_cfg.length_bp}, variable_length={sampling_cfg.variable_length}, " |
| f"min_rna_bp={sampling_cfg.min_rna_bp}, " |
| f"token_length={sampling_cfg.token_length}, " |
| f"steps={sampling_cfg.steps}, temperature={sampling_cfg.temperature}, " |
| f"top_p={sampling_cfg.top_p}, top_k={sampling_cfg.top_k}, alg={sampling_cfg.alg}, " |
| f"alg_temp={sampling_cfg.alg_temp}, batch_size={sampling_cfg.batch_size}" |
| ) |
| if sampling_cfg.nucleotide_logit_bias: |
| print(f"Nucleotide logit bias: {sampling_cfg.nucleotide_logit_bias}") |
| if sampling_cfg.maskgit_eos_late_bias != 0.0: |
| print( |
| f"maskgit_eos_late: bias={sampling_cfg.maskgit_eos_late_bias}, " |
| f"late_frac={sampling_cfg.maskgit_eos_late_frac}, " |
| f"scope={sampling_cfg.maskgit_eos_late_scope}", |
| flush=True, |
| ) |
| comp_cfg_global = sd.get("composition_filter") or {} |
| if comp_cfg_global.get("enabled", False): |
| print(f"Composition filter enabled: {comp_cfg_global}") |
| if sampling_cfg.variable_length: |
| min_hint = ( |
| f" reject if len<{sampling_cfg.min_rna_bp} nt." |
| if sampling_cfg.min_rna_bp is not None |
| else "" |
| ) |
| vlen_hint = ( |
| f"variable_length=True: up to {sampling_cfg.length_bp} nt, optional trailing <eos> slot; " |
| f"decode stops at <eos>.{min_hint}" |
| ) |
| else: |
| vlen_hint = f"variable_length=False: exactly {sampling_cfg.length_bp} nt per sequence." |
| print( |
| "Tokenizer-aligned decoding: <cls> fixed; content positions use an explicit token mask. " |
| f"{vlen_hint} " |
| f"RNA vocabulary: legal {sampling_cfg.token_length}-mer(s)." |
| ) |
|
|
| n_targets = len(targets) |
| progress_path = output_dir / "sampling_progress.txt" |
| for idx, target in enumerate(tqdm(targets, desc="RBPs"), start=1): |
| p_id = str(target["p_id"]) |
| filename = str(target["filename"]) |
| num_sequences = int(target["num_sequences"]) |
| target_length_bp = int(target["length_bp"]) |
| with progress_path.open("w", encoding="utf-8") as pf: |
| pf.write( |
| f"RBP {idx}/{n_targets} STARTED | p_id={p_id} | filename={filename}\n" |
| f"Collecting {num_sequences} valid sequences; corresponding .fasta is written only after this RBP finishes.\n" |
| f"(sampling_manifest.tsv is written after all {n_targets} RBPs complete.)\n" |
| ) |
| target_cfg = SamplingConfig( |
| steps=sampling_cfg.steps, |
| temperature=sampling_cfg.temperature, |
| top_p=sampling_cfg.top_p, |
| top_k=sampling_cfg.top_k, |
| eps=sampling_cfg.eps, |
| alg=sampling_cfg.alg, |
| alg_temp=sampling_cfg.alg_temp, |
| batch_size=sampling_cfg.batch_size, |
| num_sequences_per_rbp=num_sequences, |
| length_bp=target_length_bp, |
| token_length=sampling_cfg.token_length, |
| seed=sampling_cfg.seed, |
| use_bf16=sampling_cfg.use_bf16, |
| variable_length=sampling_cfg.variable_length, |
| min_rna_bp=sampling_cfg.min_rna_bp, |
| nucleotide_logit_bias=sampling_cfg.nucleotide_logit_bias, |
| nucleotide_bias_vector=sampling_cfg.nucleotide_bias_vector, |
| maskgit_eos_late_bias=sampling_cfg.maskgit_eos_late_bias, |
| maskgit_eos_late_frac=sampling_cfg.maskgit_eos_late_frac, |
| maskgit_eos_late_scope=sampling_cfg.maskgit_eos_late_scope, |
| ) |
|
|
| comp_filt = resolve_composition_filter_for_target( |
| sd.get("composition_filter") or {}, |
| p_id=p_id, |
| project_root=project_root, |
| config_dir=config_dir, |
| ) |
| if comp_filt.get("enabled", False): |
| ref_mean = comp_filt.get("_reference_mean_c_frac") |
| if ref_mean is not None: |
| print( |
| f" composition_filter for {p_id}: ref mean C%={100*ref_mean:.1f}, " |
| f"accept C in [{100*float(comp_filt['min_c_frac']):.1f}, " |
| f"{100*float(comp_filt['max_c_frac']):.1f}]" |
| ) |
| else: |
| print( |
| f" composition_filter for {p_id}: " |
| f"min_c_frac={comp_filt.get('min_c_frac')}, max_c_frac={comp_filt.get('max_c_frac')}" |
| ) |
|
|
| protein_emb = protein_store.get(p_id) |
| if args.unconditional: |
| protein_emb = torch.zeros_like(protein_emb) |
| generated_sequences: List[str] = [] |
| invalid_count = 0 |
| composition_rejected = 0 |
| too_short_rejected = 0 |
| max_rounds = int(comp_filt.get("max_rounds", 0) or 0) |
| rounds = 0 |
|
|
| while len(generated_sequences) < num_sequences: |
| rounds += 1 |
| if max_rounds > 0 and rounds > max_rounds: |
| raise RuntimeError( |
| f"composition_filter: exceeded max_rounds={max_rounds} for {p_id}; " |
| f"collected {len(generated_sequences)}/{num_sequences} sequences. " |
| "Relax min_c_frac / nucleotide_logit_bias or increase max_rounds." |
| ) |
| current_batch = min(target_cfg.batch_size, num_sequences - len(generated_sequences)) |
| prompt = make_prompt( |
| batch_size=current_batch, |
| num_content_tokens=target_cfg.num_prompt_content_tokens, |
| cls_token_id=tokenizer_runtime.cls_token_id, |
| mask_token_id=tokenizer_runtime.mask_token_id, |
| device=device, |
| ) |
| protein_batch = protein_emb.unsqueeze(0).repeat(current_batch, 1, 1).to(device) |
| protein_attention_mask = torch.ones( |
| current_batch, |
| protein_emb.shape[0], |
| dtype=torch.long, |
| device=device, |
| ) |
|
|
| seq_ids = conditioned_diffusion_generate( |
| model=model, |
| input_ids=prompt, |
| protein_cond=protein_batch, |
| protein_attention_mask=protein_attention_mask, |
| content_token_constraint=tokenizer_runtime.valid_content_token_mask, |
| sampling_cfg=target_cfg, |
| mask_token_id=tokenizer_runtime.mask_token_id, |
| eos_token_id=tokenizer_runtime.eos_token_id, |
| ) |
|
|
| for row in seq_ids.detach().cpu().tolist(): |
| rna = decode_sequence_ids_to_rna( |
| row, |
| id_to_token=tokenizer_runtime.id_to_token, |
| special_token_ids=tokenizer_runtime.special_token_ids, |
| cls_token_id=tokenizer_runtime.cls_token_id, |
| token_length=target_cfg.token_length, |
| eos_token_id=tokenizer_runtime.eos_token_id, |
| pad_token_id=tokenizer_runtime.pad_token_id, |
| max_rna_nt=target_length_bp, |
| variable_length=target_cfg.variable_length, |
| ) |
| if rna is None: |
| invalid_count += 1 |
| continue |
| if ( |
| target_cfg.min_rna_bp is not None |
| and len(rna) < target_cfg.min_rna_bp |
| ): |
| too_short_rejected += 1 |
| continue |
| if not passes_composition_filter(rna, comp_filt): |
| composition_rejected += 1 |
| continue |
| generated_sequences.append(rna) |
| if len(generated_sequences) >= num_sequences: |
| break |
|
|
| output_path = output_dir / filename |
| write_fasta(output_path, generated_sequences) |
| if generated_sequences: |
| c_fracs = [sequence_c_frac(s) for s in generated_sequences] |
| mean_c = sum(c_fracs) / len(c_fracs) |
| print( |
| f" {p_id}: output mean C%={100*mean_c:.2f} " |
| f"(composition_rejected={composition_rejected}, too_short_rejected={too_short_rejected}, " |
| f"invalid={invalid_count})" |
| ) |
| with progress_path.open("a", encoding="utf-8") as pf: |
| pf.write( |
| f"-> DONE | wrote {len(generated_sequences)} seq -> {output_path.name} | " |
| f"invalid_decodes={invalid_count} | too_short_rejected={too_short_rejected} | " |
| f"composition_rejected={composition_rejected}\n" |
| ) |
| manifest_rows.append( |
| { |
| "p_id": p_id, |
| "filename": filename, |
| "split": target.get("split"), |
| "species": target.get("species"), |
| "rbp_name": target.get("rbp_name"), |
| "num_sequences": len(generated_sequences), |
| "length_bp": target_length_bp, |
| "length_bp_source": target.get("length_bp_source", ""), |
| "variable_length": target_cfg.variable_length, |
| "min_rna_bp": target_cfg.min_rna_bp, |
| "invalid_decodes": invalid_count, |
| "too_short_rejected": too_short_rejected, |
| "composition_rejected": composition_rejected, |
| } |
| ) |
|
|
| manifest_path = output_dir / "sampling_manifest.tsv" |
| with manifest_path.open("w", encoding="utf-8", newline="") as f: |
| fieldnames = [ |
| "p_id", |
| "filename", |
| "split", |
| "species", |
| "rbp_name", |
| "num_sequences", |
| "length_bp", |
| "length_bp_source", |
| "variable_length", |
| "min_rna_bp", |
| "invalid_decodes", |
| "too_short_rejected", |
| "composition_rejected", |
| ] |
| writer = csv.DictWriter(f, fieldnames=fieldnames, delimiter="\t") |
| writer.writeheader() |
| writer.writerows(manifest_rows) |
|
|
| protein_store.close() |
| print(f"Done. Wrote {len(manifest_rows)} fasta files to {output_dir}") |
| print(f"Manifest: {manifest_path}") |
| finally: |
| if run_log_f is not None: |
| sys.stdout = _saved_stdout |
| sys.stderr = _saved_stderr |
| run_log_f.close() |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|