fix: 修正 size 预测的反归一化并将 MoE+LLM 模型接入应用

This commit is contained in:
Michelle0574 2026-08-14 11:24:02 +00:00
parent 866941039f
commit 71f915e683
8 changed files with 86 additions and 26 deletions

View File

@ -12,8 +12,6 @@ encapsulation_efficiency_2: score = weight, where weight=0.08
pdi_0: score = weight, where weight=0.08 pdi_0: score = weight, where weight=0.08
pdi_1: score = weight, where weight=0.02 pdi_1: score = weight, where weight=0.02
pdi_2: score = weight, where weight=0
pdi_3: score = weight, where weight=0
toxicity_0: score=weight, where weight=0.2 toxicity_0: score=weight, where weight=0.2
toxicity_1: score=weight, where weight=0 toxicity_1: score=weight, where weight=0

View File

@ -65,7 +65,7 @@ class ScoringWeightsRequest(BaseModel):
delivery_weight: float = Field(default=0.0, ge=0.0, description="量化递送权重") delivery_weight: float = Field(default=0.0, ge=0.0, description="量化递送权重")
size_weight: float = Field(default=0.0, ge=0.0, description="粒径权重 (80-150nm)") size_weight: float = Field(default=0.0, ge=0.0, description="粒径权重 (80-150nm)")
ee_class_weights: List[float] = Field(default=[0.0, 0.0, 0.0], description="EE 分类权重 [class0, class1, class2]") ee_class_weights: List[float] = Field(default=[0.0, 0.0, 0.0], description="EE 分类权重 [class0, class1, class2]")
pdi_class_weights: List[float] = Field(default=[0.0, 0.0, 0.0, 0.0], description="PDI 分类权重 [class0, class1, class2, class3]") pdi_class_weights: List[float] = Field(default=[0.0, 0.0], description="PDI 分类权重 [class0(<0.2), class1(>=0.2)]")
toxic_class_weights: List[float] = Field(default=[0.0, 0.0], description="毒性分类权重 [无毒, 有毒]") toxic_class_weights: List[float] = Field(default=[0.0, 0.0], description="毒性分类权重 [无毒, 有毒]")
def to_scoring_weights(self) -> ScoringWeights: def to_scoring_weights(self) -> ScoringWeights:
@ -87,7 +87,7 @@ class OptimizeRequest(BaseModel):
top_k: int = Field(default=20, ge=1, le=100, description="Number of top formulations to return") top_k: int = Field(default=20, ge=1, le=100, description="Number of top formulations to return")
num_seeds: Optional[int] = Field(default=None, ge=1, le=500, description="Number of seed points from first iteration (default: top_k * 5)") num_seeds: Optional[int] = Field(default=None, ge=1, le=500, description="Number of seed points from first iteration (default: top_k * 5)")
top_per_seed: int = Field(default=1, ge=1, le=10, description="Number of local best to keep per seed in refinement") top_per_seed: int = Field(default=1, ge=1, le=10, description="Number of local best to keep per seed in refinement")
rerank_top_n: int = Field(default=0, ge=0, le=1000, description="两阶段推理:粗筛后用完整模型重排的候选数0 表示关闭") rerank_top_n: int = Field(default=200, ge=0, le=1000, description="两阶段推理:粗筛后用完整模型重排的候选数。0 表示关闭,此时 LLM 会在粗筛阶段跑满全部候选")
step_sizes: Optional[List[float]] = Field(default=None, description="Mol ratio step sizes for each iteration (default: [10, 2, 1])") step_sizes: Optional[List[float]] = Field(default=None, description="Mol ratio step sizes for each iteration (default: [10, 2, 1])")
wr_step_sizes: Optional[List[float]] = Field(default=None, description="Weight ratio step sizes for each iteration (default: [5, 2, 1])") wr_step_sizes: Optional[List[float]] = Field(default=None, description="Weight ratio step sizes for each iteration (default: [5, 2, 1])")
comp_ranges: Optional[CompRangesRequest] = Field(default=None, description="组分范围配置(默认使用标准范围)") comp_ranges: Optional[CompRangesRequest] = Field(default=None, description="组分范围配置(默认使用标准范围)")
@ -127,7 +127,7 @@ class FormulationResult(BaseModel):
quantified_delivery: Optional[float] = None quantified_delivery: Optional[float] = None
unnormalized_delivery: Optional[float] = None # 反推的原始递送值z-score 逆变换) unnormalized_delivery: Optional[float] = None # 反推的原始递送值z-score 逆变换)
size: Optional[float] = None size: Optional[float] = None
pdi_class: Optional[int] = None # PDI 分类 (0: <0.2, 1: 0.2-0.3, 2: 0.3-0.4, 3: >0.4) pdi_class: Optional[int] = None # PDI 分类 (0: <0.2, 1: 0.2)
ee_class: Optional[int] = None # EE 分类 (0: <80%, 1: 80-90%, 2: >90%) ee_class: Optional[int] = None # EE 分类 (0: <80%, 1: 80-90%, 2: >90%)
toxic_class: Optional[int] = None # 毒性分类 (0: 无毒, 1: 有毒) toxic_class: Optional[int] = None # 毒性分类 (0: 无毒, 1: 有毒)
@ -146,6 +146,9 @@ class HealthResponse(BaseModel):
model_loaded: bool model_loaded: bool
device: str device: str
available_organs: List[str] available_organs: List[str]
use_moe: bool = False
use_llm: bool = False
use_rag: bool = False
# ============ Global State ============ # ============ Global State ============
@ -239,11 +242,16 @@ app.add_middleware(
@app.get("/", response_model=HealthResponse) @app.get("/", response_model=HealthResponse)
async def health_check(): async def health_check():
"""健康检查""" """健康检查"""
_m = state.model
_llm = getattr(_m, "llm_prompt", None) if _m is not None else None
return HealthResponse( return HealthResponse(
status="healthy" if state.model is not None else "model_not_loaded", status="healthy" if _m is not None else "model_not_loaded",
model_loaded=state.model is not None, model_loaded=_m is not None,
device=str(state.device), device=str(state.device),
available_organs=AVAILABLE_ORGANS, available_organs=AVAILABLE_ORGANS,
use_moe=getattr(_m, "moe", None) is not None,
use_llm=_llm is not None,
use_rag=bool(getattr(_llm, "use_rag", False)),
) )
@ -368,6 +376,8 @@ async def optimize_formulation(request: OptimizeRequest):
logger.error(f"Optimization failed: {e}") logger.error(f"Optimization failed: {e}")
if getattr(state.model, "llm_prompt", None) is not None: if getattr(state.model, "llm_prompt", None) is not None:
state.model.set_llm_enabled(True) state.model.set_llm_enabled(True)
if torch.cuda.is_available():
torch.cuda.empty_cache()
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))

View File

@ -143,6 +143,7 @@ def call_optimize_api(
top_k: int = 20, top_k: int = 20,
num_seeds: int = None, num_seeds: int = None,
top_per_seed: int = 1, top_per_seed: int = 1,
rerank_top_n: int = 200,
step_sizes: list = None, step_sizes: list = None,
wr_step_sizes: list = None, wr_step_sizes: list = None,
comp_ranges: dict = None, comp_ranges: dict = None,
@ -156,6 +157,7 @@ def call_optimize_api(
"top_k": top_k, "top_k": top_k,
"num_seeds": num_seeds, "num_seeds": num_seeds,
"top_per_seed": top_per_seed, "top_per_seed": top_per_seed,
"rerank_top_n": rerank_top_n,
"step_sizes": step_sizes, "step_sizes": step_sizes,
"wr_step_sizes": wr_step_sizes, "wr_step_sizes": wr_step_sizes,
"comp_ranges": comp_ranges, "comp_ranges": comp_ranges,
@ -270,9 +272,18 @@ def main():
# API 状态 # API 状态
if api_online: if api_online:
st.success("🟢 API 服务在线") st.success("🟢 API 服务在线")
try:
with httpx.Client(timeout=5) as _c:
_info = _c.get(f"{API_URL}/").json()
_caps = [n for n, k in (("MoE", "use_moe"), ("LLM", "use_llm"), ("RAG", "use_rag"))
if _info.get(k)]
st.caption(f"模型: {' + '.join(_caps) if _caps else '仅 backbone'}{_info.get('device', '?')}")
except Exception:
pass
else: else:
st.error("🔴 API 服务离线") st.error("🔴 API 服务离线")
st.info("请先启动 API 服务:\n```\nuvicorn app.api:app --port 8000\n```") st.info(f"请先启动 API 服务:\n```\nuvicorn app.api:app --port 8010\n```\n"
f"当前 API_URL: {API_URL}")
# st.divider() # st.divider()
@ -352,6 +363,18 @@ def main():
help="后续迭代中,每个种子点邻域保留的局部最优数量", help="后续迭代中,每个种子点邻域保留的局部最优数量",
) )
st.markdown("**LLM 推理精度**")
rerank_top_n = st.slider(
"LLM 重排候选数 (rerank_top_n)",
min_value=20,
max_value=500,
value=200,
step=20,
help="粗筛阶段只用 backbone 打分,再用完整模型对前 N 个"
"候选重新预测并排序。"
"调大更接近完整模型的判断,但耗时线性增加。",
)
st.markdown("**迭代步长与轮数**") st.markdown("**迭代步长与轮数**")
use_custom_steps = st.checkbox( use_custom_steps = st.checkbox(
"自定义迭代步长", "自定义迭代步长",
@ -512,7 +535,7 @@ def main():
help="score = normalize(delivery, route) × weight", help="score = normalize(delivery, route) × weight",
) )
sw_size = st.number_input( sw_size = st.number_input(
"粒径 (Size, 80-150nm)", "粒径 (Size, 60-150nm)",
min_value=0.00, max_value=10.00, value=0.05, min_value=0.00, max_value=10.00, value=0.05,
step=0.05, format="%.2f", key="sw_size", step=0.05, format="%.2f", key="sw_size",
help="score = (1 if 60≤size≤150 else 0) × weight", help="score = (1 if 60≤size≤150 else 0) × weight",
@ -528,15 +551,11 @@ def main():
sw_ee2 = st.number_input(">80% (高)", min_value=0.00, max_value=1.00, value=0.08, step=0.01, format="%.2f", key="sw_ee2") sw_ee2 = st.number_input(">80% (高)", min_value=0.00, max_value=1.00, value=0.08, step=0.01, format="%.2f", key="sw_ee2")
st.caption("**PDI 分类权重**") st.caption("**PDI 分类权重**")
col1, col2, col3, col4 = st.columns(4) col1, col2 = st.columns(2)
with col1: with col1:
sw_pdi0 = st.number_input("<0.2 (优)", min_value=0.00, max_value=1.00, value=0.08, step=0.01, format="%.2f", key="sw_pdi0") sw_pdi0 = st.number_input("<0.2 (优)", min_value=0.00, max_value=1.00, value=0.08, step=0.01, format="%.2f", key="sw_pdi0")
with col2: with col2:
sw_pdi1 = st.number_input("0.2-0.3 (良)", min_value=0.00, max_value=1.00, value=0.02, step=0.01, format="%.2f", key="sw_pdi1") sw_pdi1 = st.number_input("≥0.2 (欠佳)", min_value=0.00, max_value=1.00, value=0.00, step=0.01, format="%.2f", key="sw_pdi1")
with col3:
sw_pdi2 = st.number_input("0.3-0.4 (中)", min_value=0.00, max_value=1.00, value=0.00, step=0.01, format="%.2f", key="sw_pdi2")
with col4:
sw_pdi3 = st.number_input(">0.4 (差)", min_value=0.00, max_value=1.00, value=0.00, step=0.01, format="%.2f", key="sw_pdi3")
st.caption("**毒性分类权重**") st.caption("**毒性分类权重**")
col1, col2 = st.columns(2) col1, col2 = st.columns(2)
@ -550,7 +569,7 @@ def main():
"delivery_weight": sw_delivery, "delivery_weight": sw_delivery,
"size_weight": sw_size, "size_weight": sw_size,
"ee_class_weights": [sw_ee0, sw_ee1, sw_ee2], "ee_class_weights": [sw_ee0, sw_ee1, sw_ee2],
"pdi_class_weights": [sw_pdi0, sw_pdi1, sw_pdi2, sw_pdi3], "pdi_class_weights": [sw_pdi0, sw_pdi1],
"toxic_class_weights": [sw_toxic0, sw_toxic1], "toxic_class_weights": [sw_toxic0, sw_toxic1],
} }
else: else:
@ -602,6 +621,7 @@ def main():
top_k=top_k, top_k=top_k,
num_seeds=num_seeds, num_seeds=num_seeds,
top_per_seed=top_per_seed, top_per_seed=top_per_seed,
rerank_top_n=rerank_top_n,
step_sizes=step_sizes, step_sizes=step_sizes,
wr_step_sizes=wr_step_sizes_val, wr_step_sizes=wr_step_sizes_val,
comp_ranges=comp_ranges, comp_ranges=comp_ranges,

View File

@ -146,6 +146,16 @@ if not DELIVERY_NORM:
logger.warning("DELIVERY_NORM is empty — scoring normalization for delivery will be disabled") logger.warning("DELIVERY_NORM is empty — scoring normalization for delivery will be disabled")
_SIZE_STATS_PATH = Path(__file__).resolve().parent / "size_zscore_stats.json"
if _SIZE_STATS_PATH.exists():
with open(_SIZE_STATS_PATH) as _f:
SIZE_ZSCORE_STATS: Dict[str, float] = json.load(_f)
logger.info(f"Loaded size stats from {_SIZE_STATS_PATH}")
else:
SIZE_ZSCORE_STATS = {}
logger.warning(f"size_zscore_stats.json not found at {_SIZE_STATS_PATH}, "
"pred_size 将置为 NaNrun 'make preprocess' to generate it")
@dataclass @dataclass
class ScoringWeights: class ScoringWeights:
""" """
@ -161,7 +171,7 @@ class ScoringWeights:
size_weight: float = 0.0 # score = (1 if 80<=size<=150 else 0) * weight size_weight: float = 0.0 # score = (1 if 80<=size<=150 else 0) * weight
# 分类任务per-class 权重(预测为该类时,得分 = 对应权重) # 分类任务per-class 权重(预测为该类时,得分 = 对应权重)
ee_class_weights: List[float] = field(default_factory=lambda: [0.0, 0.0, 0.0]) # EE class 0, 1, 2 ee_class_weights: List[float] = field(default_factory=lambda: [0.0, 0.0, 0.0]) # EE class 0, 1, 2
pdi_class_weights: List[float] = field(default_factory=lambda: [0.0, 0.0, 0.0, 0.0]) # PDI class 0, 1, 2, 3 pdi_class_weights: List[float] = field(default_factory=lambda: [0.0, 0.0]) # PDI class 0(<0.2), 1(>=0.2)
toxic_class_weights: List[float] = field(default_factory=lambda: [0.0, 0.0]) # Toxic class 0, 1 toxic_class_weights: List[float] = field(default_factory=lambda: [0.0, 0.0]) # Toxic class 0, 1
@ -299,7 +309,7 @@ class Formulation:
quantified_delivery: Optional[float] = None quantified_delivery: Optional[float] = None
unnormalized_delivery: Optional[float] = None # 反推的原始递送值z-score 逆变换) unnormalized_delivery: Optional[float] = None # 反推的原始递送值z-score 逆变换)
size: Optional[float] = None size: Optional[float] = None
pdi_class: Optional[int] = None # PDI 分类 (0-3) pdi_class: Optional[int] = None # PDI 分类 (0: <0.2, 1: ≥0.2)
ee_class: Optional[int] = None # EE 分类 (0-2) ee_class: Optional[int] = None # EE 分类 (0-2)
toxic_class: Optional[int] = None # 毒性分类 (0: 无毒, 1: 有毒) toxic_class: Optional[int] = None # 毒性分类 (0: 无毒, 1: 有毒)
@ -597,8 +607,12 @@ def predict_all(
for i, col in enumerate(TARGET_BIODIST): for i, col in enumerate(TARGET_BIODIST):
df[f"pred_{col}"] = biodist_preds[:, i] df[f"pred_{col}"] = biodist_preds[:, i]
# size 模型输出为 log(size),转换回真实粒径 (nm) if SIZE_ZSCORE_STATS:
df["pred_size"] = np.exp(size_preds) df["pred_size"] = np.exp(
size_preds * SIZE_ZSCORE_STATS["std"] + SIZE_ZSCORE_STATS["mean"]
)
else:
df["pred_size"] = np.nan
df["pred_delivery"] = delivery_preds df["pred_delivery"] = delivery_preds
df["pred_pdi_class"] = pdi_preds df["pred_pdi_class"] = pdi_preds
df["pred_ee_class"] = ee_preds df["pred_ee_class"] = ee_preds
@ -688,7 +702,7 @@ def select_top_k(
# 额外预测值 # 额外预测值
quantified_delivery=row.get("pred_delivery"), quantified_delivery=row.get("pred_delivery"),
unnormalized_delivery=unnorm_delivery, unnormalized_delivery=unnorm_delivery,
size=row.get("pred_size"), size=float(row["pred_size"]) if pd.notna(row.get("pred_size")) else None,
pdi_class=int(row.get("pred_pdi_class")) if row.get("pred_pdi_class") is not None else None, pdi_class=int(row.get("pred_pdi_class")) if row.get("pred_pdi_class") is not None else None,
ee_class=int(row.get("pred_ee_class")) if row.get("pred_ee_class") is not None else None, ee_class=int(row.get("pred_ee_class")) if row.get("pred_ee_class") is not None else None,
toxic_class=int(row.get("pred_toxic_class")) if row.get("pred_toxic_class") is not None else None, toxic_class=int(row.get("pred_toxic_class")) if row.get("pred_toxic_class") is not None else None,
@ -817,7 +831,7 @@ def optimize(
routes: Optional[List[str]] = None, routes: Optional[List[str]] = None,
scoring_weights: Optional[ScoringWeights] = None, scoring_weights: Optional[ScoringWeights] = None,
batch_size: int = 256, batch_size: int = 256,
rerank_top_n: int = 0, rerank_top_n: int = 200,
rerank_batch_size: int = 16, rerank_batch_size: int = 16,
) -> List[Formulation]: ) -> List[Formulation]:
""" """

View File

@ -0,0 +1,4 @@
{
"mean": 4.593287493148074,
"std": 0.42933069758929093
}

View File

@ -459,12 +459,20 @@ class LNPModel(nn.Module):
完整的多任务 forward 完整的多任务 forward
Returns: Returns:
Dict[str, Tensor]: size [B,1], pdi [B,4], ee [B,3], Dict[str, Tensor]: size [B,1], pdi [B,2], ee [B,3],
delivery [B,1], biodist [B,7], toxic [B,2] delivery [B,1], biodist [B,7], toxic [B,2]
""" """
fused = self.forward_backbone(smiles, tabular) fused = self.forward_backbone(smiles, tabular)
return self.head(fused, getattr(self, "_last_pooled", None) if getattr(self, "reg_bypass", True) else None) return self.head(fused, getattr(self, "_last_pooled", None) if getattr(self, "reg_bypass", True) else None)
def set_llm_enabled(self, enabled: bool) -> None:
"""开关 LLM 旁路,供两阶段推理在粗筛阶段跳过 7B 前向。
forward getattr(self, "_llm_enabled", True) 读取因此没调用过
本方法的实例默认开启与改动前行为一致
"""
self._llm_enabled = bool(enabled)
def clear_cache(self) -> None: def clear_cache(self) -> None:
"""清空所有 encoder 的缓存""" """清空所有 encoder 的缓存"""
self.rdkit_encoder.clear_cache() self.rdkit_encoder.clear_cache()

View File

@ -68,6 +68,12 @@ def main(
df["size"] = pd.to_numeric(df["size"], errors="coerce") df["size"] = pd.to_numeric(df["size"], errors="coerce")
df["size"] = np.log(df["size"].replace(0, np.nan)) # 避免 log(0) df["size"] = np.log(df["size"].replace(0, np.nan)) # 避免 log(0)
size_stats = {"mean": float(df["size"].mean()), "std": float(df["size"].std())}
size_stats_path = APP_DIR / "size_zscore_stats.json"
with open(size_stats_path, "w") as f:
json.dump(size_stats, f, indent=2)
logger.success(f"Saved size stats to {size_stats_path}: {size_stats}")
# 保存 # 保存
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
df.to_csv(output_path, index=False) df.to_csv(output_path, index=False)