Files
dpb/backend/scripts/breeding_stats/blup.py
T
34047007@qq.com 853d01d796 checkpoint: 统计引擎确定性——系谱按 id 规范排序,消除 DB 行序对 MME 布局的影响
- _order_pedigree: base 与同世代并列个体均按 id 确定性排序,输入/DB 行序不再参与计算
- 平似然/退化数据上同内容输入必逐位同输出(此前 golden-section/二分会被末位浮点噪声带偏到任意 h²,导致回归间歇失败)
2026-08-07 21:47:55 +08:00

1705 lines
72 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""纯 Python 单性状动物模型 BLUPHenderson MME + 剖面 REML)。
仅依赖 numpy,无 R / 外部运行时。
系谱锚点(本系统约定):
- 个体 = bre_tree 单株(子代),其 dam/sire = bre_germplasm 种质(base/founder,无父母)。
- 同组合单株互为全同胞(A=0.5),跨组合共享单一亲本为半同胞(A=0.25),
单株↔亲本 A=0.5 —— 全部由 A 矩阵递归自动得出,无需人工指定。
- 无父母的个体一律视为 founderbase population,彼此不相关)。
算法:
1. A⁻¹ 用 Henderson 稀疏规则构造(无需求 A 的逆);
2. MME 求解:y = Xb + Zu + e,X=截距+固定效应虚拟列+协变量(默认仅截距),λ = σ²e/σ²a;
个体数 ≤ N_SPARSE(默认 1000)→ 稠密精确求解(solver="dense":索引化 Z、稠密 A、
剖面 REML 黄金分割);个体数 > N_SPARSE 且系谱存在 → 稀疏路径(solver="sparse-em"):
Z 索引化设计矩阵(bincount)、稀疏 A⁻¹ 三元组 + 共轭梯度迭代,全程不落稠密 Z/A/V
(35k 树在常规内存内跑通)。方差分量按 n 分档:
个体数 ≤ N_EXACT3000)→ tr(A⁻¹C²²) 逐列 CG 精确(无随机噪声),REML 解
h(λ)=g(λ)−λ=0 单调单根 → log10λ 二分 ~32 步到 1e-5(规避 EM 慢收缩);
个体数 > N_EXACT 且观测数 m>N_SUBSAMPLE(可真实子采样)→ 随机子样本(观测
≤N_SUBSAMPLE,含祖先闭包)上精确迹二分求 REML 解——子样本 REML 为全群方差组分
一致估计(抽样 SE h² 约 ±0.04),再全群 MME 于子样本 λ* 下解 EBV(大样本 EM 慢收缩
ρ≈0.999 冻结不可用;EBV 对 λ 稳健,warning 标注);
3. reliability:稠密路径精确(diag(C22) 取 MME 逆对角);稀疏路径 n≤EXACT_DIAG_N 用
稠密 MME 逆精确、n 大用 Hutchinson 随机探测(reliability_approx=True,均值/PA 可靠、个体级近似);
4. h² = σ²a/(σ²a+σ²e)reliability = 1 - PEV/σ²aPEV = C22[ii]·σ²e。
无系谱(所有个体均无父母)时:不背书遗传力——
h2 置 None、reliability 全部置 0、EBV 取中心化表型,并返回 warning。
"""
from __future__ import annotations
import math
import random
import numpy as np
TOL = 1e-4
MAX_PROFILE_EVALS = 100
MAX_GXE_ITERS = 25 # G×E 分支坐标上升迭代上限
_FLOOR = 1e-8 # 方差下界,避免除零/负方差
H2_MIN, H2_MAX = 1e-3, 1.0 - 1e-3
N_SPARSE = 1000 # 个体数超过该阈值 → 稀疏 A⁻¹ + 共轭梯度(回避 O(n³) 稠密 MME 逆)
CG_MAX_ITER = 800 # 共轭梯度迭代上限
CG_TOL = 1e-9 # 共轭梯度相对残差收敛阈值
# Hutchinson 随机探测次数(估计 C22 对角线 → PEV → reliability)。稀疏路径 EBV/h²/σ² 为
# CG 精确解;reliability 是 MC 对角估计——同家族强相关的 C22 令其收敛慢(~1/√k),
# 误差随 k 减小但非精确,作为文档标注的近似(排序可靠、量级可信)。
HUTCH_K = 100
# 稀疏路径 reliability 分档:个体数 ≤ EXACT_DIAG_N → diag(C22) 用稠密 MME 逆精确求
# (golden 等价档,无 MC 噪声——n=20 全同胞小样本 h²→1 边界实测 Hutchinson 均值误差 ~0.08);
# > EXACT_DIAG_N → Hutchinson 随机探测(排序可靠、量级可信,误差随个体数稀释)。
EXACT_DIAG_N = 1000
# 稀疏路径分档:精确迹二分求 λ*(逐列 CG 精确 tr(A⁻¹C²²),无随机噪声,
# REML 解 = h(λ)=g(λ)−λ=0 单调单根,log10λ 二分 ~32 步到 1e-5,规避 EM 慢收缩 ρ≈0.987)。
# 精确迹 O(n²)(每步似然 n 次逐列 CG),仅个体数 ≤ N_EXACT 才可负担;>N_EXACT 且可真实
# 子采样观测(m>N_SUBSAMPLE)→ 随机子样本精确迹二分(观测 ≤N_SUBSAMPLE,含祖先闭包)
# 求 λ*,再全群 MME 求解。
# 大样本 EM 收缩率 ρ→0.999(100 步 λ 几乎不动,冻结在初始猜测),Hutchinson 噪声又淹没
# h(λ) 信号(~2e-4 vs 噪声 ~2e-3),全群精确迹 O(n²) 不可行;子样本 iid 抽样 REML 为全群
# 方差组分一致估计(n_sub=3000 抽样 SE h² ≈ ±0.04),EBV 对 λ 稳健(偏 25% Kendall τ>0.97)。
# N_EXACT 对齐 N_SUBSAMPLEm>N_SUBSAMPLE 时 n≈m+系谱必超 N_EXACT → 走子样本封顶 ~70min
# 消除 3000<n≤6000 精确迹数小时级尾(性能倒挂:局部选 3000-6000 树比全量还慢)。
N_EXACT = 3000
N_SUBSAMPLE = 3000 # 生产档子样本观测数上限(n>N_EXACT 且 m>N_SUBSAMPLE 才触发)
BISECT_LO, BISECT_HI = -4.0, 4.0 # log10(λ=Ve/Va) 二分区间(REML 解通常在其内)
BISECT_TOL = 1e-8 # 二分 log10(λ) 收敛宽度(→ λ 相对精度 ~1e-8)
MAX_BISECT_EVAL = 60 # 二分求值上限
ENGINE_VERSION = "1.7.0" # 单性状动物模型 BLUP 求解器版本(落 bre_prediction.engine_version1.1.0: 供 mtblup 单性状方差复用;1.2.0: G×R 砧木×接穗互作;1.3.0: AR1×AR1 空间协方差 solve_spatial + 稀疏 A⁻¹/共轭梯度/Hutchinson 可靠性;1.4.0: AR1×AR1 各向异性双参数 ρ_row/ρ_col1.5.0: 区组随机效应 _solve_block(增广/α-格子 block_no 第二随机效应);1.6.0: 稀疏路径 EM-REML(索引化设计矩阵 + 稀疏 A⁻¹ 无稠密 A + EM-REML35k 树不再 OOMn≤N_EXACT 精确迹二分 λ*n>N_EXACT 随机子样本精确迹二分 → 全群 MME 解 EBV);1.7.0: 性能倒挂修复——N_EXACT 6000→3000 对齐 N_SUBSAMPLE,生产档仅 m>N_SUBSAMPLE 才触发;系谱闭包改 BFS 可达祖先(服务层),选中 200~3000 树不再被全量 germplasm 闭包顶进数小时精确迹尾)
def _pearson(x: list[float], y: list[float]) -> float:
n = len(x)
if n < 2:
return 0.0
mx, my = sum(x) / n, sum(y) / n
cov = sum((a - mx) * (b - my) for a, b in zip(x, y))
dx = sum((a - mx) ** 2 for a in x) ** 0.5
dy = sum((b - my) ** 2 for b in y) ** 0.5
if dx == 0 or dy == 0:
return 0.0
return cov / (dx * dy)
def _order_pedigree(base: list[int], non_base: list[tuple[int, int | None, int | None]]) -> tuple[list[int], dict[int, int]]:
"""返回拓扑序(祖先恒在子代之前,base 恒在最前)与 {个体id: 序号}。
输入顺序不参与计算:base 与同世代并列个体均按 id 确定性排序,调用方/DB 行序不再
影响 MME 变量布局——同内容输入必得逐位相同输出(平似然/退化数据上尤其关键,否则
golden-section/二分会被末位浮点噪声带偏到任意 h²)。
"""
base = sorted(base, key=str)
non_base = sorted(non_base, key=lambda it: str(it[0]))
order: list[int] = list(base)
placed: set[int] = set(base)
remaining = list(non_base)
while remaining:
progressed = False
for it in list(remaining):
i, d, s = it
if (d is None or d in placed) and (s is None or s in placed):
order.append(i)
placed.add(i)
remaining.remove(it)
progressed = True
if not progressed:
raise ValueError(
"系谱存在环或父母引用缺失,无法拓扑排序"
"(父母 id 必须是已知个体或 None)"
)
return order, {ind: idx for idx, ind in enumerate(order)}
def _build_ainv(order: list[int], n_base: int,
parent_of: dict[int, tuple[int | None, int | None]]) -> tuple[np.ndarray, np.ndarray]:
"""返回 (A, A⁻¹)。A 稠密递归 O(n²)A⁻¹ 用 Henderson 稀疏规则。"""
n = len(order)
idx = {ind: pos for pos, ind in enumerate(order)}
A = np.eye(n)
for pos, ind in enumerate(order):
d, s = parent_of.get(ind, (None, None))
if d is None and s is None:
continue
di = idx.get(d) if d is not None else None
si = idx.get(s) if s is not None else None
row = np.zeros(n)
if di is not None:
row += A[di]
if si is not None:
row += A[si]
A[pos, :] = 0.5 * row
A[:pos, pos] = A[pos, :pos]
apq = A[di, si] if (di is not None and si is not None) else 0.0
A[pos, pos] = 1.0 + 0.5 * apq
Ainv = np.zeros((n, n))
for i in range(n_base):
Ainv[i, i] += 1.0
for ind, (d, s) in parent_of.items():
i = idx[ind]
di = idx.get(d) if d is not None else None
si = idx.get(s) if s is not None else None
fp = A[di, di] - 1.0 if di is not None else 0.0
fq = A[si, si] - 1.0 if si is not None else 0.0
t = 0.5 - 0.25 * (fp + fq)
# 极端近交(双亲 F→1)时 t→0,除零保护;1e-6 下界对正常系谱无感
inv_t = 1.0 / max(t, 1e-6)
Ainv[i, i] += inv_t
for p in (di, si):
if p is not None:
Ainv[p, p] += 0.25 * inv_t
Ainv[i, p] -= 0.5 * inv_t
Ainv[p, i] -= 0.5 * inv_t
if di is not None and si is not None:
Ainv[di, si] += 0.25 * inv_t
Ainv[si, di] += 0.25 * inv_t
return A, Ainv
def _build_ainv_sparse(order: list[int], n_base: int,
parent_of: dict[int, tuple[int | None, int | None]]
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""返回 (diag_a, ii, jj, vv)A 对角(近交 F=A_ii−1 来源);A⁻¹ 以 COO 稀疏三元组存储。
不构建稠密 A:加性关系对角用「祖先稀疏行」递推(每体一行 {祖先: A_ij},浅系谱祖先数
小,O(n·s²)),Henderson 规则只需 A 对角(fp/fq)与双亲交叉 A[di,si],均可在稀疏行上
读出;A⁻¹ 非零元(对角 + 2 亲本 × 对角/双向共祖先)逐项与 _build_ainv 一致。
matvec 时用 np.bincount 按 ii 累加 ainv_vv·u[jj] 即得 A⁻¹·u(重复元自动合并)。
"""
n = len(order)
idx = {ind: pos for pos, ind in enumerate(order)}
anc: list[dict[int, float]] = [None] * n
diag_a = np.zeros(n)
for pos, ind in enumerate(order):
d, s = parent_of.get(ind, (None, None))
if d is None and s is None:
anc[pos] = {pos: 1.0}
diag_a[pos] = 1.0
continue
di = idx.get(d) if d is not None else None
si = idx.get(s) if s is not None else None
row: dict[int, float] = {}
for src in (di, si):
if src is not None:
for j, v in anc[src].items():
row[j] = row.get(j, 0.0) + 0.5 * v
apq = 0.0
if di is not None and si is not None:
small, big = (anc[di], anc[si]) if len(anc[di]) < len(anc[si]) else (anc[si], anc[di])
apq = sum(v * big[j] for j, v in small.items() if j in big)
row[pos] = 1.0 + 0.5 * apq
anc[pos] = row
diag_a[pos] = row[pos]
ii: list[int] = []
jj: list[int] = []
vv: list[float] = []
def _put(i: int, j: int, v: float) -> None:
if v == 0.0:
return
ii.append(i)
jj.append(j)
vv.append(v)
for i in range(n_base):
_put(i, i, 1.0)
for ind, (d, s) in parent_of.items():
i = idx[ind]
di = idx.get(d) if d is not None else None
si = idx.get(s) if s is not None else None
fp = diag_a[di] - 1.0 if di is not None else 0.0
fq = diag_a[si] - 1.0 if si is not None else 0.0
t = 0.5 - 0.25 * (fp + fq)
# 极端近交(双亲 F→1)时 t→0,除零保护;1e-6 下界对正常系谱无感
inv_t = 1.0 / max(t, 1e-6)
_put(i, i, inv_t)
for pp in (di, si):
if pp is not None:
_put(pp, pp, 0.25 * inv_t)
_put(i, pp, -0.5 * inv_t)
_put(pp, i, -0.5 * inv_t)
if di is not None and si is not None:
_put(di, si, 0.25 * inv_t)
_put(si, di, 0.25 * inv_t)
return (diag_a, np.array(ii, dtype=np.int64), np.array(jj, dtype=np.int64),
np.array(vv, dtype=float))
def _cg_solve(matvec, rhs: np.ndarray, max_iter: int = CG_MAX_ITER,
tol: float = CG_TOL) -> tuple[np.ndarray, int, bool]:
"""共轭梯度求解 C·x = rhs(C 对称正定,仅需 matvec 回调)。
收敛判据:‖r‖ ≤ tol·‖rhs‖(相对残差);达到 max_iter 未达标 → 返回 (解, max_iter, False)。
调用方对未收敛结果回退稠密路径(小 n)或加 warning 并采用已探明最优。
"""
n = rhs.shape[0]
x = np.zeros(n)
r = rhs.astype(float).copy()
p = r.copy()
rsold = float(r @ r)
rhs_norm = float(np.linalg.norm(rhs))
if rhs_norm == 0.0 or rsold == 0.0:
return x, 0, True
target2 = (tol * rhs_norm) ** 2
for it in range(1, max_iter + 1):
Ap = matvec(p)
pAp = float(p @ Ap)
if pAp <= 1e-300:
return x, it, False
alpha = rsold / pAp
x += alpha * p
r -= alpha * Ap
rsnew = float(r @ r)
if rsnew <= target2:
return x, it, True
p = r + (rsnew / rsold) * p
rsold = rsnew
return x, max_iter, False
def _golden_max(f, lo: float, hi: float, tol: float = TOL, max_iter: int = MAX_PROFILE_EVALS) -> tuple[float, int]:
"""黄金分割一维最大化(f 单调时收敛到边界)。返回 (极值点, 函数求值次数)。"""
invphi = (np.sqrt(5.0) - 1.0) / 2.0
a, b = lo, hi
x1 = b - invphi * (b - a)
x2 = a + invphi * (b - a)
f1, f2 = f(x1), f(x2)
n = 2
while abs(b - a) > tol and n < max_iter:
if f1 > f2:
b, x2, f2 = x2, x1, f1
x1 = b - invphi * (b - a)
f1 = f(x1)
else:
a, x1, f1 = x1, x2, f2
x2 = a + invphi * (b - a)
f2 = f(x2)
n += 1
return (a + b) / 2.0, n
def relationship_matrix(pedigree: list[dict]) -> dict:
"""计算个体间加性亲缘矩阵 A(个体数少时稠密递归),供亲缘/近交分析。
亲缘系数 r_ij = A_ij(加性相关,双列之间 0.5=亲子/全同胞、0.25=半同胞);
近交系数 F_i = A_ii - 1。
参数:
pedigree: 同 solve() 的系谱结构(id 唯一,dam/sire 为 None 表示 founder)。
返回:
individuals: [个体id](拓扑序:founder 在前);
matrix: A 矩阵(len×len6 位小数);
inbreeding: {individual_id: F}(仅 F>0 的个体)。
"""
base: list[int] = []
non_base: list[tuple[int, int | None, int | None]] = []
seen: set[int] = set()
for rec in pedigree:
i = rec["individual"]
if i in seen:
raise ValueError(f"系谱个体重复: {i}")
seen.add(i)
d, s = rec.get("dam"), rec.get("sire")
if d is None and s is None:
base.append(i)
else:
non_base.append((i, d, s))
if not seen:
raise ValueError("系谱为空")
order, _ = _order_pedigree(base, non_base)
parent_of = {i: (d, s) for (i, d, s) in non_base}
A, _ = _build_ainv(order, len(base), parent_of)
names = [str(o) for o in order]
inbreeding = {
str(o): float(round(A[i, i] - 1.0, 6))
for i, o in enumerate(order)
if A[i, i] - 1.0 > 1e-8
}
return {
"individuals": names,
"matrix": A.round(6).tolist(),
"inbreeding": inbreeding,
}
def _build_design(obs_ids: list[int], fixed: dict | None = None,
covariate: dict | None = None) -> np.ndarray:
"""构建设计阵 X:截距 + 固定效应水平虚拟列 + 协变量列。
fixed: {因子名: {individual_id: 水平}}——每因子对基准水平(排序首个)加虚拟列,
缺失水平的个体归基准(全 0);仅 1 个水平的因子无信息,跳过。
covariate: {individual_id: float}——单一协变量列,缺失按观测均值填充;
全部同值(无变异)时无信息,跳过。
"""
m = len(obs_ids)
cols: list[np.ndarray] = [np.ones(m)]
for _fname, mapping in (fixed or {}).items():
levels = sorted({str(mapping[i]) for i in obs_ids if mapping.get(i) is not None})
if len(levels) <= 1:
continue
ref = levels[0]
for lev in levels[1:]:
col = np.array(
[1.0 if str(mapping.get(i)) == lev else 0.0 for i in obs_ids],
dtype=float,
)
cols.append(col)
if covariate:
raw = [covariate.get(i) for i in obs_ids]
obs_vals = [float(v) for v in raw if v is not None]
if obs_vals:
mean = float(np.mean(obs_vals))
filled = np.array([float(v) if v is not None else mean for v in raw])
if np.std(filled) > 1e-12:
cols.append(filled)
return np.column_stack(cols)
def solve(pedigree: list[dict], phenotypes: dict, *, tol: float = TOL,
fixed: dict | None = None, covariate: dict | None = None,
gxe: dict | None = None, record_map: dict | None = None,
block: dict | None = None,
factor_label: str = "G×E", factor_name: str = "环境") -> dict:
"""运行单性状动物模型 BLUP(可含固定效应与协变量,如站点、crop_load)。
参数:
pedigree: [{"individual": id, "dam": id|None, "sire": id|None}]
个体 id 唯一;dam/sire 为 None 表示 founder(如种质)。
phenotypes: {记录id: float};默认每记录=一个加性个体(树级均值);
提供 record_map 时记录可多条映射到同一个体(如同一 clone 多年观测)。
fixed: {因子名: {记录id: 水平}}——固定效应(如 {"site": {1:"A", 2:"B"}});
每因子对基准水平加虚拟列,缺失个体归基准。
covariate: {记录id: float}——一个协变量列(如 crop_load,缺失按观测均值)。
gxe: {记录id: (基因型分组, 环境水平)}——提供即启用 G×E 分支:
增加第二随机效应 v(基因型×环境,协方差 I·σ²gxe),
剖面 REML 坐标上升估三方差分量(比率边界与 h² 网格一致,λ 恒正)。
block: {记录id: 区组水平}——提供即启用区组随机效应分支(不完全区组/增广/α-格子):
增加第二随机效应 v(区组,协方差 I·σ²block),与 gxe 互斥(共用 Z₂ 槽)。
record_map: {记录id: 加性个体id}——记录→个体映射(gxe/block 分支用),默认恒等。
factor_label / factor_name: 交互随机效应文案(默认 G×E/环境;G×R 传 G×R/砧木),仅影响警告字符串。
返回:
ebv: {id: float} 全部个体(含 base 种质)的育种值;
reliability: {id: float} 个体可靠性(无系谱时为 0);
h2: float | None 窄义遗传力(无系谱时为 None);
sigma_a / sigma_e: floatsigma_gxe: floatG×E 分支);sigma_block: float(区组分支);
gxe_ratio / n_gxe / n_cross_env / n_genotypesG×E 分支);
block_ratio / n_blocks / block_effects(区组分支);
n_obs / n_individuals / n_base / n_with_parents / n_fixed: int
converged: booln_iter: int(稠密=剖面求值次数,稀疏=迹二分/子样本求值次数);warning: str | None
solver: str"dense" 稠密精确 / "sparse-em" 稀疏 EM-REMLn>N_SPARSE 时触发);
稀疏路径另含 cg_iterCG 迭代数)与 reliability_approx=True(可靠性为 Hutchinson 近似);
生产档(n>N_EXACT)方差组分为随机子样本 REML 一致估计(抽样 SE h² ≈ ±0.04),EBV 由
全群 MME 于子样本 λ 下求解——对 λ 稳健,排序可靠,h²/σ² 视为近似区间。
"""
global N_EXACT # 生产档内联递归临时抬档(try/finally 还原);须在函数内首次读取 N_EXACT 前声明
if gxe is not None:
return _solve_gxe(pedigree, phenotypes, fixed=fixed, covariate=covariate,
gxe=gxe, record_map=record_map or {}, tol=tol,
factor_label=factor_label, factor_name=factor_name)
if block is not None:
return _solve_block(pedigree, phenotypes, fixed=fixed, covariate=covariate,
block_of=block, record_map=record_map or {}, tol=tol,
factor_label=factor_label, factor_name=factor_name)
base: list[int] = []
non_base: list[tuple[int, int | None, int | None]] = []
seen: set[int] = set()
for rec in pedigree:
i = rec["individual"]
if i in seen:
raise ValueError(f"系谱个体重复: {i}")
seen.add(i)
d = rec.get("dam")
s = rec.get("sire")
if d is None and s is None:
base.append(i)
else:
non_base.append((i, d, s))
# 表型中出现的个体若不在系谱中,自动并入 base(鲁棒)。
for pid in phenotypes:
if pid not in seen:
seen.add(pid)
base.append(pid)
if not seen:
raise ValueError("系谱与表型均为空")
order, idx = _order_pedigree(base, non_base)
n = len(order)
n_base = len(base)
parent_of: dict[int, tuple[int | None, int | None]] = {i: (d, s) for (i, d, s) in non_base}
n_with_parents = len(parent_of)
obs_ids = [ind for ind in order if ind in phenotypes]
m = len(obs_ids)
if m < 2:
raise ValueError("有效表型个体不足 2 个,无法估计方差组分")
X = _build_design(obs_ids, fixed, covariate)
p = X.shape[1]
n_fixed = p - 1
y = np.array([float(phenotypes[i]) for i in obs_ids], dtype=float)
if n_with_parents == 0:
# 无系谱:不背书遗传力。EBV 取表型残差(固定效应已校正)。
beta = np.linalg.lstsq(X, y, rcond=None)[0]
resid = y - X @ beta
ebv: dict[int, float] = {}
for k, ind in enumerate(obs_ids):
ebv[ind] = float(resid[k])
for ind in order:
ebv.setdefault(ind, 0.0)
return {
"ebv": ebv,
"reliability": {ind: 0.0 for ind in order},
"h2": None,
"sigma_a": 0.0,
"sigma_e": 0.0,
"n_obs": m,
"n_individuals": n,
"n_base": n_base,
"n_with_parents": 0,
"n_fixed": n_fixed,
"converged": True,
"n_iter": 0,
"warning": "无系谱信息(所有个体均无父母):EBV 仅为表型残差(固定效应已校正),未估计遗传力与可靠性。",
}
XtX = X.T @ X
Xty = X.T @ y
# 索引化设计矩阵(Z 每行恰一个 1):Zty / ZtZ 对角 / XtZ 全用 bincount 累加,
# 稀疏路径不再落稠密 Z(35k 树稠密 Z ≈ 54 GiB)。稠密分支在小 n 下重建等价 Z。
obs_idx = np.array([idx[ind] for ind in obs_ids], dtype=np.int64)
Zty = np.bincount(obs_idx, weights=y, minlength=n)
ZtZ_diag = np.bincount(obs_idx, minlength=n).astype(float)
XtZ = np.zeros((p, n))
for col in range(p):
XtZ[col] = np.bincount(obs_idx, weights=X[:, col], minlength=n)
# A7 阈值分派:个体数 > N_SPARSE 且系谱存在 → 稀疏 A⁻¹ + 索引化设计 + EM-REML
# (回避 O(n³) 稠密 MME 逆与 O(m²) 稠密 V);可靠性 n≤EXACT_DIAG_N 用稠密 MME 逆精确,
# n 大用 Hutchinson 随机探测估计 C22 对角线。
use_sparse = n_with_parents > 0 and n > N_SPARSE
if use_sparse:
diag_a, ainv_ii, ainv_jj, ainv_vv = _build_ainv_sparse(order, n_base, parent_of)
else:
A, Ainv = _build_ainv(order, n_base, parent_of)
if use_sparse:
# ---- 稀疏路径:EM-REMLMeyer 1985 单性状动物模型)----
# Va = (û'A⁻¹û + σ²e·tr(A⁻¹C²²))/nVe = yPy/(mp);λ=Ve/Va。全程不落稠密 Z/A/V。
# n ≤ N_EXACTtr(A⁻¹C²²) 逐列 CG 精确(无随机噪声);REML 解 = h(λ)=g(λ)−λ=0
# 单调单根 → log10λ 二分,~32 步到 1e-5(规避 EM 慢收缩 ρ≈0.987);
# n > N_EXACT:随机子样本(≤N_SUBSAMPLE 观测,祖先闭包)内联递归走精确迹二分
# 求 λ*,再全群 MME 于 λ* 解 EBV——子样本抽样误差即真实不确定性(warning 标注)。
ztz = ZtZ_diag
def _matvec(lam: float):
def mv(v: np.ndarray) -> np.ndarray:
b, u = v[:p], v[p:]
out1 = XtX @ b + XtZ @ u
out2 = XtZ.T @ b + ztz * u
# A⁻¹·ubincount 按 ii 累加(重复元自动合并),比 np.add.at 快一个量级
out2 += np.bincount(ainv_ii, weights=lam * ainv_vv * u[ainv_jj], minlength=n)
return np.concatenate([out1, out2])
return mv
mme_rhs = np.concatenate([Xty, Zty])
rng = np.random.RandomState(20260804)
em_it = 0
em_ok = True
cg_ok = True
cg_iter = 0
# 生产档仅当能真实子采样观测(m>N_SUBSAMPLE)才走:m 不大时子样本=全样本,退化为
# 精确迹 + 冗余全群 MME(更慢且子样本误差 SE 无意义)——直接全 n 精确迹更省更准。
use_production = n > N_EXACT and m > N_SUBSAMPLE
if not use_production:
# ---- 精确迹二分(golden 等价档)----
# A⁻¹ 第 j 列 = {i: ainv[i,j]}C²²e_j = MME 解 RHS=[0;e_j] 的 u 块;
# tr(A⁻¹C²²) = Σ_j (A⁻¹e_j)ᵀ(C²²e_j)。逐列 CG 精确,无 Hutchinson 噪声。
col_of: list[list[tuple[int, float]]] = [[] for _ in range(n)]
for a, b, v in zip(ainv_ii.tolist(), ainv_jj.tolist(), ainv_vv.tolist()):
col_of[b].append((a, v))
def _em_step(lam: float) -> tuple[float, float]:
mv = _matvec(lam)
sol, _ci, _ok = _cg_solve(mv, mme_rhs)
b, u = sol[:p], sol[p:]
yPy = float(y @ y - b @ Xty - u @ Zty)
Ve = max(yPy / max(m - p, 1), _FLOOR)
tr = 0.0
for j in range(n):
rhs2 = np.zeros(p + n)
rhs2[p + j] = 1.0
sol2, _i2, _o2 = _cg_solve(mv, rhs2)
uj = sol2[p:]
s = 0.0
for i, v in col_of[j]:
s += v * uj[i]
tr += s
uAu = float(np.bincount(ainv_ii, weights=ainv_vv * u[ainv_jj], minlength=n) @ u)
Va = max((uAu + Ve * tr) / max(n, 1), _FLOOR)
return Va, Ve
def _h(lv: float) -> float:
lam = 10.0 ** lv
Va, Ve = _em_step(lam)
return Ve / Va - lam
lo, hi = BISECT_LO, BISECT_HI
h_lo, h_hi = _h(lo), _h(hi)
while h_lo <= 0.0 and lo > BISECT_LO - 4.0:
lo -= 2.0
h_lo = _h(lo)
while h_hi >= 0.0 and hi < BISECT_HI + 4.0:
hi += 2.0
h_hi = _h(hi)
if h_lo > 0.0 > h_hi:
em_it = 2
while (hi - lo) > BISECT_TOL and em_it < MAX_BISECT_EVAL:
mid = 0.5 * (lo + hi)
h_mid = _h(mid)
em_it += 1
if h_mid > 0.0:
lo = mid
else:
hi = mid
Va, Ve = _em_step(10.0 ** (0.5 * (lo + hi)))
else:
# 无变号(REML 解在区间外/边界最优):取 |h| 最小的网格点
lv_grid = np.linspace(max(lo - 4.0, -8.0), min(hi + 4.0, 8.0), 41)
h_abs = [abs(_h(lv)) for lv in lv_grid]
Va, Ve = _em_step(10.0 ** float(lv_grid[int(np.argmin(h_abs))]))
em_ok = False
else:
# ---- 生产档(n>N_EXACT 且 m>N_SUBSAMPLE):随机子样本精确迹二分 → 全群 MME ----
# 全群精确迹 O(n²) 不可行;EM 收缩率 ρ→0.999 冻结在初始猜测,Hutchinson 噪声
# 又淹没 h(λ) 信号(~2e-4 vs 噪声 ~2e-3)。子样本 iid 抽样 REML 为全群方差组分
# 一致估计(n_sub=3000 抽样 SE h² ≈ ±0.04EBV corr>0.998),抽样误差即真实
# 不确定性。内联递归把 N_EXACT 临时抬到 10⁹,让子样本走精确迹二分求 λ*。
chosen = set(rng.choice(obs_ids, size=min(N_SUBSAMPLE, m), replace=False).tolist())
# 祖先闭包:parent_of 以个体 id 为键、值亦为 id(与 _build_ainv_sparse 一致);
# 勿用 idx[]/order[] 做中间层(那是位置空间,混用会把闭包加进错对象)。
q: list[int] = list(chosen)
while q:
cur = q.pop()
d, s = parent_of.get(cur, (None, None))
for pp in (d, s):
if pp is not None and pp not in chosen:
chosen.add(pp)
q.append(pp)
ped_sub = []
for i in chosen:
d, s = parent_of.get(i, (None, None))
ped_sub.append({
"individual": i,
"dam": d if d is not None else None,
"sire": s if s is not None else None,
})
phen_sub = {k: v for k, v in phenotypes.items() if k in chosen}
fixed_sub = None
if fixed:
fixed_sub = {fk: {k: v for k, v in fv.items() if k in chosen}
for fk, fv in fixed.items()}
cov_sub = None
if covariate:
cov_sub = {k: v for k, v in covariate.items() if k in chosen}
saved_exact = N_EXACT
try:
N_EXACT = 10 ** 9 # noqa: PLW0603 临时抬档(子样本个体数远小于全群)
sub = solve(ped_sub, phen_sub, fixed=fixed_sub, covariate=cov_sub)
finally:
N_EXACT = saved_exact
Va = max(float(sub["sigma_a"]), _FLOOR)
Ve = max(float(sub["sigma_e"]), _FLOOR)
em_ok = bool(sub.get("converged", False))
em_it = int(sub.get("n_iter", 0))
lam = Ve / Va
mv = _matvec(lam)
sol, cg_iter, cg_ok = _cg_solve(mv, mme_rhs)
u = sol[p:]
h2 = Va / (Va + Ve) if (Va + Ve) > 0 else None
# 可靠性:n ≤ EXACT_DIAG_N(稠密逆仍便宜)→ diag(C22) 用稠密 MME 逆精确求(golden
# 等价档,无 MC 噪声——全同胞小样本 h²→1 边界 Hutchinson 均值误差实测 ~0.08 量级,
# 见 e2e_spatial_sparse n=20 段);n 大时退 Hutchinson 随机探测(排序可靠、量级可信)。
if n <= EXACT_DIAG_N:
Ainv_dense = np.zeros((n, n))
np.add.at(Ainv_dense, (ainv_ii, ainv_jj), ainv_vv) # 三元组含重复 (i,j)(多后代),须累加
big = np.zeros((p + n, p + n))
big[:p, :p] = XtX
big[:p, p:] = XtZ
big[p:, :p] = XtZ.T
big[p:, p:] = np.diag(ZtZ_diag) + lam * Ainv_dense
try:
big_inv = np.linalg.inv(big)
except np.linalg.LinAlgError:
big_inv = np.linalg.pinv(big)
C22d = big_inv[p:, p:]
pev = np.diag(C22d) * Ve
else:
diag_c22 = np.zeros(n)
for _ in range(HUTCH_K):
pv = rng.choice([-1.0, 1.0], size=n)
rhs2 = np.zeros(p + n)
rhs2[p:] = pv
sol2, _it2, _ok2 = _cg_solve(mv, rhs2)
diag_c22 += sol2[p:] * pv
diag_c22 /= HUTCH_K
pev = diag_c22 * Ve
ebv = {ind: float(u[idx[ind]]) for ind in order}
rel = {ind: float(np.clip(1.0 - pev[idx[ind]] / Va, 0.0, 1.0)) for ind in order}
warning = None
if use_production:
# 生产档(n>N_EXACT 且 m>N_SUBSAMPLE):方差组分来自随机子样本
# (≤N_SUBSAMPLE 观测,含祖先闭包)的精确 REML 估计——一致估计但带抽样误差
# n_sub=3000 SE h² ≈ ±0.04);EBV 由全群 MME 在子样本 λ* 下求解,
# 对 λ 稳健(偏 25% 内 Kendall τ>0.97),排序可靠。
warning = (f"生产档(个体数 {n})方差分量取自随机子样本"
f"(≤{N_SUBSAMPLE} 观测,含祖先闭包)的 REML 一致估计:存在抽样误差"
"h² 标准误约 ±0.04),h²/σ² 报告值请视为近似区间;EBV 由全群 MME"
"在子样本 λ 下求解,排名可靠。")
elif not em_ok:
warning = ("REML 解落在 log10λ 二分区间外(似然面边界最优),"
"结果采用 |h| 最小网格点,可能不精确。")
if not cg_ok:
resid = mme_rhs - mv(sol)
rel_res = float(np.linalg.norm(resid) / (np.linalg.norm(mme_rhs) + 1e-12))
w = (f"共轭梯度 {cg_iter} 次迭代未达收敛阈值(相对残差 {rel_res:.2e}),"
"结果采用已探明最优,可靠性为 Hutchinson 近似。")
warning = f"{warning} {w}" if warning else w
return {
"ebv": ebv,
"reliability": rel,
"h2": float(h2) if h2 is not None else None,
"sigma_a": float(Va),
"sigma_e": float(Ve),
"n_obs": m,
"n_individuals": n,
"n_base": n_base,
"n_with_parents": n_with_parents,
"n_fixed": n_fixed,
"converged": em_ok and cg_ok,
"n_iter": em_it,
"solver": "sparse-em",
"cg_iter": cg_iter,
"reliability_approx": n > EXACT_DIAG_N,
"warning": warning,
}
# 稠密分支(n ≤ N_SPARSE):重建等价 Z / ZtZbincount 已给各体观测计数)
Z = np.zeros((m, n))
for k, ind in enumerate(obs_ids):
Z[k, idx[ind]] = 1.0
ZtZ = np.diag(ZtZ_diag)
def _reml_ll(Va: float, Ve: float) -> float:
"""精确 REML 对数似然(直接 V 计算,O(m³))。"""
V = (Z @ A @ Z.T) * Va + np.eye(m) * Ve
VinvX = np.linalg.solve(V, X) # p 个 RHS1 次 LU
Vinvy = np.linalg.solve(V, y)
XtVinvX = X.T @ VinvX
XtVinvY = X.T @ Vinvy
yPy = float(y @ Vinvy) - float(XtVinvY @ np.linalg.solve(XtVinvX, XtVinvY))
_, lv = np.linalg.slogdet(V)
_, lx = np.linalg.slogdet(XtVinvX)
return -0.5 * (float(lv) + float(lx) + yPy)
def _mme_solve(lam: float) -> tuple[np.ndarray, np.ndarray]:
big = np.zeros((p + n, p + n))
big[:p, :p] = XtX
big[:p, p:] = XtZ
big[p:, :p] = XtZ.T
big[p:, p:] = ZtZ + lam * Ainv
rhs = np.concatenate([Xty, Zty])
try:
sol = np.linalg.solve(big, rhs)
big_inv = np.linalg.inv(big)
except np.linalg.LinAlgError:
sol = np.linalg.pinv(big) @ rhs
big_inv = np.linalg.pinv(big)
return sol, big_inv
best = {"h2": None, "ll": -np.inf, "Va": 0.0, "Ve": 0.0}
def _profile(h2: float) -> float:
lam = (1.0 - h2) / h2
sol, _ = _mme_solve(lam)
b = sol[:p]
u = sol[p:]
yPy = float(y @ y - b @ Xty - u @ Zty)
Ve = max(yPy / max(m - p, 1), _FLOOR)
Va = Ve / lam
ll = _reml_ll(Va, Ve)
if ll > best["ll"]:
best.update(h2=h2, ll=ll, Va=Va, Ve=Ve)
return ll
h2_opt, n_iter = _golden_max(_profile, H2_MIN, H2_MAX, tol=tol)
# 黄金分割端点通常已探明;再用最优 h2 精确求一次
_profile(h2_opt)
h2 = best["h2"] if best["h2"] is not None else h2_opt
Va, Ve = best["Va"], best["Ve"]
lam = (1.0 - h2) / h2
sol, big_inv = _mme_solve(lam)
u = sol[p:]
C22 = big_inv[p:, p:]
ebv = {ind: float(u[idx[ind]]) for ind in order}
pev = np.diag(C22) * Ve
rel = {ind: float(np.clip(1.0 - pev[idx[ind]] / Va, 0.0, 1.0)) for ind in order}
warning = None
if n_iter >= MAX_PROFILE_EVALS:
warning = "剖面 REML 未完全收敛(似然面极平/边界最优),结果已采用已探明最优。"
return {
"ebv": ebv,
"reliability": rel,
"h2": float(h2),
"sigma_a": float(Va),
"sigma_e": float(Ve),
"n_obs": m,
"n_individuals": n,
"n_base": n_base,
"n_with_parents": n_with_parents,
"n_fixed": n_fixed,
"converged": n_iter < MAX_PROFILE_EVALS,
"n_iter": n_iter,
"solver": "dense",
"warning": warning,
}
def solve_spatial(pedigree: list[dict], phenotypes: dict, *, coords: dict,
tol: float = TOL, fixed: dict | None = None,
covariate: dict | None = None, aniso: bool = False) -> dict:
"""AR1×AR1 空间协方差动物模型 BLUPv1:ρ_row=ρ_col=ρ 降维;aniso=True:双参数)。
y = Xb + Zu + ee~N(0, σ²e·R)R_ij = ρ^(|Δrow|+|Δcol|)(行/列 AR1 之积,
即 AR1(ρ)⊗AR1(ρ))。R⁻¹ 用纯 numpy eigh 对角化;REML 对 (h², ρ) 坐标上升黄金分割。
aniso=True 时 R_ij = ρ_row^|Δrow|·ρ_col^|Δcol|,对 (h², ρ_row, ρ_col) 坐标上升
(方法标签 method="AR1×AR1(aniso)"、落 rho_row/rho_col);aniso=False 时输出与 v1 逐字节一致。
参数:
pedigree / phenotypes / fixed / covariate: 同 solve()。
coords: {个体id: (row_no, col_no)}——每个观测个体必须提供行/列坐标,
缺失 → ValueError(门禁:缺坐标无法建模空间残差)。
aniso: 是否启用各向异性双参数 ρ_row/ρ_col(默认 False=单 ρ 降维)。
返回:
ebv / reliability / h2 / sigma_a / sigma_e / n_obs / n_individuals /
n_base / n_with_parents / n_fixed / converged / n_iter / warning(同 solve);
另含 rho(单参,aniso=False)或 rho_row/rho_colaniso=True)、
spatial=True、method="AR1×AR1" 或 "AR1×AR1(aniso)"。
"""
if aniso:
return _solve_spatial_aniso(pedigree, phenotypes, coords=coords, tol=tol,
fixed=fixed, covariate=covariate)
base: list[int] = []
non_base: list[tuple[int, int | None, int | None]] = []
seen: set[int] = set()
for rec in pedigree:
i = rec["individual"]
if i in seen:
raise ValueError(f"系谱个体重复: {i}")
seen.add(i)
d, s = rec.get("dam"), rec.get("sire")
if d is None and s is None:
base.append(i)
else:
non_base.append((i, d, s))
for pid in phenotypes:
if pid not in seen:
seen.add(pid)
base.append(pid)
if not seen:
raise ValueError("系谱与表型均为空")
order, idx = _order_pedigree(base, non_base)
n = len(order)
n_base = len(base)
parent_of: dict[int, tuple[int | None, int | None]] = {i: (d, s) for (i, d, s) in non_base}
n_with_parents = len(parent_of)
obs_ids = [ind for ind in order if ind in phenotypes]
m = len(obs_ids)
if m < 2:
raise ValueError("有效表型个体不足 2 个,无法估计方差组分")
missing = [i for i in obs_ids if coords.get(i) is None]
if missing:
raise ValueError(f"缺行/列坐标的个体(空间协方差需每个观测提供 row_no/col_no: {missing[:5]}")
X = _build_design(obs_ids, fixed, covariate)
p = X.shape[1]
n_fixed = p - 1
y = np.array([float(phenotypes[i]) for i in obs_ids], dtype=float)
if n_with_parents == 0:
beta = np.linalg.lstsq(X, y, rcond=None)[0]
resid = y - X @ beta
ebv: dict[int, float] = {}
for k, ind in enumerate(obs_ids):
ebv[ind] = float(resid[k])
for ind in order:
ebv.setdefault(ind, 0.0)
return {
"ebv": ebv,
"reliability": {ind: 0.0 for ind in order},
"h2": None, "sigma_a": 0.0, "sigma_e": 0.0,
"rho": 0.0, "spatial": True, "method": "AR1×AR1",
"n_obs": m, "n_individuals": n, "n_base": n_base,
"n_with_parents": 0, "n_fixed": n_fixed,
"converged": True, "n_iter": 0,
"solver": "dense",
"warning": "无系谱信息(所有个体均无父母):EBV 仅为表型残差,未估计遗传力与可靠性。",
}
A, Ainv = _build_ainv(order, n_base, parent_of)
Z = np.zeros((m, n))
for k, ind in enumerate(obs_ids):
Z[k, idx[ind]] = 1.0
rows = np.array([coords[i][0] for i in obs_ids], dtype=float)
cols = np.array([coords[i][1] for i in obs_ids], dtype=float)
# R_ij = ρ^(|Δrow|+|Δcol|)
dmat = np.abs(rows[:, None] - rows[None, :]) + np.abs(cols[:, None] - cols[None, :])
XtX, Xty = X.T @ X, X.T @ y
Zty = Z.T @ y
ZtZ = Z.T @ Z
XtZ = X.T @ Z
def _rinv(rho: float) -> np.ndarray:
R = np.power(rho, dmat)
evals, Q = np.linalg.eigh(R)
return Q @ np.diag(1.0 / np.maximum(evals, _FLOOR)) @ Q.T
def _mme_solve(lam: float, rho: float) -> tuple[np.ndarray, np.ndarray]:
Rinv = _rinv(rho)
XrX = X.T @ Rinv @ X
XrZ = X.T @ Rinv @ Z
ZrZ = Z.T @ Rinv @ Z
Xry = X.T @ Rinv @ y
Zry = Z.T @ Rinv @ y
big = np.zeros((p + n, p + n))
big[:p, :p] = XrX
big[:p, p:] = XrZ
big[p:, :p] = XrZ.T
big[p:, p:] = ZrZ + lam * Ainv
rhs = np.concatenate([Xry, Zry])
try:
sol = np.linalg.solve(big, rhs)
big_inv = np.linalg.inv(big)
except np.linalg.LinAlgError:
sol = np.linalg.pinv(big) @ rhs
big_inv = np.linalg.pinv(big)
return sol, big_inv
def _reml_ll(Va: float, Ve: float, rho: float) -> float:
R = np.power(rho, dmat)
V = (Z @ A @ Z.T) * Va + R * Ve
VinvX = np.linalg.solve(V, X)
Vinvy = np.linalg.solve(V, y)
XtVinvX = X.T @ VinvX
XtVinvY = X.T @ Vinvy
yPy = float(y @ Vinvy) - float(XtVinvY @ np.linalg.solve(XtVinvX, XtVinvY))
_, lv = np.linalg.slogdet(V)
_, lx = np.linalg.slogdet(XtVinvX)
return -0.5 * (float(lv) + float(lx) + yPy)
RHO_LO, RHO_HI = 0.02, 0.98
best = {"h2": None, "rho": 0.0, "ll": -np.inf, "Va": 0.0, "Ve": 0.0}
h2_l, rho_l = 0.5, 0.5
def _track(h2: float, rho: float) -> float:
lam = (1.0 - h2) / h2
sol, _ = _mme_solve(lam, rho)
b, u = sol[:p], sol[p:]
resid = y - X @ b - Z @ u
yPy = float(resid @ _rinv(rho) @ resid)
Ve = max(yPy / max(m - p, 1), _FLOOR)
Va = Ve / lam
ll = _reml_ll(Va, Ve, rho)
if ll > best["ll"]:
best.update(h2=h2, rho=rho, ll=ll, Va=Va, Ve=Ve)
return ll
def _f_h2(h2: float) -> float:
return _track(h2, rho_l)
def _f_rho(rho: float) -> float:
return _track(h2_l, rho)
converged = False
n_iter = 0
prev_best = -np.inf
for _ in range(MAX_GXE_ITERS):
n_iter += 1
h2_l, _ = _golden_max(_f_h2, H2_MIN, H2_MAX, tol=1e-3)
rho_l, _ = _golden_max(_f_rho, RHO_LO, RHO_HI, tol=1e-3)
if best["ll"] - prev_best < 1e-2:
converged = True
break
prev_best = best["ll"]
_track(h2_l, rho_l)
h2 = best["h2"] if best["h2"] is not None else h2_l
Va, Ve = best["Va"], best["Ve"]
rho = best["rho"]
lam = (1.0 - h2) / h2
sol, big_inv, *_ = _mme_solve(lam, rho)
u = sol[p:]
C22 = big_inv[p:, p:]
ebv = {ind: float(u[idx[ind]]) for ind in order}
pev = np.diag(C22) * Ve
rel = {ind: float(np.clip(1.0 - pev[idx[ind]] / Va, 0.0, 1.0)) for ind in order}
warning = None
if rho <= RHO_LO * 1.05:
warning = "空间自相关 ρ 收敛到下界,残差接近独立(空间结构弱)。"
elif not converged:
warning = "剖面 REML 坐标上升未完全收敛(空间似然面极平/边界最优),结果采用已探明最优。"
return {
"ebv": ebv,
"reliability": rel,
"h2": float(h2),
"sigma_a": float(Va),
"sigma_e": float(Ve),
"rho": float(rho),
"spatial": True,
"method": "AR1×AR1",
"n_obs": m,
"n_individuals": n,
"n_base": n_base,
"n_with_parents": n_with_parents,
"n_fixed": n_fixed,
"converged": converged,
"n_iter": n_iter,
"solver": "dense",
"warning": warning,
}
def _solve_spatial_aniso(pedigree: list[dict], phenotypes: dict, *, coords: dict,
tol: float = TOL, fixed: dict | None = None,
covariate: dict | None = None) -> dict:
"""AR1×AR1 各向异性空间协方差 BLUP(aniso=True 分支,双参数 ρ_row/ρ_col)。
R_ij = ρ_row^|Δrow| · ρ_col^|Δcol|(行/列独立 AR1 之积)。REML 对 (h², ρ_row, ρ_col)
三变量坐标上升黄金分割;门禁/互斥/无系谱语义与 v1 solve_spatial 逐项一致,
仅 ρ 由单参扩展为行/列双参。方法标签 method="AR1×AR1(aniso)"、落 rho_row/rho_col。
"""
base: list[int] = []
non_base: list[tuple[int, int | None, int | None]] = []
seen: set[int] = set()
for rec in pedigree:
i = rec["individual"]
if i in seen:
raise ValueError(f"系谱个体重复: {i}")
seen.add(i)
d, s = rec.get("dam"), rec.get("sire")
if d is None and s is None:
base.append(i)
else:
non_base.append((i, d, s))
for pid in phenotypes:
if pid not in seen:
seen.add(pid)
base.append(pid)
if not seen:
raise ValueError("系谱与表型均为空")
order, idx = _order_pedigree(base, non_base)
n = len(order)
n_base = len(base)
parent_of: dict[int, tuple[int | None, int | None]] = {i: (d, s) for (i, d, s) in non_base}
n_with_parents = len(parent_of)
obs_ids = [ind for ind in order if ind in phenotypes]
m = len(obs_ids)
if m < 2:
raise ValueError("有效表型个体不足 2 个,无法估计方差组分")
missing = [i for i in obs_ids if coords.get(i) is None]
if missing:
raise ValueError(f"缺行/列坐标的个体(空间协方差需每个观测提供 row_no/col_no: {missing[:5]}")
X = _build_design(obs_ids, fixed, covariate)
p = X.shape[1]
n_fixed = p - 1
y = np.array([float(phenotypes[i]) for i in obs_ids], dtype=float)
if n_with_parents == 0:
beta = np.linalg.lstsq(X, y, rcond=None)[0]
resid = y - X @ beta
ebv: dict[int, float] = {}
for k, ind in enumerate(obs_ids):
ebv[ind] = float(resid[k])
for ind in order:
ebv.setdefault(ind, 0.0)
return {
"ebv": ebv,
"reliability": {ind: 0.0 for ind in order},
"h2": None, "sigma_a": 0.0, "sigma_e": 0.0,
"rho_row": 0.0, "rho_col": 0.0, "spatial": True, "method": "AR1×AR1(aniso)",
"n_obs": m, "n_individuals": n, "n_base": n_base,
"n_with_parents": 0, "n_fixed": n_fixed,
"converged": True, "n_iter": 0,
"solver": "dense",
"warning": "无系谱信息(所有个体均无父母):EBV 仅为表型残差,未估计遗传力与可靠性。",
}
A, Ainv = _build_ainv(order, n_base, parent_of)
Z = np.zeros((m, n))
for k, ind in enumerate(obs_ids):
Z[k, idx[ind]] = 1.0
rows = np.array([coords[i][0] for i in obs_ids], dtype=float)
cols = np.array([coords[i][1] for i in obs_ids], dtype=float)
dmat_row = np.abs(rows[:, None] - rows[None, :])
dmat_col = np.abs(cols[:, None] - cols[None, :])
XtX, Xty = X.T @ X, X.T @ y
Zty = Z.T @ y
ZtZ = Z.T @ Z
XtZ = X.T @ Z
def _rinv(rr: float, rc: float) -> np.ndarray:
R = np.power(rr, dmat_row) * np.power(rc, dmat_col)
evals, Q = np.linalg.eigh(R)
return Q @ np.diag(1.0 / np.maximum(evals, _FLOOR)) @ Q.T
def _mme_solve(lam: float, rr: float, rc: float) -> tuple[np.ndarray, np.ndarray]:
Rinv = _rinv(rr, rc)
XrX = X.T @ Rinv @ X
XrZ = X.T @ Rinv @ Z
ZrZ = Z.T @ Rinv @ Z
Xry = X.T @ Rinv @ y
Zry = Z.T @ Rinv @ y
big = np.zeros((p + n, p + n))
big[:p, :p] = XrX
big[:p, p:] = XrZ
big[p:, :p] = XrZ.T
big[p:, p:] = ZrZ + lam * Ainv
rhs = np.concatenate([Xry, Zry])
try:
sol = np.linalg.solve(big, rhs)
big_inv = np.linalg.inv(big)
except np.linalg.LinAlgError:
sol = np.linalg.pinv(big) @ rhs
big_inv = np.linalg.pinv(big)
return sol, big_inv
def _reml_ll(Va: float, Ve: float, rr: float, rc: float) -> float:
R = np.power(rr, dmat_row) * np.power(rc, dmat_col)
V = (Z @ A @ Z.T) * Va + R * Ve
VinvX = np.linalg.solve(V, X)
Vinvy = np.linalg.solve(V, y)
XtVinvX = X.T @ VinvX
XtVinvY = X.T @ Vinvy
yPy = float(y @ Vinvy) - float(XtVinvY @ np.linalg.solve(XtVinvX, XtVinvY))
_, lv = np.linalg.slogdet(V)
_, lx = np.linalg.slogdet(XtVinvX)
return -0.5 * (float(lv) + float(lx) + yPy)
RHO_LO, RHO_HI = 0.02, 0.98
best = {"h2": None, "rho_row": 0.5, "rho_col": 0.5, "ll": -np.inf,
"Va": 0.0, "Ve": 0.0}
h2_l, rho_row_l, rho_col_l = 0.5, 0.5, 0.5
def _track(h2: float, rr: float, rc: float) -> float:
lam = (1.0 - h2) / h2
sol, _ = _mme_solve(lam, rr, rc)
b, u = sol[:p], sol[p:]
resid = y - X @ b - Z @ u
yPy = float(resid @ _rinv(rr, rc) @ resid)
Ve = max(yPy / max(m - p, 1), _FLOOR)
Va = Ve / lam
ll = _reml_ll(Va, Ve, rr, rc)
if ll > best["ll"]:
best.update(h2=h2, rho_row=rr, rho_col=rc, ll=ll, Va=Va, Ve=Ve)
return ll
def _f_h2(h2: float) -> float:
return _track(h2, rho_row_l, rho_col_l)
def _f_rr(rr: float) -> float:
return _track(h2_l, rr, rho_col_l)
def _f_rc(rc: float) -> float:
return _track(h2_l, rho_row_l, rc)
converged = False
n_iter = 0
prev_best = -np.inf
for _ in range(MAX_GXE_ITERS):
n_iter += 1
h2_l, _ = _golden_max(_f_h2, H2_MIN, H2_MAX, tol=1e-3)
rho_row_l, _ = _golden_max(_f_rr, RHO_LO, RHO_HI, tol=1e-3)
rho_col_l, _ = _golden_max(_f_rc, RHO_LO, RHO_HI, tol=1e-3)
if best["ll"] - prev_best < 1e-2:
converged = True
break
prev_best = best["ll"]
_track(h2_l, rho_row_l, rho_col_l)
h2 = best["h2"] if best["h2"] is not None else h2_l
Va, Ve = best["Va"], best["Ve"]
rho_row, rho_col = best["rho_row"], best["rho_col"]
lam = (1.0 - h2) / h2
sol, big_inv, *_ = _mme_solve(lam, rho_row, rho_col)
u = sol[p:]
C22 = big_inv[p:, p:]
ebv = {ind: float(u[idx[ind]]) for ind in order}
pev = np.diag(C22) * Ve
rel = {ind: float(np.clip(1.0 - pev[idx[ind]] / Va, 0.0, 1.0)) for ind in order}
warning = None
if rho_row <= RHO_LO * 1.05 and rho_col <= RHO_LO * 1.05:
warning = "空间自相关 ρ_row/ρ_col 均收敛到下界,残差接近独立(空间结构弱)。"
elif not converged:
warning = "剖面 REML 坐标上升未完全收敛(空间似然面极平/边界最优),结果采用已探明最优。"
return {
"ebv": ebv,
"reliability": rel,
"h2": float(h2),
"sigma_a": float(Va),
"sigma_e": float(Ve),
"rho_row": float(rho_row),
"rho_col": float(rho_col),
"spatial": True,
"method": "AR1×AR1(aniso)",
"n_obs": m,
"n_individuals": n,
"n_base": n_base,
"n_with_parents": n_with_parents,
"n_fixed": n_fixed,
"converged": converged,
"n_iter": n_iter,
"solver": "dense",
"warning": warning,
}
def _solve_gxe(pedigree: list[dict], phenotypes: dict, *, fixed: dict | None,
covariate: dict | None, gxe: dict, record_map: dict, tol: float,
factor_label: str = "G×E", factor_name: str = "环境") -> dict:
"""G×E 分支:y = Xb + Z₁u + Z₂v + eu~N(0,A·σ²a)、v~N(0,I·σ²gxe)、e~N(0,I·σ²e)。
交互随机效应槽 Z₂ 按 (基因型, 因子水平) 分组,不限定因子语义——
G×R(砧木)复用同一求解器:factor_label/factor_name 仅影响警告文案
(默认「G×E/环境」,G×R 传「G×R/砧木」),求解逻辑逐字节不变。
三方差分量用剖面 REML 估计:对 (log10 σ²a/σ²e, log10 σ²gxe/σ²e) 坐标上升黄金分割,
比率边界 λ∈[1e-3,1e3] 恒正,避免 EM-REML 边界崩塌(σ²e→0 → λ→0 → 插值)。
记录级:phenotypes/gxe/fixed/covariate 均以记录 id 为键;record_map 记录→加性个体。
"""
base: list[int] = []
non_base: list[tuple[int, int | None, int | None]] = []
seen: set[int] = set()
for rec in pedigree:
i = rec["individual"]
if i in seen:
raise ValueError(f"系谱个体重复: {i}")
seen.add(i)
d, s = rec.get("dam"), rec.get("sire")
if d is None and s is None:
base.append(i)
else:
non_base.append((i, d, s))
for pid in phenotypes:
ind = record_map.get(pid, pid)
if ind not in seen:
seen.add(ind)
base.append(ind)
if not seen:
raise ValueError("系谱与表型均为空")
order, idx = _order_pedigree(base, non_base)
n = len(order)
n_base = len(base)
parent_of: dict[int, tuple[int | None, int | None]] = {i: (d, s) for (i, d, s) in non_base}
n_with_parents = len(parent_of)
rec_ids = list(phenotypes.keys())
m = len(rec_ids)
if m < 2:
raise ValueError("有效表型个体不足 2 个,无法估计方差组分")
A, Ainv = _build_ainv(order, n_base, parent_of)
X = _build_design(rec_ids, fixed, covariate)
p = X.shape[1]
n_fixed = p - 1
y = np.array([float(phenotypes[i]) for i in rec_ids], dtype=float)
Z1 = np.zeros((m, n))
for k, rid in enumerate(rec_ids):
Z1[k, idx[record_map.get(rid, rid)]] = 1.0
pairs: dict[tuple[str, str], int] = {}
pair_list: list[tuple[str, str]] = []
for rid in rec_ids:
key = (str(gxe[rid][0]), str(gxe[rid][1]))
if key not in pairs:
pairs[key] = len(pair_list)
pair_list.append(key)
q = len(pair_list)
if q < 1:
raise ValueError("无 G×E 效应水平")
Z2 = np.zeros((m, q))
for k, rid in enumerate(rec_ids):
Z2[k, pairs[(str(gxe[rid][0]), str(gxe[rid][1]))]] = 1.0
XtX, Xty = X.T @ X, X.T @ y
Z1tZ1, Z1ty = Z1.T @ Z1, Z1.T @ y
Z2tZ2, Z2ty = Z2.T @ Z2, Z2.T @ y
XtZ1, XtZ2, Z1tZ2 = X.T @ Z1, X.T @ Z2, Z1.T @ Z2
def _mme(la: float, lg: float) -> tuple[np.ndarray, np.ndarray]:
big = np.zeros((p + n + q, p + n + q))
big[:p, :p] = XtX
big[:p, p:p + n] = XtZ1
big[:p, p + n:] = XtZ2
big[p:p + n, :p] = XtZ1.T
big[p:p + n, p:p + n] = Z1tZ1 + la * Ainv
big[p:p + n, p + n:] = Z1tZ2
big[p + n:, :p] = XtZ2.T
big[p + n:, p:p + n] = Z1tZ2.T
big[p + n:, p + n:] = Z2tZ2 + lg * np.eye(q)
rhs = np.concatenate([Xty, Z1ty, Z2ty])
try:
sol = np.linalg.solve(big, rhs)
big_inv = np.linalg.inv(big)
except np.linalg.LinAlgError:
sol = np.linalg.pinv(big) @ rhs
big_inv = np.linalg.pinv(big)
return sol, big_inv
# 剖面 REML:对 (log10 σ²a/σ²e, log10 σ²gxe/σ²e) 坐标上升黄金分割最大化。
# 比率边界与单随机效应分支的 h² 网格一致(λ∈[1e-3,1e3] 恒正),
# 避免 EM-REML 边界崩塌(σ²e→0 → λ→0 → MME 退化为插值)。
rank_x = max(np.linalg.matrix_rank(X), 1)
df = max(m - rank_x, 1)
def _profile(ra: float, rg: float) -> tuple[float, float]:
"""固定比率 (ra,rg)=(σ²a/σ²e, σ²gxe/σ²e),剖面出 σ²e,返回 (REML LL, σ²e)。"""
V0 = (Z1 @ A @ Z1.T) * ra + (Z2 @ Z2.T) * rg + np.eye(m)
VinvX = np.linalg.solve(V0, X)
Vinvy = np.linalg.solve(V0, y)
XtVinvX = X.T @ VinvX
XtVinvY = X.T @ Vinvy
yP0y = float(y @ Vinvy) - float(XtVinvY @ np.linalg.solve(XtVinvX, XtVinvY))
_, lv = np.linalg.slogdet(V0)
_, lx = np.linalg.slogdet(XtVinvX)
ve = max(yP0y / df, _FLOOR)
ll = -0.5 * (df * np.log(ve) + float(lv) + float(lx))
return ll, ve
RA_LO, RA_HI = np.log10(1e-3), np.log10(1e3)
best = {"ll": -np.inf, "ra": 1.0, "rg": 1.0, "ve": 1.0}
ra_l, rg_l = 0.0, 0.0 # 起始比率=1(等方差)
def _track(ra: float, rg: float) -> float:
ll, ve = _profile(ra, rg)
if ll > best["ll"]:
best.update(ll=ll, ra=ra, rg=rg, ve=ve)
return ll
def _f_ra(lra: float) -> float:
return _track(10.0 ** lra, 10.0 ** rg_l)
def _f_rg(lrg: float) -> float:
return _track(10.0 ** ra_l, 10.0 ** lrg)
converged = False
n_iter = 0
prev_best = -np.inf
for _ in range(MAX_GXE_ITERS):
n_iter += 1
new_ra_l, _ = _golden_max(_f_ra, RA_LO, RA_HI, tol=1e-3)
ra_l = new_ra_l
new_rg_l, _ = _golden_max(_f_rg, RA_LO, RA_HI, tol=1e-3)
rg_l = new_rg_l
# 坐标上升在似然山脊上 LL 增益趋零即收敛;阈值放宽避免斜坡上反复横跳
if best["ll"] - prev_best < 1e-2:
converged = True
break
prev_best = best["ll"]
_track(10.0 ** ra_l, 10.0 ** rg_l)
ra, rg, ve = best["ra"], best["rg"], best["ve"]
va, vg = ra * ve, rg * ve
la, lg = 1.0 / ra, 1.0 / rg
sol, big_inv = _mme(la, lg)
u, v = sol[p:p + n], sol[p + n:]
C22 = big_inv[p:p + n, p:p + n]
ebv = {ind: float(u[idx[ind]]) for ind in order}
pev = np.diag(C22) * ve
rel = {ind: float(np.clip(1.0 - pev[idx[ind]] / va, 0.0, 1.0)) for ind in order}
gxe_eff = {f"{g}|{e}": float(v[i]) for i, (g, e) in enumerate(pair_list)}
total = va + vg + ve
h2 = va / total if total > 0 else None
gxe_ratio = vg / total if total > 0 else None
geno_env: dict[str, set[str]] = {}
for rid in rec_ids:
grp, env = str(gxe[rid][0]), str(gxe[rid][1])
geno_env.setdefault(grp, set()).add(env)
n_cross_env = sum(1 for s in geno_env.values() if len(s) >= 2)
n_genotypes = len(geno_env)
warning = None
if not converged:
warning = f"剖面 REML 坐标上升未完全收敛({factor_label} 似然面可能极平/边界最优),结果采用已探明最优。"
if n_with_parents == 0:
w = "无系谱信息:σ²a 与 σ²gxe 的遗传学解释受限(EBV 无加性依据)。"
warning = w if warning is None else f"{warning} {w}"
if vg <= _FLOOR * 1.01:
w = f"{factor_label} 方差分量收敛到 0(数据不支持基因型×{factor_name}互作)。"
warning = w if warning is None else f"{warning} {w}"
return {
"ebv": ebv,
"reliability": rel,
"h2": float(h2) if h2 is not None else None,
"sigma_a": float(va),
"sigma_e": float(ve),
"sigma_gxe": float(vg),
"gxe_ratio": float(gxe_ratio) if gxe_ratio is not None else None,
"gxe_effects": gxe_eff,
"n_gxe": q,
"n_cross_env": n_cross_env,
"n_genotypes": n_genotypes,
"n_obs": m,
"n_individuals": n,
"n_base": n_base,
"n_with_parents": n_with_parents,
"n_fixed": n_fixed,
"converged": converged,
"n_iter": n_iter,
"solver": "dense",
"warning": warning,
}
def _solve_block(pedigree: list[dict], phenotypes: dict, *, fixed: dict | None,
covariate: dict | None, block_of: dict, record_map: dict, tol: float,
factor_label: str = "区组", factor_name: str = "区组") -> dict:
"""区组随机效应分支:y = Xb + Z₁u + Z₂v + eu~N(0,A·σ²a)、v~N(0,I·σ²block)、e~N(0,I·σ²e)。
为不完全区组设计(增广/α-格子)服务:trial_design 已把 block_no 落库到观测株,
run_ablup 把区组作第二随机效应,精度收益进入遗传评估。Z₂ 按记录所属区组水平分组
block_of: {记录id: 区组水平}),求解器与 G×E 分支同构(剖面 REML 坐标上升估
(log10 σ²a/σ²e, log10 σ²block/σ²e) 三方差分量),仅 Z₂ 分组与输出键改名。
"""
base: list[int] = []
non_base: list[tuple[int, int | None, int | None]] = []
seen: set[int] = set()
for rec in pedigree:
i = rec["individual"]
if i in seen:
raise ValueError(f"系谱个体重复: {i}")
seen.add(i)
d, s = rec.get("dam"), rec.get("sire")
if d is None and s is None:
base.append(i)
else:
non_base.append((i, d, s))
for pid in phenotypes:
ind = record_map.get(pid, pid)
if ind not in seen:
seen.add(ind)
base.append(ind)
if not seen:
raise ValueError("系谱与表型均为空")
order, idx = _order_pedigree(base, non_base)
n = len(order)
n_base = len(base)
parent_of: dict[int, tuple[int | None, int | None]] = {i: (d, s) for (i, d, s) in non_base}
n_with_parents = len(parent_of)
rec_ids = list(phenotypes.keys())
m = len(rec_ids)
if m < 2:
raise ValueError("有效表型个体不足 2 个,无法估计方差组分")
A, Ainv = _build_ainv(order, n_base, parent_of)
X = _build_design(rec_ids, fixed, covariate)
p = X.shape[1]
n_fixed = p - 1
y = np.array([float(phenotypes[i]) for i in rec_ids], dtype=float)
Z1 = np.zeros((m, n))
for k, rid in enumerate(rec_ids):
Z1[k, idx[record_map.get(rid, rid)]] = 1.0
pairs: dict[str, int] = {}
pair_list: list[str] = []
for rid in rec_ids:
key = str(block_of[rid])
if key not in pairs:
pairs[key] = len(pair_list)
pair_list.append(key)
q = len(pair_list)
if q < 1:
raise ValueError("无区组效应水平")
Z2 = np.zeros((m, q))
for k, rid in enumerate(rec_ids):
Z2[k, pairs[str(block_of[rid])]] = 1.0
XtX, Xty = X.T @ X, X.T @ y
Z1tZ1, Z1ty = Z1.T @ Z1, Z1.T @ y
Z2tZ2, Z2ty = Z2.T @ Z2, Z2.T @ y
XtZ1, XtZ2, Z1tZ2 = X.T @ Z1, X.T @ Z2, Z1.T @ Z2
def _mme(la: float, lg: float) -> tuple[np.ndarray, np.ndarray]:
big = np.zeros((p + n + q, p + n + q))
big[:p, :p] = XtX
big[:p, p:p + n] = XtZ1
big[:p, p + n:] = XtZ2
big[p:p + n, :p] = XtZ1.T
big[p:p + n, p:p + n] = Z1tZ1 + la * Ainv
big[p:p + n, p + n:] = Z1tZ2
big[p + n:, :p] = XtZ2.T
big[p + n:, p:p + n] = Z1tZ2.T
big[p + n:, p + n:] = Z2tZ2 + lg * np.eye(q)
rhs = np.concatenate([Xty, Z1ty, Z2ty])
try:
sol = np.linalg.solve(big, rhs)
big_inv = np.linalg.inv(big)
except np.linalg.LinAlgError:
sol = np.linalg.pinv(big) @ rhs
big_inv = np.linalg.pinv(big)
return sol, big_inv
rank_x = max(np.linalg.matrix_rank(X), 1)
df = max(m - rank_x, 1)
def _profile(ra: float, rb: float) -> tuple[float, float]:
"""固定比率 (ra,rb)=(σ²a/σ²e, σ²block/σ²e),剖面出 σ²e,返回 (REML LL, σ²e)。"""
V0 = (Z1 @ A @ Z1.T) * ra + (Z2 @ Z2.T) * rb + np.eye(m)
VinvX = np.linalg.solve(V0, X)
Vinvy = np.linalg.solve(V0, y)
XtVinvX = X.T @ VinvX
XtVinvY = X.T @ Vinvy
yP0y = float(y @ Vinvy) - float(XtVinvY @ np.linalg.solve(XtVinvX, XtVinvY))
_, lv = np.linalg.slogdet(V0)
_, lx = np.linalg.slogdet(XtVinvX)
ve = max(yP0y / df, _FLOOR)
ll = -0.5 * (df * np.log(ve) + float(lv) + float(lx))
return ll, ve
RA_LO, RA_HI = np.log10(1e-3), np.log10(1e3)
best = {"ll": -np.inf, "ra": 1.0, "rb": 1.0, "ve": 1.0}
ra_l, rb_l = 0.0, 0.0 # 起始比率=1(等方差)
def _track(ra: float, rb: float) -> float:
ll, ve = _profile(ra, rb)
if ll > best["ll"]:
best.update(ll=ll, ra=ra, rb=rb, ve=ve)
return ll
def _f_ra(lra: float) -> float:
return _track(10.0 ** lra, 10.0 ** rb_l)
def _f_rb(lrb: float) -> float:
return _track(10.0 ** ra_l, 10.0 ** lrb)
converged = False
n_iter = 0
prev_best = -np.inf
for _ in range(MAX_GXE_ITERS):
n_iter += 1
new_ra_l, _ = _golden_max(_f_ra, RA_LO, RA_HI, tol=1e-3)
ra_l = new_ra_l
new_rb_l, _ = _golden_max(_f_rb, RA_LO, RA_HI, tol=1e-3)
rb_l = new_rb_l
if best["ll"] - prev_best < 1e-2:
converged = True
break
prev_best = best["ll"]
_track(10.0 ** ra_l, 10.0 ** rb_l)
ra, rb, ve = best["ra"], best["rb"], best["ve"]
va, vb = ra * ve, rb * ve
la, lb = 1.0 / ra, 1.0 / rb
sol, big_inv = _mme(la, lb)
u, v = sol[p:p + n], sol[p + n:]
C22 = big_inv[p:p + n, p:p + n]
ebv = {ind: float(u[idx[ind]]) for ind in order}
pev = np.diag(C22) * ve
rel = {ind: float(np.clip(1.0 - pev[idx[ind]] / va, 0.0, 1.0)) for ind in order}
block_eff = {f"b{pair_list[i]}": float(v[i]) for i in range(q)}
total = va + vb + ve
h2 = va / total if total > 0 else None
block_ratio = vb / total if total > 0 else None
warning = None
if not converged:
warning = f"剖面 REML 坐标上升未完全收敛({factor_label} 似然面可能极平/边界最优),结果采用已探明最优。"
if n_with_parents == 0:
w = "无系谱信息:σ²a 与 σ²block 的遗传学解释受限(EBV 无加性依据)。"
warning = w if warning is None else f"{warning} {w}"
if vb <= _FLOOR * 1.01:
w = f"{factor_label} 方差分量收敛到 0(数据不支持{factor_name}随机效应)。"
warning = w if warning is None else f"{warning} {w}"
return {
"ebv": ebv,
"reliability": rel,
"h2": float(h2) if h2 is not None else None,
"sigma_a": float(va),
"sigma_e": float(ve),
"sigma_block": float(vb),
"block_ratio": float(block_ratio) if block_ratio is not None else None,
"block_effects": block_eff,
"n_blocks": q,
"n_obs": m,
"n_individuals": n,
"n_base": n_base,
"n_with_parents": n_with_parents,
"n_fixed": n_fixed,
"converged": converged,
"n_iter": n_iter,
"solver": "dense",
"warning": warning,
}
def kfold_cv(pedigree: list[dict], phenotypes: dict, *, k: int = 5,
fixed: dict | None = None, covariate: dict | None = None,
gxe: dict | None = None, record_map: dict | None = None,
tol: float = TOL, seed: int = 20260804) -> dict:
"""k 折交叉验证(外部验证:预测准确度,区别于 PEV 可靠性)。
折划分按**加性个体**分层:优先按父本(sire)家系分组留出(同一家系不跨训练/测试双折,
避免同家系信息泄漏导致精度高估);无 sire 的个体自成一家系(单个体);家系用固定种子
shuffle 后 round-robin 分配到 k 折。gxe 分支同一个体多环境记录一起留出。
每折:训练子集 re-estimate 方差组分(solve 复用)→ 留出个体 EBV 由系谱预测
(个体保留在 A 矩阵,仅掩蔽其表型)→ pearson(EBV, 观测表型均值) + RMSE。
单折失败(训练不足 / G×E 结构不可辨识)→ 该折 pearson=None 并累计 warning,不中断整体。
参数: 同 solve()phenotypes 键为记录/个体 idgxe 分支按 record_map 归一到个体。
返回:
k / n_total / n_individuals: int
folds: [{fold, n_train, n_test, n_eval, pearson, rmse, error?, h2?}]
mean_pearson / mean_rmse: 有效折均值;pooled_pearson / pooled_rmse: 合并全部
(pred, obs) 对计算;cv_accuracy: 预测准确度(=mean_pearson);
h2: 有效折均值;warning: str | None(失败折汇总)。
"""
if gxe is not None:
ind_of = {rid: record_map.get(rid, rid) for rid in phenotypes}
else:
ind_of = {ind: ind for ind in phenotypes}
individuals = sorted({v for v in ind_of.values()})
n_total = len(phenotypes)
n_individuals = len(individuals)
if n_individuals < 2 or n_total < 2:
return {
"k": k, "n_total": n_total, "n_individuals": n_individuals,
"folds": [], "mean_pearson": None, "mean_rmse": None,
"pooled_pearson": None, "pooled_rmse": None, "cv_accuracy": None,
"h2": None, "warning": "有效个体或记录不足 2,无法交叉验证",
}
k = max(2, min(int(k or 5), 10))
sire_of: dict = {}
for rec in pedigree:
if rec.get("sire") is not None:
sire_of.setdefault(rec["individual"], rec["sire"])
fam_of = {ind: sire_of.get(ind, ("self", ind)) for ind in individuals}
fams = sorted({f for f in fam_of.values()})
rng = random.Random(seed)
rng.shuffle(fams)
fam_bin = {f: i % k for i, f in enumerate(fams)}
ind_bin = {ind: fam_bin[fam_of[ind]] for ind in individuals}
def _subset(bin_idx: int) -> tuple[dict, dict, set[str], set[str]]:
test_inds = {ind for ind, b in ind_bin.items() if b == bin_idx}
train_inds = set(individuals) - test_inds
train_p, test_p = {}, {}
for key, ind in ind_of.items():
if ind in train_inds:
train_p[key] = phenotypes[key]
else:
test_p[key] = phenotypes[key]
return train_p, test_p, train_inds, test_inds
folds: list[dict] = []
all_x: list[float] = []
all_y: list[float] = []
for fold in range(k):
train_p, test_p, train_inds, test_inds = _subset(fold)
if not test_inds or len(train_inds) < 2:
folds.append({
"fold": fold + 1, "n_train": len(train_p), "n_test": len(test_inds),
"n_eval": 0, "pearson": None, "rmse": None,
"error": "训练集个体不足 2 或测试集为空",
})
continue
try:
res = solve(pedigree, train_p, tol=tol, fixed=fixed, covariate=covariate,
gxe=gxe, record_map=record_map)
except Exception as e: # noqa: BLE001
folds.append({
"fold": fold + 1, "n_train": len(train_p), "n_test": len(test_inds),
"n_eval": 0, "pearson": None, "rmse": None,
"error": f"求解失败: {e!s}",
})
continue
ebv = res.get("ebv", {})
pred: dict[str, list[float]] = {}
obs: dict[str, list[float]] = {}
for key, v in test_p.items():
ind = ind_of[key]
pv = ebv.get(ind)
if pv is None:
continue
pred.setdefault(ind, []).append(float(pv))
obs.setdefault(ind, []).append(float(v))
xs = [sum(pred[i]) / len(pred[i]) for i in pred]
ys = [sum(obs[i]) / len(obs[i]) for i in obs]
pearson = _pearson(xs, ys) if len(xs) >= 2 else 0.0
rmse = (math.sqrt(sum((a - b) ** 2 for a, b in zip(xs, ys)) / len(xs))
if xs else None)
if len(xs) >= 2:
all_x.extend(xs)
all_y.extend(ys)
folds.append({
"fold": fold + 1, "n_train": len(train_p), "n_test": len(test_inds),
"n_eval": len(xs), "pearson": round(pearson, 4) if xs else None,
"rmse": round(rmse, 6) if rmse is not None else None,
"h2": float(res["h2"]) if res.get("h2") is not None else None,
})
valid = [f for f in folds if f.get("pearson") is not None]
mean_pearson = round(sum(f["pearson"] for f in valid) / len(valid), 4) if valid else None
rmse_vals = [f["rmse"] for f in valid if f.get("rmse") is not None]
mean_rmse = round(sum(rmse_vals) / len(rmse_vals), 6) if rmse_vals else None
pooled_pearson = round(_pearson(all_x, all_y), 4) if len(all_x) >= 2 else None
pooled_rmse = (round(math.sqrt(sum((a - b) ** 2 for a, b in zip(all_x, all_y)) / len(all_x)), 6)
if len(all_x) else None)
h2_vals = [f["h2"] for f in valid if f.get("h2") is not None]
h2_mean = round(sum(h2_vals) / len(h2_vals), 4) if h2_vals else None
errors = [f.get("error") for f in folds if f.get("error")]
warning = f"{len(errors)} 折失败: " + "".join(errors) if errors else None
return {
"k": k, "n_total": n_total, "n_individuals": n_individuals,
"folds": folds,
"mean_pearson": mean_pearson, "mean_rmse": mean_rmse,
"pooled_pearson": pooled_pearson, "pooled_rmse": pooled_rmse,
"cv_accuracy": mean_pearson, "h2": h2_mean, "warning": warning,
}