|
| 1 | +# pyXenium/analysis/microenv_analysis.py |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | +import os |
| 5 | +import warnings |
| 6 | +import numpy as np |
| 7 | +import pandas as pd |
| 8 | +from scipy.spatial import cKDTree |
| 9 | +from sklearn.mixture import GaussianMixture |
| 10 | +from anndata import AnnData |
| 11 | +from pyXenium.io.xenium_gene_protein_loader import load_xenium_gene_protein |
| 12 | +from pyXenium.analysis import ProteinMicroEnv # 确保依赖 ProteinMicroEnv 类 |
| 13 | +from pyXenium.utils.name_resolver import resolve_protein_column |
| 14 | + |
| 15 | +def analyze_microenvironment( |
| 16 | + mode: str, |
| 17 | + adata: AnnData | None = None, |
| 18 | + base_path: str | None = None, |
| 19 | + output_dir: str | None = None |
| 20 | +) -> AnnData: |
| 21 | + """ |
| 22 | + 执行免疫微环境 ('immune') 或肿瘤-间质边界 ('tumor_border') 分析的统一入口函数。 |
| 23 | +
|
| 24 | + 参数: |
| 25 | + - mode: 分析模式,'immune' 表示免疫微环境分析,'tumor_border' 表示肿瘤与间质边界分析。 |
| 26 | + - adata: AnnData 对象,包含 Xenium 空间转录组+蛋白数据。如果未提供,则需要提供 base_path 以加载数据。 |
| 27 | + - base_path: Xenium 输出数据的根目录路径。当 adata 未提供时,将从该路径下加载数据。 |
| 28 | + - output_dir: 可选。若提供路径,则将在分析完成后把结果表格导出为 CSV 文件保存到此目录。 |
| 29 | +
|
| 30 | + 功能: |
| 31 | + 根据指定模式自动筛选“锚点”细胞,并进行空间邻域分析,包括: |
| 32 | + 1. **锚点筛选**: |
| 33 | + - 模式 'immune': 自动选择免疫细胞作为锚点(优先使用 CD8 标记,如无则使用 CD45)。 |
| 34 | + - 模式 'tumor_border': 自动选择肿瘤细胞作为锚点(优先使用 PanCK 标记,如无则使用 E-Cadherin)。 |
| 35 | + 使用 Gaussian 混合模型 (GMM) 对锚点标记的蛋白表达进行双峰拟合,计算阈值,将高于阈值的细胞判定为锚点。 |
| 36 | + 对于肿瘤边界模式,会进一步筛选出靠近非肿瘤细胞的肿瘤锚点(即真正位于肿瘤-基质交界处的细胞)。 |
| 37 | + 2. **邻域设置**:默认采用同心圆邻域半径 [0, 10, 20, 40] 微米。其中 40μm 视为微环境影响范围。 |
| 38 | + 分析时将锚点周围 40μm 半径内的细胞定义为“邻居”群体,并可根据 0–10, 10–20, 20–40μm 不同距离分层计算。 |
| 39 | + 3. **基因邻域密度与 Fold-change 分析**:比较“邻居”群体与远端细胞群体的基因表达差异。 |
| 40 | + 计算每个基因在邻居细胞中的平均表达密度和在远端细胞中的平均表达密度,并计算二者之比(Fold-change)。 |
| 41 | + 4. **邻居细胞类型统计**:统计每个锚点细胞邻域内特定类型细胞的数量,例如标记 alphaSMA⁺(成纤维细胞)和 CD31⁺(内皮细胞)的邻居数量。 |
| 42 | +
|
| 43 | + 输出: |
| 44 | + 函数直接修改并返回输入的 AnnData: |
| 45 | + - 对于免疫微环境分析: |
| 46 | + - 在 `adata.obs` 新增布尔列 `immune_is_anchor`,标记每个细胞是否被选为免疫锚点。 |
| 47 | + - 在 `adata.obsm` 新增 DataFrame `immune_gene_stats`,存储基因在邻居与远端的平均表达及fold-change(行索引为基因)。 |
| 48 | + - 对于肿瘤边界分析: |
| 49 | + - 在 `adata.obs` 新增布尔列 `tumor_border_is_anchor`,标记每个细胞是否被选为肿瘤边界锚点。 |
| 50 | + - 在 `adata.obsm` 新增 DataFrame `tumor_border_gene_stats`,存储基因在邻居与远端的平均表达及fold-change。 |
| 51 | + - 无论哪种模式,`adata.obs` 将新增列 `neighbors_alphaSMA_count` 与 `neighbors_CD31_count`, |
| 52 | + 其中锚点细胞的该列值为其 40μm 邻域内 alphaSMA⁺ 邻居细胞数和 CD31⁺ 邻居细胞数(非锚点细胞该列为空值)。 |
| 53 | +
|
| 54 | + 此外,如果提供了 output_dir 参数,将把主要结果导出为 CSV 文件: |
| 55 | + - `immune_gene_stats.csv` 或 `tumor_border_gene_stats.csv`:基因邻域分析结果表(含基因名称、邻居平均表达、远端平均表达和fold-change)。 |
| 56 | + - `neighbors_counts.csv`:各锚点细胞的 alphaSMA⁺ 和 CD31⁺ 邻居数量统计表。 |
| 57 | +
|
| 58 | + 注意: |
| 59 | + - 函数对默认参数友好,旨在 Notebook 中快速调用。大部分参数内部自动处理,如坐标获取、阈值选择等无需用户干预。 |
| 60 | + - 实现上兼容不同版本的 ProteinMicroEnv 工具类(方法名和构造参数自动适配),并利用其中的别名解析等功能。 |
| 61 | + """ |
| 62 | + # 参数校验 |
| 63 | + mode = mode.lower() |
| 64 | + if mode not in ("immune", "tumor_border"): |
| 65 | + raise ValueError("mode 必须为 'immune' 或 'tumor_border'") |
| 66 | + |
| 67 | + # 若未提供 adata,则尝试根据 base_path 加载 Xenium 数据 |
| 68 | + if adata is None: |
| 69 | + if base_path is None: |
| 70 | + raise ValueError("adata 未提供时必须指定 base_path") |
| 71 | + try: |
| 72 | + adata = load_xenium_gene_protein(base_path) |
| 73 | + except Exception as e: |
| 74 | + raise RuntimeError(f"数据加载失败: {e}") |
| 75 | + |
| 76 | + # 从 AnnData 获取空间坐标 |
| 77 | + if "spatial" in adata.obsm_keys(): |
| 78 | + coords = np.asarray(adata.obsm["spatial"], dtype=float) |
| 79 | + if coords.ndim != 2 or coords.shape[1] < 2: |
| 80 | + raise ValueError("adata.obsm['spatial'] 应为 shape (n_cells, >=2) 的坐标矩阵") |
| 81 | + coords_xy = coords[:, :2] |
| 82 | + elif {"x_centroid", "y_centroid"}.issubset(adata.obs.columns): |
| 83 | + coords_xy = adata.obs.loc[:, ["x_centroid", "y_centroid"]].to_numpy(dtype=float) |
| 84 | + else: |
| 85 | + raise KeyError("AnnData 中未找到空间坐标信息(既不存在 obsm['spatial'],也没有 obs['x_centroid','y_centroid'] 列)") |
| 86 | + |
| 87 | + # 初始化 KDTree 用于邻居查询 |
| 88 | + tree = cKDTree(coords_xy) |
| 89 | + |
| 90 | + # 标记锚点的布尔掩码数组 |
| 91 | + anchor_mask = np.zeros(adata.n_obs, dtype=bool) |
| 92 | + # 根据模式确定锚点筛选的蛋白标记候选 |
| 93 | + if mode == "immune": |
| 94 | + anchor_markers = ["CD8", "CD45"] # 优先 CD8,如缺失则用 CD45 |
| 95 | + anchor_flag_col = "immune_is_anchor" |
| 96 | + else: # tumor_border |
| 97 | + anchor_markers = ["PanCK", "E-Cadherin"] # 优先 PanCK,如缺失则用 E-Cadherin |
| 98 | + anchor_flag_col = "tumor_border_is_anchor" |
| 99 | + |
| 100 | + # 尝试对每个候选标记进行锚点筛选 |
| 101 | + for marker in anchor_markers: |
| 102 | + try: |
| 103 | + prot_col = resolve_protein_column(adata, marker) |
| 104 | + except KeyError: |
| 105 | + continue # 该标记不在数据中,尝试下一个 |
| 106 | + # 提取该标记的蛋白表达值数组 |
| 107 | + values = adata.obsm["protein"][prot_col].to_numpy(dtype=float) |
| 108 | + finite_vals = values[np.isfinite(values)] |
| 109 | + if finite_vals.size == 0: |
| 110 | + continue |
| 111 | + # 使用双峰高斯混合模型估计阈值;若失败则退而求其次用中位数 |
| 112 | + try: |
| 113 | + gmm = GaussianMixture(n_components=2, random_state=0) |
| 114 | + gmm.fit(finite_vals.reshape(-1, 1)) |
| 115 | + mean1, mean2 = np.sort(gmm.means_.ravel()) |
| 116 | + threshold = float((mean1 + mean2) / 2.0) |
| 117 | + except Exception: |
| 118 | + threshold = float(np.nanquantile(finite_vals, 0.5)) |
| 119 | + # 更新锚点掩码:值大于等于阈值的细胞记为 True |
| 120 | + anchor_mask |= (adata.obsm["protein"][prot_col].to_numpy(dtype=float) >= threshold) |
| 121 | + |
| 122 | + # 如果模式为肿瘤边界,需要进一步筛选出真正位于边界的锚点 |
| 123 | + if mode == "tumor_border": |
| 124 | + if anchor_mask.any(): |
| 125 | + border_mask = np.zeros_like(anchor_mask) |
| 126 | + anchor_indices = np.where(anchor_mask)[0] |
| 127 | + for idx in anchor_indices: |
| 128 | + # 查找该锚点在半径40μm内的邻居 |
| 129 | + neighbor_idxs = tree.query_ball_point(coords_xy[idx], r=40.0) |
| 130 | + if idx in neighbor_idxs: |
| 131 | + neighbor_idxs.remove(idx) |
| 132 | + # 如存在至少一个非锚点邻居,则该锚点位于边界 |
| 133 | + if any(not anchor_mask[j] for j in neighbor_idxs): |
| 134 | + border_mask[idx] = True |
| 135 | + anchor_mask = border_mask # 仅保留边界锚点 |
| 136 | + else: |
| 137 | + warnings.warn("未找到任何候选肿瘤锚点细胞,无法进行边界分析。") |
| 138 | + |
| 139 | + # 将锚点标记写入 AnnData.obs |
| 140 | + adata.obs[anchor_flag_col] = pd.Series(anchor_mask, index=adata.obs_names, dtype=bool) |
| 141 | + |
| 142 | + # 确定邻居细胞和远端细胞集合 |
| 143 | + anchor_indices = np.where(anchor_mask)[0] |
| 144 | + neighbor_set = set() |
| 145 | + for idx in anchor_indices: |
| 146 | + # 获取锚点 idx 在 40μm 半径内的所有邻居索引 |
| 147 | + neighbor_idxs = tree.query_ball_point(coords_xy[idx], r=40.0) |
| 148 | + if idx in neighbor_idxs: |
| 149 | + neighbor_idxs.remove(idx) |
| 150 | + for j in neighbor_idxs: |
| 151 | + # 仅记录非锚点的邻居细胞 |
| 152 | + if j not in anchor_indices: |
| 153 | + neighbor_set.add(j) |
| 154 | + neighbor_indices = np.array(sorted(neighbor_set)) |
| 155 | + far_set = set(range(adata.n_obs)) - neighbor_set - set(anchor_indices) |
| 156 | + far_indices = np.array(sorted(far_set)) |
| 157 | + |
| 158 | + # 若RNA表达矩阵存在,则进行基因邻域差异分析 |
| 159 | + gene_stats_df = None |
| 160 | + if adata.n_vars > 0: |
| 161 | + # 提取基因表达矩阵(使用原始counts层) |
| 162 | + X = adata.layers["rna"] if "rna" in adata.layers else adata.X |
| 163 | + # 计算邻居组和远端组各基因平均表达 |
| 164 | + if neighbor_indices.size > 0: |
| 165 | + neighbor_mean = np.asarray(X[neighbor_indices].mean(axis=0)).ravel() |
| 166 | + else: |
| 167 | + neighbor_mean = np.zeros(adata.n_vars, dtype=float) |
| 168 | + if far_indices.size > 0: |
| 169 | + far_mean = np.asarray(X[far_indices].mean(axis=0)).ravel() |
| 170 | + else: |
| 171 | + far_mean = np.zeros(adata.n_vars, dtype=float) |
| 172 | + # 计算fold-change比值(邻居平均 / 远端平均) |
| 173 | + with np.errstate(divide='ignore', invalid='ignore'): |
| 174 | + fold_change = neighbor_mean / far_mean |
| 175 | + # 将无法计算的情况(如远端均值和邻域均值皆为0)置为 NaN |
| 176 | + fold_change = np.where(np.isfinite(fold_change), fold_change, np.nan) |
| 177 | + # 准备结果 DataFrame:基因名称,邻居均值,远端均值,Fold-change |
| 178 | + gene_names = adata.var.get("name") if "name" in adata.var.columns else adata.var_names |
| 179 | + gene_stats_df = pd.DataFrame({ |
| 180 | + "neighbor_mean": neighbor_mean, |
| 181 | + "far_mean": far_mean, |
| 182 | + "fold_change": fold_change |
| 183 | + }, index=pd.Index(gene_names, name="gene")) |
| 184 | + # 根据 fold-change 大小降序排序(忽略 NaN) |
| 185 | + gene_stats_df.sort_values(by="fold_change", ascending=False, inplace=True, na_position='last') |
| 186 | + # 将结果存入 AnnData.obsm |
| 187 | + result_key = "immune_gene_stats" if mode == "immune" else "tumor_border_gene_stats" |
| 188 | + adata.obsm[result_key] = gene_stats_df |
| 189 | + |
| 190 | + # 统计每个锚点邻域内 alphaSMA+ 和 CD31+ 邻居数量,结果存入 AnnData.obs |
| 191 | + # 初始化计数列为 NaN,以便非锚点保持 NaN |
| 192 | + adata.obs["neighbors_alphaSMA_count"] = np.nan |
| 193 | + adata.obs["neighbors_CD31_count"] = np.nan |
| 194 | + for idx in anchor_indices: |
| 195 | + neighbor_idxs = tree.query_ball_point(coords_xy[idx], r=40.0) |
| 196 | + if idx in neighbor_idxs: |
| 197 | + neighbor_idxs.remove(idx) |
| 198 | + # 统计 alphaSMA 邻居 |
| 199 | + alpha_count = 0 |
| 200 | + try: |
| 201 | + col_alpha = resolve_protein_column(adata, "alphaSMA") |
| 202 | + except KeyError: |
| 203 | + col_alpha = None |
| 204 | + if col_alpha: |
| 205 | + for j in neighbor_idxs: |
| 206 | + # 排除锚点细胞本身,统计邻居中 alphaSMA 蛋白值 >0 的细胞数 |
| 207 | + if not anchor_mask[j]: |
| 208 | + val = float(adata.obsm["protein"].iloc[j][col_alpha]) |
| 209 | + if np.isfinite(val) and val > 0: |
| 210 | + alpha_count += 1 |
| 211 | + # 统计 CD31 邻居 |
| 212 | + cd31_count = 0 |
| 213 | + try: |
| 214 | + col_cd31 = resolve_protein_column(adata, "CD31") |
| 215 | + except KeyError: |
| 216 | + col_cd31 = None |
| 217 | + if col_cd31: |
| 218 | + for j in neighbor_idxs: |
| 219 | + if not anchor_mask[j]: |
| 220 | + val = float(adata.obsm["protein"].iloc[j][col_cd31]) |
| 221 | + if np.isfinite(val) and val > 0: |
| 222 | + cd31_count += 1 |
| 223 | + # 写入该锚点的统计结果 |
| 224 | + cell_name = adata.obs_names[idx] |
| 225 | + adata.obs.at[cell_name, "neighbors_alphaSMA_count"] = alpha_count |
| 226 | + adata.obs.at[cell_name, "neighbors_CD31_count"] = cd31_count |
| 227 | + |
| 228 | + # 如指定了输出目录,则将结果数据表保存为 CSV 文件 |
| 229 | + if output_dir is not None: |
| 230 | + os.makedirs(output_dir, exist_ok=True) |
| 231 | + # 基因差异分析结果 |
| 232 | + if gene_stats_df is not None: |
| 233 | + out_path = os.path.join( |
| 234 | + output_dir, |
| 235 | + "immune_gene_stats.csv" if mode == "immune" else "tumor_border_gene_stats.csv" |
| 236 | + ) |
| 237 | + gene_stats_df.to_csv(out_path) |
| 238 | + # 邻居细胞类型计数结果 |
| 239 | + # 导出所有锚点的 alphaSMA/CD31 邻居计数(只导出锚点行,以免输出大量 NaN) |
| 240 | + anchor_obs = adata.obs.loc[adata.obs[anchor_flag_col] == True, |
| 241 | + ["neighbors_alphaSMA_count", "neighbors_CD31_count"]] |
| 242 | + if not anchor_obs.empty: |
| 243 | + anchor_obs.to_csv(os.path.join(output_dir, "neighbors_counts.csv")) |
| 244 | + |
| 245 | + return adata |
0 commit comments