1331 lines
54 KiB
Python
1331 lines
54 KiB
Python
"""Shared RNA-seq differential-expression design inference (Modular v2.0).
|
||
|
||
The standard user path is sample-table driven: every included sample must carry
|
||
an explicit group_role, then this module derives experiment/compare/de blocks.
|
||
|
||
v2.0 新增:
|
||
- M1-M6 六大实验设计模块自动检测
|
||
- batch 校正(正交性检查)
|
||
- subject_id 配对设计
|
||
- genotype/tissue 多因素设计
|
||
- dose 剂量响应
|
||
- time × condition 时间序列 LRT
|
||
- 模糊边界检测(10种场景)
|
||
- 完全向后兼容旧接口
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import Any
|
||
|
||
|
||
ROLE_ALIASES = {
|
||
"baseline": "control",
|
||
"reference": "control",
|
||
"ref": "control",
|
||
"case": "compare",
|
||
"treated": "compare",
|
||
"treat": "compare",
|
||
"treatment": "compare",
|
||
"compare_group": "compare",
|
||
"comparison": "compare",
|
||
"skip": "ignore",
|
||
"ignored": "ignore",
|
||
"exclude": "ignore",
|
||
}
|
||
|
||
|
||
# 可识别的额外字段(超出旧版 condition/time)
|
||
EXTRA_FIELD_KEYS = {
|
||
"genotype", "tissue", "dose", "batch",
|
||
"subject_id", "subject", "patient_id", "plant_id",
|
||
"phenotype", "trait",
|
||
}
|
||
|
||
# phenotype_ 前缀匹配
|
||
PHENOTYPE_PREFIX = "phenotype_"
|
||
|
||
# 剂量类字段别名
|
||
DOSE_ALIASES = {"dose", "concentration", "conc", "dosage", "amount"}
|
||
|
||
|
||
def parse_time_sort_value(value: Any) -> tuple[int, Any]:
|
||
text = str(value)
|
||
match = re.search(r"-?\d+(?:\.\d+)?", text)
|
||
if match:
|
||
return (0, float(match.group(0)))
|
||
return (1, text)
|
||
|
||
|
||
def parse_bool(value: Any, default: bool = True) -> bool:
|
||
if value is None:
|
||
return default
|
||
if isinstance(value, bool):
|
||
return value
|
||
text = str(value).strip().lower()
|
||
if text in {"", "none", "null"}:
|
||
return default
|
||
return text in {"1", "true", "yes", "y", "on"}
|
||
|
||
|
||
def text_value(value: Any) -> str:
|
||
if value is None:
|
||
return ""
|
||
return str(value).strip()
|
||
|
||
|
||
def first_text_value(*values: Any) -> str:
|
||
for value in values:
|
||
text = text_value(value)
|
||
if text:
|
||
return text
|
||
return ""
|
||
|
||
|
||
def infer_optional_batch(
|
||
samples: dict[str, dict[str, Any]],
|
||
active_samples: list[str],
|
||
) -> dict[str, Any]:
|
||
"""Return a deliberately small optional-batch plan.
|
||
|
||
Batch is opt-in: no filled values means the historical design is unchanged.
|
||
Complete metadata with at least two levels enables batch; incomplete or
|
||
single-level metadata falls back to the historical no-batch design.
|
||
Runtime DESeq2 failures are handled by the CLI with one no-batch retry.
|
||
"""
|
||
values = {
|
||
str(sample): text_value((samples.get(str(sample)) or {}).get("batch"))
|
||
for sample in active_samples
|
||
}
|
||
filled = {sample: value for sample, value in values.items() if value}
|
||
requested = bool(filled)
|
||
missing = [sample for sample, value in values.items() if not value]
|
||
levels = sorted(set(filled.values()))
|
||
used = requested and not missing and len(levels) >= 2
|
||
reason = ""
|
||
if requested and missing:
|
||
reason = "部分参与分析的样本未填写 batch,已按无批次模型运行。"
|
||
elif requested and len(levels) < 2:
|
||
reason = "batch 只有一个有效水平,无需校正,已按无批次模型运行。"
|
||
return {
|
||
"batch_requested": requested,
|
||
"batch_used": used,
|
||
"batch_values": filled if used else {},
|
||
"batch_levels": levels,
|
||
"batch_fallback": bool(requested and not used),
|
||
"batch_fallback_reason": reason,
|
||
}
|
||
|
||
|
||
def normalize_group_role(value: Any) -> str:
|
||
role = str(value or "").strip().lower().replace("-", "_")
|
||
role = ROLE_ALIASES.get(role, role)
|
||
if role not in {"control", "compare", "ignore"}:
|
||
raise ValueError(f"samples.yaml 分组角色非法: {value}; 允许 control/compare/ignore")
|
||
return role
|
||
|
||
|
||
def normalize_group_label(value: Any) -> str:
|
||
return text_value(value)
|
||
|
||
|
||
def dedupe_comparisons(comparisons: list[list[str]]) -> list[list[str]]:
|
||
seen: set[tuple[str, str]] = set()
|
||
out: list[list[str]] = []
|
||
for comp in comparisons:
|
||
if len(comp) != 2:
|
||
continue
|
||
key = (normalize_group_label(comp[0]), normalize_group_label(comp[1]))
|
||
if not key[0] or not key[1] or key in seen:
|
||
continue
|
||
seen.add(key)
|
||
out.append([key[0], key[1]])
|
||
return out
|
||
|
||
|
||
def _configured_comparisons(value: Any) -> list[list[str]]:
|
||
if not isinstance(value, list):
|
||
return []
|
||
return dedupe_comparisons(
|
||
[[str(comp[0]), str(comp[1])] for comp in value if isinstance(comp, list) and len(comp) == 2]
|
||
)
|
||
|
||
|
||
# ────────────────────────────────────────────────
|
||
# v2.0 新增:字段检测
|
||
# ────────────────────────────────────────────────
|
||
|
||
def _detect_fields(
|
||
meta: dict[str, Any],
|
||
) -> dict[str, Any]:
|
||
"""从单个样本的 metadata 检测额外字段。
|
||
|
||
Returns:
|
||
{field_name: value, ...} 不含 condition/time/group_role
|
||
"""
|
||
detected: dict[str, Any] = {}
|
||
for key in EXTRA_FIELD_KEYS:
|
||
val = meta.get(key)
|
||
if val is not None and text_value(val):
|
||
detected[key] = text_value(val)
|
||
# phenotype_* 字段
|
||
for key in meta:
|
||
if key.startswith(PHENOTYPE_PREFIX) or key.lower() in {"phenotype", "trait"}:
|
||
val = meta.get(key)
|
||
if val is not None and text_value(val):
|
||
detected[key] = text_value(val)
|
||
# dose aliases
|
||
for alias in DOSE_ALIASES:
|
||
if alias in meta and alias != "dose":
|
||
val = text_value(meta.get(alias))
|
||
if val and "dose" not in detected:
|
||
detected["dose"] = val
|
||
return detected
|
||
|
||
|
||
def _collect_fields_report(
|
||
active_items: list[tuple[str, dict[str, Any], str]],
|
||
) -> dict[str, Any]:
|
||
"""收集所有活跃样本的字段统计。
|
||
|
||
Returns:
|
||
完整字段报告 dict,包含 has_*, *_levels, *_values 等
|
||
"""
|
||
report: dict[str, Any] = {
|
||
"has_condition": True,
|
||
"condition_levels": 0,
|
||
"condition_values": [],
|
||
"has_time": False,
|
||
"time_levels": 0,
|
||
"time_values": [],
|
||
"has_genotype": False,
|
||
"genotype_levels": 0,
|
||
"genotype_values": [],
|
||
"has_tissue": False,
|
||
"tissue_levels": 0,
|
||
"tissue_values": [],
|
||
"has_dose": False,
|
||
"dose_levels": 0,
|
||
"dose_values": [],
|
||
"dose_is_numeric": False,
|
||
"has_subject_id": False,
|
||
"subject_id_levels": 0,
|
||
"n_complete_pairs": 0,
|
||
"n_total_pairs": 0,
|
||
"pairing_completeness": 1.0,
|
||
"has_batch": False,
|
||
"batch_levels": 0,
|
||
"batch_values": [],
|
||
"batch_orthogonal": True,
|
||
"has_phenotype": False,
|
||
"phenotype_fields": [],
|
||
"n_active_samples": len(active_items),
|
||
"replicate_counts": {},
|
||
}
|
||
|
||
condition_set: set[str] = set()
|
||
time_set: set[str] = set()
|
||
genotype_set: set[str] = set()
|
||
tissue_set: set[str] = set()
|
||
dose_set: set[str] = set()
|
||
batch_set: set[str] = set()
|
||
subject_id_map: dict[str, set[str]] = {} # subject_id -> {conditions}
|
||
sample_to_condition: dict[str, str] = {}
|
||
sample_to_batch: dict[str, str] = {}
|
||
phenotype_fields: set[str] = set()
|
||
group_counts: dict[str, int] = {}
|
||
|
||
for sample, meta, _role in active_items:
|
||
cond = first_text_value(
|
||
meta.get("condition"), meta.get("group"), meta.get("treatment")
|
||
)
|
||
if cond:
|
||
condition_set.add(cond)
|
||
sample_to_condition[sample] = cond
|
||
group_counts[cond] = group_counts.get(cond, 0) + 1
|
||
|
||
time_val = text_value(meta.get("time"))
|
||
if time_val:
|
||
time_set.add(time_val)
|
||
|
||
genotype_val = text_value(meta.get("genotype"))
|
||
if genotype_val:
|
||
genotype_set.add(genotype_val)
|
||
|
||
tissue_val = text_value(meta.get("tissue"))
|
||
if tissue_val:
|
||
tissue_set.add(tissue_val)
|
||
|
||
dose_val = text_value(meta.get("dose"))
|
||
if dose_val:
|
||
dose_set.add(dose_val)
|
||
|
||
batch_val = text_value(meta.get("batch"))
|
||
if batch_val:
|
||
batch_set.add(batch_val)
|
||
sample_to_batch[sample] = batch_val
|
||
|
||
# subject_id detection
|
||
sid = first_text_value(
|
||
meta.get("subject_id"), meta.get("subject"),
|
||
meta.get("patient_id"), meta.get("plant_id"),
|
||
)
|
||
if sid and cond:
|
||
if sid not in subject_id_map:
|
||
subject_id_map[sid] = set()
|
||
subject_id_map[sid].add(cond)
|
||
|
||
# phenotype detection
|
||
for key in meta:
|
||
if key.startswith(PHENOTYPE_PREFIX) or key.lower() in {"phenotype", "trait"}:
|
||
val = text_value(meta.get(key))
|
||
if val:
|
||
phenotype_fields.add(key)
|
||
|
||
# Fill report
|
||
report["condition_levels"] = len(condition_set)
|
||
report["condition_values"] = sorted(condition_set)
|
||
report["has_condition"] = len(condition_set) > 0
|
||
|
||
report["has_time"] = len(time_set) > 0
|
||
report["time_levels"] = len(time_set)
|
||
report["time_values"] = sorted(time_set, key=parse_time_sort_value)
|
||
|
||
report["has_genotype"] = len(genotype_set) > 0
|
||
report["genotype_levels"] = len(genotype_set)
|
||
report["genotype_values"] = sorted(genotype_set)
|
||
|
||
report["has_tissue"] = len(tissue_set) > 0
|
||
report["tissue_levels"] = len(tissue_set)
|
||
report["tissue_values"] = sorted(tissue_set)
|
||
|
||
report["has_dose"] = len(dose_set) > 0
|
||
report["dose_levels"] = len(dose_set)
|
||
report["dose_values"] = sorted(dose_set, key=parse_time_sort_value)
|
||
# 检查 dose 是否都是数值
|
||
dose_all_numeric = all(
|
||
parse_time_sort_value(v)[0] == 0 for v in dose_set
|
||
) if dose_set else False
|
||
report["dose_is_numeric"] = dose_all_numeric
|
||
|
||
report["has_subject_id"] = len(subject_id_map) > 0
|
||
report["subject_id_levels"] = len(subject_id_map)
|
||
# 配对完整度:跨 condition 的 subject 数
|
||
complete_pairs = sum(
|
||
1 for conditions in subject_id_map.values() if len(conditions) >= 2
|
||
)
|
||
total_subjects = len(subject_id_map)
|
||
report["n_complete_pairs"] = complete_pairs
|
||
report["n_total_pairs"] = total_subjects
|
||
report["pairing_completeness"] = (
|
||
complete_pairs / total_subjects if total_subjects > 0 else 1.0
|
||
)
|
||
|
||
report["has_batch"] = len(batch_set) > 1
|
||
report["batch_levels"] = len(batch_set)
|
||
report["batch_values"] = sorted(batch_set)
|
||
# batch 正交性:每个 batch 里是否包含多个 condition
|
||
if batch_set and condition_set:
|
||
batch_orth = True
|
||
batch_to_conditions: dict[str, set[str]] = {}
|
||
for sample, meta, _role in active_items:
|
||
b = sample_to_batch.get(sample, "__no_batch__")
|
||
c = sample_to_condition.get(sample, "__no_cond__")
|
||
batch_to_conditions.setdefault(b, set()).add(c)
|
||
# 如果任何 batch 只包含一个 condition,检查是否混杂
|
||
mixing_batches = []
|
||
for b, conds in batch_to_conditions.items():
|
||
if b == "__no_batch__":
|
||
continue
|
||
if len(conds) == 1 and len(batch_set) > 1:
|
||
# 这个 batch 只对应一个 condition,可能是混杂
|
||
mixing_batches.append(b)
|
||
# 所有 batch 都只对应一个 condition → 完全混杂
|
||
if len(mixing_batches) == len(batch_set) and len(batch_set) > 1:
|
||
batch_orth = False
|
||
report["batch_orthogonal"] = batch_orth
|
||
else:
|
||
report["batch_orthogonal"] = True
|
||
|
||
report["has_phenotype"] = len(phenotype_fields) > 0
|
||
report["phenotype_fields"] = sorted(phenotype_fields)
|
||
|
||
report["replicate_counts"] = group_counts
|
||
|
||
return report
|
||
|
||
|
||
# ────────────────────────────────────────────────
|
||
# v2.0 新增:模块检测
|
||
# ────────────────────────────────────────────────
|
||
|
||
def detect_module(
|
||
fields_report: dict[str, Any],
|
||
*,
|
||
user_module: str = "",
|
||
) -> dict[str, Any]:
|
||
"""从字段报告中推断最佳实验设计模块。
|
||
|
||
Args:
|
||
fields_report: _collect_fields_report() 的输出
|
||
user_module: 用户显式指定的模块 (M1-M6),空串表示自动推断
|
||
|
||
Returns:
|
||
{
|
||
"primary_module": "M5", # 主推荐模块
|
||
"primary_module_name": "时间序列分析",
|
||
"alternatives": ["M3", "M1"], # 备选模块
|
||
"not_recommended": [], # 不推荐
|
||
"design_formula": "~ batch + condition * time",
|
||
"test_type": "LRT",
|
||
"comparison_strategy": "baseline",
|
||
"module_source": "auto", # "auto" / "user"
|
||
}
|
||
"""
|
||
if user_module:
|
||
return _build_module_result(user_module, fields_report, source="user")
|
||
|
||
# 精简模块模式只自动选择 M4/M3/M1。M1 保持为基础保底流程,
|
||
# 其余历史模块不再参与自动竞争。
|
||
candidates: list[str] = []
|
||
|
||
# M4: 交互项 (需要 genotype + condition)
|
||
if (
|
||
fields_report.get("has_genotype")
|
||
and fields_report.get("has_condition")
|
||
and fields_report.get("genotype_levels", 0) >= 2
|
||
and fields_report.get("condition_levels", 0) >= 2
|
||
):
|
||
candidates.append("M4")
|
||
|
||
# M3 只承担多因子内的分层比较。普通多处理共享对照仍由 M1 处理。
|
||
has_multi_factor = (
|
||
(fields_report.get("has_genotype") and fields_report.get("genotype_levels", 0) >= 2)
|
||
or (fields_report.get("has_tissue") and fields_report.get("tissue_levels", 0) >= 2)
|
||
)
|
||
if (
|
||
fields_report.get("has_condition")
|
||
and (
|
||
has_multi_factor and fields_report.get("condition_levels", 0) >= 2
|
||
)
|
||
):
|
||
candidates.append("M3")
|
||
|
||
# M1: 兜底(标准比较)
|
||
if fields_report.get("has_condition") and fields_report.get("condition_levels", 0) >= 2:
|
||
candidates.append("M1")
|
||
|
||
if not candidates:
|
||
raise ValueError(
|
||
"无法推断实验设计模块。请确保样本表中至少标记了 condition 字段(>=2 个水平)。"
|
||
)
|
||
|
||
primary = candidates[0]
|
||
alternatives = [m for m in candidates[1:] if m != primary]
|
||
|
||
return _build_module_result(primary, fields_report, source="auto", alternatives=alternatives)
|
||
|
||
|
||
def _build_module_result(
|
||
module: str,
|
||
fields: dict[str, Any],
|
||
source: str = "auto",
|
||
alternatives: list[str] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""构建模块检测结果 dict。"""
|
||
from .rnaseq_de_modules import MODULE_CONFIGS
|
||
|
||
cfg = MODULE_CONFIGS.get(module, MODULE_CONFIGS["M1"])
|
||
batch_ok = fields.get("has_batch") and fields.get("batch_orthogonal", True)
|
||
batch_prefix = "batch + " if batch_ok else ""
|
||
|
||
# 构建 design formula
|
||
if module == "M5":
|
||
extra = ""
|
||
if fields.get("has_genotype"):
|
||
extra = "genotype + "
|
||
design = f"~ {batch_prefix}{extra}condition * time"
|
||
elif module == "M4":
|
||
factor = "genotype"
|
||
if fields.get("has_tissue"):
|
||
factor = "tissue"
|
||
design = f"~ {batch_prefix}{factor} * condition"
|
||
elif module == "M3":
|
||
design = f"~ {batch_prefix}group"
|
||
elif module == "M2":
|
||
design = f"~ {batch_prefix}subject_id + condition"
|
||
elif module == "M6":
|
||
design = f"~ {batch_prefix}dose"
|
||
else: # M1
|
||
design = f"~ {batch_prefix}condition"
|
||
|
||
test_type = cfg.get("test", "Wald")
|
||
|
||
# 构建分组逻辑
|
||
group_field = "condition"
|
||
if module == "M5":
|
||
group_field = "time_condition"
|
||
elif module == "M4":
|
||
group_field = "interaction"
|
||
elif module == "M3":
|
||
group_field = "group"
|
||
elif module == "M6":
|
||
group_field = "dose"
|
||
|
||
return {
|
||
"primary_module": module,
|
||
"primary_module_name": cfg.get("name", module),
|
||
"alternatives": alternatives or [],
|
||
"not_recommended": [],
|
||
"design_formula": design,
|
||
"test_type": test_type,
|
||
"comparison_strategy": cfg.get("comparison_strategy", "all_vs_control"),
|
||
"module_source": source,
|
||
"group_field": group_field,
|
||
"batch_used": batch_ok,
|
||
"fields_detected": fields,
|
||
}
|
||
|
||
|
||
def infer_priority_advanced_design(
|
||
samples: dict[str, dict[str, Any]],
|
||
*,
|
||
matrix_samples: list[str],
|
||
user_module: str = "",
|
||
minimum_replicates: int = 2,
|
||
design_options: dict[str, Any] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Build the small M3/M4 priority layer used ahead of the legacy M1 path.
|
||
|
||
An empty result means that no supported advanced axis varies and the caller
|
||
should use the unchanged legacy design. Invalid factorial metadata raises
|
||
instead of silently pooling biologically different strata into M1.
|
||
"""
|
||
options = dict(design_options or {})
|
||
requested = str(user_module or "").strip().upper()
|
||
if requested and requested not in {"M1", "M3", "M4"}:
|
||
raise ValueError("精简模块模式只支持 M1、M3 和 M4。")
|
||
if requested == "M1":
|
||
return {}
|
||
|
||
active: list[dict[str, str]] = []
|
||
missing_meta: list[str] = []
|
||
for sample_id in matrix_samples:
|
||
meta = samples.get(str(sample_id))
|
||
if not isinstance(meta, dict):
|
||
missing_meta.append(str(sample_id))
|
||
continue
|
||
if not parse_bool(meta.get("include"), default=True):
|
||
continue
|
||
role_raw = text_value(meta.get("group_role")).lower()
|
||
role = "compare" if role_raw == "treatment" else role_raw
|
||
if role in {"ignore", "exclude"}:
|
||
continue
|
||
if role and role not in {"control", "compare"}:
|
||
raise ValueError(f"样本 {sample_id} 的 group_role 无效: {role_raw}")
|
||
condition = first_text_value(meta.get("condition"), meta.get("group"), meta.get("treatment"))
|
||
if not condition:
|
||
raise ValueError(f"样本 {sample_id} 缺少 condition,不能生成 DE 设计。")
|
||
active.append({
|
||
"sample_id": str(sample_id),
|
||
"condition": condition,
|
||
"role": role,
|
||
"genotype": text_value(meta.get("genotype")),
|
||
"tissue": text_value(meta.get("tissue")),
|
||
"time": text_value(meta.get("time")),
|
||
"dose": text_value(meta.get("dose")),
|
||
"batch": text_value(meta.get("batch")),
|
||
})
|
||
if missing_meta:
|
||
raise ValueError("samples.yaml 缺少 count matrix 样本 metadata: " + ", ".join(missing_meta[:10]))
|
||
if not active:
|
||
raise ValueError("没有可用于 DE 的样本。")
|
||
|
||
varying_axes = {
|
||
field: sorted({row[field] for row in active if row[field]})
|
||
for field in ("genotype", "tissue", "time", "dose")
|
||
}
|
||
advanced_axes = [field for field in ("genotype", "tissue") if len(varying_axes[field]) >= 2]
|
||
if not advanced_axes:
|
||
return {}
|
||
if len(advanced_axes) > 1:
|
||
raise ValueError("同时检测到 genotype 和 tissue 变化;精简 M3/M4 模式不能静默忽略其中一个因子。")
|
||
if len(varying_axes["dose"]) >= 2:
|
||
raise ValueError("M3/M4 精简模式暂不支持同时变化的 dose 因子。")
|
||
|
||
factor = advanced_axes[0]
|
||
factor_levels = varying_axes[factor]
|
||
condition_levels = sorted({row["condition"] for row in active})
|
||
missing_factor = [row["sample_id"] for row in active if not row[factor]]
|
||
if missing_factor:
|
||
raise ValueError(f"部分样本缺少 {factor}: " + ", ".join(missing_factor[:10]))
|
||
|
||
cells: dict[tuple[str, str], list[str]] = {}
|
||
for row in active:
|
||
cells.setdefault((row[factor], row["condition"]), []).append(row["sample_id"])
|
||
def role_axis(axis: str, levels: list[str]) -> dict[str, Any]:
|
||
roles_by_level = {
|
||
level: {row["role"] for row in active if row[axis] == level and row["role"]}
|
||
for level in levels
|
||
}
|
||
complete = all(roles_by_level[level] for level in levels)
|
||
consistent = complete and all(len(roles_by_level[level]) == 1 for level in levels)
|
||
controls = [level for level in levels if roles_by_level[level] == {"control"}]
|
||
compares = [level for level in levels if roles_by_level[level] == {"compare"}]
|
||
return {
|
||
"valid": consistent and len(controls) == 1 and bool(compares),
|
||
"control": controls[0] if len(controls) == 1 else "",
|
||
"compares": compares,
|
||
"roles": roles_by_level,
|
||
}
|
||
|
||
condition_role = role_axis("condition", condition_levels)
|
||
factor_role = role_axis(factor, factor_levels)
|
||
|
||
def option(*keys: str) -> str:
|
||
for key in keys:
|
||
value = str(options.get(key) or "").strip()
|
||
if value:
|
||
return value
|
||
return ""
|
||
|
||
warnings: list[dict[str, str]] = []
|
||
batch_plan = infer_optional_batch(
|
||
samples,
|
||
[row["sample_id"] for row in active],
|
||
)
|
||
if batch_plan["batch_fallback"]:
|
||
warnings.append({
|
||
"code": "batch_fallback_without_adjustment",
|
||
"message": batch_plan["batch_fallback_reason"],
|
||
})
|
||
# M3 extension: one genotype factor plus multiple time points.
|
||
# Build genotype__time groups and compare every later time with the
|
||
# genotype-specific baseline. This intentionally does not fit a
|
||
# genotype x condition x time model.
|
||
if factor == "genotype" and len(varying_axes["time"]) >= 2:
|
||
time_levels = sorted(varying_axes["time"], key=parse_time_sort_value)
|
||
time_cells: dict[tuple[str, str], list[str]] = {}
|
||
for row in active:
|
||
if not row["time"]:
|
||
raise ValueError(f"样本 {row['sample_id']} 缺少 time,不能执行品种分层时间比较。")
|
||
time_cells.setdefault((row[factor], row["time"]), []).append(row["sample_id"])
|
||
|
||
explicit_reference_time = option(
|
||
"reference_time", "referenceTime", "baseline_time", "baselineTime",
|
||
"baseline_group", "baselineGroup",
|
||
)
|
||
reference_time = ""
|
||
reference_time_source = ""
|
||
if explicit_reference_time:
|
||
reference_time = next(
|
||
(level for level in time_levels if level.lower() == explicit_reference_time.lower()),
|
||
"",
|
||
)
|
||
if reference_time:
|
||
reference_time_source = "user"
|
||
else:
|
||
warnings.append({
|
||
"code": "invalid_reference_time_ignored",
|
||
"message": f"reference_time={explicit_reference_time} 不存在,已自动选择时间基线。",
|
||
})
|
||
|
||
if not reference_time:
|
||
roles_by_time = {
|
||
level: {row["role"] for row in active if row["time"] == level and row["role"]}
|
||
for level in time_levels
|
||
}
|
||
control_times = [
|
||
level for level in time_levels if roles_by_time[level] == {"control"}
|
||
]
|
||
if len(control_times) == 1:
|
||
reference_time = control_times[0]
|
||
reference_time_source = "group_role"
|
||
else:
|
||
zero_time = next(
|
||
(
|
||
level for level in time_levels
|
||
if re.sub(r"[\s_.-]+", "", level.strip().lower()) in {"0", "0h", "t0"}
|
||
),
|
||
"",
|
||
)
|
||
if zero_time:
|
||
reference_time = zero_time
|
||
reference_time_source = "semantic"
|
||
else:
|
||
reference_time = time_levels[0]
|
||
reference_time_source = "earliest_time"
|
||
warnings.append({
|
||
"code": "reference_time_auto_earliest",
|
||
"message": f"未识别到唯一对照时间,使用最早时间 {reference_time} 作为基线。",
|
||
})
|
||
|
||
comparisons: list[list[str]] = []
|
||
skipped: list[dict[str, str]] = []
|
||
for factor_level in factor_levels:
|
||
reference_n = len(time_cells.get((factor_level, reference_time), []))
|
||
for time_level in time_levels:
|
||
if time_level == reference_time:
|
||
continue
|
||
compare_n = len(time_cells.get((factor_level, time_level), []))
|
||
if reference_n >= minimum_replicates and compare_n >= minimum_replicates:
|
||
comparisons.append([
|
||
f"{factor_level}__{time_level}",
|
||
f"{factor_level}__{reference_time}",
|
||
])
|
||
else:
|
||
skipped.append({
|
||
"comparison_axis": "time",
|
||
"stratum": factor_level,
|
||
"comparison": f"{time_level}_vs_{reference_time}",
|
||
"reason": f"replicates reference={reference_n}, compare={compare_n}",
|
||
})
|
||
if not comparisons:
|
||
raise ValueError("没有任何满足重复要求的品种分层时间比较。")
|
||
|
||
requested_axis = option(
|
||
"comparison_axis", "comparisonAxis", "primary_factor", "primaryFactor"
|
||
).lower()
|
||
if requested_axis and requested_axis != "time":
|
||
warnings.append({
|
||
"code": "comparison_axis_overridden_for_stratified_time",
|
||
"message": f"品种×时间场景固定按 time 比较,已忽略 comparison_axis={requested_axis}。",
|
||
})
|
||
|
||
groups = {
|
||
row["sample_id"]: f"{row[factor]}__{row['time']}"
|
||
for row in active
|
||
}
|
||
condition_values = {row["sample_id"]: row["condition"] for row in active}
|
||
factor_values = {row["sample_id"]: row[factor] for row in active}
|
||
batch_prefix = "batch + " if batch_plan["batch_used"] else ""
|
||
fallback_from = "M4" if requested == "M4" else ""
|
||
if fallback_from:
|
||
warnings.append({
|
||
"code": "m4_to_m3_stratified_time",
|
||
"message": "品种×时间场景按 M3 品种分层时间比较执行;未运行 M4 品种×处理交互。",
|
||
})
|
||
result = {
|
||
"design": "stratified_time",
|
||
"module": "M3",
|
||
"module_name": "品种分层时间比较",
|
||
"module_source": "user" if requested else "auto",
|
||
"advanced_factor": factor,
|
||
"groups": groups,
|
||
"condition_values": condition_values,
|
||
"factor_values": factor_values,
|
||
"active_samples": [row["sample_id"] for row in active],
|
||
"ignored_samples": [sample for sample in matrix_samples if sample not in groups],
|
||
"control_group": f"{factor_levels[0]}__{reference_time}",
|
||
"control_condition": reference_time,
|
||
"reference_condition": reference_time,
|
||
"reference_time": reference_time,
|
||
"reference_factor": factor_levels[0],
|
||
"reference_genotype": factor_levels[0],
|
||
"condition_reference_source": reference_time_source,
|
||
"factor_reference_source": "not_applicable",
|
||
"comparison_axis": "time",
|
||
"comparison_axis_source": reference_time_source,
|
||
"stratify_axis": factor,
|
||
"condition_levels": condition_levels,
|
||
"factor_levels": factor_levels,
|
||
"time_levels": time_levels,
|
||
"treatment_groups": [pair[0] for pair in comparisons],
|
||
"compare": comparisons,
|
||
"comparison_mode": "factor_stratified_time_baseline",
|
||
"design_formula": f"~ {batch_prefix}group",
|
||
"test_type": "Wald",
|
||
"m4_eligible": False,
|
||
"fallback_from": fallback_from,
|
||
"fallback_reason": (
|
||
"genotype x time routed to M3 genotype-stratified baseline comparisons"
|
||
if fallback_from else ""
|
||
),
|
||
"skipped_comparisons": skipped,
|
||
"warnings": warnings,
|
||
}
|
||
result.update(batch_plan)
|
||
return result
|
||
|
||
if len(varying_axes["time"]) >= 2:
|
||
raise ValueError("M3/M4 精简模式暂只支持 genotype × time 的分层时间比较。")
|
||
if len(condition_levels) < 2:
|
||
return {}
|
||
|
||
explicit_axis = option("comparison_axis", "comparisonAxis", "primary_factor", "primaryFactor").lower()
|
||
if explicit_axis in {"genotype", "tissue"}:
|
||
explicit_axis = factor if explicit_axis == factor else explicit_axis
|
||
if explicit_axis and explicit_axis not in {"condition", factor}:
|
||
warnings.append({
|
||
"code": "invalid_comparison_axis_ignored",
|
||
"message": f"comparison_axis={explicit_axis} 不适用于当前 {factor} x condition 设计,已自动推断。",
|
||
})
|
||
explicit_axis = ""
|
||
|
||
if explicit_axis:
|
||
comparison_axis = explicit_axis
|
||
axis_source = "user"
|
||
elif condition_role["valid"] and not factor_role["valid"]:
|
||
comparison_axis = "condition"
|
||
axis_source = "group_role"
|
||
elif factor_role["valid"] and not condition_role["valid"]:
|
||
comparison_axis = factor
|
||
axis_source = "group_role"
|
||
elif condition_role["valid"]:
|
||
comparison_axis = "condition"
|
||
axis_source = "group_role"
|
||
else:
|
||
comparison_axis = "condition"
|
||
axis_source = "auto"
|
||
warnings.append({
|
||
"code": "comparison_axis_auto_default",
|
||
"message": "group_role 未能唯一确定比较轴,默认按 condition 比较;可显式设置 comparison_axis。",
|
||
})
|
||
stratify_axis = factor if comparison_axis == "condition" else "condition"
|
||
|
||
def normalized_label(value: str) -> str:
|
||
return re.sub(r"[\s_.-]+", "", value.strip().lower())
|
||
|
||
def choose_reference(
|
||
axis: str,
|
||
levels: list[str],
|
||
explicit: str,
|
||
role_info: dict[str, Any],
|
||
semantic_tokens: set[str],
|
||
) -> tuple[str, str]:
|
||
if explicit:
|
||
match = next((level for level in levels if level.lower() == explicit.lower()), "")
|
||
if match:
|
||
return match, "user"
|
||
warnings.append({
|
||
"code": f"invalid_reference_{axis}_ignored",
|
||
"message": f"reference_{axis}={explicit} 不存在,已自动选择参考水平。",
|
||
})
|
||
if role_info["valid"]:
|
||
return str(role_info["control"]), "group_role"
|
||
semantic = next(
|
||
(level for level in levels if normalized_label(level) in semantic_tokens),
|
||
"",
|
||
)
|
||
if semantic:
|
||
return semantic, "semantic"
|
||
warnings.append({
|
||
"code": f"reference_{axis}_auto_sorted",
|
||
"message": f"无法识别 {axis} 参考水平,使用稳定排序后的 {levels[0]};建议用户确认。",
|
||
})
|
||
return levels[0], "auto_sorted"
|
||
|
||
reference_condition, condition_reference_source = choose_reference(
|
||
"condition",
|
||
condition_levels,
|
||
option("reference_condition", "referenceCondition", "control_condition", "controlCondition"),
|
||
condition_role,
|
||
{"control", "ctrl", "ck", "mock", "vehicle", "dmso", "untreated", "baseline", "0", "0h"},
|
||
)
|
||
reference_factor, factor_reference_source = choose_reference(
|
||
factor,
|
||
factor_levels,
|
||
option(f"reference_{factor}", f"reference{factor.title()}", "control_genotype", "controlGenotype"),
|
||
factor_role,
|
||
{"wt", "wildtype", "reference", "ref"},
|
||
)
|
||
|
||
def axis_comparisons(axis: str) -> tuple[list[list[str]], list[dict[str, str]]]:
|
||
comparisons: list[list[str]] = []
|
||
skipped: list[dict[str, str]] = []
|
||
if axis == "condition":
|
||
for factor_level in factor_levels:
|
||
reference_n = len(cells.get((factor_level, reference_condition), []))
|
||
for condition in condition_levels:
|
||
if condition == reference_condition:
|
||
continue
|
||
compare_n = len(cells.get((factor_level, condition), []))
|
||
if reference_n >= minimum_replicates and compare_n >= minimum_replicates:
|
||
comparisons.append([
|
||
f"{factor_level}__{condition}",
|
||
f"{factor_level}__{reference_condition}",
|
||
])
|
||
else:
|
||
skipped.append({
|
||
"comparison_axis": "condition",
|
||
"stratum": factor_level,
|
||
"comparison": f"{condition}_vs_{reference_condition}",
|
||
"reason": f"replicates reference={reference_n}, compare={compare_n}",
|
||
})
|
||
else:
|
||
for condition in condition_levels:
|
||
reference_n = len(cells.get((reference_factor, condition), []))
|
||
for factor_level in factor_levels:
|
||
if factor_level == reference_factor:
|
||
continue
|
||
compare_n = len(cells.get((factor_level, condition), []))
|
||
if reference_n >= minimum_replicates and compare_n >= minimum_replicates:
|
||
comparisons.append([
|
||
f"{factor_level}__{condition}",
|
||
f"{reference_factor}__{condition}",
|
||
])
|
||
else:
|
||
skipped.append({
|
||
"comparison_axis": factor,
|
||
"stratum": condition,
|
||
"comparison": f"{factor_level}_vs_{reference_factor}",
|
||
"reason": f"replicates reference={reference_n}, compare={compare_n}",
|
||
})
|
||
return comparisons, skipped
|
||
|
||
primary_comparisons, primary_skipped = axis_comparisons(comparison_axis)
|
||
secondary_axis = factor if comparison_axis == "condition" else "condition"
|
||
secondary_comparisons, secondary_skipped = axis_comparisons(secondary_axis)
|
||
if not primary_comparisons and secondary_comparisons and not explicit_axis:
|
||
comparison_axis, secondary_axis = secondary_axis, comparison_axis
|
||
stratify_axis = factor if comparison_axis == "condition" else "condition"
|
||
primary_comparisons, secondary_comparisons = secondary_comparisons, primary_comparisons
|
||
primary_skipped, secondary_skipped = secondary_skipped, primary_skipped
|
||
warnings.append({
|
||
"code": "comparison_axis_auto_switched",
|
||
"message": f"默认比较轴没有可执行比较,已切换为 {comparison_axis}。",
|
||
})
|
||
|
||
m4_eligible = all(
|
||
len(cells.get((level, condition), [])) >= minimum_replicates
|
||
for level in factor_levels
|
||
for condition in condition_levels
|
||
)
|
||
if requested == "M4" and not m4_eligible and not primary_comparisons:
|
||
raise ValueError("M4 不可估计,且没有任何满足重复要求的 M3 分层比较。")
|
||
if requested == "M3" and not primary_comparisons:
|
||
raise ValueError("没有任何满足重复要求的 M3 分层比较。")
|
||
if not m4_eligible and not primary_comparisons:
|
||
raise ValueError("复杂因子正在变化,但 M4/M3 均不满足完整组合和重复要求;拒绝合并为 M1。")
|
||
|
||
module = "M4" if m4_eligible and requested != "M3" else "M3"
|
||
comparisons = list(primary_comparisons)
|
||
skipped = list(primary_skipped)
|
||
if module == "M4":
|
||
seen = {tuple(item) for item in comparisons}
|
||
comparisons.extend(item for item in secondary_comparisons if tuple(item) not in seen)
|
||
skipped.extend(secondary_skipped)
|
||
groups = {
|
||
row["sample_id"]: f"{row[factor]}__{row['condition']}"
|
||
for row in active
|
||
}
|
||
condition_values = {row["sample_id"]: row["condition"] for row in active}
|
||
factor_values = {row["sample_id"]: row[factor] for row in active}
|
||
fallback_from = "M4" if module == "M3" else ""
|
||
batch_prefix = "batch + " if batch_plan["batch_used"] else ""
|
||
result = {
|
||
"design": "interaction" if module == "M4" else "stratified",
|
||
"module": module,
|
||
"module_name": "响应差异分析" if module == "M4" else "多组合分层比较",
|
||
"module_source": "user" if requested else "auto",
|
||
"advanced_factor": factor,
|
||
"groups": groups,
|
||
"condition_values": condition_values,
|
||
"factor_values": factor_values,
|
||
"active_samples": [row["sample_id"] for row in active],
|
||
"ignored_samples": [sample for sample in matrix_samples if sample not in groups],
|
||
"control_group": (
|
||
f"{factor_levels[0]}__{reference_condition}"
|
||
if comparison_axis == "condition"
|
||
else f"{reference_factor}__{condition_levels[0]}"
|
||
),
|
||
"control_condition": reference_condition,
|
||
"reference_condition": reference_condition,
|
||
"reference_factor": reference_factor,
|
||
f"reference_{factor}": reference_factor,
|
||
"condition_reference_source": condition_reference_source,
|
||
"factor_reference_source": factor_reference_source,
|
||
"comparison_axis": comparison_axis,
|
||
"comparison_axis_source": axis_source,
|
||
"stratify_axis": stratify_axis,
|
||
"condition_levels": condition_levels,
|
||
"factor_levels": factor_levels,
|
||
"treatment_groups": [pair[0] for pair in comparisons],
|
||
"compare": comparisons,
|
||
"comparison_mode": "factor_stratified_control",
|
||
"design_formula": f"~ {batch_prefix}{factor} * condition" if module == "M4" else f"~ {batch_prefix}group",
|
||
"test_type": "LRT+Wald" if module == "M4" else "Wald",
|
||
"interaction_full": f"~ {batch_prefix}{factor} * condition",
|
||
"interaction_reduced": f"~ {batch_prefix}{factor} + condition",
|
||
"m4_eligible": m4_eligible,
|
||
"fallback_from": fallback_from,
|
||
"fallback_reason": "M4 cells incomplete; using valid M3 strata" if fallback_from else "",
|
||
"skipped_comparisons": skipped,
|
||
"warnings": warnings,
|
||
}
|
||
result.update(batch_plan)
|
||
return result
|
||
|
||
|
||
# ────────────────────────────────────────────────
|
||
# 原始 infer_rnaseq_de_design (保持向后兼容)
|
||
# ────────────────────────────────────────────────
|
||
|
||
def infer_rnaseq_de_design(
|
||
samples: dict[str, dict[str, Any]],
|
||
*,
|
||
matrix_samples: list[str] | None = None,
|
||
configured_compare: list[list[str]] | None = None,
|
||
legacy_mode: bool = True,
|
||
user_module: str = "",
|
||
) -> dict[str, Any]:
|
||
"""Infer DE design from sample group_role values.
|
||
|
||
Included samples must have group_role. Ignore/excluded samples are retained
|
||
in samples.yaml but omitted from groups and DE comparisons.
|
||
|
||
When legacy_mode=False, also detect module (M1-M6) and extra factors.
|
||
"""
|
||
selected = list(matrix_samples) if matrix_samples is not None else list(samples.keys())
|
||
selected_set = set(map(str, selected))
|
||
sample_items: list[tuple[str, dict[str, Any]]] = []
|
||
missing_sample_meta = []
|
||
for sample in selected:
|
||
meta = samples.get(str(sample))
|
||
if not isinstance(meta, dict):
|
||
missing_sample_meta.append(str(sample))
|
||
continue
|
||
sample_items.append((str(sample), meta))
|
||
if missing_sample_meta:
|
||
raise ValueError(
|
||
"samples.yaml 缺少这些 count matrix 样本的 metadata: "
|
||
+ ", ".join(missing_sample_meta[:10])
|
||
)
|
||
|
||
missing_roles: list[str] = []
|
||
active_items: list[tuple[str, dict[str, Any], str]] = []
|
||
ignored_samples: list[str] = []
|
||
for sample, meta in sample_items:
|
||
include = parse_bool(meta.get("include"), default=True)
|
||
raw_role = str(meta.get("group_role") or "").strip()
|
||
if not raw_role and include:
|
||
missing_roles.append(sample)
|
||
continue
|
||
role = normalize_group_role(raw_role) if raw_role else "ignore"
|
||
if not include or role == "ignore":
|
||
ignored_samples.append(sample)
|
||
continue
|
||
active_items.append((sample, meta, role))
|
||
|
||
if missing_roles:
|
||
raise ValueError(
|
||
"samples.yaml 缺少 group_role;请在样本表中为这些样本标记 control/compare/ignore: "
|
||
+ ", ".join(missing_roles[:10])
|
||
)
|
||
if not active_items:
|
||
raise ValueError("没有可用于 DE 的样本;请至少保留一个 control 和一个 compare 样本。")
|
||
|
||
fields_report = _collect_fields_report(active_items)
|
||
module_info: dict[str, Any] = {}
|
||
if not legacy_mode:
|
||
module_info = detect_module(fields_report, user_module=user_module)
|
||
module_id = str(module_info.get("primary_module") or "")
|
||
|
||
time_present = [bool(text_value(meta.get("time"))) for _sample, meta, _role in active_items]
|
||
if any(time_present) and not all(time_present):
|
||
missing_time = [
|
||
sample
|
||
for sample, meta, _role in active_items
|
||
if not text_value(meta.get("time"))
|
||
]
|
||
raise ValueError(
|
||
"检测到部分样本填写了 time;time_series 设计要求所有参与 DE 的样本都有 time: "
|
||
+ ", ".join(missing_time[:10])
|
||
)
|
||
design = "time_series" if all(time_present) else ""
|
||
if module_id == "M6":
|
||
design = "dose_response"
|
||
|
||
# 分组逻辑:时间序列按 condition + time 分组,否则按 condition 分组
|
||
groups: dict[str, str] = {}
|
||
roles_by_group: dict[str, set[str]] = {}
|
||
for sample, meta, role in active_items:
|
||
if module_id == "M6":
|
||
group = normalize_group_label(meta.get("dose") or meta.get("phenotype") or meta.get("condition"))
|
||
if not group:
|
||
raise ValueError(f"样本 {sample} 缺少 dose/phenotype/condition,不能生成剂量响应设计。")
|
||
elif design == "time_series":
|
||
t = normalize_group_label(meta.get("time"))
|
||
if not t:
|
||
raise ValueError(f"样本 {sample} 缺少 time,不能生成 DE 分组。")
|
||
group = t
|
||
else:
|
||
group = first_text_value(meta.get("condition"), meta.get("group"), meta.get("treatment"))
|
||
if not group:
|
||
raise ValueError(f"样本 {sample} 缺少 condition,不能生成 DE 分组。")
|
||
groups[sample] = group
|
||
roles_by_group.setdefault(group, set()).add(role)
|
||
|
||
conflict_groups = sorted(group for group, roles in roles_by_group.items() if len(roles) > 1)
|
||
|
||
# Auto-upgrade: if conflict and genotype/tissue exists, retry with compound grouping
|
||
upgrade_design = False
|
||
if conflict_groups:
|
||
has_genotype = any(first_text_value(meta.get("genotype")) for _, meta, _ in active_items)
|
||
has_tissue = any(first_text_value(meta.get("tissue")) for _, meta, _ in active_items)
|
||
if has_genotype or has_tissue:
|
||
inter_field = "genotype" if has_genotype else "tissue"
|
||
print(f"[INFO] 检测到分组冲突,启用了 {inter_field} 交互模式")
|
||
upgrade_design = True
|
||
design = "interaction"
|
||
# Rebuild groups with compound key: condition_genotype
|
||
groups = {}
|
||
roles_by_group = {}
|
||
for sample, meta, role in active_items:
|
||
cond = first_text_value(meta.get("condition"), meta.get("group"), meta.get("treatment"))
|
||
extra = first_text_value(meta.get(inter_field))
|
||
if not cond or not extra:
|
||
raise ValueError(f"样本 {sample} 缺少 condition 或 {inter_field},无法生成交互分组。")
|
||
group = f"{extra}_{cond}"
|
||
groups[sample] = group
|
||
roles_by_group.setdefault(group, set()).add(role)
|
||
conflict_groups = sorted(group for group, roles in roles_by_group.items() if len(roles) > 1)
|
||
|
||
if conflict_groups and not upgrade_design and module_id != "M6":
|
||
raise ValueError("同一 DE 分组内不能混合 control/compare: " + ", ".join(conflict_groups))
|
||
|
||
control_groups = sorted(
|
||
[group for group, roles in roles_by_group.items() if "control" in roles],
|
||
key=parse_time_sort_value,
|
||
)
|
||
compare_groups = sorted(
|
||
[group for group, roles in roles_by_group.items() if "compare" in roles],
|
||
key=parse_time_sort_value,
|
||
)
|
||
if module_id == "M6":
|
||
control_groups = control_groups[:1] or sorted(set(groups.values()), key=parse_time_sort_value)[:1]
|
||
compare_groups = sorted([g for g in set(groups.values()) if g not in set(control_groups)], key=parse_time_sort_value)
|
||
elif len(control_groups) != 1:
|
||
if not control_groups:
|
||
raise ValueError("请在样本表中标记唯一对照组 group_role=control;系统不会自动猜测 control。")
|
||
if design == "time_series":
|
||
control_groups = [control_groups[0]]
|
||
else:
|
||
raise ValueError("只能有一个对照组,当前为: " + ", ".join(control_groups))
|
||
if not compare_groups and module_id != "M6":
|
||
raise ValueError("请在样本表中至少标记一个比较组 group_role=compare。")
|
||
|
||
control = control_groups[0] if control_groups else ""
|
||
if not design:
|
||
design = "two_group" if len(compare_groups) == 1 else "multi_group"
|
||
|
||
explicit_compare = _configured_comparisons(configured_compare or [])
|
||
valid_groups = set(groups.values())
|
||
if explicit_compare:
|
||
invalid = [comp for comp in explicit_compare if comp[0] not in valid_groups or comp[1] not in valid_groups]
|
||
if invalid:
|
||
raise ValueError(
|
||
"compare 引用了不存在或已 ignore 的分组: "
|
||
+ ", ".join(f"{left}_vs_{right}" for left, right in invalid)
|
||
)
|
||
comparisons = explicit_compare
|
||
comparison_mode = "configured"
|
||
elif design == "time_series":
|
||
comparisons = [[group, control] for group in compare_groups if group != control]
|
||
comparison_mode = "time_baseline_group_role"
|
||
elif module_id == "M6":
|
||
comparisons = []
|
||
comparison_mode = "dose_response"
|
||
else:
|
||
comparisons = [[group, control] for group in compare_groups]
|
||
comparison_mode = "group_role_control"
|
||
|
||
result: dict[str, Any] = {
|
||
"design": design,
|
||
"groups": groups,
|
||
"active_samples": [sample for sample, _meta, _role in active_items if sample in selected_set],
|
||
"ignored_samples": ignored_samples,
|
||
"control_group": control,
|
||
"treatment_groups": compare_groups,
|
||
"compare": comparisons,
|
||
"comparison_mode": comparison_mode,
|
||
"experiment": {
|
||
"design": design,
|
||
"control_group": control,
|
||
"treatment_groups": compare_groups,
|
||
},
|
||
"de": {
|
||
"strategy": "auto",
|
||
"design": design,
|
||
"control_group": control,
|
||
"treatment_groups": compare_groups,
|
||
"run_lrt": bool(design == "time_series" and len(set(
|
||
normalize_group_label(meta.get("time")) for _s, meta, _r in active_items
|
||
)) >= 3),
|
||
"run_pairwise": True,
|
||
"pairwise_modes": ["baseline"] if design == "time_series" else ["control"],
|
||
"baseline_group": control,
|
||
},
|
||
}
|
||
|
||
# ── v2.0: 非 legacy 模式,附加模块检测 ──
|
||
if not legacy_mode:
|
||
if not module_info:
|
||
module_info = detect_module(fields_report, user_module=user_module)
|
||
result["module"] = module_info["primary_module"]
|
||
result["module_name"] = module_info["primary_module_name"]
|
||
result["design_formula"] = module_info["design_formula"]
|
||
result["test_type"] = module_info["test_type"]
|
||
result["fields_report"] = fields_report
|
||
result["batch_used"] = module_info["batch_used"]
|
||
result["alternatives"] = module_info.get("alternatives", [])
|
||
result["module_source"] = module_info.get("module_source", "auto")
|
||
|
||
# 覆盖 de block
|
||
result["de"]["strategy"] = "modular"
|
||
result["de"]["design"] = module_info["primary_module"]
|
||
result["de"]["test_type"] = module_info["test_type"]
|
||
result["de"]["design_formula"] = module_info["design_formula"]
|
||
result["de"]["batch_used"] = module_info["batch_used"]
|
||
|
||
return result
|
||
|
||
|
||
def infer_rnaseq_de_design_v2(
|
||
samples: dict[str, dict[str, Any]],
|
||
*,
|
||
matrix_samples: list[str] | None = None,
|
||
configured_compare: list[list[str]] | None = None,
|
||
user_module: str = "",
|
||
) -> dict[str, Any]:
|
||
"""v2.0 完整推断:模块检测 + 模糊场景 + 字段报告。
|
||
|
||
与 infer_rnaseq_de_design(legacy_mode=False) 等效,但返回更丰富的字段报告。
|
||
"""
|
||
result = infer_rnaseq_de_design(
|
||
samples,
|
||
matrix_samples=matrix_samples,
|
||
configured_compare=configured_compare,
|
||
legacy_mode=False,
|
||
user_module=user_module,
|
||
)
|
||
return result
|
||
|
||
|
||
def plan_rnaseq_de_design(
|
||
samples: dict[str, dict[str, Any]],
|
||
*,
|
||
matrix_samples: list[str] | None = None,
|
||
configured_compare: list[list[str]] | None = None,
|
||
user_module: str = "",
|
||
design_options: dict[str, Any] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Use the same M3/M4-first planning policy for web review and execution."""
|
||
selected_samples = list(matrix_samples or samples.keys())
|
||
requested = str(user_module or "").strip().upper()
|
||
advanced = infer_priority_advanced_design(
|
||
samples,
|
||
matrix_samples=selected_samples,
|
||
user_module=requested,
|
||
design_options=design_options,
|
||
)
|
||
if advanced:
|
||
return advanced
|
||
|
||
plan = infer_rnaseq_de_design(
|
||
samples,
|
||
matrix_samples=selected_samples,
|
||
configured_compare=configured_compare,
|
||
legacy_mode=True,
|
||
)
|
||
plan["module"] = "M1"
|
||
plan["module_name"] = "标准组间比较"
|
||
plan["module_source"] = "user" if requested == "M1" else "fallback"
|
||
batch_plan = infer_optional_batch(samples, list(plan.get("active_samples") or selected_samples))
|
||
plan.update(batch_plan)
|
||
batch_prefix = "batch + " if batch_plan["batch_used"] else ""
|
||
plan["design_formula"] = f"~ {batch_prefix}group"
|
||
plan["test_type"] = "Wald"
|
||
if batch_plan["batch_fallback"]:
|
||
plan.setdefault("warnings", []).append({
|
||
"code": "batch_fallback_without_adjustment",
|
||
"message": batch_plan["batch_fallback_reason"],
|
||
})
|
||
if requested in {"M3", "M4"}:
|
||
plan["fallback_from"] = requested
|
||
plan["fallback_reason"] = "样品表未检测到可执行的 genotype/tissue × condition 高级设计,使用 M1。"
|
||
return plan
|
||
|
||
|
||
def build_de_design_review(plan: dict[str, Any]) -> dict[str, Any]:
|
||
"""Convert an executable design plan into a stable, UI-facing contract."""
|
||
module = str(plan.get("module") or "M1")
|
||
factor = str(plan.get("advanced_factor") or "")
|
||
comparison_axis = str(plan.get("comparison_axis") or "condition")
|
||
stratify_axis = str(plan.get("stratify_axis") or "")
|
||
|
||
comparisons: list[dict[str, str]] = []
|
||
for pair in plan.get("compare") or []:
|
||
if not isinstance(pair, (list, tuple)) or len(pair) < 2:
|
||
continue
|
||
compare_group, reference_group = str(pair[0]), str(pair[1])
|
||
display = f"{compare_group} vs {reference_group}"
|
||
if factor and "__" in compare_group and "__" in reference_group:
|
||
compare_factor, compare_condition = compare_group.split("__", 1)
|
||
reference_factor, reference_condition = reference_group.split("__", 1)
|
||
if compare_factor == reference_factor and compare_condition != reference_condition:
|
||
display = f"{compare_factor} 中:{compare_condition} vs {reference_condition}"
|
||
elif compare_condition == reference_condition and compare_factor != reference_factor:
|
||
display = f"{compare_condition} 条件下:{compare_factor} vs {reference_factor}"
|
||
comparisons.append({
|
||
"name": f"{compare_group}_vs_{reference_group}",
|
||
"compare": compare_group,
|
||
"reference": reference_group,
|
||
"display": display,
|
||
})
|
||
|
||
raw_warnings = plan.get("warnings") or []
|
||
warnings = [
|
||
dict(item) if isinstance(item, dict) else {"code": "design_warning", "message": str(item)}
|
||
for item in raw_warnings
|
||
]
|
||
fallback_from = str(plan.get("fallback_from") or "")
|
||
return {
|
||
"status": "ready",
|
||
"module": module,
|
||
"module_name": str(plan.get("module_name") or module),
|
||
"module_source": str(plan.get("module_source") or "auto"),
|
||
"design": str(plan.get("design") or ""),
|
||
"design_formula": str(plan.get("design_formula") or "~ group"),
|
||
"test_type": str(plan.get("test_type") or "Wald"),
|
||
"factor": factor,
|
||
"condition_factor": "condition",
|
||
"comparison_axis": comparison_axis,
|
||
"comparison_axis_source": str(plan.get("comparison_axis_source") or ""),
|
||
"stratify_axis": stratify_axis,
|
||
"reference_levels": {
|
||
"condition": str(plan.get("reference_condition") or plan.get("control_condition") or plan.get("control_group") or ""),
|
||
factor: str(plan.get("reference_factor") or plan.get(f"reference_{factor}") or "") if factor else "",
|
||
},
|
||
"reference_sources": {
|
||
"condition": str(plan.get("condition_reference_source") or ""),
|
||
factor: str(plan.get("factor_reference_source") or "") if factor else "",
|
||
},
|
||
"comparisons": comparisons,
|
||
"interaction": {
|
||
"enabled": module == "M4",
|
||
"display": f"{factor} × condition 交互效应" if module == "M4" and factor else "",
|
||
"methods": ["LRT", "Wald"] if module == "M4" else [],
|
||
},
|
||
"batch": {
|
||
"requested": bool(plan.get("batch_requested")),
|
||
"used": bool(plan.get("batch_used")),
|
||
"levels": list(plan.get("batch_levels") or []),
|
||
"fallback": bool(plan.get("batch_fallback")),
|
||
"fallback_reason": str(plan.get("batch_fallback_reason") or ""),
|
||
"display": (
|
||
"检测到 batch,将优先使用批次校正模型;若执行失败会自动改用无批次模型。"
|
||
if plan.get("batch_used")
|
||
else str(plan.get("batch_fallback_reason") or "未填写 batch,使用默认无批次模型。")
|
||
),
|
||
},
|
||
"fallback": {
|
||
"occurred": bool(fallback_from),
|
||
"from": fallback_from,
|
||
"target": module,
|
||
"reason": str(plan.get("fallback_reason") or ""),
|
||
},
|
||
"skipped_comparisons": list(plan.get("skipped_comparisons") or []),
|
||
"warnings": warnings,
|
||
}
|