G2PInsight 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ """
2
+ assoG2P - Genome-wide Association Analysis Toolkit
3
+
4
+ A comprehensive toolkit for Genome-Wide Association Studies (GWAS)
5
+ with machine learning integration.
6
+ """
7
+
8
+ __version__ = "1.0.0"
9
+ __author__ = "chenrf"
10
+ __email__ = "12024128035@stu.ynu.edu.cn"
File without changes
@@ -0,0 +1,135 @@
1
+ """
2
+ 字体检测和设置工具
3
+ 自动检测系统中可用的中英文字体,并设置为matplotlib和plotly的默认字体
4
+ """
5
+
6
+ import logging
7
+ import platform
8
+ from typing import Optional, Tuple
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ def detect_available_fonts() -> Tuple[Optional[str], Optional[str]]:
13
+ """
14
+ 检测系统中可用的中英文字体
15
+
16
+ 使用matplotlib.font_manager.FontManager获取所有可用字体,
17
+ 然后按照指定顺序检测中文字体。
18
+
19
+ Returns:
20
+ (chinese_font, english_font): 中文字体和英文字体名称
21
+ """
22
+ try:
23
+ from matplotlib.font_manager import FontManager
24
+
25
+ # 获取所有可用字体列表
26
+ mpl_fonts = set(f.name for f in FontManager().ttflist)
27
+
28
+ # 中文字体候选列表(按优先级顺序)
29
+ chinese_font_candidates = [
30
+ 'SimHei', # 黑体
31
+ 'SimSun', # 宋体
32
+ 'Microsoft YaHei', # 微软雅黑
33
+ 'KaiTi', # 楷体
34
+ 'FangSong', # 仿宋
35
+ 'STSong', # 华文宋体
36
+ 'STKaiti', # 华文楷体
37
+ ]
38
+
39
+ # 英文字体候选列表(按优先级)
40
+ english_fonts = [
41
+ 'Arial', 'DejaVu Sans', 'Liberation Sans',
42
+ 'Helvetica', 'Times New Roman', 'Calibri'
43
+ ]
44
+
45
+ # 按照指定顺序检测中文字体
46
+ chinese_font = None
47
+ for font_name in chinese_font_candidates:
48
+ if font_name in mpl_fonts:
49
+ chinese_font = font_name
50
+ break
51
+
52
+ # 检测英文字体
53
+ english_font = None
54
+ for font_name in english_fonts:
55
+ if font_name in mpl_fonts:
56
+ english_font = font_name
57
+ break
58
+
59
+ # 如果没有找到,使用默认字体
60
+ if not chinese_font:
61
+ chinese_font = 'DejaVu Sans' # matplotlib默认字体,支持基本字符
62
+ if not english_font:
63
+ english_font = 'DejaVu Sans'
64
+
65
+ return chinese_font, english_font
66
+
67
+ except Exception as e:
68
+ return 'DejaVu Sans', 'DejaVu Sans'
69
+
70
+ def setup_matplotlib_font(chinese_font: Optional[str] = None, english_font: Optional[str] = None) -> None:
71
+ """
72
+ 设置matplotlib的默认字体
73
+
74
+ Args:
75
+ chinese_font: 中文字体名称
76
+ english_font: 英文字体名称
77
+ """
78
+ try:
79
+ import matplotlib
80
+ matplotlib.use('Agg') # 使用非交互式后端
81
+ import matplotlib.pyplot as plt
82
+ import matplotlib.font_manager as fm
83
+
84
+ # 如果没有指定,自动检测
85
+ if chinese_font is None or english_font is None:
86
+ chinese_font, english_font = detect_available_fonts()
87
+
88
+ # 优先使用中文字体(通常也支持英文)
89
+ default_font = chinese_font if chinese_font else english_font
90
+
91
+ # 设置matplotlib默认字体
92
+ plt.rcParams['font.sans-serif'] = [default_font, english_font, 'DejaVu Sans']
93
+ plt.rcParams['axes.unicode_minus'] = False # 解决负号显示问题
94
+
95
+
96
+ except Exception as e:
97
+ pass
98
+
99
+ def setup_plotly_font(chinese_font: Optional[str] = None, english_font: Optional[str] = None) -> dict:
100
+ """
101
+ 设置plotly的默认字体配置
102
+
103
+ Args:
104
+ chinese_font: 中文字体名称
105
+ english_font: 英文字体名称
106
+
107
+ Returns:
108
+ plotly字体配置字典
109
+ """
110
+ try:
111
+ # 如果没有指定,自动检测
112
+ if chinese_font is None or english_font is None:
113
+ chinese_font, english_font = detect_available_fonts()
114
+
115
+ # 优先使用中文字体
116
+ default_font = chinese_font if chinese_font else english_font
117
+
118
+ # plotly字体配置
119
+ font_config = {
120
+ 'family': default_font,
121
+ 'size': 12
122
+ }
123
+
124
+
125
+ return font_config
126
+
127
+ except Exception as e:
128
+ return {'family': 'Arial', 'size': 12}
129
+
130
+ # 在模块导入时自动设置字体
131
+ try:
132
+ chinese_font, english_font = detect_available_fonts()
133
+ setup_matplotlib_font(chinese_font, english_font)
134
+ except Exception:
135
+ pass # 静默处理字体设置失败
@@ -0,0 +1,402 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ GEMMA GWAS模块 - 严格匹配shell命令逻辑
4
+ 固定输入格式:PLINK二进制文件(.bed/.bim/.fam)
5
+ """
6
+
7
+ import subprocess
8
+ import logging
9
+ import os
10
+ import sys
11
+ import shutil
12
+ import pandas as pd
13
+ from pathlib import Path
14
+ from typing import Optional, Tuple
15
+
16
+ # ======================== 基础配置 =========================
17
+ # 初始化基础日志(仅控制台输出)
18
+ logging.basicConfig(
19
+ level=logging.INFO,
20
+ format='%(asctime)s - %(levelname)s - %(message)s'
21
+ )
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # 软件路径:优先使用项目内集成的软件,其次回退到系统PATH中的可执行文件(standardized)
25
+ bin_dir = Path(__file__).parent
26
+ software_dir = bin_dir / "software"
27
+ _bundled_plink = software_dir / "plink"
28
+ _bundled_gemma = software_dir / "gemma-0.98.5-linux-static-AMD64"
29
+
30
+
31
+ def _get_plink_executable() -> str:
32
+ """
33
+ 获取PLINK可执行路径(standardized)
34
+ 优先顺序:
35
+ 1) 项目内 software 目录下的集成版本
36
+ 2) 系统PATH中的 plink
37
+ """
38
+ if _bundled_plink.exists():
39
+ return str(_bundled_plink)
40
+ found = shutil.which("plink")
41
+ if found:
42
+ logger.info(f"Using system PLINK executable from PATH: {found}")
43
+ return found
44
+ raise FileNotFoundError(
45
+ f"PLINK executable not found. "
46
+ f"Checked bundled path: {_bundled_plink} and system PATH."
47
+ )
48
+
49
+
50
+ def _get_gemma_executable() -> str:
51
+ """
52
+ 获取GEMMA可执行路径(standardized)
53
+ 优先顺序:
54
+ 1) 项目内 software 目录下的集成版本
55
+ 2) 系统PATH中的 gemma
56
+ """
57
+ if _bundled_gemma.exists():
58
+ return str(_bundled_gemma)
59
+ # GEMMA 在PATH中的命令名可能为 gemma,这里做一次简单尝试
60
+ found = shutil.which("gemma")
61
+ if found:
62
+ logger.info(f"Using system GEMMA executable from PATH: {found}")
63
+ return found
64
+ raise FileNotFoundError(
65
+ f"GEMMA executable not found. "
66
+ f"Checked bundled path: {_bundled_gemma} and system PATH."
67
+ )
68
+
69
+ # 初始化可执行文件路径(在模块加载时执行,standardized)
70
+ plink_executable = _get_plink_executable()
71
+ gemma_executable = _get_gemma_executable()
72
+
73
+ # 固定输入格式要求
74
+ REQUIRED_INPUT_FILES = {
75
+ "bed": ".bed",
76
+ "bim": ".bim",
77
+ "fam": ".fam"
78
+ }
79
+
80
+ # ======================== 参数合法性校验 =========================
81
+ def validate_input_files(input_prefix: str) -> None:
82
+ """验证PLINK二进制文件完整性"""
83
+ if not isinstance(input_prefix, str) or not input_prefix.strip():
84
+ raise ValueError("input_prefix must be a non-empty string")
85
+
86
+ missing_files = []
87
+ for file_type, suffix in REQUIRED_INPUT_FILES.items():
88
+ file_path = f"{input_prefix}{suffix}"
89
+ if not Path(file_path).exists():
90
+ missing_files.append(file_path)
91
+
92
+ if missing_files:
93
+ raise FileNotFoundError(
94
+ f"缺失PLINK二进制文件:{', '.join(missing_files)}"
95
+ )
96
+ logger.info(f"Input file validation passed: {input_prefix}")
97
+
98
+ def validate_phenotype_file(phenotype_file: str) -> pd.DataFrame:
99
+ """验证表型文件存在性,自动读取前两列并打印前5行"""
100
+ if not isinstance(phenotype_file, str) or not phenotype_file.strip():
101
+ raise ValueError("phenotype_file must be a non-empty string")
102
+ if not Path(phenotype_file).exists():
103
+ raise FileNotFoundError(f"Phenotype file not found: {phenotype_file}")
104
+
105
+ # 读取表型文件(仅取前两列)
106
+ try:
107
+ # usecols=[0,1] 强制读取前两列,skipinitialspace=True 忽略列前空格
108
+ pheno_df = pd.read_csv(
109
+ phenotype_file,
110
+ sep=r'\s+',
111
+ header=None,
112
+ usecols=[0, 1], # 强制仅读取前两列
113
+ skipinitialspace=True
114
+ )
115
+ # 重命名列
116
+ pheno_df.columns = ['IID', 'PHENO']
117
+
118
+ # 校验前两列是否为空
119
+ if pheno_df['IID'].isnull().any() or pheno_df['PHENO'].isnull().any():
120
+ logger.warning("The first two columns in the phenotype file contain missing values")
121
+
122
+ return pheno_df
123
+ except Exception as e:
124
+ raise RuntimeError(f"Failed to read phenotype file: {str(e)}")
125
+
126
+ # ======================== 核心执行函数 =========================
127
+ def run_shell_command(cmd: list, step_name: str) -> None:
128
+ """执行shell命令(仅保留核心逻辑)"""
129
+ cmd_str = " ".join(cmd)
130
+ logger.info(f"[RUN] {step_name}: {cmd_str}")
131
+
132
+ result = subprocess.run(
133
+ cmd,
134
+ stdout=subprocess.PIPE,
135
+ stderr=subprocess.PIPE,
136
+ text=True,
137
+ encoding='utf-8'
138
+ )
139
+
140
+ if result.stdout:
141
+ # 标准输出通常信息量较大,仅在调试时完整查看
142
+ pass
143
+
144
+ # 处理标准错误输出:区分真正的错误和信息性消息,并过滤掉GEMMA的进度条
145
+ stderr_content = ""
146
+ if result.stderr:
147
+ stderr_content = result.stderr[:1000]
148
+ # GEMMA 在stderr中用类似“空格 + = + 百分比”的形式打印进度条,
149
+ # 直接记录到日志会变成多行噪声,这里先按行过滤掉这类仅含进度条的行。
150
+ lines = stderr_content.splitlines()
151
+ filtered_lines = []
152
+ for line in lines:
153
+ # 去掉左右空白后,如果只剩下 0-9、% 和 =,视为进度条行,丢弃
154
+ stripped = line.strip()
155
+ if stripped and all(ch in "=%0123456789" for ch in stripped):
156
+ continue
157
+ filtered_lines.append(line)
158
+ stderr_content = "\n".join(filtered_lines).strip()
159
+
160
+ if stderr_content:
161
+ # GEMMA 会将信息性消息(如 "**** INFO: Done.")输出到 stderr
162
+ # 如果命令成功执行(returncode == 0),且 stderr 中只包含 INFO/Done 等关键词,则记录为 info 级别
163
+ if result.returncode == 0:
164
+ # 检查是否包含真正的错误关键词
165
+ error_keywords = ['ERROR', 'FATAL', 'FAIL', 'Exception', 'Traceback']
166
+ if any(keyword in stderr_content.upper() for keyword in error_keywords):
167
+ logger.warning(f"stderr (potential issue): {stderr_content}")
168
+ else:
169
+ # 信息性消息,记录为 info 级别
170
+ logger.info(f"stderr (informational): {stderr_content}")
171
+ else:
172
+ # 命令失败,stderr 肯定是错误
173
+ logger.error(f"stderr: {stderr_content}")
174
+
175
+ if result.returncode != 0:
176
+ raise RuntimeError(f"{step_name} failed with return code: {result.returncode}")
177
+ logger.info(f"[DONE] {step_name}")
178
+
179
+ def plink_quality_control(input_prefix: str, output_prefix: str) -> None:
180
+ """PLINK质控(geno 0.2、maf 0.05)"""
181
+ cmd = [
182
+ plink_executable,
183
+ "--bfile", input_prefix,
184
+ "--maf", "0.05",
185
+ "--geno", "0.2",
186
+ "--allow-extra-chr",
187
+ "--allow-no-sex",
188
+ "--make-bed",
189
+ "--out", output_prefix
190
+ ]
191
+ run_shell_command(cmd, "PLINK quality control")
192
+
193
+ def merge_phenotype_to_fam(pheno_df: pd.DataFrame, fam_file: str) -> None:
194
+ """表型匹配到FAM文件(使用预处理好的前两列表型数据)"""
195
+ # 备份原始fam文件
196
+ bak_fam = f"{fam_file}.bak"
197
+ if not Path(bak_fam).exists():
198
+ cmd = ["mv", fam_file, bak_fam]
199
+ run_shell_command(cmd, "Backup FAM file")
200
+
201
+ # 构建表型映射(IID转字符串避免类型不匹配)
202
+ pheno_dict = dict(zip(
203
+ pheno_df['IID'].astype(str).str.strip(),
204
+ pheno_df['PHENO'].astype(str).str.strip()
205
+ ))
206
+
207
+ # 读取并替换FAM第六列
208
+ fam_df = pd.read_csv(
209
+ bak_fam,
210
+ sep=r'\s+',
211
+ header=None,
212
+ names=['FID', 'IID', 'PAT', 'MAT', 'SEX', 'OLD_PHENO']
213
+ )
214
+ # IID转字符串并去空格,避免匹配失败
215
+ fam_df['IID'] = fam_df['IID'].astype(str).str.strip()
216
+ # 替换表型,缺失填-9
217
+ fam_df['PHENO'] = fam_df['IID'].map(pheno_dict).fillna("-9")
218
+
219
+ # 保存新FAM文件
220
+ new_fam_df = fam_df[['FID', 'IID', 'PAT', 'MAT', 'SEX', 'PHENO']]
221
+ new_fam_df.to_csv(fam_file, sep=' ', header=False, index=False)
222
+
223
+ # 基础统计
224
+ matched = (fam_df['PHENO'] != "-9").sum()
225
+ total = len(fam_df)
226
+ logger.info(f"Phenotype matching completed: {matched}/{total} samples matched (-9 used for missing phenotypes)")
227
+
228
+ def calculate_pca(input_prefix: str, output_prefix: str) -> None:
229
+ """计算PCA(前5个主成分)"""
230
+ cmd = [
231
+ plink_executable,
232
+ "--bfile", input_prefix,
233
+ "--pca", "5",
234
+ "--allow-extra-chr",
235
+ "--allow-no-sex",
236
+ "--out", output_prefix
237
+ ]
238
+ run_shell_command(cmd, "PCA computation")
239
+
240
+ def filter_valid_samples(fam_file: str, output_sample_file: str) -> None:
241
+ """筛选有效表型样本(排除-9)"""
242
+ fam_df = pd.read_csv(
243
+ fam_file,
244
+ sep=r'\s+',
245
+ header=None,
246
+ names=['FID', 'IID', 'PAT', 'MAT', 'SEX', 'PHENO']
247
+ )
248
+ valid_df = fam_df[fam_df['PHENO'] != "-9"][['FID', 'IID']]
249
+ valid_df.to_csv(output_sample_file, sep=' ', header=False, index=False)
250
+ logger.info(f"Valid samples saved to: {output_sample_file} (n={len(valid_df)})")
251
+
252
+ def filter_plink_samples(input_prefix: str, sample_file: str, output_prefix: str) -> None:
253
+ """过滤PLINK样本(仅保留有效样本)"""
254
+ cmd = [
255
+ plink_executable,
256
+ "--bfile", input_prefix,
257
+ "--keep", sample_file,
258
+ "--allow-extra-chr",
259
+ "--allow-no-sex",
260
+ "--make-bed",
261
+ "--out", output_prefix
262
+ ]
263
+ run_shell_command(cmd, "PLINK sample filtering")
264
+
265
+ def filter_pca_by_samples(pca_file: str, sample_file: str, output_pca_file: str) -> None:
266
+ """过滤PCA结果(仅保留有效样本)"""
267
+ # 读取有效样本
268
+ valid_df = pd.read_csv(
269
+ sample_file,
270
+ sep=r'\s+',
271
+ header=None,
272
+ names=['FID', 'IID']
273
+ )
274
+ valid_set = set(zip(
275
+ valid_df['FID'].astype(str).str.strip(),
276
+ valid_df['IID'].astype(str).str.strip()
277
+ ))
278
+
279
+ # 过滤PCA文件
280
+ pca_df = pd.read_csv(pca_file, sep=r'\s+', header=None)
281
+ pca_df['key'] = list(zip(
282
+ pca_df[0].astype(str).str.strip(),
283
+ pca_df[1].astype(str).str.strip()
284
+ ))
285
+ filtered_df = pca_df[pca_df['key'].isin(valid_set)].drop('key', axis=1)
286
+ filtered_df.to_csv(output_pca_file, sep=' ', header=False, index=False)
287
+ logger.info(f"Filtered PCA saved to: {output_pca_file} (n={len(filtered_df)} samples)")
288
+
289
+ def extract_pca_covariates(pca_file: str, output_cov_file: str) -> None:
290
+ """提取PCA协变量(仅保留主成分数值)"""
291
+ pca_df = pd.read_csv(pca_file, sep=r'\s+', header=None)
292
+ cov_df = pca_df.iloc[:, 2:] # 跳过前2列(FID/IID)
293
+ cov_df.to_csv(output_cov_file, sep=' ', header=False, index=False)
294
+ logger.info(f"PCA covariates saved to: {output_cov_file} (PCs={cov_df.shape[1]})")
295
+
296
+ def calculate_kinship_matrix(genotype_prefix: str, output_prefix: str) -> str:
297
+ """计算亲缘关系矩阵(移除-p参数)"""
298
+ base_prefix = Path(output_prefix).name
299
+ kinship_prefix = f"{base_prefix}_kinship"
300
+ cmd = [
301
+ gemma_executable,
302
+ "-bfile", genotype_prefix,
303
+ "-gk", "1",
304
+ "-o", kinship_prefix
305
+ ]
306
+ run_shell_command(cmd, "Kinship matrix computation")
307
+
308
+ # 返回kinship文件路径
309
+ kinship_file = Path("output") / f"{kinship_prefix}.cXX.txt"
310
+ if not kinship_file.exists():
311
+ raise FileNotFoundError(f"Kinship matrix file was not generated: {kinship_file}")
312
+ return kinship_file.absolute().as_posix()
313
+
314
+ def run_gemma_gwas(genotype_prefix: str, kinship_file: str, cov_file: str, output_prefix: str) -> None:
315
+ """运行GWAS分析(移除-p参数)"""
316
+ # 使用与亲缘矩阵相同的策略:仅使用前缀的 basename,避免在 GEMMA 的 -o 参数中出现绝对路径
317
+ # 注意:GEMMA 的 -o 参数是相对于当前工作目录的,所以使用相对路径
318
+ # GEMMA 会在当前工作目录下创建 output/ 目录,并在其中生成结果文件
319
+ base_prefix = Path(output_prefix).name
320
+ gwas_prefix = f"{base_prefix}_gwas"
321
+
322
+ # 记录当前工作目录,用于调试
323
+ current_dir = os.getcwd()
324
+ logger.info(f"GWAS working directory: {current_dir}")
325
+ logger.info(f"GWAS output prefix: {gwas_prefix} (results expected under {current_dir}/output/)")
326
+
327
+ cmd = [
328
+ gemma_executable,
329
+ "-bfile", genotype_prefix,
330
+ "-k", kinship_file,
331
+ "-c", cov_file,
332
+ "-lmm", "1",
333
+ "-o", gwas_prefix
334
+ ]
335
+ run_shell_command(cmd, "GWAS association analysis")
336
+
337
+ # 验证结果文件是否生成
338
+ expected_result_file = Path(current_dir) / "output" / f"{gwas_prefix}.assoc.txt"
339
+ if expected_result_file.exists():
340
+ logger.info(f"GWAS result file generated: {expected_result_file}")
341
+ else:
342
+ logger.warning(f"GWAS result file not found at expected path: {expected_result_file}")
343
+
344
+ def run_complete_gwas_pipeline(
345
+ input_plink_prefix: str,
346
+ phenotype_file: str,
347
+ output_prefix: str,
348
+ perform_plink_qc: bool = True,
349
+ ) -> None:
350
+ """完整GWAS流程
351
+
352
+ Args:
353
+ input_plink_prefix: 输入PLINK前缀
354
+ phenotype_file: 表型文件路径(两列:IID PHENO)
355
+ output_prefix: 输出前缀(可包含路径)
356
+ perform_plink_qc: 是否在GWAS前执行PLINK质控(--maf 0.05, --geno 0.2)
357
+ """
358
+ # 输入校验
359
+ validate_input_files(input_plink_prefix)
360
+ # 读取表型文件(自动取前两列+打印前5行)
361
+ pheno_df = validate_phenotype_file(phenotype_file)
362
+
363
+ # 1. PLINK质控(可选)
364
+ if perform_plink_qc:
365
+ clean_geno_prefix = f"{output_prefix}_clean_geno"
366
+ plink_quality_control(input_plink_prefix, clean_geno_prefix)
367
+ else:
368
+ logger.info("Skipping PLINK QC in GWAS step based on upstream configuration")
369
+ clean_geno_prefix = input_plink_prefix
370
+
371
+ # 2. 表型匹配到FAM(使用预处理的表型数据)
372
+ clean_fam_file = f"{clean_geno_prefix}.fam"
373
+ merge_phenotype_to_fam(pheno_df, clean_fam_file)
374
+
375
+ # 3. 计算PCA
376
+ pca_prefix = f"{output_prefix}_geno_pca"
377
+ calculate_pca(clean_geno_prefix, pca_prefix)
378
+
379
+ # 4. 筛选有效样本
380
+ valid_sample_file = f"{output_prefix}_valid_samples.txt"
381
+ filter_valid_samples(clean_fam_file, valid_sample_file)
382
+
383
+ # 5. 过滤PLINK样本
384
+ filtered_geno_prefix = f"{output_prefix}_clean_geno_filtered"
385
+ filter_plink_samples(clean_geno_prefix, valid_sample_file, filtered_geno_prefix)
386
+
387
+ # 6. 过滤PCA(从过滤后样本提取协变量)
388
+ pca_eigenvec_file = f"{pca_prefix}.eigenvec"
389
+ filtered_pca_file = f"{output_prefix}_geno_pca_filtered.eigenvec"
390
+ filter_pca_by_samples(pca_eigenvec_file, valid_sample_file, filtered_pca_file)
391
+
392
+ # 7. 提取PCA协变量
393
+ cov_file = f"{output_prefix}_covariates_filtered.txt"
394
+ extract_pca_covariates(filtered_pca_file, cov_file)
395
+
396
+ # 8. 计算亲缘矩阵(无-p参数)
397
+ kinship_file = calculate_kinship_matrix(filtered_geno_prefix, output_prefix)
398
+
399
+ # 9. 运行GWAS(无-p参数)
400
+ run_gemma_gwas(filtered_geno_prefix, kinship_file, cov_file, output_prefix)
401
+
402
+ logger.info("GWAS pipeline completed")