153 lines
6.6 KiB
Python
153 lines
6.6 KiB
Python
"""稳定性分析:AMMI(加性主效应乘性互作 SVD)与 Finlay-Wilkinson(环境指数回归)。
|
||||
|
|
|
|||
|
|
仅依赖 numpy。输入为基因型×环境两因素均值表 {genotype: {env: mean}}(缺格 NaN)。
|
|||
|
|
|
|||
|
|
- Finlay-Wilkinson:每基因型对该环境指数(该环境全部基因型均值)回归 y_gj = b·I_j + a。
|
|||
|
|
b≈1(|b-1| ≤ 2·se(b))且残差小 → 稳定(与总体响应一致);b>1 高响应(好环境下增益更大)、
|
|||
|
|
b<1 低响应。
|
|||
|
|
- AMMI:两因素主效应分解(μ+g+e)后残差矩阵 SVD → IPC1/IPC2 得分、
|
|||
|
|
ASV(Purchase et al. 2000:两轴权重按各自 SS 归一后合成)、Wricke ecovalence
|
|||
|
|
(Σ_j (y_ij − y_i. − y_.j + y..)²)。
|
|||
|
|
|
|||
|
|
稳定排名:AMMI 按 ASV 升序(越小越稳定,rank=1 最稳);FW 由 flag_stable 直接标注。
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import math
|
|||
|
|
|
|||
|
|
import numpy as np
|
|||
|
|
|
|||
|
|
ENGINE_VERSION = "1.0.0"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _table_to_arrays(table: dict[str, dict[str, float]]):
|
|||
|
|
genotypes = sorted(table)
|
|||
|
|
envs: list[str] = []
|
|||
|
|
seen: set[str] = set()
|
|||
|
|
for g in genotypes:
|
|||
|
|
for e in table[g]:
|
|||
|
|
if e not in seen:
|
|||
|
|
seen.add(e)
|
|||
|
|
envs.append(e)
|
|||
|
|
envs.sort()
|
|||
|
|
Y = np.full((len(genotypes), len(envs)), np.nan)
|
|||
|
|
for gi, g in enumerate(genotypes):
|
|||
|
|
for e, v in table[g].items():
|
|||
|
|
Y[gi, envs.index(e)] = float(v)
|
|||
|
|
return Y, genotypes, envs
|
|||
|
|
|
|||
|
|
|
|||
|
|
def finlay_wilkinson(table: dict) -> dict:
|
|||
|
|
"""Finlay-Wilkinson:每基因型对环境指数回归,返回 b/截距/R²/残差MS/稳定标记。"""
|
|||
|
|
Y, genotypes, envs = _table_to_arrays(table)
|
|||
|
|
env_index = np.nanmean(Y, axis=0) # 环境指数 = 该环境全基因型均值
|
|||
|
|
rows: list[dict] = []
|
|||
|
|
for gi, g in enumerate(genotypes):
|
|||
|
|
y = Y[gi]
|
|||
|
|
mask = (~np.isnan(y)) & (~np.isnan(env_index))
|
|||
|
|
n = int(mask.sum())
|
|||
|
|
if n < 2:
|
|||
|
|
rows.append({"genotype": g, "n_env": n, "b": None, "intercept": None,
|
|||
|
|
"r2": None, "se_b": None, "dev_ms": None,
|
|||
|
|
"flag_stable": None, "note": "环境数不足 2,无法回归"})
|
|||
|
|
continue
|
|||
|
|
xs = env_index[mask]
|
|||
|
|
ys = y[mask]
|
|||
|
|
A = np.column_stack([np.ones(n), xs])
|
|||
|
|
coef, *_ = np.linalg.lstsq(A, ys, rcond=None)
|
|||
|
|
intercept, b = float(coef[0]), float(coef[1])
|
|||
|
|
resid = ys - A @ coef
|
|||
|
|
dof = n - 2
|
|||
|
|
dev_ms = float(resid @ resid / dof) if dof > 0 else float("nan")
|
|||
|
|
ss_tot = float(((ys - ys.mean()) ** 2).sum())
|
|||
|
|
r2 = 1.0 - float(resid @ resid) / ss_tot if ss_tot > 0 else float("nan")
|
|||
|
|
var_x = float(((xs - xs.mean()) ** 2).sum())
|
|||
|
|
se_b = math.sqrt(dev_ms / var_x) if var_x > 0 and not math.isnan(dev_ms) else float("nan")
|
|||
|
|
y_scale = float(np.ptp(ys)) if len(ys) > 1 else 0.0
|
|||
|
|
if math.isnan(se_b):
|
|||
|
|
flag_stable = False
|
|||
|
|
elif dev_ms <= 1e-12 * max(1.0, y_scale ** 2):
|
|||
|
|
# 完美拟合(残差≈0)时 se_b 无信息量;b 精确已知,b≈1 才视为稳定
|
|||
|
|
flag_stable = abs(b - 1.0) <= 1e-6
|
|||
|
|
else:
|
|||
|
|
flag_stable = abs(b - 1.0) <= 2.0 * se_b
|
|||
|
|
rows.append({
|
|||
|
|
"genotype": g, "n_env": n, "b": round(b, 4), "intercept": round(intercept, 4),
|
|||
|
|
"r2": round(r2, 4) if not math.isnan(r2) else None,
|
|||
|
|
"se_b": round(se_b, 4) if not math.isnan(se_b) else None,
|
|||
|
|
"dev_ms": round(dev_ms, 6) if not math.isnan(dev_ms) else None,
|
|||
|
|
"flag_stable": bool(flag_stable),
|
|||
|
|
})
|
|||
|
|
return {
|
|||
|
|
"method": "finlay_wilkinson",
|
|||
|
|
"genotypes": genotypes, "environments": envs,
|
|||
|
|
"env_index": [None if math.isnan(v) else round(float(v), 4) for v in env_index],
|
|||
|
|
"rows": rows,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def ammi(table: dict, n_components: int = 2) -> dict:
|
|||
|
|
"""AMMI:两因素主效应分解 + 残差 SVD,输出 IPC 得分/ASV/ecovalence 与稳定排名。"""
|
|||
|
|
Y, genotypes, envs = _table_to_arrays(table)
|
|||
|
|
g, e = Y.shape
|
|||
|
|
if g < 2 or e < 2:
|
|||
|
|
return {"method": "ammi", "genotypes": genotypes, "environments": envs,
|
|||
|
|
"rows": [], "ipc_variance": [], "n": 0,
|
|||
|
|
"warning": "基因型或环境数不足 2,无法 AMMI"}
|
|||
|
|
has_missing = bool(np.isnan(Y).any())
|
|||
|
|
grand = float(np.nanmean(Y))
|
|||
|
|
row_mean = np.nanmean(Y, axis=1)
|
|||
|
|
col_mean = np.nanmean(Y, axis=0)
|
|||
|
|
resid = np.nan_to_num(Y - row_mean[:, None] - col_mean[None, :] + grand, nan=0.0)
|
|||
|
|
U, S, _Vt = np.linalg.svd(resid, full_matrices=False)
|
|||
|
|
k = min(g, e)
|
|||
|
|
ss_total = float(np.sum(resid ** 2))
|
|||
|
|
ipc_var = []
|
|||
|
|
for i in range(k):
|
|||
|
|
prop = (S[i] ** 2) / ss_total if ss_total > 0 else 0.0
|
|||
|
|
ipc_var.append({"ipc": i + 1, "singular_value": round(float(S[i]), 6),
|
|||
|
|
"ss": round(float(S[i] ** 2), 6), "proportion": round(prop, 4)})
|
|||
|
|
n_keep = min(n_components, k)
|
|||
|
|
g_ipc = U[:, :n_keep] * S[:n_keep] # 基因型 IPC 得分(sqrt(λ)·U)
|
|||
|
|
ss_ipc1 = float((g_ipc[:, 0] ** 2).sum()) if n_keep >= 1 else 0.0
|
|||
|
|
ss_ipc2 = float((g_ipc[:, 1] ** 2).sum()) if n_keep >= 2 else 0.0
|
|||
|
|
rows: list[dict] = []
|
|||
|
|
for gi in range(g):
|
|||
|
|
ipc1 = float(g_ipc[gi, 0]) if n_keep >= 1 else 0.0
|
|||
|
|
ipc2 = float(g_ipc[gi, 1]) if n_keep >= 2 else 0.0
|
|||
|
|
# ASV(Purchase 2000):IPC1 按 SS 比例缩放到 IPC2 尺度,与 IPC2 正交合成
|
|||
|
|
if n_keep >= 2 and ss_ipc1 > 0:
|
|||
|
|
asv = math.sqrt((ipc1 * ss_ipc2 / ss_ipc1) ** 2 + ipc2 ** 2)
|
|||
|
|
else:
|
|||
|
|
asv = abs(ipc1)
|
|||
|
|
ecoval = float(np.nansum((Y[gi] - row_mean[gi] - col_mean + grand) ** 2))
|
|||
|
|
rows.append({
|
|||
|
|
"genotype": genotypes[gi],
|
|||
|
|
"ipc1": round(ipc1, 4),
|
|||
|
|
"ipc2": round(ipc2, 4) if n_keep >= 2 else None,
|
|||
|
|
"asv": round(asv, 4),
|
|||
|
|
"ecovalence": round(ecoval, 6),
|
|||
|
|
})
|
|||
|
|
rows.sort(key=lambda r: r["asv"])
|
|||
|
|
for rank, r in enumerate(rows, 1):
|
|||
|
|
r["rank"] = rank
|
|||
|
|
warning = None
|
|||
|
|
if has_missing:
|
|||
|
|
warning = "两因素表存在缺格,SVD 前缺格按 0 填充,IPC/ASV 估计仅供参考"
|
|||
|
|
return {
|
|||
|
|
"method": "ammi", "genotypes": genotypes, "environments": envs,
|
|||
|
|
"rows": rows, "ipc_variance": ipc_var, "n": g * e,
|
|||
|
|
"ss_interaction": round(ss_total, 6), "warning": warning,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def stability_analysis(table: dict, *, methods: tuple[str, ...] = ("ammi", "finlay"),
|
|||
|
|
n_components: int = 2) -> dict:
|
|||
|
|
"""稳定性综合分析:AMMI + Finlay-Wilkinson 可选。"""
|
|||
|
|
out: dict = {"genotypes": sorted(table), "methods": list(methods)}
|
|||
|
|
if "finlay" in methods:
|
|||
|
|
out["finlay"] = finlay_wilkinson(table)
|
|||
|
|
if "ammi" in methods:
|
|||
|
|
out["ammi"] = ammi(table, n_components=n_components)
|
|||
|
|
return out
|