OSCR

The genetic architecture of fibromyalgia across 2.5 million individuals.

Code ↔ Paper

4 matches between paragraphs of the paper and lines of its authors' code, computed by the harvester (lexical-v1). Click a colored paragraph or line to see its counterpart.

The 4 matches
  1. [1] § Methods › Harmonization and meta-analysis ↔ fibromyalgia_utils.py, lines 3650–3794 · score 0.94 · single nucleotide variants, multiple rs, allele flips, alt allele, minimal representation, alternate alleles
  2. [2] § Methods › Harmonization and meta-analysis ↔ fibromyalgia_utils.py, lines 2453–2519 · score 0.55 · reference allele, alternate allele, harmonization, rs, plink, matched
  3. [3] § Methods › Genotyping, imputation and quality control › Estonian Biobank ↔ fibromyalgia_utils.py, lines 2453–2519 · score 0.55 · PLINK format, allele frequency, match, filtered, chromosome, variants
  4. [4] § Methods › Genotyping, imputation and quality control › All of Us ↔ fibromyalgia_utils.py, lines 2839–2905 · score 0.52 · minor allele frequency, MAF, sex, GWAS, chromosome, genome

Paper

Loaded from Europe PMC by your browser, not stored by OSCR: doi.org · Europe PMC

The paper is loaded when this pane is shown.

The authors' code

Python · 3,893 lines · 195 KB · no license · 4 matches

  1. from __future__ import annotations
  2. import os
  3. import polars as pl
  4. import signal
  5. import subprocess
  6. import sys
  7. from functools import cache, reduce
  8. from typing import Any, Iterable
  9. pl.enable_string_cache()
  10. def raise_error_if_on_compute_node(message=None):
  11. """
  12. Raises an error if the user is on a compute node
  13. Args:
  14. message: Message to print when user is not on a login node; if None,
  15. prints a default message
  16. """
  17. import re
  18. import socket
  19. if re.search('(nia|nc|nl|ng)[0-9]', socket.gethostname()):
  20. # nia matches the Niagara compute nodes; nc/nl/ng match the Narval ones
  21. import inspect
  22. calling_function = inspect.currentframe().f_back.f_code.co_name
  23. raise RuntimeError(message if message is not None else
  24. f'{calling_function}() needs internet access! Run '
  25. f'once on the login node to download required '
  26. f'files, then re-run here on this compute node')
  27. def check_cluster(cluster):
  28. """
  29. Check the cluster the user is on.
  30. Returns:
  31. The cluster: "narval" or "niagara". Raises an error if $CLUSTER is not
  32. set to one of those two.
  33. """
  34. if cluster is None:
  35. raise RuntimeError('The environment variable $CLUSTER is not set; it '
  36. 'must be set to "narval" or "niagara"')
  37. if cluster != 'narval' and cluster != 'niagara':
  38. raise RuntimeError(f"The environment variable $CLUSTER is set to "
  39. f"{cluster!r}, but must be set to 'narval' or "
  40. f"'niagara'")
  41. @cache
  42. def get_base_data_directory():
  43. """
  44. Get the location of the "base" data directory, where data will be stored.
  45. Returns:
  46. The path of the base data directory
  47. """
  48. cluster = os.environ.get('CLUSTER')
  49. return '/home/wainberg/projects/def-wainberg' if cluster == 'narval' else \
  50. '/scratch/w/wainberg/wainberg' if cluster == 'niagara' else '.'
  51. def plural(string: str, count: int) -> str:
  52. """
  53. Adds an s to the end of string, unless `count` is 1 or -1.
  54. Args:
  55. string: a string
  56. count: a count
  57. Returns:
  58. `string`, with an s at the end if `count` is 1 or -1
  59. """
  60. return string if abs(count) == 1 else f'{string}s'
  61. def check_type(variable: Any, variable_name: str,
  62. expected_types: type | tuple[type, ...],
  63. expected_type_name: str) -> None:
  64. """
  65. Check whether `variable` has the expected type.
  66. Args:
  67. variable: the variable to be checked
  68. variable_name: the name of the variable, used in the error message
  69. expected_types: the expected type or types (specifying int, float, or
  70. bool also implicitly includes their NumPy equivalents)
  71. expected_type_name: the name of the expected type, used in the error
  72. message (e.g. 'a polars DataFrame')
  73. """
  74. if isinstance(variable, expected_types):
  75. return
  76. if not isinstance(expected_types, tuple):
  77. expected_types = expected_types,
  78. for t in expected_types:
  79. if t is int:
  80. import numpy as np
  81. if isinstance(variable, np.integer):
  82. return
  83. elif t is float:
  84. import numpy as np
  85. if isinstance(variable, np.floating):
  86. return
  87. elif t is bool:
  88. import numpy as np
  89. if isinstance(variable, np.bool_):
  90. return
  91. error_message = (
  92. f'{variable_name} must be {expected_type_name}, but has type '
  93. f'{type(variable).__name__!r}')
  94. raise TypeError(error_message)
  95. def check_types(variable: Iterable[Any],
  96. variable_name: str,
  97. expected_types: type | tuple[type, ...],
  98. expected_type_name: str):
  99. """
  100. Check whether all elements of `variable` are of the expected type(s).
  101. Args:
  102. variable: the variable to be checked
  103. variable_name: the name of the variable, used in the error message
  104. expected_types: the expected type or types
  105. expected_type_name: the name of the expected type, used in the error
  106. message (e.g. 'polars DataFrames')
  107. """
  108. if not isinstance(expected_types, tuple):
  109. expected_types = expected_types,
  110. for element in variable:
  111. if not isinstance(element, expected_types):
  112. for t in expected_types:
  113. if t is int:
  114. import numpy as np
  115. if isinstance(variable, np.integer):
  116. break
  117. elif t is float:
  118. import numpy as np
  119. if isinstance(variable, np.floating):
  120. break
  121. elif t is bool:
  122. import numpy as np
  123. if isinstance(variable, np.bool_):
  124. break
  125. else:
  126. error_message = (
  127. f'all elements of {variable_name} must be '
  128. f'{expected_type_name}, but it contains an element of '
  129. f'type {type(element).__name__!r}')
  130. raise TypeError(error_message)
  131. def check_dtype(series: pl.Series,
  132. series_name: str,
  133. expected_dtypes: pl.datatypes.classes.DataTypeClass | str |
  134. tuple[pl.datatypes.classes.DataTypeClass |
  135. str, ...]) -> None:
  136. """
  137. Check whether `series` has the expected polars dtype.
  138. Args:
  139. series: the polars Series to be checked
  140. series_name: the name of the variable, used in the error message
  141. expected_dtypes: the expected dtype or dtypes. Specify the string
  142. `'integer'` to include all integer dtypes, and
  143. `'floating-point'` to include all floating-point
  144. dtypes.
  145. """
  146. base_type = series.dtype.base_type()
  147. if not isinstance(expected_dtypes, tuple):
  148. expected_dtypes = expected_dtypes,
  149. for expected_type in expected_dtypes:
  150. if base_type == expected_type or expected_type == 'integer' and \
  151. base_type in pl.INTEGER_DTYPES or \
  152. expected_type == 'floating-point' and \
  153. base_type in pl.FLOAT_DTYPES:
  154. return
  155. if len(expected_dtypes) == 1:
  156. expected_dtypes = str(expected_dtypes[0])
  157. elif len(expected_dtypes) == 2:
  158. expected_dtypes = ' or '.join(map(str, expected_dtypes))
  159. else:
  160. expected_dtypes = ', '.join(map(str, expected_dtypes[:-1])) + \
  161. ', or ' + str(expected_dtypes[-1])
  162. error_message = (
  163. f'{series_name} must be {expected_dtypes}, but has data type '
  164. f'{base_type!r}')
  165. raise TypeError(error_message)
  166. class ProcessPool(object):
  167. """
  168. Like multiprocessing.Pool but 1) child processes ignore KeyboardInterrupts
  169. and 2) apply_async() is called submit() and takes actual *args and **kwargs
  170. rather than a list of args and a dictionary of kwargs (like
  171. ProcessPoolExecutor.submit() from the concurrent.futures module)
  172. Attributes:
  173. pool (multiprocessing.Pool): the underlying multiprocessing.Pool object
  174. """
  175. def __init__(self, max_concurrent, start_method='forkserver'):
  176. """
  177. Sets up the multiprocessing.Pool object underlying this ProcessPool.
  178. Args:
  179. max_concurrent: The number of worker processes to be spawned by the
  180. Pool object, i.e. the maximum number of concurrent
  181. processes that will be allowed to run at once.
  182. start_method: How worker processes will be started. Possible
  183. values are 'fork', 'spawn', 'forkserver'. For
  184. details, see docs.python.org/3/library/
  185. multiprocessing.html#contexts-and-start-methods.
  186. """
  187. import multiprocessing
  188. self.pool = multiprocessing.get_context(start_method) \
  189. .Pool(max_concurrent, initializer=self.ignore_keyboard_interrupts)
  190. @staticmethod
  191. def ignore_keyboard_interrupts():
  192. """
  193. When provided as an initializer to a multiprocessing.Pool object,
  194. this function tells the Pool to ignore KeyboardInterrupts (i.e. SIGINT)
  195. so that pressing Ctrl + C doesn't kill all your background processes.
  196. """
  197. signal.signal(signal.SIGINT, signal.SIG_IGN)
  198. def submit(self, func, *args, **kwargs):
  199. """
  200. Submits a function to the process pool. A thin wrapper over
  201. Pool.apply_async() that allows the user to pass actual *args and
  202. **kwargs rather than a list of args and a dictionary of kwargs.
  203. Args:
  204. func: the function to be submitted
  205. *args: positional arguments to be passed to function
  206. **kwargs: keyword arguments to be passed to function
  207. Returns:
  208. The multiprocessing.pool.AsyncResult object returned by
  209. Pool.apply_async().
  210. """
  211. return self.pool.apply_async(func, args, kwargs)
  212. @cache
  213. def get_process_pool(max_concurrent, start_method='forkserver'):
  214. """
  215. Creates a ProcessPool for a given value of max_concurrent and start_method,
  216. which due to the @cache decorator will persist across multiple calls to
  217. functions like run_background() or run_function_background(), so long as
  218. they call this function with the same max_concurrent and start_method.
  219. Args:
  220. max_concurrent: The number of worker processes to be spawned by the
  221. ProcessPool, i.e. the maximum number of concurrent
  222. processes that will be allowed to run at once.
  223. start_method: How worker processes will be started. Possible
  224. values are 'fork', 'spawn', 'forkserver'. For
  225. details, see docs.python.org/3/library/
  226. multiprocessing.html#contexts-and-start-methods.
  227. Returns:
  228. A ProcessPool for the given values of max_concurrent and start_method.
  229. A new ProcessPool will be created the first time this function is run
  230. for a given value of max_concurrent and start_method, which will then
  231. be cached for all subsequent calls with the same max_concurrent and
  232. start_method.
  233. """
  234. return ProcessPool(max_concurrent, start_method=start_method)
  235. @cache
  236. def cython_inline(code, debug=False, boundscheck=None, cdivision=True,
  237. initializedcheck=None, wraparound=False,
  238. warn_undeclared=True, include_dirs=None, libraries=None,
  239. clang=False, extra_compiler_flags=None,
  240. extra_linker_flags=None, verbose=False,
  241. **other_cython_settings):
  242. """
  243. A drop-in replacement for `cython.inline()` that supports cimports. It
  244. turns on the major Cython optimizations (`boundscheck=False`,
  245. `cdivision=True`, `initializedcheck=False`, `wraparound=False`) and sets
  246. `language_level=3` for full Python 3 compatibility.
  247. Args:
  248. code: a string of Cython code to compile
  249. debug: whether to turn off most compiler optimizations (`-Ofast`,
  250. `-funroll-loops`) and turn on debug compilation (`-g -Og`) and
  251. Cython's boundscheck and initializedcheck
  252. boundscheck: whether to perform array bounds checking when indexing;
  253. always affects array/memoryview indexing, but also affects
  254. list, tuple, and string indexing when wraparound=False
  255. cdivision: whether to use C-style rather than Python-style division and
  256. remainder operations; disabling leads to a ~35% speed
  257. penalty for these operations
  258. initializedcheck: whether to check whether memoryviews and C++ classes
  259. are initialized before using them
  260. wraparound: whether to support Python-style negative indexing
  261. warn_undeclared: whether to warn about undeclared variables (i.e. those
  262. without a cdef type declaration)
  263. include_dirs: an optional tuple of include directories of libraries to
  264. link against; `np.get_include()` will always be included
  265. libraries: an optional tuple of libraries to link against,
  266. e.g. ('hdf5',)
  267. clang: whether to compile with clang instead of GCC
  268. extra_compiler_flags: a tuple of extra compiler flags to use on top of
  269. the defaults
  270. extra_linker_flags: a tuple of extra linker flags to use on top of the
  271. defaults
  272. verbose: if True, print Cython's compilation logs
  273. **other_cython_settings: other Cython settings, which will be written
  274. into the source code as #cython compiler
  275. directives
  276. Returns:
  277. The {function_name: function} dictionary of compiled functions that
  278. would be returned by cython.inline().
  279. """
  280. import numpy as np
  281. from hashlib import md5
  282. from inspect import getmembers
  283. from textwrap import dedent
  284. # ~ is read-only on Niagara compute nodes, so build in CYTHON_CACHE_DIR in
  285. # scratch instead
  286. cython_cache_dir = os.path.abspath(os.environ.get(
  287. 'CYTHON_CACHE_DIR', os.path.expanduser('~/.cython')))
  288. os.makedirs(cython_cache_dir, exist_ok=True)
  289. # If `boundscheck` and/or `initializedcheck` are `None`, turn them on if
  290. # `debug` is `True`, and turn them off otherwise
  291. if boundscheck is None:
  292. boundscheck = debug
  293. if initializedcheck is None:
  294. initializedcheck = debug
  295. # Remove extra levels of indentation from the code string (since it's
  296. # usually defined inside a function, so there's at least one extra level of
  297. # indentation that would cause a syntax error if not removed) and remove
  298. # any leading newlines if present (since when users define code strings,
  299. # they usually use triple-quoted strings, and the code usually doesn't
  300. # start until the line after the three opening quotes, leading to a single
  301. # leading newline)
  302. settings = dict(language_level=3, boundscheck=boundscheck,
  303. cdivision=cdivision, initializedcheck=initializedcheck,
  304. wraparound=wraparound,
  305. **{'warn.undeclared': warn_undeclared})
  306. settings.update(other_cython_settings)
  307. code = ''.join(f'#cython: {setting_name}={setting}\n'
  308. for setting_name, setting in settings.items()) + \
  309. f'#distutils: define_macros=NPY_NO_DEPRECATED_API=' \
  310. f'NPY_1_7_API_VERSION\n' + dedent(code)
  311. # Define build options and environment variables
  312. if include_dirs is not None:
  313. include_dirs = (np.get_include(),) + include_dirs
  314. else:
  315. include_dirs = np.get_include(),
  316. include_dirs = \
  317. '[' + ', '.join(f'{include_dir!r}'
  318. for include_dir in include_dirs) + ']'
  319. if libraries is not None:
  320. libraries = \
  321. f'[' + ', '.join(f'{library!r}' for library in libraries) + ']'
  322. narval = os.environ.get('CLUSTER') == 'narval'
  323. niagara = os.environ.get('CLUSTER') == 'niagara'
  324. march = 'znver2' if narval else 'skylake' if niagara else 'native'
  325. extra_compiler_flags = \
  326. ''.join(f', {flag!r}' for flag in extra_compiler_flags) \
  327. if extra_compiler_flags is not None else ''
  328. extra_linker_flags = \
  329. ''.join(f', {flag!r}' for flag in extra_linker_flags) \
  330. if extra_linker_flags is not None else ''
  331. build_options = f'''
  332. language='c++',
  333. include_dirs={include_dirs},
  334. libraries={libraries},
  335. extra_compile_args=['-g', '-Og', '-march={march}', '-fopenmp',
  336. '-Wall', '-Wextra', '-Wpedantic',
  337. '-Werror', '-Wno-maybe-uninitialized',
  338. '-Wno-ignored-qualifiers'
  339. {extra_compiler_flags}]
  340. if {debug} else ['-Ofast', '-march={march}',
  341. '-funroll-loops', '-fopenmp', '-Wall',
  342. '-Wextra', '-Wpedantic', '-Werror',
  343. '-Wno-maybe-uninitialized',
  344. '-Wno-ignored-qualifiers'
  345. {extra_compiler_flags}],
  346. extra_link_args=['-fopenmp'{extra_linker_flags}] if {debug}
  347. else
  348. ['-Ofast', '-fopenmp'{extra_linker_flags}]'''
  349. build_environment_variables = \
  350. f'CXX={"clang++" if clang else "g++"}{" CFLAGS=-g " if debug else ""}'
  351. # Make a short alphabetic module name by taking the MD5 hash of the
  352. # concatenated code, build options, and build environment variables, and
  353. # converting the hexadecimal digits to letters (0 -> a, 1 -> b, ...,
  354. # 9 --> j, a --> k, ..., f --> p)
  355. module_name = ''.join(chr(ord(c) + (49 if c <= '9' else 10))
  356. for c in md5((code + build_options +
  357. build_environment_variables)
  358. .encode('utf-8')).hexdigest())
  359. code_file = os.path.join(cython_cache_dir, f'{module_name}.pyx')
  360. # Try to import the module; build it if it does not exist
  361. sys.path.append(cython_cache_dir)
  362. try:
  363. module = __import__(module_name)
  364. except ModuleNotFoundError:
  365. # Create the code file
  366. with open(code_file, 'w') as f:
  367. # noinspection PyTypeChecker
  368. print(code, file=f)
  369. # Write the build script to a temp file based on the module name
  370. build_file = os.path.join(cython_cache_dir, f'{module_name}_build.py')
  371. with open(build_file, 'w') as f:
  372. build_script = dedent(f'''
  373. from setuptools import Extension, setup
  374. from Cython.Build import cythonize
  375. setup(name='{module_name}', ext_modules=cythonize([
  376. Extension('{module_name}', ['{code_file}'],
  377. {build_options})],
  378. build_dir='{cython_cache_dir}'))''')
  379. # noinspection PyTypeChecker
  380. print(build_script, file=f)
  381. # Build the code (note: `sys.executable` is the location of Python)
  382. run(f'cd {cython_cache_dir} && {build_environment_variables} '
  383. f'{sys.executable} {build_file} build_ext --inplace'
  384. f'{"" if verbose else " > /dev/null"}')
  385. # Remove the temp file
  386. os.unlink(build_file)
  387. # Try again
  388. module = __import__(module_name)
  389. finally:
  390. # noinspection PyInconsistentReturns
  391. sys.path = sys.path[:-1]
  392. # Create a dict of all the Cython functions defined in the module
  393. function_dict = {function_name: function
  394. for function_name, function in getmembers(module)
  395. if repr(function).startswith('<cyfunction')}
  396. # Return the dict of Cython functions
  397. return function_dict
  398. def print_df(df, num_rows=-1, num_columns=-1):
  399. """
  400. Prints the entirety of a polars DataFrame without truncating.
  401. Args:
  402. df: the DataFrame to print
  403. num_rows: the number of rows to print (-1 to print all rows)
  404. num_columns: the number of columns to print (-1 to print all columns)
  405. """
  406. with pl.Config(tbl_rows=num_rows, tbl_cols=num_columns):
  407. print(df)
  408. def map_df(df, map_col, other_df, key_col, value_col, *,
  409. retain_missing=False):
  410. """
  411. Maps df[map_col] based on the mapping other_df[key_col] ->
  412. other_df[value_col].
  413. In other words, for each element of df[map_col], check if it's in
  414. other_df[key_col], and if so, replace it with the corresponding entry of
  415. other_df[value_col].
  416. Equivalent to df.with_columns(pl.col(map_col).replace_strict(dict(zip(
  417. other_df[key_col], other_df[value_col])), default=pl.first() if
  418. retain_missing else None)).
  419. Implementation detail: uses a join, but prefixes other_df's key_col and
  420. value_col with "__MAP_DF_" to handle the possibility that map_col might
  421. have the same name as key_col or value_col.
  422. Args:
  423. df: a polars DataFrame
  424. map_col: a column in df
  425. other_df: another polars DataFrame
  426. key_col: a column in other_df with the mapping keys; all values must be
  427. unique, although this is not checked, for speed
  428. value_col: a column in other_df with the mapping values
  429. retain_missing: if False, sets elements of map_col that don't appear in
  430. key_col to null; if True, leaves them unchanged
  431. Returns:
  432. df with map_col transformed so that each of its values that are in
  433. key_col are transformed to the corresponding value in value_col.
  434. """
  435. if key_col == value_col:
  436. raise ValueError(f'Both key_col and value_col are set to the column '
  437. f'name "{key_col}"')
  438. if isinstance(df, pl.LazyFrame):
  439. other_df = other_df.lazy()
  440. if isinstance(other_df, pl.LazyFrame):
  441. df = df.lazy()
  442. prefix = '__MAP_DF_'
  443. df = df.join(other_df.select(pl.col(key_col, value_col)
  444. .name.prefix(prefix)),
  445. left_on=map_col, right_on=prefix + key_col, how='left')
  446. if retain_missing:
  447. df = df.with_columns(pl.col(prefix + value_col)
  448. .fill_null(pl.col(map_col)))
  449. df = df.with_columns(pl.col(prefix + value_col).alias(map_col)) \
  450. .drop(prefix + value_col)
  451. return df
  452. def thread_string(num_threads):
  453. """
  454. Generates a string for setting relevant environment variables to limit the
  455. number of threads for a called process.
  456. Args:
  457. num_threads: The number of threads.
  458. Returns:
  459. A string for setting the relevant environment variables to num_threads.
  460. """
  461. return f'export MKL_NUM_THREADS={num_threads}; ' \
  462. f'export OMP_NUM_THREADS={num_threads}; ' \
  463. f'export OPENBLAS_NUM_THREADS={num_threads}; ' \
  464. f'export NUMEXPR_MAX_THREADS={num_threads}; ' \
  465. if num_threads is not None else ''
  466. def run(cmd, *, log_file=None, unbuffered=False, pipefail=True,
  467. num_threads=None, **kwargs):
  468. """
  469. Runs a bash code segment interactively.
  470. Args:
  471. cmd: the command to be run
  472. log_file: a filename to log stdout/stderr to, in addition to printing
  473. unbuffered: set to True for unbuffered I/O (currently broken when cmd
  474. contains multiple commands)
  475. pipefail: set to False when piping commands to head to avoid errors
  476. num_threads: set to a positive integer to limit how many threads cmd
  477. uses, or to None to not limit the number of threads
  478. **kwargs: passed on to subprocess.run()
  479. Returns:
  480. The CompletedProcess object returned by subprocess.run().
  481. """
  482. run_kwargs = dict(check=True, shell=True, executable='/bin/bash')
  483. run_kwargs.update(**kwargs)
  484. return subprocess.run(
  485. f'{thread_string(num_threads)}'
  486. f'set -eu{"o pipefail" if pipefail else ""}; '
  487. f'{"stdbuf -i0 -o0 -e0 " if unbuffered else ""}{cmd}'
  488. f'{f" 2>&1 | tee {log_file}" if log_file is not None else ""}',
  489. **run_kwargs)
  490. def read_csv_from_command(cmd, *, run_kwargs={}, **kwargs):
  491. """
  492. Read a columnar file with polars from the output of a bash command.
  493. Args:
  494. cmd: the bash command
  495. run_kwargs: keyword arguments to utils.run()
  496. **kwargs: keyword arguments to pl.read_csv()
  497. Returns:
  498. A polars DataFrame with the contents of the columnar file produced by
  499. the bash command.
  500. """
  501. from io import BytesIO
  502. # noinspection PyTypeChecker,PyArgumentList
  503. return pl.read_csv(BytesIO(run(cmd, stdout=subprocess.PIPE, **run_kwargs)
  504. .stdout), **kwargs)
  505. def read_csv_delim_whitespace(whitespace_delimited_file, **kwargs):
  506. """
  507. Reads a whitespace-delimited file with polars, mimicking the behavior of
  508. delim_whitespace=True in pandas.read_csv().
  509. Explanation of the sed command:
  510. s/^[[:space:]]\\+// removes leading whitespace
  511. s/[[:space:]]\\+$// removes trailing whitespace
  512. s/[[:space:]]\\+/\t/g replaces internal whitespace with tabs
  513. Args:
  514. whitespace_delimited_file: the whitespace-delimited file
  515. **kwargs: keyword arguments to pl.read_csv()
  516. Returns:
  517. A polars DataFrame with the contents of the whitespace-delimted file.
  518. """
  519. if not os.path.exists(whitespace_delimited_file):
  520. error_message = \
  521. f'No such file or directory: {whitespace_delimited_file}'
  522. raise FileNotFoundError(error_message)
  523. if whitespace_delimited_file.endswith('.gz'):
  524. whitespace_delimited_file = f'<(zcat {whitespace_delimited_file})'
  525. return read_csv_from_command(
  526. f"sed 's/^[[:space:]]\\+//; s/[[:space:]]\\+$//; "
  527. f"s/[[:space:]]\\+/\t/g' {whitespace_delimited_file}",
  528. separator='\t', **kwargs)
  529. def savefig(filename, *, despine=False, **kwargs):
  530. """
  531. A drop-in replacement for plt.savefig(). Saves a matplotlib figure to
  532. filename, but additionally:
  533. - removes the right and top spines for a cleaner-looking plot (if
  534. despine=True)
  535. - saves with dpi=450 for higher-resolution plots
  536. - saves with bbox_inches='tight' and pad_inches=0 to avoid extra whitespace
  537. around plots
  538. - saves PDF files with transparent=True so transparency info isn't
  539. discarded
  540. - closes the plot with plt.close() to avoid memory leaks
  541. Args:
  542. filename: the filename to save the matplotlib figure to
  543. despine: whether to remove the right and top spines using seaborn's
  544. despine() function
  545. **kwargs:
  546. """
  547. import matplotlib.pyplot as plt
  548. if despine:
  549. spines = plt.gca().spines
  550. spines['top'].set_visible(False)
  551. spines['right'].set_visible(False)
  552. all_kwargs = dict(dpi=450, bbox_inches='tight', pad_inches=0,
  553. transparent=filename.endswith('pdf'))
  554. all_kwargs.update(kwargs)
  555. plt.savefig(filename, **all_kwargs)
  556. plt.close()
  557. def manhattan_plot(sumstats, *, clumped_variants=None, genome_build=None,
  558. SNP_col='SNP', chrom_col='CHROM', bp_col='BP',
  559. ref_col='REF', alt_col='ALT', p_col='P',
  560. max_p=1e-3, max_p_to_label=5e-8,
  561. p_thresholds={5e-8: ('#D62728', 'dashed')},
  562. label_overrides={}, label_kwargs={},
  563. gene_annotation_dir=f'{get_base_data_directory()}/'
  564. f'gene-annotations'):
  565. """
  566. Make a Manhattan plot of the variants in sumstats; highlight lead variants.
  567. If clumped_variants is not None, highlight the LD clumps as well.
  568. If genome_build is not None, label each lead variant with its nearest
  569. coding gene(s) from that genome build.
  570. After running, call savefig() to save the plot, or customize further first.
  571. Style inspired by ncbi.nlm.nih.gov/pmc/articles/PMC6481311/figure/F1.
  572. Requires the textalloc package (github.com/ckjellson/textalloc) to make
  573. sure the gene labels don't overlap. Install it with:
  574. pip install --no-deps --no-build-isolation textalloc
  575. Args:
  576. sumstats: the summary statistics to plot
  577. clumped_variants: a DataFrame of clumped variants from ld_clump() to
  578. highlight lead variants on the Manhattan plot, or
  579. None to skip
  580. genome_build: if not None, a genome build used to label each lead
  581. variant with its nearest coding gene(s)
  582. SNP_col: the name of the variant ID column in sumstats
  583. chrom_col: the name of the chromosome column in sumstats
  584. bp_col: the name of the base-pair position column in sumstats
  585. ref_col: the name of the reference allele column in sumstats
  586. alt_col: the name of the alternate allele column in sumstats
  587. p_col: the name of the p-value column in sumstats
  588. max_p: defines the bottom y limit of the Manhattan plot; variants with
  589. p-values >= this value will not be plotted
  590. max_p_to_label: variants with p-values >= this value will not be
  591. labeled; only has an effect if genome_build is not None
  592. p_thresholds: a dictionary of p-value thresholds to draw horizontal
  593. lines at; values are tuples of (line color, line style)
  594. label_overrides: a dictionary of {gene_name: label} used to override
  595. specific gene labels.
  596. label_kwargs: a dictionary of keyword arguments to be passed to
  597. `textalloc.allocate()` when adding gene labels to
  598. control the text properties, such as:
  599. - `textcolor`/`textsize`: the text color and size
  600. - `x_scatter`/`y_scatter`: the x/y coordinates of
  601. points in the scatter plot, to repel labels away
  602. from. Defaults to all points in the plot.
  603. - `min_distance`/`max_distance`: the minimum and
  604. maximum distances from each point to its label, as
  605. a proportion of the width of the x-axis. Defaults
  606. to 0 and 0.02, instead of textalloc's defaults of
  607. 0.015 and 0.2
  608. - `draw_lines`: whether to draw lines between each
  609. label and its corresponding point. Defaults to
  610. `False`, instead of textalloc's default of
  611. `True`.
  612. See github.com/ckjellson/textalloc#parameters for the
  613. full list of possible arguments.
  614. gene_annotation_dir: the directory where the coding gene locations
  615. returned by get_coding_genes() will be cached.
  616. Must be run on the login node to generate this
  617. cache, if it doesn't exist. Because generating the
  618. cache can take a long time, you will probably want
  619. to leave this argument at its default value.
  620. """
  621. import matplotlib.pyplot as plt
  622. import numpy as np
  623. # noinspection PyUnresolvedReferences
  624. import textalloc
  625. if sumstats.is_empty():
  626. raise ValueError(f'sumstats is empty!')
  627. if SNP_col not in sumstats:
  628. raise ValueError(f'{SNP_col!r} not in sumstats; specify SNP_col')
  629. if chrom_col not in sumstats:
  630. raise ValueError(f'{chrom_col!r} not in sumstats; specify chrom_col')
  631. if bp_col not in sumstats:
  632. raise ValueError(f'{bp_col!r} not in sumstats; specify bp_col')
  633. if p_col not in sumstats:
  634. raise ValueError(f'{p_col!r} not in sumstats; specify p_col')
  635. if genome_build is not None:
  636. check_valid_genome_build(genome_build=genome_build)
  637. # Subset sumstats to p < max_p; standardize chromosome names; alternate
  638. # each chromosome in a different color (dark blue then light blue); offset
  639. # each chromosome by the cumulative number of base pairs from the start of
  640. # chr1 to the start of that chromosome
  641. sumstats = sumstats \
  642. .filter(pl.col(p_col) < max_p) \
  643. .with_columns(pl.col(chrom_col)
  644. .pipe(standardize_chromosomes, omit_chr_prefix=True)) \
  645. .with_columns(color=pl.col(chrom_col).cast(pl.Categorical)
  646. .to_physical().mod(2)
  647. .replace_strict({0: '#0A6FA5', 1: '#008FCD'}))
  648. # Get the total number of bps to the start and end of each chromosome
  649. cumulative_bp = sumstats \
  650. .group_by(chrom_col, maintain_order=True) \
  651. .agg(pl.max(bp_col)) \
  652. .with_columns(end=pl.col(bp_col).cum_sum()) \
  653. .with_columns(start=pl.col.end.shift().fill_null(0)) \
  654. .drop(bp_col)
  655. # Get x and y coordinates to plot
  656. sumstats = sumstats \
  657. .join(cumulative_bp, on=chrom_col, how='left') \
  658. .with_columns(x=pl.col.start + pl.col(bp_col),
  659. y=-pl.col(p_col).log10())
  660. # Plot horizontal lines indicating significance thresholds
  661. for p_threshold, (color, linestyle) in p_thresholds.items():
  662. plt.axhline(y=-np.log10(p_threshold), color=color, linestyle=linestyle,
  663. zorder=-1)
  664. # Overplot three times: first non-clumped variants, then clumped
  665. # variants, then lead variants. Rasterize non-clumped and (optionally)
  666. # clumped variants for quick plotting and rendering.
  667. w, h = plt.gcf().get_size_inches()
  668. plt.gcf().set_size_inches(1.2 * w, h)
  669. if clumped_variants is None:
  670. plt.scatter(sumstats['x'], sumstats['y'], c=sumstats['color'], s=1,
  671. rasterized=True)
  672. else:
  673. non_clump_sumstats = sumstats.filter(
  674. ~pl.col(SNP_col).is_in(clumped_variants[SNP_col]))
  675. clump_sumstats = sumstats.filter(
  676. pl.col(SNP_col).is_in(clumped_variants[SNP_col]),
  677. ~pl.col(SNP_col).is_in(clumped_variants[f'{SNP_col}_lead']))
  678. join_columns = SNP_col, chrom_col, bp_col, ref_col, alt_col
  679. lead_sumstats = clumped_variants \
  680. .filter('is_lead') \
  681. .select(join_columns) \
  682. .join(sumstats, on=join_columns, how='left')
  683. plt.scatter(non_clump_sumstats['x'], non_clump_sumstats['y'],
  684. c=non_clump_sumstats['color'], s=1, rasterized=True)
  685. plt.scatter(clump_sumstats['x'], clump_sumstats['y'], c='#DBA756', s=1)
  686. plt.scatter(lead_sumstats['x'], lead_sumstats['y'],
  687. c='#DBA756', marker='D', edgecolors='k', s=10)
  688. if genome_build is not None:
  689. lead_sumstats = lead_sumstats \
  690. .pipe(lambda df: df.join(
  691. df.filter(pl.col(p_col) < max_p_to_label)
  692. .pipe(get_nearest_gene, genome_build=genome_build,
  693. SNP_col=SNP_col, chrom_col=chrom_col, bp_col=bp_col,
  694. gene_annotation_dir=gene_annotation_dir),
  695. on=(SNP_col, chrom_col, bp_col), how='left')) \
  696. .drop_nulls('gene')
  697. labels = lead_sumstats['gene']
  698. if label_overrides:
  699. labels = \
  700. labels.list.eval(pl.element().replace(label_overrides))
  701. labels = labels.list.join(', ')
  702. default_label_kwargs = dict(
  703. ax=plt.gca(), x=lead_sumstats['x'], y=lead_sumstats['y'],
  704. text_list=labels, x_scatter=sumstats['x'],
  705. y_scatter=sumstats['y'], min_distance=0, max_distance=0.02,
  706. draw_lines=False)
  707. label_kwargs = default_label_kwargs | label_kwargs \
  708. if label_kwargs is not None else default_label_kwargs
  709. textalloc.allocate(**label_kwargs)
  710. # Put each chrom's label in the chrom's center; hide chr17/19/21 labels
  711. plt.xticks((cumulative_bp['start'] + cumulative_bp['end']) / 2,
  712. cumulative_bp[chrom_col]
  713. .replace({'17': '', '19': '', '21': ''}))
  714. # Set x and y labels and limits, resize
  715. plt.xlabel('Chromosome')
  716. plt.ylabel('-log$_{10}$(p)')
  717. padding = 10_000_000 # avoid clipping SNPs at the far left or right
  718. plt.xlim(sumstats['x'].min() - padding, sumstats['x'].max() + padding)
  719. plt.ylim(bottom=-np.log10(max_p))
  720. def qqplot(ps, *, equal_aspect=True, **kwargs):
  721. """
  722. Generates a quantile-quantile (Q-Q) plot of p-values.
  723. Inspired by qqplot() from the qmplot package.
  724. Args:
  725. ps: a polars Series or 1D NumPy array of p-values to plot
  726. equal_aspect: if True, calls ax.set_aspect('equal') so the x and y axes
  727. use the same number of inches per unit increase in x and
  728. y
  729. **kwargs: passed to ax.scatter()
  730. """
  731. #
  732. import matplotlib.pyplot as plt
  733. import numpy as np
  734. if ps.is_empty():
  735. raise ValueError(f'ps is empty!')
  736. ax = kwargs.pop('ax') if 'ax' in kwargs else plt.gca()
  737. rasterized = kwargs.pop('rasterized') if 'rasterized' in kwargs else True
  738. ppoints = lambda n, a=0.5: (np.arange(n) + 1 - a) / (n + 1 - 2 * a)
  739. ax.scatter(-np.log10(ppoints(len(ps))),
  740. -np.log10(np.sort(ps).clip(5e-324)),
  741. edgecolors='none', rasterized=rasterized, **kwargs)
  742. xmax = ax.get_xlim()[1]
  743. ax.plot([0, xmax], [0, xmax], c='k', zorder=-1)
  744. if equal_aspect:
  745. ax.set_aspect('equal')
  746. ax.set_xlim(0)
  747. ax.set_ylim(0)
  748. ax.spines['top'].set_visible(False)
  749. ax.spines['right'].set_visible(False)
  750. def standardize(array):
  751. """
  752. Standardize a polars Series, DataFrame or expression or a NumPy array to
  753. zero mean and unit variance (using 1 delta degree of freedom), columnwise.
  754. Args:
  755. array: the Series, DataFrame, expression or array to standardize
  756. Returns:
  757. The standardized Series, DataFrame, expression or array.
  758. """
  759. if isinstance(array, (pl.Series, pl.Expr)):
  760. return (array - array.mean()) / array.std()
  761. if isinstance(array, pl.DataFrame):
  762. return array.with_columns((pl.selectors.numeric() -
  763. pl.selectors.numeric().mean()) /
  764. pl.selectors.numeric().std())
  765. import numpy as np
  766. if not isinstance(array, np.ndarray):
  767. raise ValueError(f'array must be a polars Series, DataFrame or '
  768. f'expression or a NumPy array!')
  769. return (array - np.mean(array, axis=0)) / np.std(array, ddof=1, axis=0)
  770. def inflation_factor(pvalues):
  771. """
  772. Calculates the genomic inflation factor from a vector of p-values.
  773. Args:
  774. pvalues: a polars Series or expression or NumPy array of p-values
  775. Returns:
  776. A single floating-point number with the genomic inflation factor.
  777. """
  778. from scipy.special import chdtri # chdtri(df, p) == chi2.isf(p, df)
  779. if isinstance(pvalues, (pl.Series, pl.Expr)):
  780. return chdtri(1, pvalues.median()) / chdtri(1, 0.5)
  781. import numpy as np
  782. if not isinstance(pvalues, np.ndarray):
  783. raise ValueError('pvalues must be a polars Series or expression or a '
  784. 'NumPy array!')
  785. return chdtri(1, np.median(pvalues)) / chdtri(1, 0.5)
  786. def inverse_normal_transform(values, *, c=3 / 8):
  787. """
  788. Calculates the rank-based inverse normal transform of a polars Series or
  789. expression or 1D NumPy array.
  790. Args:
  791. values: a polars Series or expression or 1D NumPy array.
  792. c: a parameter of the transformation: a fractional shift to the ranks
  793. that's applied before transforming. By default, we use the Blom
  794. transform (c = 3/8). For more details on c, see:
  795. ncbi.nlm.nih.gov/pmc/articles/PMC2921808/#S2title
  796. Returns:
  797. The rank-based inverse normal transform of values.
  798. """
  799. from scipy.special import ndtri # ntdri(x) == norm.ppf(x)
  800. if isinstance(values, (pl.Series, pl.Expr)):
  801. rank = values.rank()
  802. transformed_rank = (rank - c) / \
  803. (rank.len() - rank.null_count() - 2 * c + 1)
  804. else:
  805. import numpy as np
  806. from scipy.stats import rankdata
  807. if not isinstance(values, np.ndarray) or values.ndim != 1:
  808. raise ValueError('values must be a polars Series or expression or '
  809. 'a 1D NumPy array!')
  810. rank = rankdata(values)
  811. transformed_rank = (rank - c) / ((~np.isnan(rank)).sum() - 2 * c + 1)
  812. return ndtri(transformed_rank)
  813. @polars_numpy_autoconvert()
  814. def z_to_p(z_scores, *, high_precision=False):
  815. """
  816. Converts a polars Series or NumPy array of z-scores to p-values
  817. Args:
  818. z_scores: the polars Series or NumPy array of z-scores
  819. high_precision: if True, uses R's pnorm function for high-precision
  820. output - important for very small p-values
  821. Returns:
  822. The corresponding p-values; same type as z_scores.
  823. """
  824. import numpy as np
  825. if high_precision:
  826. from ryp import to_py, to_r
  827. to_r(np.abs(z_scores), 'abs.z')
  828. pvalues = 2 * np.exp(np.float128(to_py(
  829. 'pnorm(abs.z, lower.tail=False, log.p=True)')))
  830. else:
  831. from scipy.special import ndtr
  832. pvalues = 2 * ndtr(-np.abs(z_scores)) # ndtr(-x) == norm.sf(x)
  833. return pvalues
  834. def p_to_abs_z(pvalues):
  835. """
  836. Converts a polars Series or expression or NumPy array of p-values to
  837. abs(z-scores)
  838. Args:
  839. pvalues: the polars Series or expression or NumPy array of p-values
  840. Returns:
  841. The corresponding abs(z-scores); same type as pvalues.
  842. """
  843. from scipy.special import ndtri
  844. abs_z_scores = -ndtri(pvalues / 2) # -ndtri(x) == norm.isf(x)
  845. return abs_z_scores
  846. def standardize_chromosomes(chromosome, *, return_numeric=False,
  847. omit_chr_prefix=False):
  848. """
  849. Standardizes the chromosome name(s) in chromosome, which can be an integer,
  850. string, polars Series or polars expression.
  851. Args:
  852. chromosome: the chromosome name(s) to standardize
  853. return_numeric: whether to return chromosomes as numbers
  854. omit_chr_prefix: whether to leave out the 'chr' prefix. Mutually
  855. exclusive with return_numeric.
  856. Returns:
  857. The corresponding standardized chromosome name(s):
  858. - 'chr1' to 'chr22': return as-is
  859. - 1 to 22 or '1' to '22': convert to 'chr1' to 'chr22'
  860. - 23, '23', 'chr23', 'X': convert to 'chrX'
  861. - 24, '24', 'chr24', 'Y': convert to 'chrY'
  862. - 'M' or 'MT': convert to 'chrM'
  863. - 25, '25', 'chr25': disallow; could refer to either chrM or chrXY (the
  864. pseudoautosomal region of the X and Y chromosomes)
  865. Or, if return_numeric=True, disallow chrM and its aliases and convert
  866. everything to a number between 1 and 24, inclusive.
  867. Or, if omit_chr_prefix=True, leave out the 'chr' prefix.
  868. """
  869. import numpy as np
  870. if return_numeric and omit_chr_prefix:
  871. raise ValueError(f'Only one of return_numeric and omit_chr_prefix can '
  872. f'be True!')
  873. check_type(chromosome, 'chromosome', (int, str, pl.Series, pl.Expr),
  874. 'an int, str, or polars Series or expression')
  875. if isinstance(chromosome, pl.Expr):
  876. return chromosome.map_batches(lambda col: standardize_chromosomes(
  877. col, return_numeric=return_numeric,
  878. omit_chr_prefix=omit_chr_prefix))
  879. elif isinstance(chromosome, str) or \
  880. isinstance(chromosome, (int, np.integer)):
  881. if isinstance(chromosome, str):
  882. original_chromosome = chromosome
  883. chromosome = chromosome.removeprefix('chr').replace('X', '23') \
  884. .replace('Y', '24').replace('MT', 'M')
  885. if not ((chromosome.isdigit() and 1 <= int(chromosome) <= 24) or
  886. (chromosome == 'M' and not return_numeric)):
  887. raise ValueError(f'Invalid chromosome '
  888. f'{original_chromosome!r}!')
  889. else:
  890. if chromosome not in range(1, 25):
  891. raise ValueError(f'chromosome == "{chromosome}" but should be '
  892. f'between 1 and 24 (chrY) inclusive!')
  893. if return_numeric:
  894. # noinspection PyTypeChecker
  895. return int(chromosome)
  896. else:
  897. chromosome = str(chromosome).replace('23', 'X').replace('24', 'Y')
  898. return chromosome if omit_chr_prefix else f'chr{chromosome}'
  899. else:
  900. check_dtype(chromosome, 'chromosome',
  901. (pl.String, pl.Categorical, pl.Enum, 'integer'))
  902. if chromosome.dtype in pl.INTEGER_DTYPES:
  903. mask = chromosome.is_in(range(1, 25))
  904. if not mask.all():
  905. error_message = (
  906. 'chroms were specified as integers, but some were not '
  907. 'between 1 and 24 (chrY) inclusive!')
  908. raise ValueError(error_message)
  909. chromosome = chromosome.cast(pl.String)
  910. else:
  911. # Remove this if statement once polars allows Categorical
  912. # .str.len_bytes(): github.com/pola-rs/polars/issues/9773
  913. if chromosome.dtype == pl.Categorical or \
  914. chromosome.dtype == pl.Enum:
  915. chromosome = chromosome.cast(pl.String)
  916. chromosome = chromosome.str.replace('^chr', '') \
  917. .str.replace('X', '23').str.replace('Y', '24') \
  918. .str.replace('MT', 'M')
  919. valid = chromosome.cast(pl.Int8, strict=False).is_in(range(1, 25))
  920. if not return_numeric:
  921. valid |= chromosome == 'M'
  922. if not valid.all():
  923. raise ValueError('Some chromosomes are invalid!')
  924. if return_numeric:
  925. return chromosome.cast(pl.Int8)
  926. else:
  927. chromosome = chromosome.replace({'23': 'X', '24': 'Y'})
  928. return chromosome if omit_chr_prefix else 'chr' + chromosome
  929. def load_alias_to_gene_map(gene_annotation_dir=f'{get_base_data_directory()}/'
  930. f'gene-annotations'):
  931. """
  932. Load a map from old gene names ("aliases") to their current gene names.
  933. Remove "ambiguous" aliases that map to multiple current gene names (like
  934. ACSM2, which maps to both ACSM2A and ACSM2B). This is done with
  935. pl.col.alias.is_unique() below.
  936. Also remove aliases that are gene names themselves, e.g. TTLL5 is an alias
  937. of TTLL10, but TTLL5 is also a gene name. This can happen when a gene
  938. "splits" into two genes. This is done with the two ~pl.col.alias.is_in
  939. conditions below.
  940. Args:
  941. gene_annotation_dir: the directory where the alias-to-gene map will be
  942. cached. Must be run on the login node to generate
  943. this cache, if it doesn't exist. Because
  944. generating the cache can take a long time, you
  945. will probably want to leave this argument at its
  946. default value.
  947. Returns:
  948. A two-column DataFrame with old gene names in the "alias" column and
  949. their current gene names in the "gene" column.
  950. """
  951. gene_alias_file = f'{gene_annotation_dir}/gene_aliases.tsv'
  952. if not os.path.exists(gene_alias_file):
  953. raise_error_if_on_compute_node()
  954. os.makedirs(gene_annotation_dir, exist_ok=True)
  955. run(f'curl -fsSL https://ftp.ebi.ac.uk/pub/databases/genenames/new/'
  956. f'tsv/locus_groups/protein-coding_gene.txt | cut -f2,9,11 > '
  957. f'{gene_alias_file}')
  958. Ensembl_genes = get_Ensembl_map(gene_annotation_dir=gene_annotation_dir,
  959. no_unaliasing=True) \
  960. .filter(pl.col.most_recent_Ensembl_version ==
  961. pl.col.most_recent_Ensembl_version.first()) \
  962. ['gene_name']
  963. alias_to_gene = pl.scan_csv(gene_alias_file, separator='\t') \
  964. .select(alias=pl.col.alias_symbol.str.split('|')
  965. .list.concat(pl.col.prev_symbol), gene='symbol') \
  966. .drop_nulls() \
  967. .explode('alias') \
  968. .filter(pl.col.alias.is_unique(), ~pl.col.alias.is_in(pl.col.gene),
  969. ~pl.col.alias.is_in(Ensembl_genes)) \
  970. .collect()
  971. return alias_to_gene
  972. def unalias(df, gene_col, *,
  973. gene_annotation_dir=f'{get_base_data_directory()}/'
  974. f'gene-annotations'):
  975. """
  976. "Unaliases" the genes in df's gene_col by mapping old gene names to their
  977. current gene names, according to the map returned by
  978. load_alias_to_gene_map().
  979. Before matching a gene list from a third-party dataset
  980. Before matching gene lists from two third-party datasets to each other, run
  981. them both through this function. You don't have to run this on the result
  982. of Ensembl_to_gene(), though.
  983. Args:
  984. df: a polars DataFrame
  985. gene_col: the name of the column with the gene names to be unaliased
  986. gene_annotation_dir: the directory where the alias-to-gene map returned
  987. by load_alias_to_gene_map() will be cached. Must
  988. be run on the login node to generate this cache,
  989. if it doesn't exist. Because generating the cache
  990. can take a long time, you will probably want to
  991. leave this argument at its default value.
  992. Returns:
  993. df with each matching gene name in gene_col mapped to its alias. gene
  994. names not matching any of the old gene names in
  995. load_alias_to_gene_map() are left as-is.
  996. """
  997. return map_df(df, gene_col, load_alias_to_gene_map(
  998. gene_annotation_dir=gene_annotation_dir),
  999. key_col='alias', value_col='gene', retain_missing=True)
  1000. def get_Ensembl_map(ENSP=False,
  1001. gene_annotation_dir=f'{get_base_data_directory()}/'
  1002. f'gene-annotations',
  1003. no_unaliasing=False):
  1004. """
  1005. Gets a map from ENSGs (or ENSPs, if ENSP=True) to their most recent gene
  1006. symbols in the Ensembl database, according to the map returned by
  1007. get_Ensembl_map(). The returned gene names are unaliased, so you don't have
  1008. to run unalias() on them.
  1009. Ensembl IDs retired on or before release-42 (in 2006) are not included
  1010. since these releases lack GTF files on the Ensembl website, and these IDs
  1011. would be unlikely to be encountered in modern genomics data anyway.
  1012. Args:
  1013. ENSP: whether to convert ENSPs (protein IDs) instead of ENSGs (gene
  1014. IDs)
  1015. gene_annotation_dir: the directory where the Ensembl to gene symbol map
  1016. will be cached. Must be run on the login node to
  1017. generate this cache, if it doesn't exist. Because
  1018. generating the cache can take a long time, you
  1019. will probably want to leave this argument at its
  1020. default value.
  1021. no_unaliasing: if True, gene names will not be unalised
  1022. Returns:
  1023. A two-column polars DataFrame with an "Ensembl_ID" column containing
  1024. each of the Ensembl IDs in the Ensembl database and a "gene_name"
  1025. column containing their gene names.
  1026. """
  1027. Ensembl_ID_type = 'ENSP' if ENSP else 'ENSG'
  1028. mapping_file = os.path.join(gene_annotation_dir,
  1029. f'{Ensembl_ID_type}_to_gene_name.tsv')
  1030. if not os.path.exists(mapping_file):
  1031. print(f'Generating "{mapping_file}"...')
  1032. raise_error_if_on_compute_node()
  1033. os.makedirs(gene_annotation_dir, exist_ok=True)
  1034. latest_Ensembl_version = max(map(int, run(
  1035. 'curl -fsSL https://ftp.ensembl.org/pub/current_gtf/'
  1036. 'homo_sapiens/ | grep -oP "GRCh38\\.\\K[0-9]{3}" | uniq',
  1037. stdout=subprocess.PIPE).stdout.split()))
  1038. get_build = lambda i: "GRCh38" if i in range(76, 82) else "GRCh37" \
  1039. if i in range(55, 76) else "NCBI36"
  1040. GTF_basenames = [
  1041. basename
  1042. for i in range(latest_Ensembl_version, 81, -1) for
  1043. basename in (
  1044. [f'release-{i}/gtf/homo_sapiens/'
  1045. f'Homo_sapiens.GRCh38.{i}.chr_patch_hapl_scaff',
  1046. f'grch37/release-{i}/gtf/homo_sapiens/'
  1047. f'Homo_sapiens.GRCh37.{i}.chr_patch_hapl_scaff']
  1048. if i in (87, 85, 82) else
  1049. [f'release-{i}/gtf/homo_sapiens/'
  1050. f'Homo_sapiens.GRCh38.{i}.chr_patch_hapl_scaff'])
  1051. ] + [
  1052. f'release-{i}/gtf/homo_sapiens/Homo_sapiens.{get_build(i)}.{i}'
  1053. for i in range(81, 47, -1)
  1054. ] + [
  1055. 'release-47/gtf/Homo_sapiens.NCBI36.47',
  1056. 'release-46/homo_sapiens_46_36h/data/gtf/Homo_sapiens.NCBI36.46',
  1057. 'release-44/homo_sapiens_44_36f/data/gtf/Homo_sapiens.NCBI36.44',
  1058. 'release-43/homo_sapiens_43_36e/data/gtf/Homo_sapiens.NCBI36.43']
  1059. sed_command = \
  1060. 's/.*gene_name "(\\S*)".*protein_id "(\\S*)".*/\\2\\t\\1/p' \
  1061. if ENSP else \
  1062. 's/.*gene_id "(\\S*)".*gene_name "(\\S*)".*/\\1\\t\\2/p'
  1063. cache_dir = os.path.join(gene_annotation_dir,
  1064. f'{Ensembl_ID_type}_by_version')
  1065. os.makedirs(cache_dir, exist_ok=True)
  1066. version_mapping_files = [os.path.join(
  1067. cache_dir, f'{basename.split("/")[1].replace("-", "_")}_grch37.tsv'
  1068. if basename.startswith('grch37/') else
  1069. f'{basename.split("/")[0].replace("-", "_")}.tsv')
  1070. for basename in GTF_basenames]
  1071. for basename, version_mapping_file in zip(GTF_basenames,
  1072. version_mapping_files):
  1073. if not os.path.exists(version_mapping_file):
  1074. print(f'Generating "{version_mapping_file}"...')
  1075. run(f'curl -fsSL https://ftp.ensembl.org/pub/'
  1076. f'{basename}.gtf.gz | zcat | sed -nr \'{sed_command}\' | '
  1077. f'awk \'!seen[$1]++\' > {version_mapping_file}')
  1078. run(f'awk \'!seen[$1]++ {{print $1, $2, gensub(/.*\\/([^/]+)\\..*/, '
  1079. f'"\\\\1", "", FILENAME)}}\' OFS="\t" '
  1080. f'{" ".join(version_mapping_files)} > {mapping_file}')
  1081. return pl.read_csv(mapping_file, separator='\t', has_header=False,
  1082. new_columns=['Ensembl_ID', 'gene_name',
  1083. 'most_recent_Ensembl_version'],
  1084. comment_prefix='#') \
  1085. .pipe(lambda df: df if no_unaliasing else unalias(
  1086. df, 'gene_name', gene_annotation_dir=gene_annotation_dir))
  1087. def check_valid_genome_build(genome_build):
  1088. """
  1089. Checks whether a genome build is valid (for the functions here)
  1090. Args:
  1091. genome_build: the genome build; must be hg19 or hg38
  1092. """
  1093. assert genome_build == 'hg19' or genome_build == 'hg38', genome_build
  1094. def get_coding_genes(genome_build='hg38', gencode_version=46,
  1095. gene_annotation_dir=f'{get_base_data_directory()}/'
  1096. f'gene-annotations',
  1097. return_file=False):
  1098. """
  1099. Get the coordinates, gene symbols and Ensembl IDs of coding genes on
  1100. autosomes and sex chromosomes.
  1101. Args:
  1102. genome_build: the genome build (hg19 or hg38) to get coordinates for
  1103. gencode_version: the Gencode version to take coding genes from
  1104. gene_annotation_dir: the directory where a BED file of the coding genes
  1105. will be cached. Must be run on the login node to
  1106. generate this cache, if it doesn't exist. Because
  1107. generating the cache can take a long time, you
  1108. will probably want to leave this argument at its
  1109. default value.
  1110. return_file: If True, return a BED file path instead of a DataFrame.
  1111. Note: the start coordinates in the BED file are one less
  1112. than in the returned DataFrame, because BED files are
  1113. 0-based while the returned DataFrame (and most sumstats)
  1114. are 1-based.
  1115. Returns:
  1116. A DataFrame with chrom, start, end, strand, gene, and Ensembl_IDs as
  1117. columns, or a BED file of the same if return_file=True. The gene names
  1118. are unaliased with unalias(), so you don't have to run unalias() again.
  1119. Genes in the pseudoautosomal regions have chrX as their chromosome, and
  1120. their chrY positions are not reported. Ensembl_IDs is a list column
  1121. since gene symbols very occasionally have multiple Ensembl IDs.
  1122. """
  1123. check_valid_genome_build(genome_build)
  1124. coding_genes_file = f'{gene_annotation_dir}/coding_genes_{genome_build}_' \
  1125. f'gencode_v{gencode_version}.bed'
  1126. if os.path.exists(coding_genes_file):
  1127. return coding_genes_file if return_file else pl.read_csv(
  1128. coding_genes_file, separator='\t', has_header=False,
  1129. new_columns=['chrom', 'start', 'end', 'strand', 'gene',
  1130. 'Ensembl_IDs']) \
  1131. .with_columns(pl.col.start + 1, # convert BED to one-based
  1132. pl.col.Ensembl_IDs.str.split(','))
  1133. os.makedirs(gene_annotation_dir, exist_ok=True)
  1134. coding_genes_intermediate_file = \
  1135. coding_genes_file.removesuffix('.bed') + '.intermediate.bed'
  1136. if not os.path.exists(coding_genes_intermediate_file):
  1137. raise_error_if_on_compute_node()
  1138. gencode_URL = (
  1139. f'https://ftp.ebi.ac.uk/pub/databases/gencode/Gencode_human/'
  1140. f'release_{gencode_version}'
  1141. f'{"/GRCh37_mapping" if genome_build == "hg19" else ""}/gencode.v'
  1142. f'{gencode_version}{"lift37" if genome_build == "hg19" else ""}.'
  1143. f'annotation.gtf.gz')
  1144. # Subtract 1 from the start coordinate because BED is 0-based, whereas
  1145. # GTF is 1-based
  1146. run(f'curl -fsSL {gencode_URL} | zcat | tr -d ";\\"" | awk \'$3 == '
  1147. f'"gene" && $0 ~ /protein_coding|IG_.*_gene|TR_.*_gene/ {{for '
  1148. f'(x = 1; x <= NF; x++) {{ if ($x == "gene_name") gene_name = '
  1149. f'$(x + 1); else if ($x == "gene_id") gene_id = $(x + 1); }} '
  1150. f'print $1, $4 - 1, $5, $7, gene_name, gene_id}}\' OFS="\t" | '
  1151. f'sort -k1,1V -k2,3n > {coding_genes_intermediate_file}')
  1152. coding_genes = pl.scan_csv(
  1153. coding_genes_intermediate_file, separator='\t', has_header=False,
  1154. new_columns=['chrom', 'start', 'end', 'strand', 'gene',
  1155. 'Ensembl_IDs']) \
  1156. .filter(pl.col.chrom != 'chrM') \
  1157. .with_columns(Ensembl_IDs=pl.col.Ensembl_IDs.str.split_exact('.', 1)
  1158. .struct.field('field_0')) \
  1159. .collect()
  1160. # For hg19, 7 coding genes were subsequently merged into another gene
  1161. # (PRAMEF21 -> PRAMEF20, NBPF16 -> NBPF15, FOXD4L2 -> FOXD4L4,
  1162. # MRC1L1 -> MRC1, ANXA8L2 -> ANXA8L1, ASAH2C -> ASAH2B, CT45A4 -> CT45A3);
  1163. # to avoid the same gene being present in two different locations, do NOT
  1164. # unalias. Also, for both hg19 and hg38, ZNF475 is an alias of ZFP1, but
  1165. # also a gene in its own right; we can similarly ignore the alias.
  1166. assert len(coding_genes
  1167. .with_columns(unaliased_gene='gene')
  1168. .pipe(unalias, 'unaliased_gene',
  1169. gene_annotation_dir=gene_annotation_dir)
  1170. .filter(pl.col.unaliased_gene != pl.col.gene)) == \
  1171. (1 if genome_build == 'hg38' else 8)
  1172. # No genes should have more than one chromosome, except those in the
  1173. # pseudoautosomal regions
  1174. chrX_PAR1_end = 2_781_479 if genome_build == 'hg38' else 2_699_520
  1175. chrX_PAR2_start = 155_701_383 if genome_build == 'hg38' else 154_931_044
  1176. chrY_PAR1_end = 2_781_479 if genome_build == 'hg38' else 2_649_520
  1177. chrY_PAR2_start = 56_887_903 if genome_build == 'hg38' else 59_034_050
  1178. non_PAR_coding_genes = coding_genes.filter(
  1179. ~(pl.col.chrom.eq('chrX') & (pl.col.end.lt(chrX_PAR1_end) |
  1180. pl.col.start.ge(chrX_PAR2_start))),
  1181. ~(pl.col.chrom.eq('chrY') & (pl.col.end.lt(chrY_PAR1_end) |
  1182. pl.col.start.ge(chrY_PAR2_start))))
  1183. assert len(non_PAR_coding_genes.filter(
  1184. pl.col.chrom.n_unique().over('gene') != 1)) == 0
  1185. # No genes should have more than one strand
  1186. assert len(coding_genes.filter(
  1187. pl.col.strand.n_unique().over('gene') != 1)) == 0
  1188. # Occasionally, non-pseudoautosomal genes may appear more than once, always
  1189. # under different Ensembl IDs. Confirm that these genes are always
  1190. # overlapping, then merge them, and make a list of their Ensembl IDs.
  1191. duplicated_non_PAR_genes = \
  1192. non_PAR_coding_genes.filter(pl.col.gene.is_duplicated())
  1193. assert len(duplicated_non_PAR_genes.filter(
  1194. pl.col.start.max().over('gene') >= pl.col.end.min().over('gene'))) == 0
  1195. assert not duplicated_non_PAR_genes['Ensembl_IDs'].is_duplicated().any()
  1196. # Merge isoforms and genes with multiple Ensembl IDs into a single entry
  1197. # per gene symbol. The start and end are the min and max across isoforms,
  1198. # and the Ensembl IDs are aggregated into a list column. Only report the
  1199. # chrX position for pseudoautosomal genes by filtering out the chrY
  1200. # pseudoautosomal regions ahead of time.
  1201. coding_genes = coding_genes \
  1202. .lazy() \
  1203. .filter(~(pl.col.chrom.eq('chrY') & (
  1204. pl.col.end.lt(chrY_PAR1_end) | pl.col.start.ge(chrY_PAR2_start)))) \
  1205. .group_by('gene', maintain_order=True) \
  1206. .agg(pl.first('chrom'), pl.min('start'), pl.max('end'),
  1207. pl.first('strand'), 'Ensembl_IDs') \
  1208. .with_columns(pl.col.Ensembl_IDs.list.unique().list.sort()) \
  1209. .select('chrom', 'start', 'end', 'strand', 'gene', 'Ensembl_IDs') \
  1210. .collect()
  1211. assert not coding_genes['gene'].is_duplicated().any()
  1212. # Write to a file, and return
  1213. coding_genes \
  1214. .with_columns(pl.col.Ensembl_IDs.list.join(',')) \
  1215. .write_csv(coding_genes_file, separator='\t', include_header=False)
  1216. run(f"rm '{coding_genes_intermediate_file}'")
  1217. return coding_genes_file if return_file else \
  1218. coding_genes.with_columns(pl.col.start + 1)
  1219. def get_coding_exons(genome_build='hg38', gencode_version=46,
  1220. gene_annotation_dir=f'{get_base_data_directory()}/'
  1221. f'gene-annotations',
  1222. return_file=False):
  1223. """
  1224. Get the coordinates, gene symbols and Ensembl IDs of exons of coding genes
  1225. on autosomes and sex chromosomes.
  1226. Args:
  1227. genome_build: the genome build (hg19 or hg38) to get coordinates for
  1228. gencode_version: the Gencode version to take coding exons from
  1229. gene_annotation_dir: the directory where a BED file of the coding exons
  1230. will be cached. Must be run on the login node to
  1231. generate this cache, if it doesn't exist. Because
  1232. generating the cache can take a long time, you
  1233. will probably want to leave this argument at its
  1234. default value.
  1235. return_file: If True, return a BED file path instead of a DataFrame.
  1236. Note: the start coordinates in the BED file are one less
  1237. than in the returned DataFrame, because BED files are
  1238. 0-based while the returned DataFrame (and most sumstats)
  1239. are 1-based.
  1240. Returns:
  1241. A DataFrame with chrom, start, end, strand, gene, and Ensembl_IDs of
  1242. each exon as columns, or a BED file of the same if return_file=True.
  1243. The gene names are unaliased with unalias(), so you don't have to run
  1244. unalias() again. Exons in the pseudoautosomal regions have chrX as
  1245. their chromosome. Ensembl_IDs is a list column since gene symbols very
  1246. occasionally have multiple Ensembl IDs.
  1247. """
  1248. check_valid_genome_build(genome_build)
  1249. coding_exons_file = f'{gene_annotation_dir}/coding_exons_{genome_build}_' \
  1250. f'gencode_v{gencode_version}.bed'
  1251. if os.path.exists(coding_exons_file):
  1252. return coding_exons_file if return_file else pl.read_csv(
  1253. coding_exons_file, separator='\t', has_header=False,
  1254. new_columns=['chrom', 'start', 'end', 'strand', 'gene',
  1255. 'Ensembl_IDs']) \
  1256. .with_columns(pl.col.start + 1, # convert BED to one-based
  1257. pl.col.Ensembl_IDs.str.split(','))
  1258. os.makedirs(gene_annotation_dir, exist_ok=True)
  1259. coding_exons_intermediate_file = \
  1260. coding_exons_file.removesuffix('.bed') + '.intermediate.bed'
  1261. if not os.path.exists(coding_exons_intermediate_file):
  1262. raise_error_if_on_compute_node()
  1263. gencode_URL = (
  1264. f'https://ftp.ebi.ac.uk/pub/databases/gencode/Gencode_human/'
  1265. f'release_{gencode_version}'
  1266. f'{"/GRCh37_mapping" if genome_build == "hg19" else ""}/gencode.v'
  1267. f'{gencode_version}{"lift37" if genome_build == "hg19" else ""}.'
  1268. f'annotation.gtf.gz')
  1269. # Subtract 1 from the start coordinate because BED is 0-based, whereas
  1270. # GTF is 1-based
  1271. run(f'curl -fsSL {gencode_URL} | zcat | tr -d ";\\"" | awk \'$3 == '
  1272. f'"exon" && $0 ~ /protein_coding|IG_.*_gene|TR_.*_gene/ {{for '
  1273. f'(x = 1; x <= NF; x++) {{ if ($x == "gene_name") gene_name = '
  1274. f'$(x + 1); else if ($x == "gene_id") gene_id = $(x + 1); }} '
  1275. f'print $1, $4 - 1, $5, $7, gene_name, gene_id}}\' OFS="\t" | '
  1276. f'sort -k1,1V -k2,3n > {coding_exons_intermediate_file}')
  1277. coding_exons = pl.scan_csv(
  1278. coding_exons_intermediate_file, separator='\t', has_header=False,
  1279. new_columns=['chrom', 'start', 'end', 'strand', 'gene',
  1280. 'Ensembl_IDs']) \
  1281. .filter(pl.col.chrom != 'chrM') \
  1282. .with_columns(Ensembl_IDs=pl.col.Ensembl_IDs.str.split_exact('.', 1)
  1283. .struct.field('field_0')) \
  1284. .unique(maintain_order=True) \
  1285. .collect()
  1286. # For hg19, 7 coding genes were subsequently merged into another gene
  1287. # (PRAMEF21 -> PRAMEF20, NBPF16 -> NBPF15, FOXD4L2 -> FOXD4L4,
  1288. # MRC1L1 -> MRC1, ANXA8L2 -> ANXA8L1, ASAH2C -> ASAH2B, CT45A4 -> CT45A3);
  1289. # to avoid the same gene being present in two different locations, do NOT
  1290. # unalias. Also, for both hg19 and hg38, ZNF475 is an alias of ZFP1, but
  1291. # also a gene in its own right; we can similarly ignore the alias.
  1292. assert len(coding_exons
  1293. .with_columns(unaliased_gene='gene')
  1294. .pipe(unalias, 'unaliased_gene',
  1295. gene_annotation_dir=gene_annotation_dir)
  1296. .filter(pl.col.unaliased_gene != pl.col.gene)
  1297. .unique('gene')) == (1 if genome_build == 'hg38' else 8)
  1298. # No exons should have more than one chromosome, except those in the
  1299. # pseudoautosomal regions
  1300. chrX_PAR1_end = 2_781_479 if genome_build == 'hg38' else 2_699_520
  1301. chrX_PAR2_start = 155_701_383 if genome_build == 'hg38' else 154_931_044
  1302. chrY_PAR1_end = 2_781_479 if genome_build == 'hg38' else 2_649_520
  1303. chrY_PAR2_start = 56_887_903 if genome_build == 'hg38' else 59_034_050
  1304. non_PAR_coding_exons = coding_exons.filter(
  1305. ~(pl.col.chrom.eq('chrX') & (pl.col.end.lt(chrX_PAR1_end) |
  1306. pl.col.start.ge(chrX_PAR2_start))),
  1307. ~(pl.col.chrom.eq('chrY') & (pl.col.end.lt(chrY_PAR1_end) |
  1308. pl.col.start.ge(chrY_PAR2_start))))
  1309. assert len(non_PAR_coding_exons.filter(
  1310. pl.col.chrom.n_unique().over('gene') != 1)) == 0
  1311. # No exons should have more than one strand
  1312. assert len(coding_exons.filter(
  1313. pl.col.strand.n_unique().over('gene') != 1)) == 0
  1314. # Occasionally, non-pseudoautosomal exons with the same gene symbol may
  1315. # appear multiple times under different Ensembl IDs. Merge them and make a
  1316. # list of their Ensembl IDs.
  1317. coding_exons = coding_exons \
  1318. .group_by('chrom', 'start', 'end', 'strand', 'gene',
  1319. maintain_order=True) \
  1320. .agg('Ensembl_IDs')
  1321. # Write to a file, and return
  1322. coding_exons \
  1323. .with_columns(pl.col.Ensembl_IDs.list.join(',')) \
  1324. .write_csv(coding_exons_file, separator='\t', include_header=False)
  1325. run(f"rm '{coding_exons_intermediate_file}'")
  1326. return coding_exons_file if return_file else \
  1327. coding_exons.with_columns(pl.col.start + 1)
  1328. def get_coding_TSSs(genome_build='hg38', gencode_version=46,
  1329. gene_annotation_dir=f'{get_base_data_directory()}/'
  1330. f'gene-annotations',
  1331. return_file=False):
  1332. """
  1333. Get the coordinates, gene symbols and Ensembl IDs of transcription start
  1334. sites (TSSs) for coding genes on autosomes and sex chromosomes. Genes may
  1335. have multiple TSSs, one per transcript.
  1336. Args:
  1337. genome_build: the genome build (hg19 or hg38) to get coordinates for
  1338. gencode_version: the Gencode version to take coding genes from
  1339. gene_annotation_dir: the directory where a BED file of the coding TSSs
  1340. will be cached. Must be run on the login node to
  1341. generate this cache, if it doesn't exist. Because
  1342. generating the cache can take a long time, you
  1343. will probably want to leave this argument at its
  1344. default value.
  1345. return_file: If True, return a BED file path instead of a DataFrame.
  1346. The BED file will have start = bp - 1 and end = bp, since
  1347. BED files are 0-based while the returned DataFrame (and
  1348. most sumstats) are 1-based.
  1349. Returns:
  1350. A DataFrame with chrom, bp, strand, gene, and Ensembl_ID as columns,
  1351. or a BED file of the same if return_file=True. The gene names are
  1352. unaliased with unalias(), so you don't have to run unalias() again.
  1353. Genes in the pseudoautosomal regions have chrX as their chromosome.
  1354. """
  1355. check_valid_genome_build(genome_build)
  1356. coding_TSSs_file = f'{gene_annotation_dir}/coding_TSSs_{genome_build}_' \
  1357. f'gencode_v{gencode_version}.bed'
  1358. if os.path.exists(coding_TSSs_file):
  1359. return coding_TSSs_file if return_file else pl.read_csv(
  1360. coding_TSSs_file, separator='\t', has_header=False,
  1361. new_columns=['chrom', 'start', 'end', 'strand', 'gene',
  1362. 'Ensembl_ID']) \
  1363. .drop('start') \
  1364. .rename({'end': 'bp'})
  1365. os.makedirs(gene_annotation_dir, exist_ok=True)
  1366. coding_TSSs_intermediate_file = \
  1367. coding_TSSs_file.removesuffix('.bed') + '.intermediate.bed'
  1368. if not os.path.exists(coding_TSSs_intermediate_file):
  1369. raise_error_if_on_compute_node()
  1370. gencode_URL = (
  1371. f'https://ftp.ebi.ac.uk/pub/databases/gencode/Gencode_human/'
  1372. f'release_{gencode_version}'
  1373. f'{"/GRCh37_mapping" if genome_build == "hg19" else ""}/gencode.v'
  1374. f'{gencode_version}{"lift37" if genome_build == "hg19" else ""}.'
  1375. f'annotation.gtf.gz')
  1376. # Subtract 1 from the start coordinate because BED is 0-based, whereas
  1377. # GTF is 1-based; also uniquify (sort -u) since multiple transcripts of
  1378. # the same gene may share a TSS
  1379. run(f'curl -fsSL {gencode_URL} | zcat | tr -d ";\\"" | awk \'$3 == '
  1380. f'"transcript" && $0 ~ /protein_coding|IG_.*_gene|TR_.*_gene/ '
  1381. f'{{for (x = 1; x <= NF; x++) {{ if ($x == "gene_name") '
  1382. f'gene_name = $(x + 1); else if ($x == "gene_id") gene_id = '
  1383. f'$(x + 1); }} print $1, $7 == "+" ? $4 - 1 : $5 - 1, '
  1384. f'$7 == "+" ? $4 : $5, $7, gene_name, gene_id}}\' OFS="\t" | '
  1385. f'sort -k1,1V -k2,3n -u > {coding_TSSs_intermediate_file}')
  1386. coding_TSSs = pl.scan_csv(
  1387. coding_TSSs_intermediate_file, separator='\t', has_header=False,
  1388. new_columns=['chrom', 'start', 'end', 'strand', 'gene', 'Ensembl_ID']) \
  1389. .filter(pl.col.chrom != 'chrM') \
  1390. .with_columns(Ensembl_IDs=pl.col.Ensembl_IDs.str.split_exact('.', 1)
  1391. .struct.field('field_0')) \
  1392. .collect()
  1393. # For hg19, 7 coding genes were subsequently merged into another gene
  1394. # (PRAMEF21 -> PRAMEF20, NBPF16 -> NBPF15, FOXD4L2 -> FOXD4L4,
  1395. # MRC1L1 -> MRC1, ANXA8L2 -> ANXA8L1, ASAH2C -> ASAH2B, CT45A4 -> CT45A3);
  1396. # to avoid the same gene being present in two different locations, do NOT
  1397. # unalias. Also, for both hg19 and hg38, ZNF475 is an alias of ZFP1, but
  1398. # also a gene in its own right; we can similarly ignore the alias.
  1399. assert len(coding_TSSs
  1400. .with_columns(unaliased_gene='gene')
  1401. .pipe(unalias, 'unaliased_gene',
  1402. gene_annotation_dir=gene_annotation_dir)
  1403. .filter(pl.col.unaliased_gene != pl.col.gene)
  1404. .unique('gene')) == (1 if genome_build == 'hg38' else 8)
  1405. # No TSSs should have more than one chromosome, except those in the
  1406. # pseudoautosomal regions
  1407. chrX_PAR1_end = 2_781_479 if genome_build == 'hg38' else 2_699_520
  1408. chrX_PAR2_start = 155_701_383 if genome_build == 'hg38' else 154_931_044
  1409. chrY_PAR1_end = 2_781_479 if genome_build == 'hg38' else 2_649_520
  1410. chrY_PAR2_start = 56_887_903 if genome_build == 'hg38' else 59_034_050
  1411. non_PAR_coding_TSSs = coding_TSSs.filter(
  1412. ~(pl.col.chrom.eq('chrX') & (pl.col.end.lt(chrX_PAR1_end) |
  1413. pl.col.start.ge(chrX_PAR2_start))),
  1414. ~(pl.col.chrom.eq('chrY') & (pl.col.end.lt(chrY_PAR1_end) |
  1415. pl.col.start.ge(chrY_PAR2_start))))
  1416. assert len(non_PAR_coding_TSSs.filter(
  1417. pl.col.chrom.n_unique().over('gene') != 1)) == 0
  1418. # No TSSs should have more than one strand
  1419. assert len(coding_TSSs.filter(
  1420. pl.col.strand.n_unique().over('gene') != 1)) == 0
  1421. # Make sure no genes share a TSS
  1422. assert len(coding_TSSs.filter(
  1423. pl.struct('chrom', 'start', 'end').is_duplicated())) == 0
  1424. # Write to a file, and return
  1425. coding_TSSs \
  1426. .write_csv(coding_TSSs_file, separator='\t', include_header=False)
  1427. run(f"rm '{coding_TSSs_intermediate_file}'")
  1428. return coding_TSSs_file if return_file else \
  1429. coding_TSSs.drop('start').rename({'end': 'bp'})
  1430. # noinspection GrazieInspection
  1431. def get_nearest_gene(sumstats, genome_build, *, gencode_version=46,
  1432. SNP_col='SNP', chrom_col='CHROM', bp_col='BP',
  1433. gene_col='gene', gene_distance_col='gene_distance',
  1434. TSS_col='TSS', TSS_distance_col='TSS_distance', TSS=None,
  1435. gene_annotation_dir=f'{get_base_data_directory()}/'
  1436. f'gene-annotations',
  1437. signed=False, include_details=False):
  1438. """
  1439. Gets the nearest coding gene(s) and/or TSS(s) for each variant in sumstats.
  1440. Given a DataFrame of sumstats with columns SNP_col, chrom_col and bp_col,
  1441. returns a DataFrame with those three columns plus (by default) four others,
  1442. described below: gene_col, gene_distance_col, TSS_col, and
  1443. TSS_distance_col. If include_details=True, include additional columns with
  1444. more details (see below).
  1445. Args:
  1446. sumstats: the summary statistics, as a polars DataFrame
  1447. genome_build: the genome build of sumstats
  1448. gencode_version: the Gencode version to take coding genes from
  1449. SNP_col: the name of the variant ID column in sumstats
  1450. chrom_col: the name of the chromosome column in sumstats
  1451. bp_col: the name of the base-pair column in sumstats
  1452. gene_col: the name of the column in the output DataFrame containing the
  1453. gene symbol(s) of the nearest gene(s) to each variant, as a
  1454. list
  1455. gene_distance_col: the name of the column in the output DataFrame
  1456. containing the distance from each variant to (each
  1457. of) the nearest gene(s) in gene_col, as a list. The
  1458. distance is 0 if the variant is inside the gene
  1459. (i.e. between the TSS and TES), and the distance to
  1460. whichever of the TSS and TES is closer, otherwise.
  1461. TSS_col: the name of the column in the output DataFrame containing the
  1462. gene symbol(s) of the gene(s) with the nearest TSS(s) to each
  1463. variant, as a list. For genes with multiple transcripts, only
  1464. the transcript(s) with the nearest TSS are counted.
  1465. TSS_distance_col: the name of the column in the output DataFrame
  1466. containing the distance from each variant to the
  1467. TSS(s) of (each of) the gene(s) in TSS_col, as a list
  1468. TSS: if None (the default), include both gene_col/gene_distance_col and
  1469. TSS_col/TSS_distance_col in the output. If False, include only
  1470. gene_col/gene_distance_col. If True, include only
  1471. TSS_col/TSS_distance_col.
  1472. gene_annotation_dir: the directory where the coding gene locations
  1473. returned by get_coding_genes() will be cached.
  1474. Must be run on the login node to generate this
  1475. cache, if it doesn't exist. Because generating the
  1476. cache can take a long time, you will probably want
  1477. to leave this argument at its default value.
  1478. signed: whether to report signed instead of unsigned distances
  1479. include_details: whether to include additional columns in the output:
  1480. For nearest genes (TSS=False or TSS=None):
  1481. - previous_gene_boundary: the base-pair position of the previous
  1482. gene boundary
  1483. - previous_genes: a list of the previous gene(s)
  1484. - next_gene_boundary: the base-pair position of the next gene
  1485. boundary
  1486. - next_genes: a list of the next gene(s)
  1487. - genes_inside: a list of gene(s) containing the variant
  1488. For nearest TSSs (TSS=True or TSS=None):
  1489. - previous_TSS: the base-pair position of the previous TSS
  1490. - previous_TSS_genes: a list of the previous TSS's gene(s)
  1491. - next_TSS: the base-pair position of the next TSS
  1492. - next_TSS_genes: a list of the next TSS's gene(s)
  1493. Returns:
  1494. A DataFrame with chrom_col, bp_col, gene_col/distance_col and/or
  1495. TSS_col/TSS_distance_col columns, and optionally the other columns
  1496. above if include_details=True.
  1497. """
  1498. # Sanity-check inputs
  1499. check_valid_genome_build(genome_build)
  1500. if sumstats.is_empty():
  1501. raise ValueError(f'sumstats is empty!')
  1502. if SNP_col not in sumstats:
  1503. raise ValueError(f'{SNP_col!r} is not in sumstats; specify SNP_col')
  1504. if chrom_col not in sumstats:
  1505. raise ValueError(f'{chrom_col!r} is not in sumstats; specify '
  1506. f'chrom_col')
  1507. if bp_col not in sumstats:
  1508. raise ValueError(f'{bp_col!r} is not in sumstats; specify bp_col')
  1509. if TSS is not True and TSS is not False and TSS is not None:
  1510. raise ValueError(f'TSS must be True, False, or None')
  1511. include_nearest_gene = TSS is not True
  1512. include_nearest_TSS = TSS is not False
  1513. if include_nearest_gene and gene_col in sumstats:
  1514. raise ValueError(f'{gene_col!r} is already in sumstats; specify a '
  1515. f'different gene_col')
  1516. if include_nearest_gene and gene_distance_col in sumstats:
  1517. raise ValueError(f'{gene_distance_col!r} is already in sumstats; '
  1518. f'specify a different gene_distance_col')
  1519. if include_nearest_TSS and TSS_col in sumstats:
  1520. raise ValueError(f'{TSS_col!r} is already in sumstats; specify a '
  1521. f'different TSS_col')
  1522. if include_nearest_TSS and TSS_distance_col in sumstats:
  1523. raise ValueError(f'{TSS_distance_col!r} is already in sumstats; '
  1524. f'specify a different TSS_distance_col')
  1525. if include_details and TSS is True:
  1526. raise ValueError(f'include_details must be False when TSS=True')
  1527. # Standardize sumstats' chromosomes and sort
  1528. standardized_sorted_sumstats = sumstats \
  1529. .lazy() \
  1530. .select(SNP_col,
  1531. pl.col(chrom_col).pipe(standardize_chromosomes),
  1532. pl.col(chrom_col).alias('original_chrom'),
  1533. bp_col) \
  1534. .sort(chrom_col, bp_col)
  1535. if include_nearest_gene:
  1536. # Get coding genes
  1537. coding_genes = get_coding_genes(
  1538. genome_build=genome_build, gencode_version=gencode_version,
  1539. gene_annotation_dir=gene_annotation_dir)
  1540. # Get coding gene boundaries (starts and ends). Occasionally genes
  1541. # share the same boundary, so make a list of the genes that share each
  1542. # boundary. Almost all of these lists have just one gene, though.
  1543. gene_boundaries = pl.concat([coding_genes.select('chrom', key, 'gene')
  1544. .rename({'chrom': chrom_col, key: bp_col})
  1545. for key in ('start', 'end')]) \
  1546. .group_by(chrom_col, bp_col).agg('gene') \
  1547. .sort(chrom_col, bp_col)
  1548. # If genes are nested (e.g. GPR52 is entirely contained within
  1549. # RABGAP1L) or partially overlapping such that a gene boundary is
  1550. # contained within another gene, include the containing gene in the
  1551. # list as well. This is so that we can correctly infer which genes
  1552. # contain each variant below.
  1553. gene_boundaries = gene_boundaries \
  1554. .lazy() \
  1555. .drop('gene') \
  1556. .with_row_index() \
  1557. .join(gene_boundaries
  1558. .lazy()
  1559. .with_row_index()
  1560. .explode('gene')
  1561. .group_by('gene')
  1562. .agg(pl.col.index.min().alias('min_index'),
  1563. pl.col.index.max().alias('max_index'))
  1564. .with_columns(pl.int_ranges(
  1565. 'min_index', pl.col.max_index + 1,
  1566. dtype=pl.UInt32).alias('index'))
  1567. .drop('min_index', 'max_index')
  1568. .explode('index')
  1569. .group_by('index')
  1570. .agg('gene'),
  1571. on='index') \
  1572. .sort('index') \
  1573. .drop('index') \
  1574. .collect()
  1575. # join_asof both forward, to the start of the next gene boundary, and
  1576. # backward, to the end of the previous gene boundary.
  1577. #
  1578. # If any of the next and previous gene(s) are the same, then those are
  1579. # the nearest genes, and since the variant is inside them, the distance
  1580. # is 0.
  1581. #
  1582. # Otherwise, get the distances to both the next and previous genes:
  1583. # when equal (very rare), take all the next and all the previous genes
  1584. # as nearest genes; otherwise, just take the ones on the closer side.
  1585. #
  1586. # Deduplicate and sort each variant's list of nearest genes at the end.
  1587. nearest_genes = standardized_sorted_sumstats \
  1588. .join_asof(gene_boundaries.lazy()
  1589. .rename({bp_col: 'previous_gene_boundary',
  1590. 'gene': 'previous_genes'}),
  1591. left_on=bp_col, right_on='previous_gene_boundary',
  1592. by=chrom_col, strategy='backward',
  1593. check_sortedness=False) \
  1594. .join_asof(gene_boundaries.lazy()
  1595. .rename({bp_col: 'next_gene_boundary',
  1596. 'gene': 'next_genes'}),
  1597. left_on=bp_col, right_on='next_gene_boundary',
  1598. by=chrom_col, strategy='forward',
  1599. check_sortedness=False) \
  1600. .with_columns(pl.col('previous_genes', 'next_genes')
  1601. .fill_null([])) \
  1602. .with_columns(distance_to_previous=pl.col(bp_col) -
  1603. pl.col.previous_gene_boundary,
  1604. distance_to_next=pl.col.next_gene_boundary -
  1605. pl.col(bp_col),
  1606. genes_inside=pl.col.previous_genes
  1607. .list.set_intersection(pl.col.next_genes)) \
  1608. .with_columns(pl.when(pl.col.genes_inside.list.len() > 0)
  1609. .then('genes_inside')
  1610. .when(pl.col.distance_to_previous ==
  1611. pl.col.distance_to_next)
  1612. .then(pl.concat_list('previous_genes', 'next_genes'))
  1613. .when((pl.col.distance_to_previous <
  1614. pl.col.distance_to_next) |
  1615. pl.col.distance_to_next.is_null())
  1616. .then('previous_genes')
  1617. .otherwise('next_genes')
  1618. .alias(gene_col),
  1619. pl.when(pl.col.genes_inside.list.len() > 0)
  1620. .then(0)
  1621. .when((pl.col.distance_to_previous <
  1622. pl.col.distance_to_next) |
  1623. pl.col.distance_to_next.is_null())
  1624. .then(-pl.col.distance_to_previous if signed else
  1625. 'distance_to_previous')
  1626. .otherwise('distance_to_next')
  1627. .alias(gene_distance_col)) \
  1628. .with_columns(pl.col(gene_col).list.unique().list.sort()) \
  1629. .drop(chrom_col) \
  1630. .rename({'original_chrom': chrom_col}) \
  1631. .collect()
  1632. if include_details:
  1633. nearest_genes = nearest_genes \
  1634. .select(SNP_col, chrom_col, bp_col, gene_col,
  1635. gene_distance_col, 'previous_gene_boundary',
  1636. 'previous_genes', 'next_gene_boundary', 'next_genes',
  1637. 'genes_inside')
  1638. else:
  1639. nearest_genes = nearest_genes \
  1640. .select(SNP_col, chrom_col, bp_col, gene_col,
  1641. gene_distance_col)
  1642. if include_nearest_TSS:
  1643. # Get coding TSSs; note that since no genes share a TSS, we do not need
  1644. # to group_by(chrom_col, bp_col).agg('gene') as we did for
  1645. # gene_boundaries. Instead, just box each gene as a one-element list.
  1646. coding_TSSs = get_coding_TSSs(
  1647. genome_build=genome_build, gencode_version=gencode_version,
  1648. gene_annotation_dir=gene_annotation_dir) \
  1649. .with_columns(pl.col.gene.reshape((-1, 1))
  1650. .cast(pl.List(pl.String)))
  1651. # The logic for finding the nearest TSS is much simpler: just do two
  1652. # asof joins to find the nearest TSS in each direction, then take the
  1653. # closer of the two (or both, if equidistant)
  1654. nearest_TSSs = standardized_sorted_sumstats \
  1655. .join_asof(coding_TSSs.lazy()
  1656. .rename({'chrom': chrom_col, 'bp': 'previous_TSS',
  1657. 'gene': 'previous_TSS_genes'}),
  1658. left_on=bp_col, right_on='previous_TSS', by=chrom_col,
  1659. strategy='backward', check_sortedness=False) \
  1660. .join_asof(coding_TSSs.lazy()
  1661. .rename({'chrom': chrom_col, 'bp': 'next_TSS',
  1662. 'gene': 'next_TSS_genes'}),
  1663. left_on=bp_col, right_on='next_TSS', by=chrom_col,
  1664. strategy='forward', check_sortedness=False) \
  1665. .with_columns(pl.col('previous_TSS_genes', 'next_TSS_genes')
  1666. .fill_null([])) \
  1667. .with_columns(distance_to_previous=pl.col(bp_col) -
  1668. pl.col.previous_TSS,
  1669. distance_to_next=pl.col.next_TSS - pl.col(bp_col)) \
  1670. .with_columns(pl.when(pl.col.distance_to_previous ==
  1671. pl.col.distance_to_next)
  1672. .then(pl.concat_list('previous_TSS_genes',
  1673. 'next_TSS_genes'))
  1674. .when((pl.col.distance_to_previous <
  1675. pl.col.distance_to_next) |
  1676. pl.col.distance_to_next.is_null())
  1677. .then('previous_TSS_genes')
  1678. .otherwise('next_TSS_genes')
  1679. .alias(TSS_col),
  1680. pl.when((pl.col.distance_to_previous <
  1681. pl.col.distance_to_next) |
  1682. pl.col.distance_to_next.is_null())
  1683. .then(-pl.col.distance_to_previous if signed else
  1684. 'distance_to_previous')
  1685. .otherwise('distance_to_next')
  1686. .alias(TSS_distance_col)) \
  1687. .with_columns(pl.col(TSS_col).list.unique().list.sort()) \
  1688. .drop(chrom_col) \
  1689. .rename({'original_chrom': chrom_col}) \
  1690. .collect()
  1691. if include_details:
  1692. nearest_TSSs = nearest_TSSs \
  1693. .select(SNP_col, chrom_col, bp_col, TSS_col, TSS_distance_col,
  1694. 'previous_TSS', 'previous_TSS_genes', 'next_TSS',
  1695. 'next_TSS_genes')
  1696. else:
  1697. nearest_TSSs = nearest_TSSs \
  1698. .select(SNP_col, chrom_col, bp_col, TSS_col, TSS_distance_col)
  1699. if TSS is None:
  1700. # noinspection PyUnboundLocalVariable
  1701. return pl.concat([nearest_genes,
  1702. nearest_TSSs.drop(SNP_col, chrom_col, bp_col)],
  1703. how='horizontal')
  1704. elif TSS is False:
  1705. # noinspection PyUnboundLocalVariable
  1706. return nearest_genes
  1707. else:
  1708. # noinspection PyUnboundLocalVariable
  1709. return nearest_TSSs
  1710. def get_nearby_genes(sumstats, genome_build, *,
  1711. cache_directory='.', gencode_version=46,
  1712. max_distance=500_000, SNP_col='SNP', chrom_col='CHROM',
  1713. bp_col='BP', gene_col='gene',
  1714. gene_distance_col='gene_distance',
  1715. gene_annotation_dir=f'{get_base_data_directory()}/'
  1716. f'gene-annotations',
  1717. signed=False):
  1718. """
  1719. Gets all coding genes within max_distance for each variant in sumstats, via
  1720. bedtools window.
  1721. Given a DataFrame of sumstats with columns SNP_col, chrom_col and bp_col,
  1722. returns a DataFrame with these three columns plus two others, described
  1723. below: gene_col and gene_distance_col.
  1724. Args:
  1725. sumstats: the summary statistics, as a polars DataFrame. Sumstats must
  1726. be sorted (this is not checked).
  1727. genome_build: the genome build of sumstats
  1728. cache_directory: the directory where the temporary files produced by
  1729. `bedtools window` will be stored
  1730. gencode_version: the Gencode version to take coding genes from
  1731. max_distance: the maximum distance from a variant to get nearby genes
  1732. SNP_col: the name of the variant ID column in sumstats
  1733. chrom_col: the name of the chromosome column in sumstats
  1734. bp_col: the name of the base-pair column in sumstats
  1735. gene_col: the name of the column in the output DataFrame containing the
  1736. gene symbol of each nearby gene, as a list
  1737. gene_distance_col: the name of the column in the output DataFrame
  1738. containing the distance from the variant to each of
  1739. the nearby genes in gene_col, as a list. The
  1740. distance is 0 if the variant is inside the gene
  1741. (i.e. between the TSS and TES), and the distance to
  1742. whichever of the TSS and TES is closer, otherwise.
  1743. gene_annotation_dir: the directory where the coding gene locations
  1744. returned by get_coding_genes() will be cached.
  1745. Must be run on the login node to generate this
  1746. cache, if it doesn't exist. Because generating the
  1747. cache can take a long time, you will probably want
  1748. to leave this argument at its default value.
  1749. signed: whether to report signed instead of unsigned distances
  1750. Returns:
  1751. A DataFrame with SNP_col, chrom_col, bp_col, gene_col,
  1752. gene_distance_col, and TSS_distance_col columns.
  1753. """
  1754. # Sanity-check inputs
  1755. check_valid_genome_build(genome_build)
  1756. if sumstats.is_empty():
  1757. raise ValueError(f'sumstats is empty!')
  1758. if SNP_col not in sumstats:
  1759. raise ValueError(f'{SNP_col!r} not in sumstats; specify SNP_col')
  1760. if chrom_col not in sumstats:
  1761. raise ValueError(f'{chrom_col!r} not in sumstats; specify chrom_col')
  1762. if bp_col not in sumstats:
  1763. raise ValueError(f'{bp_col!r} not in sumstats; specify bp_col')
  1764. # Get coding genes BED file
  1765. coding_genes_bed_file = get_coding_genes(
  1766. genome_build=genome_build, gencode_version=gencode_version,
  1767. gene_annotation_dir=gene_annotation_dir, return_file=True)
  1768. # Make sumstats BED file
  1769. cache_prefix = f'{cache_directory}/get_nearby_genes'
  1770. sumstats_bed_file = f'{cache_prefix}.sumstats.bed'
  1771. sumstats \
  1772. .with_columns(pl.col(chrom_col).pipe(standardize_chromosomes),
  1773. original_chrom=pl.col(chrom_col)) \
  1774. .select(chrom_col, pl.col(bp_col).sub(1).alias('start'),
  1775. pl.col(bp_col).alias('end'), pl.col(SNP_col),
  1776. 'original_chrom') \
  1777. .write_csv(sumstats_bed_file, separator='\t', include_header=False)
  1778. # Run bedtools window
  1779. nearby_genes_bed_file = f'{cache_prefix}.nearby_genes.bed'
  1780. run(f'bedtools window -a {sumstats_bed_file} -b {coding_genes_bed_file} '
  1781. f'-w {max_distance} > {nearby_genes_bed_file}')
  1782. # Load output; remember to add 1 to the gene start to convert to 1-based
  1783. nearby_genes = pl.scan_csv(
  1784. nearby_genes_bed_file, separator='\t', has_header=False,
  1785. new_columns=['__get_nearby_genes_variant_chrom',
  1786. '__get_nearby_genes_variant_start', bp_col, SNP_col,
  1787. chrom_col, '__get_nearby_genes_gene_chrom',
  1788. '__get_nearby_genes_gene_start',
  1789. '__get_nearby_genes_gene_end',
  1790. '__get_nearby_genes_gene_strand', gene_col,
  1791. '__get_nearby_genes_gene_Ensembl_ID'],
  1792. schema_overrides={chrom_col: sumstats[chrom_col].dtype}) \
  1793. .select(SNP_col, chrom_col, bp_col, gene_col,
  1794. pl.col.__get_nearby_genes_gene_start.add(1),
  1795. '__get_nearby_genes_gene_end') \
  1796. .with_columns(pl.when(pl.col(bp_col)
  1797. .is_between('__get_nearby_genes_gene_start',
  1798. '__get_nearby_genes_gene_end'))
  1799. .then(0)
  1800. .otherwise(
  1801. pl.when((pl.col(bp_col) -
  1802. pl.col.__get_nearby_genes_gene_start <
  1803. pl.col.__get_nearby_genes_gene_end -
  1804. pl.col(bp_col)) |
  1805. pl.col.__get_nearby_genes_gene_end.is_null())
  1806. .then(pl.col.__get_nearby_genes_gene_start -
  1807. pl.col(bp_col))
  1808. .otherwise(pl.col.__get_nearby_genes_gene_end -
  1809. pl.col(bp_col))
  1810. if signed else
  1811. pl.min_horizontal(
  1812. pl.col('__get_nearby_genes_gene_start',
  1813. '__get_nearby_genes_gene_end')
  1814. .sub(pl.col(bp_col)).abs()))
  1815. .alias(gene_distance_col)) \
  1816. .with_columns(pl.all()
  1817. .sort_by(pl.col(gene_distance_col).abs()
  1818. if signed else pl.col(gene_distance_col))
  1819. .over(SNP_col, chrom_col, bp_col)) \
  1820. .group_by(SNP_col, chrom_col, bp_col, maintain_order=True) \
  1821. .agg(pl.col(gene_col, gene_distance_col)) \
  1822. .collect()
  1823. # Align to sumstats, filling nulls with [] for variants with no genes
  1824. # within max_distance.
  1825. nearby_genes = sumstats \
  1826. .select(SNP_col, chrom_col, bp_col) \
  1827. .join(nearby_genes, on=(SNP_col, chrom_col, bp_col), how='left') \
  1828. .with_columns(pl.col(gene_col, gene_distance_col).fill_null([]))
  1829. # Clean up - but only at the end, so users can inspect what went wrong in
  1830. # case of an error
  1831. run(f"rm '{sumstats_bed_file}' '{nearby_genes_bed_file}'")
  1832. return nearby_genes
  1833. def get_nearest_variant(
  1834. genes, sumstats,
  1835. *,
  1836. cache_directory=os.environ['SCRATCH']
  1837. if os.environ.get('CLUSTER') == 'niagara' else '.',
  1838. gene_col='gene', gene_chrom_col='chrom', gene_start_col='start',
  1839. gene_end_col='end', SNP_col='SNP', sumstats_chrom_col='CHROM',
  1840. sumstats_bp_col='BP', distance_col='distance', signed=False):
  1841. """
  1842. The reverse of `get_nearest_gene()`: for each gene in `genes`, gets the
  1843. distance to the nearest variant in `sumstats` via `bedtools closest -d`.
  1844. Based on Caleb Ji's `get_gene_distances` function.
  1845. Given a DataFrame of sumstats with columns `SNP_col`, `chrom_col` and
  1846. `bp_col`, and a DataFrame of genes with columns `gene_col`,
  1847. `gene_chrom_col`, `gene_start_col`, and `gene_end_col`, returns a DataFrame
  1848. with the four columns from the genes DataFrame plus two others: `SNP_col`,
  1849. the nearest variant, and `distance_col`, the distance from the gene to this
  1850. nearest variant.
  1851. Args:
  1852. genes: the list of genes, as a polars DataFrame. Must be sorted (this
  1853. is not checked). To use all protein-coding genes, set
  1854. `genes=get_coding_genes(genome_build)`.
  1855. sumstats: the summary statistics, as a polars DataFrame. Must be sorted
  1856. (this is not checked), and `genes` and `sumstats` must use
  1857. the same genome build (this is also not checked).
  1858. cache_directory: the directory where the temporary files produced by
  1859. `bedtools closest` will be stored
  1860. gene_col: the name of the gene symbol column in `genes`
  1861. gene_chrom_col: the name of the chromosome column in `genes`
  1862. gene_start_col: the name of the start column in `genes`
  1863. gene_end_col: the name of the end column in `genes`
  1864. SNP_col: the name of the variant ID column in `sumstats`.
  1865. sumstats_chrom_col: the name of the chromosome column in `sumstats`
  1866. sumstats_bp_col: the name of the base-pair column in `sumstats`
  1867. distance_col: the name of the column in the output DataFrame containing
  1868. the distance from each gene to its nearest variant. The
  1869. distance is 0 if the variant is inside the gene (i.e.
  1870. between the TSS and TES), and the distance to whichever
  1871. of the TSS and TES is closer, otherwise.
  1872. signed: whether to report signed instead of unsigned distances
  1873. Returns:
  1874. A DataFrame with `gene_col`, `gene_chrom_col`, `gene_start_col`,
  1875. `gene_end_col`, `SNP_col`, and `distance_col` columns. `SNP_col` and
  1876. `distance_col` will be `null` if the gene has no variants in `sumstats`
  1877. on the same chromosome.
  1878. """
  1879. # Sanity-check inputs
  1880. if genes.is_empty():
  1881. error_message = 'genes is empty!'
  1882. raise ValueError(error_message)
  1883. if gene_col not in genes:
  1884. error_message = f'{gene_col!r} not in genes; specify gene_col'
  1885. raise ValueError(error_message)
  1886. if gene_chrom_col not in genes:
  1887. error_message = \
  1888. f'{gene_chrom_col!r} not in genes; specify gene_chrom_col'
  1889. raise ValueError(error_message)
  1890. if gene_start_col not in genes:
  1891. error_message = \
  1892. f'{gene_start_col!r} not in genes; specify gene_start_col'
  1893. raise ValueError(error_message)
  1894. if gene_end_col not in genes:
  1895. error_message = f'{gene_end_col!r} not in genes; specify gene_end_col'
  1896. raise ValueError(error_message)
  1897. if sumstats.is_empty():
  1898. error_message = 'sumstats is empty!'
  1899. raise ValueError(error_message)
  1900. if SNP_col not in sumstats:
  1901. error_message = f'{SNP_col!r} not in sumstats; specify SNP_col'
  1902. raise ValueError(error_message)
  1903. if sumstats_chrom_col not in sumstats:
  1904. error_message = (
  1905. f'{sumstats_chrom_col!r} not in sumstats; specify '
  1906. f'sumstats_chrom_col')
  1907. raise ValueError(error_message)
  1908. if sumstats_bp_col not in sumstats:
  1909. error_message = \
  1910. f'{sumstats_bp_col!r} not in sumstats; specify sumstats_bp_col'
  1911. raise ValueError(error_message)
  1912. # Make gene BED file
  1913. cache_prefix = f'{cache_directory}/get_nearest_variant'
  1914. gene_bed_file = f'{cache_prefix}.genes.bed'
  1915. genes \
  1916. .select(pl.col(gene_chrom_col).pipe(standardize_chromosomes),
  1917. gene_start_col, gene_end_col, gene_col) \
  1918. .write_csv(gene_bed_file, separator='\t', include_header=False)
  1919. # Make sumstats BED file
  1920. sumstats_bed_file = f'{cache_prefix}.sumstats.bed'
  1921. sumstats \
  1922. .with_columns(pl.col(sumstats_chrom_col).pipe(standardize_chromosomes),
  1923. original_chrom=pl.col(sumstats_chrom_col)) \
  1924. .select(sumstats_chrom_col,
  1925. pl.col(sumstats_bp_col).sub(1).alias('start'),
  1926. pl.col(sumstats_bp_col).alias('end'), pl.col(SNP_col),
  1927. 'original_chrom') \
  1928. .write_csv(sumstats_bed_file, separator='\t', include_header=False)
  1929. # Run `bedtools closest`
  1930. distance_bed_file = f'{cache_prefix}.distance.bed'
  1931. run(f'bedtools closest {"-D ref" if signed else "-d"} -a {gene_bed_file} '
  1932. f'-b {sumstats_bed_file} > {distance_bed_file}')
  1933. nearest_variants = pl.scan_csv(
  1934. distance_bed_file, separator='\t', has_header=False,
  1935. new_columns=['__get_nearest_variant_gene_chrom', gene_start_col,
  1936. gene_end_col, '__get_nearest_variant_gene_strand',
  1937. gene_col, '__get_nearest_variant_gene_Ensembl_ID',
  1938. '__get_nearest_variant_variant_chrom',
  1939. '__get_nearest_variant_variant_start',
  1940. '__get_nearest_variant_variant_end', SNP_col,
  1941. gene_chrom_col, distance_col],
  1942. schema_overrides={gene_chrom_col: genes[gene_chrom_col].dtype}) \
  1943. .filter(~pl.col.distance.eq(-1)) \
  1944. .select([gene_col, gene_chrom_col, gene_start_col, gene_end_col,
  1945. SNP_col, distance_col]) \
  1946. .collect()
  1947. # Align with `genes`
  1948. nearest_variants = genes \
  1949. .select(gene_col, gene_chrom_col, gene_start_col, gene_end_col) \
  1950. .join(nearest_variants,
  1951. on=(gene_col, gene_chrom_col, gene_start_col, gene_end_col),
  1952. how='left')
  1953. # Clean up - but only at the end, so users can inspect what went wrong in
  1954. # case of an error
  1955. run(f"rm '{gene_bed_file}' '{sumstats_bed_file}' '{distance_bed_file}''")
  1956. return nearest_variants
  1957. def get_bim_or_pvar_file_type(bim_or_pvar_file):
  1958. """
  1959. Gets whether a file is .bim or .pvar
  1960. Args:
  1961. bim_or_pvar_file: the bim or pvar file
  1962. Returns:
  1963. True if ends in .pvar, False if ends in .bim, raises an error otherwise
  1964. """
  1965. is_pvar = bim_or_pvar_file.endswith('.pvar')
  1966. if not is_pvar and not bim_or_pvar_file.endswith('.bim'):
  1967. raise ValueError(f'bim_or_pvar_file "{bim_or_pvar_file}" must end '
  1968. f'with .bim or .pvar!')
  1969. return is_pvar
  1970. def read_bim_or_pvar(bim_or_pvar_file, categorical=False):
  1971. """
  1972. Reads a plink variant file (.bim or .pvar) as a polars DataFrame.
  1973. Args:
  1974. bim_or_pvar_file: the .bim or .pvar file to read from; must end with
  1975. .bim or .pvar, and the extension determines which
  1976. file type it's parsed as
  1977. categorical: whether to read in the REF and ALT columns as Categorical
  1978. instead of String
  1979. Returns:
  1980. A polars DataFrame with the contents of the .bim or .pvar file,
  1981. with columns CHROM, SNP, CM (if present), BP, REF, ALT
  1982. """
  1983. if not os.path.exists(bim_or_pvar_file):
  1984. raise FileNotFoundError(f'No such file or directory: '
  1985. f'{bim_or_pvar_file}')
  1986. is_pvar = get_bim_or_pvar_file_type(bim_or_pvar_file)
  1987. # If pvar, get the number of lines at the beginning starting with ##, and
  1988. # the number of lines after that starting with (should be 0 or 1)
  1989. if is_pvar:
  1990. num_double_comment_lines = int(run(
  1991. f"sed -n '/^##/!q;p' {bim_or_pvar_file} | wc -l",
  1992. stdout=subprocess.PIPE).stdout)
  1993. num_single_comment_lines = int(run(
  1994. f"sed -n '/^##/!{{/^#/p;q}}' {bim_or_pvar_file} | wc -l",
  1995. stdout=subprocess.PIPE).stdout)
  1996. if num_single_comment_lines > 1:
  1997. raise ValueError(f'pvar file "{bim_or_pvar_file}" has multiple '
  1998. f'header lines!')
  1999. skip_rows = num_double_comment_lines
  2000. has_header = bool(num_single_comment_lines)
  2001. else:
  2002. skip_rows = 0
  2003. has_header = False
  2004. dtypes = {'#CHROM' if has_header else 'column_1': pl.String}
  2005. if categorical:
  2006. if has_header:
  2007. ref_col = 'REF'
  2008. alt_col = 'ALT'
  2009. else:
  2010. first_line = pl.read_csv(bim_or_pvar_file, separator='\t',
  2011. has_header=has_header, n_rows=0)
  2012. if first_line.width not in (5, 6):
  2013. raise ValueError(f'bim_or_pvar_file "{bim_or_pvar_file}" has '
  2014. f'{first_line.width} columns, but should '
  2015. f'have 5 or 6!')
  2016. if first_line.width == 6:
  2017. ref_col = 'column_6'
  2018. alt_col = 'column_5'
  2019. else:
  2020. ref_col = 'column_5'
  2021. alt_col = 'column_4'
  2022. dtypes |= {ref_col: pl.Categorical('lexical'),
  2023. alt_col: pl.Categorical('lexical')}
  2024. variants = pl.read_csv(bim_or_pvar_file, separator='\t',
  2025. has_header=has_header, skip_rows=skip_rows,
  2026. schema_overrides=dtypes)
  2027. if has_header:
  2028. variants = variants.rename({'#CHROM': 'CHROM', 'POS': 'BP',
  2029. 'ID': 'SNP'})
  2030. else:
  2031. if variants.width not in (5, 6):
  2032. raise ValueError(f'bim_or_pvar_file "{bim_or_pvar_file}" has '
  2033. f'{variants.width} columns, but should have 5 or '
  2034. f'6!')
  2035. if variants.width == 6:
  2036. variants = variants.rename({'column_1': 'CHROM', 'column_2': 'SNP',
  2037. 'column_3': 'CM', 'column_4': 'BP',
  2038. 'column_5': 'ALT', 'column_6': 'REF'})
  2039. else:
  2040. variants = variants.rename({'column_1': 'CHROM', 'column_2': 'SNP',
  2041. 'column_3': 'BP', 'column_4': 'ALT',
  2042. 'column_5': 'REF'})
  2043. return variants
  2044. def write_bim_or_pvar(variants, bim_or_pvar_file):
  2045. """
  2046. Writes a polars DataFrame to bim or pvar format. If the filename ends in
  2047. .pvar, a header is added. If the centimorgan (CM) column isn't specified,
  2048. it's set to 0 (if bim) or omitted (if pvar).
  2049. Args:
  2050. variants: a polars DataFrame of variants with columns CHROM, SNP, CM,
  2051. BP, ALT, REF (CM is optional)
  2052. bim_or_pvar_file: the bim or pvar file to write to; must end with .bim
  2053. or .pvar, and the extension determines which file
  2054. type it's written as
  2055. """
  2056. if variants.is_empty():
  2057. raise ValueError(f'variants is empty!')
  2058. is_pvar = get_bim_or_pvar_file_type(bim_or_pvar_file)
  2059. if is_pvar:
  2060. if 'CM' in variants:
  2061. columns = 'CHROM', 'BP', 'SNP', 'REF', 'ALT', 'CM'
  2062. else:
  2063. columns = 'CHROM', 'BP', 'SNP', 'REF', 'ALT'
  2064. else:
  2065. columns = 'CHROM', 'SNP', 'CM', 'BP', 'ALT', 'REF'
  2066. if 'CM' not in variants:
  2067. variants = variants.with_columns(CM=0)
  2068. variants = variants.select(columns) \
  2069. .rename({'CHROM': '#CHROM', 'BP': 'POS', 'SNP': 'ID'})
  2070. variants.write_csv(bim_or_pvar_file, separator='\t',
  2071. include_header=is_pvar)
  2072. def make_bim_or_pvar_IDs_unique(bim_or_pvar_file, new_bim_or_pvar_file,
  2073. separator='~'):
  2074. """
  2075. Make the variant IDs of a bim or pvar file unique when there are
  2076. multiallelic variants, by replacing each SNP ID with separator.join(
  2077. (SNP, CHROM, REF, ALT)). Write the result to a new bim or pvar file.
  2078. On the off-chance a variant ID already contains the default separator, '~',
  2079. you will get an error and will need to specify a different string via the
  2080. separator argument.
  2081. Why include CHROM and not just ID/REF/ALT? Because the same variant may map
  2082. to multiple genomic locations (ncbi.nlm.nih.gov/snp/docs/rs_multi_mapping).
  2083. Why not include BP? Because it introduces a dependence on genome build. In
  2084. any case, it's vanishingly unlikely that a multi-mapping variant would have
  2085. the same base-pair position on two different chromosomes.
  2086. Args:
  2087. bim_or_pvar_file: the bim or pvar file to read from; must end with .bim
  2088. or .pvar, and the extension determines which file
  2089. type it's parsed and written as
  2090. new_bim_or_pvar_file: the bim or pvar file to write to after
  2091. uniquifying the variant IDs
  2092. separator: the separator to join the SNP, CHROM, REF, and ALT columns
  2093. with when uniquifying; no variant IDs may contain the
  2094. separator
  2095. """
  2096. bim_or_pvar = read_bim_or_pvar(bim_or_pvar_file)
  2097. if bim_or_pvar['SNP'].str.contains(separator).any():
  2098. suffix = bim_or_pvar_file.split('.')[-1]
  2099. raise ValueError(f'Some variant IDs in the {suffix} file '
  2100. f'{bim_or_pvar_file} contain the separator '
  2101. f'{separator!r}; specify a different separator via '
  2102. f'the separator argument!')
  2103. bim_or_pvar \
  2104. .with_columns(pl.concat_str('SNP', 'CHROM', 'REF', 'ALT',
  2105. separator=separator)) \
  2106. .pipe(write_bim_or_pvar, new_bim_or_pvar_file)
  2107. def reverse_make_bim_or_pvar_IDs_unique(bim_or_pvar_file, new_bim_or_pvar_file,
  2108. separator='~'):
  2109. """
  2110. Reverse the transformation of make_bim_or_pvar_IDs_unique() by removing the
  2111. CHROM, REF and ALT added to the SNP ID by that function. Write the result
  2112. to a new bim or pvar file.
  2113. Args:
  2114. bim_or_pvar_file: the bim or pvar file to read from; must end with .bim
  2115. or .pvar, and the extension determines which file
  2116. type it's parsed and written as
  2117. new_bim_or_pvar_file: the bim or pvar file to write to after removing
  2118. the CHROM, REF and ALT from the variant IDs
  2119. separator: the separator that the SNP, CHROM, REF, and ALT columns
  2120. were joined with when uniquifying
  2121. """
  2122. bim_or_pvar = read_bim_or_pvar(bim_or_pvar_file)
  2123. if not bim_or_pvar['SNP'].str.contains(separator).all():
  2124. suffix = bim_or_pvar_file.split('.')[-1]
  2125. raise ValueError(f'not all variant IDs in the {suffix} file '
  2126. f'{bim_or_pvar_file} contain the separator '
  2127. f'{separator!r}; specify a different separator via '
  2128. f'the separator argument!')
  2129. bim_or_pvar \
  2130. .with_columns(SNP=pl.col.SNP.str.split_exact(separator, 1)
  2131. .struct.field('field_0')) \
  2132. .pipe(write_bim_or_pvar, new_bim_or_pvar_file)
  2133. def merge_bfiles(bfiles, merged_bfile):
  2134. """
  2135. Merge multiple bed/bim/fam filesets (bfiles) into one big fileset
  2136. (merged_bfile).
  2137. As an optimization, just concatenates the raw bytes of the bed files rather
  2138. than calling plink. As a result, all filesets in bfiles must have the same
  2139. sample info (.fam file) and non-overlapping variants, though this is not
  2140. checked for speed. This is almost always true when merging, e.g. in the
  2141. very common case where you want to merge genetic data with one fileset per
  2142. chromosome into a single fileset.
  2143. Supports multiallelic variants, unlike plink 1.9's --merge-list and plink
  2144. 2.0's --pmerge-list. (More precisely, plink 2.0 doesn't support
  2145. --pmerge-list on files with "split" multiallelic variants, but also doesn't
  2146. yet, as of September 2023, support merging multiallelic variants with
  2147. --make-pgen multiallelics=+. Even once this is supported, merge_bfiles()
  2148. should still be much faster than --pmerge-list.)
  2149. Args:
  2150. bfiles: a list/tuple/etc. of prefixes of bed/bim/fam filesets to merge
  2151. merged_bfile: the prefix of the merged bed/bim/fam fileset to be
  2152. created; {merged_bfile}.{bed,bim,fam} must not exist yet
  2153. """
  2154. if isinstance(bfiles, str):
  2155. raise ValueError(f'bfiles must be a list/tuple/etc. of filesets, not '
  2156. f'a single string!')
  2157. if len(bfiles) < 2:
  2158. raise ValueError(f'bfiles must contain two or more filesets, not '
  2159. f'{len(bfiles):,}!')
  2160. for suffix in 'bed', 'bim', 'fam':
  2161. output_plink_file = f'{merged_bfile}.{suffix}'
  2162. if os.path.exists(output_plink_file):
  2163. raise FileExistsError(f'{output_plink_file} already exists!')
  2164. for bfile in bfiles:
  2165. input_plink_file = f'{bfile}.{suffix}'
  2166. if not os.path.exists(input_plink_file):
  2167. raise FileNotFoundError(f'No such file or directory: '
  2168. f'{input_plink_file}')
  2169. # Concatenate bed files: the first three bytes are the header, which is
  2170. # always 01101100 00011011 00000001, and the rest is the data. Take the
  2171. # header from the first file, and just the part after the header (tail
  2172. # -qc+4) for the remaining files.
  2173. run(f"(cat '{bfiles[0]}.bed'; tail -qc+4 " + ' '.join(
  2174. f"'{bfile}.bed'" for bfile in bfiles[1:]) +
  2175. f") > '{merged_bfile}.bed'")
  2176. # Concatenate bim files
  2177. run(f"cat " + ' '.join(f"'{bfile}.bim'" for bfile in bfiles) +
  2178. f" > '{merged_bfile}.bim'")
  2179. # Take the first fam file (since they're assumed to be all the same)
  2180. run(f"cp '{bfiles[0]}.fam' '{merged_bfile}.fam'")
  2181. def merge_pfiles(pfiles, merged_pfile, temp_dir=os.environ.get('SCRATCH',
  2182. '../../../../Desktop'),
  2183. num_threads=1, memory=2000):
  2184. """
  2185. Merge multiple pgen/pvar/psam filesets (pfiles) into one big fileset
  2186. (merged_pfile) using plink 2.0's --pmerge-list.
  2187. --pmerge-list doesn't support multiallelic variants. (More precisely,
  2188. plink2 doesn't support --pmerge-list on files with "split" multiallelic
  2189. variants, but also doesn't yet, as of September 2023, support merging
  2190. multiallelic variants with --make-pgen multiallelics=+.) We circumvent this
  2191. by making the pvar IDs unique with make_bim_or_pvar_IDs_unique() before
  2192. merging, and reset them after with reverse_make_bim_or_pvar_IDs_unique().
  2193. Args:
  2194. pfiles: a list/tuple/etc. of prefixes of pgen/pvar/psam filesets to
  2195. merge
  2196. merged_pfile: the prefix of the merged pgen/pvar/psam fileset to be
  2197. created; {merged_bfile}.{pgen,pvar,psam} must not exist
  2198. yet
  2199. temp_dir: a directory to store the temporary uniquified pvars created
  2200. by make_bim_or_pvar_IDs_unique(), which will be deleted at
  2201. the end of the run if successful
  2202. num_threads: the number of threads to use for merging; if None, use all
  2203. available cores
  2204. memory: the number of megabytes of memory for plink to reserve during
  2205. merging via the --memory flag
  2206. """
  2207. temp_pfiles = [os.path.join(temp_dir, f'{os.path.basename(pfile)}.tmp')
  2208. for pfile in pfiles]
  2209. for pfile, temp_pfile in zip(pfiles, temp_pfiles):
  2210. make_bim_or_pvar_IDs_unique(f'{pfile}.pvar', f'{temp_pfile}.pvar')
  2211. pmerge_list = ';'.join(f'echo {pfile}.pgen {temp_pfile}.pvar {pfile}.psam'
  2212. for pfile, temp_pfile in zip(pfiles, temp_pfiles))
  2213. run(f'plink2 '
  2214. f'{f"--threads {num_threads} " if num_threads is not None else ""}'
  2215. f'--memory {memory} '
  2216. f'--pmerge-list <({pmerge_list}) '
  2217. f'--make-pgen '
  2218. f'--out {merged_pfile}')
  2219. reverse_make_bim_or_pvar_IDs_unique(f'{merged_pfile}.pvar',
  2220. f'{merged_pfile}.pvar')
  2221. for temp_pfile in temp_pfiles:
  2222. run(f'rm "{temp_pfile}.pvar"')
  2223. run(f'rm "{merged_pfile}-merge.pgen" "{merged_pfile}-merge.pvar" '
  2224. f'"{merged_pfile}-merge.psam"')
  2225. def flip_alleles(sumstats, *, flip_col='FLIP', ref_col='REF', alt_col='ALT',
  2226. beta_col='BETA', OR_col='OR', Z_col='Z', AAF_col='AAF',
  2227. AAF_cases_col='AAF_CASES', AAF_controls_col='AAF_controls'):
  2228. """
  2229. Flips alleles in sumstats where flip_col is True, and also flips effect
  2230. sizes, odds ratios, Z-score, and allele frequencies for these variants.
  2231. flip_col, ref_col, alt_col, and at least one of beta_col, OR_col and Z_col
  2232. are required. AAF_col, AAF_cases_col, AAF_controls_col, and the remainder
  2233. of beta_col, OR_col and Z_col are optional: if a column name doesn't appear
  2234. in sumstats, it won't be flipped, but there won't be an error.
  2235. Args:
  2236. sumstats: a polars DataFrame of summary statistics
  2237. flip_col: the name of a boolean column in sumstats saying which alleles
  2238. to flip
  2239. ref_col: the name of the reference allele column in sumstats
  2240. alt_col: the name of the alternate allele column in sumstats
  2241. beta_col: the name of the effect size column in sumstats
  2242. OR_col: the name of the odds ratio column in sumstats
  2243. Z_col: the name of the Z-score column in sumstats
  2244. AAF_col: the name of the alternate allele frequency column in sumstats
  2245. AAF_cases_col: the name of the case alternate allele frequency column
  2246. in sumstats
  2247. AAF_controls_col: the name of the control alternate allele frequency
  2248. column in sumstats
  2249. Returns:
  2250. sumstats with alleles flipped where flip_col is True.
  2251. """
  2252. if ref_col not in sumstats:
  2253. raise ValueError(f'ref_col "{ref_col}" not in sumstats! Did you '
  2254. f'forget to specify a custom ref_col?')
  2255. if alt_col not in sumstats:
  2256. raise ValueError(f'alt_col "{alt_col}" not in sumstats! Did you '
  2257. f'forget to specify a custom alt_col?')
  2258. if beta_col not in sumstats and OR_col not in sumstats and \
  2259. Z_col not in sumstats:
  2260. raise ValueError(f'none of beta_col "{beta_col}", OR_col "{OR_col}", '
  2261. f'and Z_col "{Z_col}" are in sumstats! Did you '
  2262. f'forget to specify a custom beta_col, OR_col or '
  2263. f'Z_col?')
  2264. flip_transformations = {
  2265. ref_col: pl.col(alt_col), alt_col: pl.col(ref_col),
  2266. beta_col: -pl.col(beta_col), OR_col: 1 / pl.col(OR_col),
  2267. Z_col: -pl.col(Z_col), AAF_col: 1 - pl.col(AAF_col),
  2268. AAF_cases_col: 1 - pl.col(AAF_cases_col),
  2269. AAF_controls_col: 1 - pl.col(AAF_controls_col)}
  2270. return sumstats.with_columns(**{
  2271. column_name: pl.when(pl.col(flip_col)).then(transformation)
  2272. .otherwise(pl.col(column_name))
  2273. for column_name, transformation in flip_transformations.items()
  2274. if column_name in sumstats})
  2275. def harmonize_sumstats_to_bim_or_pvar(
  2276. sumstats, bim_or_pvar, *, SNP_col='SNP', ref_col='REF', alt_col='ALT',
  2277. beta_col='BETA', OR_col='OR', Z_col='Z', AAF_col='AAF',
  2278. AAF_cases_col='AAF_CASES', AAF_controls_col='AAF_controls',
  2279. chrom_col='CHROM', convert_chromosomes=False):
  2280. """
  2281. Harmonizes sumstats to a bim or pvar file by flipping single nucleotide
  2282. variants' alleles with flip_alleles() as necessary to match the bim/pvar.
  2283. Indels that don't match, or single-nucleotide variants that don't match
  2284. even with flipping, are removed. If convert_chromosomes=True, converts
  2285. chromosomes to plink format ('1', '2', ..., '22', 'X', 'Y').
  2286. flip_col, ref_col, alt_col, and at least one of beta_col, OR_col and Z_col
  2287. are required. AAF_col, AAF_cases_col, AAF_controls_col, and the remainder
  2288. of beta_col, OR_col and Z_col are optional: if a column name doesn't appear
  2289. in sumstats, it won't be flipped, but there won't be an error.
  2290. Args:
  2291. sumstats: a polars DataFrame of sumstats
  2292. bim_or_pvar: a polars DataFrame of the contents of a bim or pvar file
  2293. created with get_rs_numbers_bim_or_pvar() and loaded into
  2294. memory with read_bim_or_pvar()
  2295. SNP_col: the name of the variant ID column in sumstats
  2296. ref_col: the name of the reference allele column in sumstats
  2297. alt_col: the name of the alternate allele column in sumstats
  2298. beta_col: the name of the effect size column in sumstats
  2299. OR_col: the name of the odds ratio column in sumstats
  2300. Z_col: the name of the Z-score column in sumstats
  2301. AAF_col: the name of the alternate allele frequency column in sumstats
  2302. AAF_cases_col: the name of the case alternate allele frequency column
  2303. in sumstats
  2304. AAF_controls_col: the name of the control alternate allele frequency
  2305. column in sumstats
  2306. chrom_col: the name of the chromosome column in sumstats; only used if
  2307. convert_chromosomes=True
  2308. convert_chromosomes: whether to convert the chromosomes in chrom_col to
  2309. plink format
  2310. Returns:
  2311. Sumstats with alleles flipped and chromosomes optionally converted to
  2312. plink format.
  2313. """
  2314. # Use 0 as a dummy value just to check whether it's not null after joining.
  2315. return sumstats \
  2316. .lazy() \
  2317. .join(bim_or_pvar.lazy().select('SNP', 'REF', 'ALT',
  2318. matches_without_flips=0),
  2319. left_on=(SNP_col, ref_col, alt_col),
  2320. right_on=('SNP', 'REF', 'ALT'), how='left') \
  2321. .with_columns(pl.col.matches_without_flips.is_not_null()) \
  2322. .join(bim_or_pvar.lazy().select('SNP', 'REF', 'ALT',
  2323. matches_with_flips=0),
  2324. left_on=(SNP_col, ref_col, alt_col),
  2325. right_on=('SNP', 'ALT', 'REF'), how='left') \
  2326. .with_columns(pl.col.matches_with_flips.is_not_null() &
  2327. pl.col(ref_col).str.len_bytes().eq(1) &
  2328. pl.col(alt_col).str.len_bytes().eq(1)) \
  2329. .pipe(flip_alleles, flip_col='matches_with_flips', ref_col=ref_col,
  2330. alt_col=alt_col, beta_col=beta_col, OR_col=OR_col, Z_col=Z_col,
  2331. AAF_col=AAF_col, AAF_cases_col=AAF_cases_col,
  2332. AAF_controls_col=AAF_controls_col) \
  2333. .filter(pl.col.matches_without_flips | pl.col.matches_with_flips) \
  2334. .drop('matches_without_flips', 'matches_with_flips') \
  2335. .pipe(lambda df: df.with_columns(pl.col(chrom_col).pipe(
  2336. standardize_chromosomes, omit_chr_prefix=True))
  2337. if convert_chromosomes else df) \
  2338. .collect()
  2339. def ld_clump(sumstats, *,
  2340. cache_directory=os.environ['SCRATCH']
  2341. if os.environ.get('CLUSTER') == 'niagara'
  2342. else '.',
  2343. pfile=None, bfile=None,
  2344. clump_p1=5e-8, clump_p2=0.001, clump_r2=0.001, clump_kb=5000,
  2345. SNP_col='SNP', chrom_col='CHROM', bp_col='BP', ref_col='REF',
  2346. alt_col='ALT', p_col='P', separator='~', num_threads=1,
  2347. memory=2000, verbose=True):
  2348. """
  2349. Performs linkage disequilibrium (LD) clumping on summary statistics using
  2350. plink's --clump (cog-genomics.org/plink/2.0/postproc#clump) and the LD info
  2351. from a plink pgen/pvar/psam ("pfile") or bed/bim/bam ("bfile") fileset.
  2352. Specify exactly one of pfile or bfile. pfile/bfile can be a list/tuple,
  2353. e.g. if there's 1 fileset per chromosome.
  2354. For example, try (on Niagara):
  2355. import os
  2356. import polars as pl
  2357. from utils import ld_clump
  2358. sumstats_file = '/scratch/w/wainberg/wainberg/sumstats/daner_MDDwoBP_' \
  2359. '20201001_2015iR15iex_HRC_MDDwoBP_iPSYCH2015i_' \
  2360. 'UKBtransformed_Wray_FinnGen_MVPaf_2_HRC_MAF01.gz'
  2361. sumstats = pl.read_csv(sumstats_file, separator=' ', null_values='-')
  2362. clumped_variants = ld_clump(
  2363. sumstats, pfile='scratch/wainberg/1000G/European_autosomal_chrX_hg38',
  2364. chrom_col='CHR', ref_col='A2', alt_col='A1')
  2365. For information on LD clumping, see:
  2366. - cog-genomics.org/plink/1.9/postproc#clump
  2367. - zzz.bwh.harvard.edu/plink/clump.shtml
  2368. - explodecomputer.github.io/EEPE_2016/worksheets_win/bioinformatics.html
  2369. sumstats will be harmonized to pfile/bfile via
  2370. harmonize_sumstats_to_bim_or_pvar() and then written to the temp file
  2371. f'{cache_prefix}.sumstats.tmp' prior to clumping. This means that it's okay
  2372. if some ref and alt alleles are flipped between the sumstats file and the
  2373. pfile/bfile, and it does not matter which column is ref_col and which is
  2374. alt_col (though you may as well specify ref_col='REF', alt_col='ALT' or
  2375. ref_col='A2', alt_col='A1' for clarity).
  2376. ld_clump() ensures all variant IDs are unique by setting IDs for both
  2377. sumstats and pfile/bfile to ID + '~' + CHROM + '~' + REF + '~' + ALT. This
  2378. requires making a temporary pvar file (or files, if pfile/bfile is a list).
  2379. (On the off-chance a variant ID already contains '~', you will get an error
  2380. and will need to specify a different string via the separator argument.)
  2381. Why include CHROM and not just ID/REF/ALT? Because the same variant may map
  2382. to multiple genomic locations (ncbi.nlm.nih.gov/snp/docs/rs_multi_mapping).
  2383. Why not include BP? Because it would require the genome builds to match,
  2384. and it's vanishingly unlikely that a multi-mapping variant would have the
  2385. same base-pair position on two different chromosomes.
  2386. Args:
  2387. sumstats: the summary statistics, as a polars DataFrame
  2388. cache_directory: the directory where temporary sumstats, pvar files and
  2389. --clump results will be stored
  2390. pfile: the plink 2.x pgen/pvar/psam fileset LD info is taken from; can
  2391. be a string (assumed to be a single bfile for all chromosomes)
  2392. or a list/tuple of bfiles (assumed to be one per chromosome).
  2393. Exactly one of pfile and bfile must be specified.
  2394. bfile: the plink 1.x bed/bim/bam fileset LD info is taken from; can be
  2395. a string (assumed to be a single bfile for all chromosomes) or a
  2396. list/tuple of bfiles (assumed to be one per chromosome).
  2397. clump_p1: the value of --clump-p1 passed to --clump; for definitions of
  2398. --clump args, see cog-genomics.org/plink/2.0/postproc#clump
  2399. clump_p2: the value of --clump-p2 passed to --clump
  2400. clump_r2: the value of --clump-r2 passed to --clump
  2401. clump_kb: the value of --clump-kb passed to --clump
  2402. SNP_col: the name of the variant ID column in sumstats
  2403. chrom_col: the name of the chromosome column in sumstats
  2404. bp_col: the name of the base-pair column in sumstats; does NOT have to
  2405. be in the same genome build as pfile/bfile
  2406. ref_col: the name of the reference allele column in sumstats
  2407. alt_col: the name of the alternate column in sumstats
  2408. p_col: the name of the p-value column in sumstats
  2409. separator: the string to place between the variant ID, chromosome,
  2410. reference allele, and alternate allele when creating the
  2411. temporary sumstats and pvar files; no variant IDs may
  2412. contain the separator
  2413. num_threads: the number of threads to use for LD clumping; if None, use
  2414. all available cores
  2415. memory: the number of megabytes of memory for plink to reserve during
  2416. LD-clumping via the --memory flag
  2417. verbose: whether to print details of the LD clumping process
  2418. Returns:
  2419. A polars DataFrame with one row per variant in an LD clump, with the
  2420. following columns:
  2421. - SNP_col: the variant's ID
  2422. - ref_col: the variant's reference allele
  2423. - alt_col: the variant's alternate allele
  2424. - chrom_col: the variant's chromosome, in the same format as sumstats
  2425. - bp_col: the variant's base-pair position, in the same genome build as
  2426. sumstats
  2427. - f'{SNP_col}_lead': the ID of the lead variant, i.e. the lowest
  2428. p-value variant in the clump
  2429. - f'{ref_col}_lead': the lead variant's reference allele
  2430. - f'{alt_col}_lead': the lead variant's alternate allele
  2431. - f'{bp_col}_lead': the lead variant's base-pair position, in the same
  2432. genome build as sumstats
  2433. - 'is_lead': whether the variant is the lead variant at its locus
  2434. (equivalent to testing whether SNP_col ==
  2435. f'{SNP_col}_lead' and similarly for ref_col, alt_col and
  2436. bp_col)
  2437. - 'clump_start': the base-pair position of the start of the clump, in
  2438. the same genome build as sumstats
  2439. - 'clump_end': the base-pair position of the end of the clump, in the
  2440. same genome build as sumstats
  2441. """
  2442. if sumstats.is_empty():
  2443. raise ValueError(f'sumstats is empty!')
  2444. if SNP_col not in sumstats:
  2445. raise ValueError(f'SNP_col "{SNP_col}" not in sumstats! Did you '
  2446. f'forget to specify a custom SNP_col?')
  2447. if chrom_col not in sumstats:
  2448. raise ValueError(f'chrom_col "{chrom_col}" not in sumstats! Did you '
  2449. f'forget to specify a custom chrom_col?')
  2450. if bp_col not in sumstats:
  2451. raise ValueError(f'bp_col "{bp_col}" not in sumstats! Did you '
  2452. f'forget to specify a custom bp_col?')
  2453. if ref_col not in sumstats:
  2454. raise ValueError(f'ref_col "{ref_col}" not in sumstats! Did you '
  2455. f'forget to specify a custom ref_col?')
  2456. if alt_col not in sumstats:
  2457. raise ValueError(f'alt_col "{alt_col}" not in sumstats! Did you '
  2458. f'forget to specify a custom alt_col?')
  2459. if p_col not in sumstats:
  2460. raise ValueError(f'p_col "{p_col}" not in sumstats!')
  2461. if sumstats[p_col].min() > clump_p1:
  2462. raise ValueError('All p-values in sumstats are > clump_p1')
  2463. if sumstats[SNP_col].str.contains(separator).any():
  2464. raise ValueError(f'Some variant IDs in sumstats contain the '
  2465. f'separator "{separator}"; specify a different '
  2466. f'separator via the separator argument!')
  2467. if pfile is None and bfile is None:
  2468. raise ValueError('Must specify either pfile or bfile (but not both)')
  2469. if pfile is not None and bfile is not None:
  2470. raise ValueError('Do not specify both pfile and bfile')
  2471. # Since only one of pfile and bfile is specified, refer to it with a single
  2472. # variable, filesets. If there's only one fileset, box it in a tuple
  2473. filesets = pfile if pfile is not None else bfile
  2474. if isinstance(filesets, str):
  2475. filesets = (filesets,)
  2476. suffix = 'pvar' if pfile is not None else 'bim'
  2477. # Load bim/pvar files from each fileset in pfile/bfile
  2478. bim_or_pvars = {fileset: read_bim_or_pvar(f'{fileset}.{suffix}')
  2479. for fileset in filesets}
  2480. for fileset, bim_or_pvar in bim_or_pvars.items():
  2481. if bim_or_pvar['SNP'].str.contains(separator).any():
  2482. raise ValueError(f'Some variant IDs in the {suffix} file '
  2483. f'{fileset}.{suffix} contain the separator '
  2484. f'{separator!r}; specify a different separator '
  2485. f'via the separator argument!')
  2486. # Harmonize sumstats to bim/pvar
  2487. old_length = len(sumstats)
  2488. sumstats = sumstats \
  2489. .with_columns(original_ref=ref_col, original_alt=alt_col) \
  2490. .pipe(harmonize_sumstats_to_bim_or_pvar,
  2491. pl.concat(bim_or_pvars.values()),
  2492. SNP_col=SNP_col, ref_col=ref_col, alt_col=alt_col,
  2493. chrom_col=chrom_col, convert_chromosomes=True)
  2494. num_filtered = old_length - len(sumstats)
  2495. if verbose:
  2496. print(f'Removing {num_filtered:,} {plural("variant", num_filtered)} '
  2497. f'in the sumstats '
  2498. f'({100 * num_filtered / old_length:.2f}%) '
  2499. f'that {"was" if num_filtered == 1 else "were"} not found in '
  2500. f'the {suffix} file')
  2501. # Create temporary sumstats file; map chromosomes to numbers to match plink
  2502. cache_prefix = f'{cache_directory}/ld_clump'
  2503. temp_sumstats_file = f'{cache_prefix}.sumstats.tmp'
  2504. sumstats \
  2505. .with_columns(pl.concat_str(SNP_col, chrom_col, ref_col, alt_col,
  2506. separator=separator)) \
  2507. .write_csv(temp_sumstats_file, separator='\t')
  2508. # Run --clump on each fileset. The default settings are:
  2509. # --clump-p1 5e-8: start with genome-wide-significant SNPs as lead SNPs
  2510. # --clump-p2 0.001: clumps will include all SNPs with p < 0.001...
  2511. # --clump-r2 0.01: ...r2 > 0.01 with the lead SNP...
  2512. # --clump-kb 5000: ...and within 5 MB of the lead SNP
  2513. # Create a temporary pvar file for each fileset first, with ID set to
  2514. # SNP + '_' + CHROM + '_' + REF + '_' + ALT.
  2515. for file_index, (fileset, bim_or_pvar) in enumerate(
  2516. bim_or_pvars.items(), start=1):
  2517. temp_pvar_file = f'{cache_prefix}.pvar' if len(filesets) == 1 else \
  2518. f'{cache_prefix}_{file_index}.pvar'
  2519. bim_or_pvar \
  2520. .with_columns(pl.concat_str('SNP', 'CHROM', 'REF', 'ALT',
  2521. separator=separator)) \
  2522. .pipe(write_bim_or_pvar, temp_pvar_file)
  2523. run(f'plink2 '
  2524. f'{f"--threads {num_threads} " if num_threads is not None else ""}'
  2525. f'--memory {memory} '
  2526. f'--seed 0 ' +
  2527. (f'--pfile {fileset.removesuffix(".pgen")} '
  2528. if pfile is not None else
  2529. f'--bfile {fileset.removesuffix(".bed")} ') +
  2530. f'--pvar {temp_pvar_file} '
  2531. f'--no-psam-pheno ' # optimization: avoids loading phenotypes
  2532. f'--clump {temp_sumstats_file} '
  2533. f'--clump-p1 {clump_p1} '
  2534. f'--clump-p2 {clump_p2} '
  2535. f'--clump-r2 {clump_r2} '
  2536. f'--clump-kb {clump_kb} '
  2537. f'--clump-snp-field {SNP_col} '
  2538. f'--clump-field {p_col} '
  2539. f'--out {cache_prefix}' +
  2540. ('' if len(filesets) == 1 else f'_{file_index}'))
  2541. # Aggregate clumping results, if more than one fileset
  2542. clumping_results_file = f'{cache_prefix}.clumps'
  2543. if len(filesets) > 1:
  2544. run(f"(cat '{cache_prefix}_1.clumps'; tail -qn+2 " + ' '.join(
  2545. f"'{cache_prefix}_{file_index}.clumps'"
  2546. for file_index in range(2, len(filesets) + 1)) +
  2547. f") > '{clumping_results_file}'")
  2548. # Load clumping results; map each clumped variant to its lead variant
  2549. # (also remember to add a mapping from each lead variant to itself, and
  2550. # unescape the double-separator back to comma at the right moment)
  2551. if not os.path.exists(clumping_results_file):
  2552. raise RuntimeError(f'Clumping results file {clumping_results_file} is '
  2553. f'empty - this is a bug in ld_clump()!')
  2554. if os.path.exists(f'{clumping_results_file}.missing_id'):
  2555. raise RuntimeError(f'Some variants in sumstats were missing from '
  2556. f'{"pfile" if pfile is not None else "bfile"} - '
  2557. f'this is a bug in ld_clump()')
  2558. clumped_variants = pl.read_csv(clumping_results_file, separator='\t',
  2559. columns=['ID', 'SP2']) \
  2560. .with_columns(pl.when(pl.col.SP2 != '.').then(pl.col.SP2)
  2561. .str.split(',')) \
  2562. .explode('SP2') \
  2563. .pipe(lambda df: pl.concat([
  2564. df.filter(pl.col.SP2 != 'NONE')
  2565. .with_columns(SP2=pl.col.SP2.str.split_exact('(', 1)
  2566. .struct.field('field_0')),
  2567. # add a mapping from each lead variant to itself
  2568. pl.DataFrame({'ID': df['ID'].unique()}).with_columns(SP2='ID')])) \
  2569. .rename({'ID': 'lead_variant', 'SP2': SNP_col}) \
  2570. .select(SNP_col, 'lead_variant') \
  2571. .with_columns(pl.col(SNP_col).str.split_exact('~', 3).struct
  2572. .rename_fields([SNP_col, chrom_col, ref_col, alt_col]),
  2573. pl.col('lead_variant').str.split_exact('~', 3).struct
  2574. .rename_fields([f'{SNP_col}_lead', f'{chrom_col}_lead',
  2575. f'{ref_col}_lead', f'{alt_col}_lead'])) \
  2576. .unnest(SNP_col, 'lead_variant') \
  2577. .pipe(lambda df: df if sumstats[chrom_col].dtype == pl.String else df
  2578. .with_columns(pl.col(chrom_col, f'{chrom_col}_lead').cast(int))) \
  2579. .drop(f'{chrom_col}_lead')
  2580. assert not clumped_variants.select(SNP_col, chrom_col, ref_col, alt_col) \
  2581. .is_duplicated().any()
  2582. # Add the base-pair extent of each clump
  2583. clumped_variants = clumped_variants \
  2584. .join(sumstats.select(SNP_col, chrom_col, ref_col, alt_col, bp_col,
  2585. 'original_ref', 'original_alt'),
  2586. on=(SNP_col, chrom_col, ref_col, alt_col), how='left') \
  2587. .join(sumstats.select(SNP_col, chrom_col, ref_col, alt_col, bp_col,
  2588. 'original_ref', 'original_alt')
  2589. .rename({bp_col: f'{bp_col}_lead',
  2590. 'original_ref': 'original_ref_lead',
  2591. 'original_alt': 'original_alt_lead'}),
  2592. left_on=(f'{SNP_col}_lead', chrom_col, f'{ref_col}_lead',
  2593. f'{alt_col}_lead'),
  2594. right_on=(SNP_col, chrom_col, ref_col, alt_col), how='left') \
  2595. .with_columns(clump_start=pl.min(bp_col).over(f'{SNP_col}_lead'),
  2596. clump_end=pl.max(bp_col).over(f'{SNP_col}_lead'),
  2597. is_lead=pl.col(SNP_col).eq(pl.col(f'{SNP_col}_lead')) &
  2598. pl.col(ref_col).eq(pl.col(f'{ref_col}_lead')) &
  2599. pl.col(alt_col).eq(pl.col(f'{alt_col}_lead')) &
  2600. pl.col(bp_col).eq(pl.col(f'{bp_col}_lead'))) \
  2601. .with_columns(pl.col('original_ref').alias(ref_col),
  2602. pl.col('original_alt').alias(alt_col),
  2603. pl.col('original_ref_lead').alias(f'{ref_col}_lead'),
  2604. pl.col('original_alt_lead').alias(f'{alt_col}_lead')) \
  2605. .select(SNP_col, ref_col, alt_col, chrom_col, bp_col,
  2606. f'{SNP_col}_lead', f'{ref_col}_lead', f'{alt_col}_lead',
  2607. f'{bp_col}_lead', 'is_lead', 'clump_start', 'clump_end')
  2608. assert clumped_variants.null_count().sum_horizontal().item() == 0
  2609. # Sort by chromosome and base-pair position
  2610. clumped_variants = clumped_variants.pipe(
  2611. sort_sumstats, chrom_col=chrom_col, bp_col=bp_col)
  2612. # Clean up - but only at the end, so users can inspect what went wrong in
  2613. # case of an error
  2614. run(f"rm '{temp_sumstats_file}'")
  2615. if len(filesets) == 1:
  2616. run(f"rm '{cache_prefix}.pvar' '{cache_prefix}.clumps' "
  2617. f"'{cache_prefix}.log'")
  2618. else:
  2619. run(f"rm '{cache_prefix}.clumps' " +
  2620. ' '.join(f"'{cache_prefix}_{file_index}.{suffix}'"
  2621. for file_index in range(1, len(filesets) + 1)
  2622. for suffix in ('pvar', 'clumps', 'log')))
  2623. return clumped_variants
  2624. def sort_sumstats(sumstats, *, chrom_col='CHROM', bp_col='BP'):
  2625. """
  2626. Sorts summary statistics by chromosome and base-pair position.
  2627. Args:
  2628. sumstats: a polars DataFrame of summary statistics
  2629. chrom_col: the name of the chromosome column in sumstats
  2630. bp_col: the name of the base-pair position column in sumstats
  2631. Returns:
  2632. sumstats, sorted by chromosome and base-pair position.
  2633. """
  2634. assert 'numeric_chrom' not in sumstats
  2635. return sumstats \
  2636. .with_columns(numeric_chrom=pl.col(chrom_col).pipe(
  2637. standardize_chromosomes, return_numeric=True)) \
  2638. .sort('numeric_chrom', bp_col) \
  2639. .drop('numeric_chrom')
  2640. # noinspection PyShadowingBuiltins
  2641. def munge_sumstats(raw_sumstats_files, munged_sumstats_file, REF, ALT, P, *,
  2642. SNP=None, CHROM=None, BP=None, BETA=None, OR=None, SE=None,
  2643. N=None, N_CASES=None, N_CONTROLS=None, NEFF=None, AAF=None,
  2644. AAF_CASES=None, AAF_CONTROLS=None, INFO=None,
  2645. quantitative=False, dbSNP=None, preamble=None,
  2646. separator='\t', verbose=True, **read_csv_kwargs):
  2647. """
  2648. "Munges" the sumstats file(s) raw_sumstats_files into a consistent format,
  2649. saving to munged_sumstats_file (if not None) and returning the sumstats.
  2650. If dbSNP is not None, infers rs numbers. You should always do this if CHROM
  2651. and BP are available, even if the sumstats already came with rs numbers!
  2652. Make sure to set schema_overrides={'CHROM': str} explicitly when the
  2653. sumstats include sex chromosomes and there is no chr prefix (e.g. when it's
  2654. '1' and 'X' rather than 'chr1' and 'chrX').
  2655. Performs the following steps, in order, on each file in raw_sumstats_files:
  2656. 1. Reads in the file with pl.read_csv(). Use separator to specify the
  2657. separator (tab by default, not comma!) and **kwargs to specify any other
  2658. arguments you want. Use separator='whitespace' to use any number of
  2659. consecutive whitespace characters as the separator.
  2660. 2. Creates columns for each of the arguments from SNP to INFO. Specify a
  2661. single column name or polars expression of other columns, e.g.:
  2662. - AAF='allele_frequency'
  2663. - AAF=(pl.col.FCAS * pl.col.NCAS + pl.col.FCON * pl.col.NCON) /
  2664. (pl.col.NCAS + pl.col.NCON)
  2665. - AAF=pl.when(pl.col.A1 == pl.col.MINOR_ALLELE).then(pl.col.MAF)
  2666. .otherwise(1 - pl.col.MAF)
  2667. Certain columns are required: REF, ALT, P, CHROM + BP (if dbSNP is not
  2668. None), SNP (if dbSNP is None), and either BETA or OR (but not both). N
  2669. is required for quantitative traits (quantitative=True) and at least one
  2670. of NEFF, AAF, and N_CASES + N_CONTROLS is required for case-control
  2671. traits. Optional columns that are None won't be included in the output
  2672. unless they can be inferred from columns that are specified.
  2673. 3. QC: removes variants with missing data in any column, non-ACGT alleles,
  2674. P outside (0, 1], OR <= 0, SE <= 0, N/N_CASES/N_CONTROLS/NEFF <= 0, AAF/
  2675. AAF_CASES/AAF_CONTROLS outside (0, 1), INFO outside (0, 1].
  2676. If verbose=True, prints stats on how many were removed.
  2677. 4. Standardizes chromosomes to chr1, chr2, ... chr22, chrX, chrY
  2678. 5. Converts variants to their minimal representations with
  2679. get_minimal_representations().
  2680. 6. If dbSNP is not None, infers rs numbers based on CHROM and BP. If
  2681. verbose=True, prints the number and % of variants that had rs numbers in
  2682. dbSNP and the number and % that had to be flipped in order to match; if
  2683. SNP is not None as well, prints the number and % of rs numbers (variant
  2684. IDs starting with rs) in the SNP column that matched the ones from
  2685. dbSNP. These messages are useful for catching errors: for instance, you
  2686. may have specified a different genome build of dbSNP than the chrom/bp
  2687. positions in raw_sumstats_files, in which case most variants won't have
  2688. rs numbers, or you may have erroneously specified the alt columns as ref
  2689. and vice versa.
  2690. 7. If dbSNP is not None, flips alleles for single-nucleotide variants if
  2691. necessary to match dbSNP, and sets BETA = -BETA, OR = 1 / OR, AAF = 1 -
  2692. AAF, AAF_CASES = 1 - AAF_CASES, and AAF_CONTROLS = 1 - AAF_CONTROLS for
  2693. these variants. Only REF/ALT flips are considered, NOT strand flips,
  2694. because modern GWAS datasets don't have strand flips - e.g. there isn't
  2695. a single non-ambiguous variant (i.e. not A/T, T/A, C/G, or G/C) in the
  2696. Als et al. 2023 depression GWAS or the Watson et al. 2019 anorexia GWAS
  2697. that matches dbSNP with a strand flip.
  2698. 8. Removes variants without rs numbers in dbSNP and variants that aren't
  2699. unique (based on SNP + REF + ALT, + CHROM/BP if they're not None).
  2700. 9. Sorts by CHROM and then BP, if those are not None.
  2701. 10. Saves to munged_sumstats_file, unless munged_sumstats_file is None. If
  2702. munged_sumstats_file ends in .gz, sumstats will be block-gzipped with
  2703. bgzip for convenience.
  2704. 11. Returns the sumstats.
  2705. For example, let's munge the depression sumstats on Niagara at
  2706. /scratch/w/wainberg/wainberg/sumstats/daner_MDDwoBP_20201001_2015iR15iex_
  2707. HRC_MDDwoBP_iPSYCH2015i_UKBtransformed_Wray_FinnGen_MVPaf_2_HRC_MAF01.gz.
  2708. The header line is:
  2709. CHR SNP BP A1 A2 FRQ_A_294322 FRQ_U_741438 INFO OR SE P ngt Direction \
  2710. HetISqt HetDf HetPVa Nca Nco Neff_half
  2711. So, filling in all the matching columns from left to right, we have:
  2712. munge_sumstats(
  2713. raw_sumstats_files='sumstats/daner_MDDwoBP_20201001_2015iR15iex_HRC_'
  2714. 'MDDwoBP_iPSYCH2015i_UKBtransformed_Wray_FinnGen_'
  2715. 'MVPaf_2_HRC_MAF01.gz',
  2716. munged_sumstats_file='MD.gz',
  2717. CHROM='CHR', SNP='SNP', BP='BP', ALT='A1', REF='A2', INFO='INFO',
  2718. OR='OR', SE='SE', P='P', N_CASES='Nca', N_CONTROLS='Nco', ...
  2719. To complete the picture, we must also specify AAF, which is a function of
  2720. the case and control Ns and frequencies. We can also specify NEFF, which
  2721. is two times the Neff_half column. We must also specify that the input
  2722. sumstats are space-delimited:
  2723. ..., AAF=(pl.col.FRQ_A_294322 * pl.col.Nca + pl.col.FRQ_U_741438 *
  2724. pl.col.Nco) / (pl.col.Nca + pl.col.Nco)',
  2725. NEFF=2 * pl.col.Neff_half, separator=' ')
  2726. Args:
  2727. raw_sumstats_files: the filename of the input sumstats, or a
  2728. list/tuple/etc. of filenames to be concatenated
  2729. munged_sumstats_file: if not None, the output sumstats filename to save
  2730. to
  2731. REF: the reference allele column/expression; mandatory
  2732. ALT: the alternate allele column/expression; mandatory
  2733. P: the p-value column/expression; mandatory
  2734. SNP: The variant ID column in raw_sumstats_files, or a polars
  2735. expression to generate a variant ID column; mandatory when dbSNP
  2736. is None.
  2737. CHROM: the chromosome column/expression; mandatory when dbSNP is not
  2738. None or BP is not None
  2739. BP: the base-pair position column/expression; mandatory when dbSNP is
  2740. not None or CHROM is not None
  2741. BETA: The effect size column/expression. Specify exactly one of BETA
  2742. and OR; if OR is specified, BETA will be calculated as log(OR).
  2743. OR: The odds ratio column/expression. Specify exactly one of BETA/OR.
  2744. Not explicitly disallowed for quantitative traits!
  2745. SE: The standard error column/expression. If None, will be back-
  2746. calculated from BETA and P: SE = |BETA| / p_to_abs_z(P).
  2747. p_to_abs_z() is an awk implementation of utils.py's p_to_abs_z()
  2748. function. log(OR) is substituted for BETA if BETA is None.
  2749. N: The sample size column/expression. Mandatory for quantitative traits
  2750. (quantitative=True). For case-control traits (quantitative=False),
  2751. will be calculated as N_CASES + N_CONTROLS if both N_CASES and
  2752. N_CONTROLS are not None. Can be a number, in which case N is
  2753. assumed to be that number for all variants.
  2754. N_CASES: The number of cases column/expression. Must be None for
  2755. quantitative traits (quantitative=True). Can be a number, in
  2756. which case N_CASES is assumed to be the same for all variants.
  2757. N_CONTROLS: The number of controls column/expression. Must be None for
  2758. quantitative traits (quantitative=True). Can be a number,
  2759. in which case N_CONTROLS is assumed to be the same for all
  2760. variants.
  2761. NEFF: The effective sample size column/expression. For quantitative
  2762. traits (quantitative=True), will be set to N if missing. For
  2763. case-control traits (quantitative=False), will be set to
  2764. ((4 / (2 * AAF * (1 - AAF) * INFO)) - BETA^2) / SE^2 if AAF is
  2765. not None (substituting log(OR) for BETA if BETA is None, and
  2766. skipping the "* INFO" if INFO is None), and
  2767. 4 / (1 / N_CASES + 1 / N_CONTROLS) if both N_CASES and N_CONTROLS
  2768. are not None; see comment below for justification. As a result,
  2769. for case-control traits, either NEFF or AAF or both N_CASES and
  2770. N_CONTROLS are mandatory. Can be a number, in which case NEFF is
  2771. assumed to be the same for all variants.
  2772. AAF: the alternate allele frequency column/expression
  2773. AAF_CASES: the case alternate allele frequency column/expression
  2774. AAF_CONTROLS: the control alternate allele frequency column/expression
  2775. INFO: the imputation INFO score column/expression
  2776. quantitative: is the trait quantitative (True) or case-control (False)?
  2777. dbSNP: a DataFrame returned by load_dbSNP(); must match the genome
  2778. build of raw_sumstats_files' BP column! If not specified, do not
  2779. infer rs numbers.
  2780. preamble: an optional function to apply immediately after loading each
  2781. file in raw_sumstats_files (so use the original column names
  2782. in the expression, not the new column names). For instance,
  2783. to filter to variants with minor allele frequencies between
  2784. 0.01 and 0.99, use preamble=lambda df: df.filter(
  2785. pl.col.all_maf.between(0.01, 0.99)).
  2786. separator: the separator to use when parsing each raw sumstats file
  2787. with pl.read_csv(), or 'whitespace' to use any number of
  2788. consecutive whitespace characters as the separator, like
  2789. delim_whitespace=True in pandas.read_table().
  2790. verbose: whether to print details of the munging process
  2791. **read_csv_kwargs: keyword arguments to pl.read_csv(). By default,
  2792. null_values='NA' is set.
  2793. Returns:
  2794. A DataFrame of the munged sumstats.
  2795. """
  2796. # Check inputs
  2797. if REF is None:
  2798. raise ValueError('REF is always mandatory')
  2799. if ALT is None:
  2800. raise ValueError('ALT is always mandatory')
  2801. if P is None:
  2802. raise ValueError('P is always mandatory')
  2803. if dbSNP is None and SNP is None:
  2804. raise ValueError('SNP is mandatory when dbSNP is None')
  2805. if CHROM is None and BP is not None:
  2806. raise ValueError('CHROM is mandatory when BP is not None')
  2807. if CHROM is not None and BP is None:
  2808. raise ValueError('BP is mandatory when CHROM is not None')
  2809. if dbSNP is not None and BP is None:
  2810. raise ValueError('CHROM and BP are mandatory when dbSNP is not None')
  2811. if BETA is None and OR is None:
  2812. raise ValueError('Neither BETA nor OR specified; specify exactly one')
  2813. if BETA is not None and OR is not None:
  2814. raise ValueError('Both BETA and OR specified; specify exactly one')
  2815. if quantitative and N is None:
  2816. raise ValueError('N is mandatory when quantitative=True')
  2817. if quantitative and N_CASES is not None:
  2818. raise ValueError('N_CASES cannot be specified when quantitative=True')
  2819. if quantitative and N_CONTROLS is not None:
  2820. raise ValueError('N_CONTROLS cannot be specified when '
  2821. 'quantitative=True')
  2822. if not quantitative and NEFF is None and AAF is None and \
  2823. (N_CASES is None or N_CONTROLS is None):
  2824. raise ValueError('quantitative=True and NEFF is None, so either AAF '
  2825. 'or both of N_CASES and N_CONTROLS need to be '
  2826. 'specified to infer it')
  2827. # Wrap string arguments in pl.col()
  2828. if isinstance(SNP, str):
  2829. SNP = pl.col(SNP)
  2830. if isinstance(REF, str):
  2831. REF = pl.col(REF)
  2832. if isinstance(ALT, str):
  2833. ALT = pl.col(ALT)
  2834. if isinstance(P, str):
  2835. P = pl.col(P)
  2836. if isinstance(CHROM, str):
  2837. CHROM = pl.col(CHROM)
  2838. if isinstance(BP, str):
  2839. BP = pl.col(BP)
  2840. if isinstance(BETA, str):
  2841. BETA = pl.col(BETA)
  2842. if isinstance(OR, str):
  2843. OR = pl.col(OR)
  2844. if isinstance(SE, str):
  2845. SE = pl.col(SE)
  2846. if isinstance(N, str):
  2847. N = pl.col(N)
  2848. if isinstance(N_CASES, str):
  2849. N_CASES = pl.col(N_CASES)
  2850. if isinstance(N_CONTROLS, str):
  2851. N_CONTROLS = pl.col(N_CONTROLS)
  2852. if isinstance(NEFF, str):
  2853. NEFF = pl.col(NEFF)
  2854. if isinstance(AAF, str):
  2855. AAF = pl.col(AAF)
  2856. if isinstance(AAF_CASES, str):
  2857. AAF_CASES = pl.col(AAF_CASES)
  2858. if isinstance(AAF_CONTROLS, str):
  2859. AAF_CONTROLS = pl.col(AAF_CONTROLS)
  2860. if isinstance(INFO, str):
  2861. INFO = pl.col(INFO)
  2862. # If SE is missing, back-calculate it from BETA and P: |Z| = |BETA| / SE,
  2863. # so SE = |BETA| / |Z|. We can get |Z| from P via p_to_abs_z().
  2864. if SE is None:
  2865. # noinspection PyUnresolvedReferences
  2866. SE = (BETA if BETA is not None else OR.log()).abs() / p_to_abs_z(P)
  2867. # Try to infer N and NEFF if not specified
  2868. #
  2869. # For quantitative traits, NEFF = N. For binary traits, NEFF can be given
  2870. # in two mathematically equivalent ways: 4/(1/N_CASES + 1/N_CONTROLS)
  2871. # (cell.com/ajhg/pdfExtended/S0002-9297(21)00145-2) and 4v(1-v)N where v =
  2872. # N_CASES/N (medrxiv.org/content/10.1101/2021.09.22.21263909v1.full-text).
  2873. #
  2874. # But this doesn't account for varying case-control ratios across the
  2875. # cohorts in a meta-analysis, which biases estimates that depend on N
  2876. # (medrxiv.org/content/10.1101/2021.09.22.21263909v1.full).
  2877. #
  2878. # Instead, biorxiv.org/content/10.1101/2021.03.29.437510v4.full suggests
  2879. # a per-variant Neff = (4 / (2 * MAF * (1 - MAF) * INFO) - BETA^2) / SE^2,
  2880. # bounded to between 0.5 and 1.1 times "the total (effective) sample size",
  2881. # which seems to be max(4/(1/N_CASES + 1/N_CONTROLS)) across SNPs:
  2882. # github.com/privefl/paper-misspec/blob/main/code/investigate-misspec-N.R
  2883. # To get a global Neff, they take the 80th %ile of the per-variant Neffs.
  2884. # This formula is based on Equation 1 of the "New formula used in LDpred2"
  2885. # section of sciencedirect.com/science/article/abs/pii/S0002929721004201.
  2886. #
  2887. # github.com/GenomicSEM/GenomicSEM/wiki/2.1-Calculating-Sum-of-Effective-
  2888. # Sample-Size-and-Preparing-GWAS-Summary-Statistics drops the BETA^2 and
  2889. # INFO, i.e. Neff = 4 / (2 * MAF * (1 - MAF)) / SE^2, bounded to between
  2890. # 0.5 and 1.1 times 4/(1/N_CASES + 1/N_CONTROLS), where N_CASES/N_CONTROLS
  2891. # are again global rather than per-SNP. This formula is based on an old
  2892. # version of the above formula that was used in the original LDpred2 paper.
  2893. #
  2894. # Here, we use Neff = (4 / (2 * AAF * (1 - AAF) * INFO) - BETA^2) / SE^2
  2895. # (dropping the INFO part if not available), without bounding (because it
  2896. # seems like a hack). If MAF not available, but N_CASES and N_CONTROLS are
  2897. # available, fall back to using 4/(1/N_CASES + 1/N_CONTROLS). Note that
  2898. # even if AAF = 1 - MAF instead of MAF, AAF * (1 - AAF) == MAF * (1 - MAF).
  2899. # "((4 / (2 * ..." has been simplified to "((2 / (..." below.
  2900. if N is None and N_CASES is not None and N_CONTROLS is not None:
  2901. N = N_CASES + N_CONTROLS
  2902. if NEFF is None:
  2903. if quantitative and N is not None:
  2904. NEFF = N
  2905. elif AAF is not None:
  2906. if INFO is not None:
  2907. NEFF = (2 / (AAF * (1 - AAF) * INFO) - (
  2908. BETA if BETA is not None else OR.log()) ** 2) / SE ** 2
  2909. else:
  2910. NEFF = (2 / (AAF * (1 - AAF)) - (
  2911. BETA if BETA is not None else OR.log()) ** 2) / SE ** 2
  2912. elif N_CASES is not None and N_CONTROLS is not None:
  2913. NEFF = 4 / (1 / N_CASES + 1 / N_CONTROLS)
  2914. else:
  2915. raise ValueError('NEFF is unexpectedly None; internal error')
  2916. # For case-control traits, output ORs even if input has betas, & vice versa
  2917. if not quantitative and OR is None:
  2918. OR = BETA.exp()
  2919. if quantitative and BETA is None:
  2920. BETA = OR.log()
  2921. # Ensure REF and ALT are capitalized
  2922. REF = REF.str.to_uppercase()
  2923. ALT = ALT.str.to_uppercase()
  2924. # Enumerate columns to include, and the formula for each column
  2925. column_formulas = {
  2926. column_name: formula for column_name, formula in {
  2927. 'SNP': SNP, 'CHROM': CHROM, 'BP': BP, 'REF': REF, 'ALT': ALT,
  2928. 'AAF': AAF, 'AAF_CASES': AAF_CASES, 'AAF_CONTROLS': AAF_CONTROLS,
  2929. 'INFO': INFO, 'BETA' if quantitative else 'OR':
  2930. BETA if quantitative else OR, 'SE': SE, 'P': P, 'N': N,
  2931. 'N_CASES': N_CASES, 'N_CONTROLS': N_CONTROLS, 'NEFF': NEFF}.items()
  2932. if formula is not None}
  2933. # Load files in raw_sumstats_files; apply the preamble function (if not
  2934. # None) and select columns
  2935. raw_sumstats_files = (raw_sumstats_files,) \
  2936. if isinstance(raw_sumstats_files, str) else tuple(raw_sumstats_files)
  2937. columns = tuple(set.union(*(set(formula.meta.root_names())
  2938. if isinstance(formula, pl.Expr) else formula
  2939. for formula in column_formulas.values()
  2940. if not isinstance(formula, (int, float)))))
  2941. default_read_csv_kwargs = dict(null_values='NA')
  2942. read_csv_kwargs = default_read_csv_kwargs | read_csv_kwargs \
  2943. if read_csv_kwargs is not None else default_read_csv_kwargs
  2944. sumstats = pl.concat((
  2945. read_csv_delim_whitespace(raw_sumstats_file,
  2946. columns=columns,
  2947. **read_csv_kwargs)
  2948. if separator == 'whitespace' else
  2949. # Use scan_csv() instead of read_csv() when possible, i.e. when the raw
  2950. # sumstats file isn't gzipped and delim_whitespace=False.
  2951. pl.read_csv(raw_sumstats_file, columns=columns,
  2952. separator=separator,
  2953. **read_csv_kwargs)
  2954. if raw_sumstats_file.endswith('.gz') else
  2955. pl.scan_csv(raw_sumstats_file,
  2956. separator=separator,
  2957. **read_csv_kwargs)
  2958. .select(columns))
  2959. .lazy()
  2960. .pipe(
  2961. preamble if preamble is not None else lambda df: df)
  2962. .select(**column_formulas)
  2963. for raw_sumstats_file in raw_sumstats_files) \
  2964. .collect()
  2965. # QC: remove variants with missing data in any column, non-ACGT alleles, P
  2966. # outside (0, 1], OR <= 0, SE <= 0, N/N_CASES/N_CONTROLS/NEFF <= 0, AAF/
  2967. # AAF_CASES/AAF_CONTROLS outside (0, 1), INFO outside (0, 1].
  2968. # If verbose=True, print stats on how many were removed.
  2969. if verbose:
  2970. num_initial_variants = len(sumstats)
  2971. # noinspection PyUnresolvedReferences
  2972. filters = {filter_name: filter for filter_name, filter in ({
  2973. 'non-ACGT alleles':
  2974. sumstats[
  2975. 'REF'].str.contains(
  2976. '[^ACGT]') |
  2977. sumstats[
  2978. 'ALT'].str.contains(
  2979. '[^ACGT]'),
  2980. 'P outside (0, 1]': ~
  2981. sumstats[
  2982. 'P'].is_between(
  2983. 0, 1,
  2984. closed='right'),
  2985. 'OR <= 0':
  2986. sumstats[
  2987. 'OR'] <= 0 if 'OR' in sumstats else None,
  2988. 'SE <= 0':
  2989. sumstats[
  2990. 'SE'] <= 0 if 'SE' in sumstats else None,
  2991. 'N <= 0':
  2992. sumstats[
  2993. 'N'] <= 0 if 'N' in sumstats else None,
  2994. 'N_CASES <= 0':
  2995. sumstats[
  2996. 'N_CASES'] <= 0
  2997. if 'N_CASES' in sumstats else None,
  2998. 'N_CONTROLS <= 0':
  2999. sumstats[
  3000. 'N_CONTROLS'] <= 0
  3001. if 'N_CONTROLS' in sumstats else None,
  3002. 'NEFF <= 0':
  3003. sumstats[
  3004. 'NEFF'] <= 0 if 'NEFF' in sumstats else None,
  3005. 'AAF outside (0, 1)': ~
  3006. sumstats[
  3007. 'AAF'].is_between(
  3008. 0, 1,
  3009. closed='none')
  3010. if 'AAF' in sumstats else None,
  3011. 'AAF_CASES outside (0, 1)': ~
  3012. sumstats[
  3013. 'AAF_CASES'].is_between(
  3014. 0, 1,
  3015. closed='none') if 'AAF_CASES' in sumstats else None,
  3016. 'AAF_CONTROLS outside (0, 1)': ~
  3017. sumstats[
  3018. 'AAF_CONTROLS'].is_between(
  3019. 0, 1,
  3020. closed='none') if 'AAF_CONTROLS' in sumstats else None,
  3021. 'INFO <= 0':
  3022. sumstats[
  3023. 'INFO'] <= 0 if 'INFO' in sumstats else None} | {
  3024. f'null {column_name}':
  3025. sumstats[
  3026. column_name].is_null()
  3027. for
  3028. column_name
  3029. in
  3030. column_formulas}).items()
  3031. if filter is not None and filter.any()}
  3032. if len(filters) > 0:
  3033. union_of_filters = reduce(lambda a, b: a | b, filters.values())
  3034. total_num_filtered = union_of_filters.sum()
  3035. if total_num_filtered == len(sumstats):
  3036. raise ValueError('All variants would be filtered out!')
  3037. if total_num_filtered > 0:
  3038. if verbose:
  3039. last_filter_name = tuple(filters)[-1] \
  3040. if len(filters) > 1 else None
  3041. print(f'Removing ' + ', '.join(
  3042. f'{"and " if filter_name == last_filter_name else ""}'
  3043. f'{num_filtered:,} {plural("variant", num_filtered)} with '
  3044. f'{filter_name}'
  3045. for filter_name, filter, num_filtered in
  3046. ((filter_name, filter, filter.sum())
  3047. for filter_name, filter in filters.items())) +
  3048. f', for a total of {total_num_filtered:,} '
  3049. f'{plural("variant", total_num_filtered)} '
  3050. f'({100 * total_num_filtered / len(sumstats):.2f}%)')
  3051. sumstats = sumstats.filter(~union_of_filters)
  3052. elif verbose:
  3053. print('All variants pass initial QC filters!')
  3054. # Standardize chromosomes
  3055. if CHROM is not None:
  3056. sumstats = sumstats \
  3057. .with_columns(pl.col.CHROM.pipe(standardize_chromosomes))
  3058. # Convert variants to their minimal representations
  3059. sumstats = sumstats.pipe(get_minimal_representations,
  3060. bp_col='BP' if BP is not None else None)
  3061. # If dbSNP is not None, infer rs numbers and flip variants to match dbSNP;
  3062. # if verbose=True, print stats
  3063. if dbSNP is not None:
  3064. if verbose and SNP is not None:
  3065. sumstats = sumstats.rename({'SNP': 'original_SNP'})
  3066. sumstats = sumstats \
  3067. .pipe(get_rs_numbers, dbSNP=dbSNP, verbose=verbose) \
  3068. .select('SNP', pl.exclude('SNP')) \
  3069. .pipe(flip_alleles)
  3070. if verbose:
  3071. num_nonmissing_rs = sumstats['SNP'].is_not_null().sum()
  3072. num_flipped = sumstats['FLIP'].sum()
  3073. percent_flipped = 100 * num_flipped / num_nonmissing_rs
  3074. print(f'{num_flipped:,} of these {num_nonmissing_rs:,} variants '
  3075. f'({percent_flipped:.2f}%) had to be flipped in order to '
  3076. f'match dbSNP')
  3077. if SNP is not None:
  3078. matching_rs = sumstats \
  3079. .select('original_SNP', 'SNP') \
  3080. .filter(pl.col.original_SNP.str.starts_with('rs'),
  3081. pl.col.SNP.is_not_null()) \
  3082. .with_columns(pl.col.SNP.str.split(',')) \
  3083. .with_columns(match=pl.col.SNP.list.contains(
  3084. pl.col.original_SNP))
  3085. num_matching_rs = matching_rs['match'].sum()
  3086. percent_matching_rs = 100 * num_matching_rs / len(matching_rs)
  3087. likely_merges = matching_rs \
  3088. .filter(~pl.col.match, ~pl.col.SNP.list.len().eq(1)) \
  3089. .select(pl.col.SNP.list.get(0).str.slice(2).cast(int) <
  3090. pl.col.original_SNP.str.slice(2).cast(int)) \
  3091. .to_series()
  3092. num_likely_merges = likely_merges.sum()
  3093. percent_likely_merges = 100 * num_likely_merges / \
  3094. len(likely_merges)
  3095. print(f'{len(matching_rs):,} variant IDs in the SNP column/'
  3096. f'expression you specified start with "rs" and have at '
  3097. f'least one rs number in dbSNP; {num_matching_rs:,} '
  3098. f'({percent_matching_rs:.2f}%) of these match dbSNP. Of '
  3099. f'those that didn\'t, {len(likely_merges):,} have '
  3100. f'exactly one matching rs number, and for '
  3101. f'{num_likely_merges:,} ({percent_likely_merges:.2f}%) '
  3102. f'of these, the matching rs number is numerically '
  3103. f'smaller than the original, which suggests the two rs '
  3104. f'numbers might have been merged.')
  3105. sumstats = sumstats.drop('original_SNP')
  3106. sumstats = sumstats.drop('FLIP')
  3107. # Remove variants without rs numbers in dbSNP
  3108. variants_with_rs_numbers = sumstats['SNP'].is_not_null()
  3109. num_without_rs_numbers = len(sumstats) - variants_with_rs_numbers.sum()
  3110. if num_without_rs_numbers > 0:
  3111. if verbose:
  3112. print(f'Removing {num_without_rs_numbers:,} '
  3113. f'{plural("variant", num_without_rs_numbers)} '
  3114. f'({100 * num_without_rs_numbers / len(sumstats):.2f}%) '
  3115. f'without rs numbers in dbSNP')
  3116. sumstats = sumstats.filter(variants_with_rs_numbers)
  3117. # Remove non-unique variants
  3118. variant_columns = ['SNP', 'REF', 'ALT'] + \
  3119. (['CHROM', 'BP'] if BP is not None else [])
  3120. unique_mask = sumstats.select(variant_columns).is_unique()
  3121. num_non_unique = len(sumstats) - unique_mask.sum()
  3122. if num_non_unique > 0:
  3123. if verbose:
  3124. print(f'Removing {num_non_unique:,} non-unique '
  3125. f'{plural("variant", num_non_unique)} '
  3126. f'({100 * num_non_unique / len(sumstats):.2f}%)')
  3127. sumstats = sumstats.filter(unique_mask)
  3128. # Print how many variants were retained
  3129. if verbose:
  3130. # noinspection PyUnboundLocalVariable
  3131. print(f'{len(sumstats):,} of {num_initial_variants:,} variants '
  3132. f'({100 * len(sumstats) / num_initial_variants:.2f}%) were '
  3133. f'retained')
  3134. # Sort
  3135. if BP is not None:
  3136. sumstats = sumstats.pipe(sort_sumstats)
  3137. # Save, if munged_sumstats_file is not None; block-gzip if
  3138. # munged_sumstats_file ends in .gz
  3139. if munged_sumstats_file is not None:
  3140. sumstats \
  3141. .with_columns(pl.selectors.float()
  3142. .map_elements('{:.12g}'.format,
  3143. return_dtype=pl.String)) \
  3144. .write_csv(munged_sumstats_file.removesuffix('.gz'),
  3145. separator='\t')
  3146. if munged_sumstats_file.endswith('.gz'):
  3147. run(f'bgzip -f {munged_sumstats_file.removesuffix(".gz")}')
  3148. # Return the sumstats, even if saving
  3149. return sumstats
  3150. def munge_regenie_sumstats(raw_sumstats_files, munged_sumstats_file, *,
  3151. quantitative=False, dbSNP=None):
  3152. """
  3153. A wrapper for munge_sumstats() when all sumstats are in regenie format.
  3154. Args:
  3155. raw_sumstats_files: a sumstats file (or list thereof) in regenie format
  3156. munged_sumstats_file: a file path where the munged sumstats file will
  3157. be output
  3158. quantitative: are the sumstats files for quantitative traits?
  3159. dbSNP: a DataFrame returned by load_dbSNP(); must match the genome
  3160. build of raw_sumstats_files' BP column! If not specified, do not
  3161. infer rs numbers.
  3162. """
  3163. munge_sumstats(raw_sumstats_files, munged_sumstats_file,
  3164. CHROM=pl.col.CHROM.cast(pl.String).replace({'23': 'X'}),
  3165. BP='GENPOS', SNP='ID', REF='ALLELE0', ALT='ALLELE1',
  3166. AAF='A1FREQ',
  3167. AAF_CASES=None if quantitative else 'A1FREQ_CASES',
  3168. AAF_CONTROLS=None if quantitative else 'A1FREQ_CONTROLS',
  3169. INFO='INFO', N='N',
  3170. N_CASES=None if quantitative else 'N_CASES',
  3171. N_CONTROLS=None if quantitative else 'N_CONTROLS',
  3172. BETA='BETA', SE='SE', P=10 ** -pl.col.LOG10P,
  3173. quantitative=quantitative,
  3174. separator=' ', dbSNP=dbSNP)
  3175. def get_minimal_representations_numpy(ref_vector, alt_vector, bp_vector):
  3176. """
  3177. Converts variants - represented as 1D NumPy arrays ref_vector, alt_vector,
  3178. and optionally bp_vector - to their minimal representations.
  3179. Modifies bp_vector, ref_vector and alt_vector in-place.
  3180. cureffi.org/2014/04/24/converting-genetic-variants-to-their-minimal-
  3181. representation explains what a minimal representation is.
  3182. github.com/ericminikel/minimal_representation/blob/master/
  3183. minimal_representation.py contains the code this function is based on.
  3184. This operation only affects indels: by definition, single-nucleotide
  3185. variants are already in their minimal representation.
  3186. Args:
  3187. ref_vector: the reference alleles of the variants
  3188. alt_vector: the alternate alleles of the variants
  3189. bp_vector: the base pairs of the variants; may be None
  3190. """
  3191. import numpy as np
  3192. assert isinstance(ref_vector, np.ndarray) and ref_vector.ndim == 1
  3193. assert isinstance(alt_vector, np.ndarray) and alt_vector.ndim == 1
  3194. assert len(alt_vector) == len(ref_vector)
  3195. if bp_vector is not None:
  3196. assert isinstance(bp_vector, np.ndarray) and bp_vector.ndim == 1
  3197. assert len(bp_vector) == len(ref_vector)
  3198. assert bp_vector.dtype in ('int32', int), bp_vector.dtype
  3199. int_type = 'int' if bp_vector.dtype == 'int32' else 'long'
  3200. # noinspection PyUnboundLocalVariable
  3201. cython_inline(rf'''
  3202. def get_minimal_representations_cython(str[:] ref_vector, str[:] alt_vector
  3203. {f', {int_type}[:] bp_vector' if bp_vector is not None else ''}):
  3204. cdef long i, min_len, ref_len, alt_len, ref_start, ref_end, \
  3205. alt_start, alt_end
  3206. {f'cdef {int_type} bp' if bp_vector is not None else ''}
  3207. cdef str ref, alt
  3208. for i in range(ref_vector.shape[0]):
  3209. ref = ref_vector[i]
  3210. alt = alt_vector[i]
  3211. ref_len = len(ref)
  3212. alt_len = len(alt)
  3213. min_len = ref_len if ref_len < alt_len else alt_len
  3214. if min_len == 1:
  3215. continue
  3216. {f'bp = bp_vector[i]' if bp_vector is not None else ''}
  3217. ref_start = 0
  3218. alt_start = 0
  3219. ref_end = ref_len - 1
  3220. alt_end = alt_len - 1
  3221. while ref[ref_end] == alt[alt_end]:
  3222. ref_end -= 1
  3223. alt_end -= 1
  3224. min_len -= 1
  3225. if min_len == 1: break
  3226. else:
  3227. while ref[ref_start] == alt[alt_start]:
  3228. ref_start += 1
  3229. alt_start += 1
  3230. {'bp += 1' if bp_vector is not None else ''}
  3231. min_len -= 1
  3232. if min_len == 1: break
  3233. ref_vector[i] = ref[ref_start:ref_end + 1]
  3234. alt_vector[i] = alt[alt_start:alt_end + 1]
  3235. {'bp_vector[i] = bp' if bp_vector is not None else ''}
  3236. ''')['get_minimal_representations_cython'](
  3237. **{'ref_vector': ref_vector, 'alt_vector': alt_vector} |
  3238. (({'bp_vector': bp_vector}) if bp_vector is not None else {}))
  3239. def get_minimal_representations(df, *, ref_col='REF', alt_col='ALT',
  3240. bp_col='BP'):
  3241. """
  3242. A wrapper for get_minimal_representations_numpy() for polars DataFrames.
  3243. Args:
  3244. df: a polars DataFrame
  3245. ref_col: the name of the reference allele column in df
  3246. alt_col: the name of the alternate allele column in df
  3247. bp_col: the name of the base-pair column in df; may be None
  3248. Returns:
  3249. A same-sized df with each variant converted to its minimal
  3250. representation.
  3251. """
  3252. import pyarrow as pa
  3253. if not isinstance(df, pl.DataFrame):
  3254. raise ValueError('df must be a polars DataFrame!')
  3255. if df.is_empty():
  3256. raise ValueError(f'df is empty!')
  3257. if ref_col not in df:
  3258. raise ValueError(f'{ref_col!r} not in df; specify ref_col')
  3259. if alt_col not in df:
  3260. raise ValueError(f'{alt_col!r} not in df; specify alt_col')
  3261. if bp_col is not None and bp_col not in df:
  3262. raise ValueError(f'{bp_col!r} not in df; specify bp_col')
  3263. ref_vector = df[ref_col].to_numpy()
  3264. alt_vector = df[alt_col].to_numpy()
  3265. bp_vector = df[bp_col].to_numpy(writable=True) \
  3266. if bp_col is not None else None
  3267. get_minimal_representations_numpy(ref_vector, alt_vector, bp_vector)
  3268. df = df.with_columns(pl.from_arrow(pa.array(
  3269. ref_vector, type=pa.large_utf8())).alias(ref_col),
  3270. pl.from_arrow(pa.array(
  3271. alt_vector, type=pa.large_utf8())).alias(alt_col),
  3272. **{bp_col: pl.from_numpy(bp_vector)[:, 0]}
  3273. if bp_col is not None else {})
  3274. return df
  3275. def get_minimal_representations_awk(include_bp=True):
  3276. """
  3277. An awk implementation of get_minimal_representations(). Assumes the
  3278. variant's reference allele, alternate allele and base-pair position are
  3279. stored in the variables ref, alt and bp.
  3280. Args:
  3281. include_bp: if False, do not include the correction for base-pair
  3282. position (include_bp=False is useful when there's no
  3283. base-pair column)
  3284. Returns:
  3285. A code string to be integrated into a larger awk command.
  3286. """
  3287. import re
  3288. return re.sub(r'\s+', ' ', f'''
  3289. if (length(ref) > 1 || length(alt) > 1) {{
  3290. while (length(alt) > 1 && length(ref) > 1 &&
  3291. substr(alt, length(alt)) == substr(ref, length(ref))) {{
  3292. alt = substr(alt, 1, length(alt) - 1);
  3293. ref = substr(ref, 1, length(ref) - 1);
  3294. }}
  3295. while (length(alt) > 1 && length(ref) > 1 &&
  3296. substr(alt, 1, 1) == substr(ref, 1, 1)) {{
  3297. alt = substr(alt, 2);
  3298. ref = substr(ref, 2);
  3299. {"bp++;" if include_bp else ""}
  3300. }}
  3301. }}
  3302. ''').strip().rstrip()
  3303. def load_dbSNP(genome_build, *,
  3304. dbSNP_dir=f'{get_base_data_directory()}/dbSNP'):
  3305. """
  3306. Load all autosomal + chrX/Y/M variants in dbSNP, caching intermediate and
  3307. final results in the cache directory dbSNP_dir. Multiallelic variants are
  3308. split, then variants are converted to their minimal representations.
  3309. Variants that map to multiple genomic locations
  3310. (ncbi.nlm.nih.gov/snp/docs/rs_multi_mapping) are removed.
  3311. Must be run on a node with a large amount of memory! 120 GiB should be
  3312. sufficient. (You will need more - closer to 188 GiB, the size of a
  3313. Niagara compute node - if the dbSNP cache has not been generated. Also,
  3314. you will need to first run it on a login node, to download the files, then
  3315. when it runs out of memory, switch to a compute node. It is hacky.)
  3316. Args:
  3317. genome_build: the genome build (hg38 or hg19)
  3318. dbSNP_dir: the cache directory to store intermediate and final results.
  3319. Because generating the cache can take a long time, you will
  3320. probably want to leave this argument at its default value.
  3321. Returns:
  3322. A polars DataFrame with columns CHROM, BP, REF, ALT and RSID.
  3323. """
  3324. check_valid_genome_build(genome_build)
  3325. dbSNP_file = os.path.join(dbSNP_dir, f'{genome_build}.tsv')
  3326. read_csv_kwargs = dict(separator='\t', schema_overrides={
  3327. 'CHROM': pl.Categorical, 'BP': pl.Int32,
  3328. 'REF': pl.Categorical('lexical'), 'ALT': pl.Categorical('lexical')})
  3329. if os.path.exists(dbSNP_file):
  3330. return pl.read_csv(dbSNP_file, **read_csv_kwargs)
  3331. else:
  3332. full_dbSNP_file = os.path.join(
  3333. dbSNP_dir, f'GCF_000001405.'
  3334. f'{40 if genome_build == "hg38" else 25}.gz')
  3335. if not os.path.exists(full_dbSNP_file):
  3336. raise_error_if_on_compute_node()
  3337. run(f'mkdir -p {dbSNP_dir} && '
  3338. f'wget https://ftp.ncbi.nih.gov/snp/latest_release/VCF/'
  3339. f'{os.path.basename(full_dbSNP_file)} -O {full_dbSNP_file}')
  3340. dbSNP_file_with_multimapping = os.path.join(
  3341. dbSNP_dir, f'{genome_build}_with_multimapping.tsv')
  3342. if not os.path.exists(dbSNP_file_with_multimapping):
  3343. dbSNP_chromosome_IDs = {
  3344. 'NC_000001.11': 'chr1', 'NC_000002.12': 'chr2',
  3345. 'NC_000003.12': 'chr3', 'NC_000004.12': 'chr4',
  3346. 'NC_000005.10': 'chr5', 'NC_000006.12': 'chr6',
  3347. 'NC_000007.14': 'chr7', 'NC_000008.11': 'chr8',
  3348. 'NC_000009.12': 'chr9', 'NC_000010.11': 'chr10',
  3349. 'NC_000011.10': 'chr11', 'NC_000012.12': 'chr12',
  3350. 'NC_000013.11': 'chr13', 'NC_000014.9': 'chr14',
  3351. 'NC_000015.10': 'chr15', 'NC_000016.10': 'chr16',
  3352. 'NC_000017.11': 'chr17', 'NC_000018.10': 'chr18',
  3353. 'NC_000019.10': 'chr19', 'NC_000020.11': 'chr20',
  3354. 'NC_000021.9': 'chr21', 'NC_000022.11': 'chr22',
  3355. 'NC_000023.11': 'chrX', 'NC_000024.10': 'chrY',
  3356. 'NC_012920.1': 'chrM'
  3357. } if genome_build == 'hg38' else {
  3358. 'NC_000001.10': 'chr1', 'NC_000002.11': 'chr2',
  3359. 'NC_000003.11': 'chr3', 'NC_000004.11': 'chr4',
  3360. 'NC_000005.9': 'chr5', 'NC_000006.11': 'chr6',
  3361. 'NC_000007.13': 'chr7', 'NC_000008.10': 'chr8',
  3362. 'NC_000009.11': 'chr9', 'NC_000010.10': 'chr10',
  3363. 'NC_000011.9': 'chr11', 'NC_000012.11': 'chr12',
  3364. 'NC_000013.10': 'chr13', 'NC_000014.8': 'chr14',
  3365. 'NC_000015.9': 'chr15', 'NC_000016.9': 'chr16',
  3366. 'NC_000017.10': 'chr17', 'NC_000018.9': 'chr18',
  3367. 'NC_000019.9': 'chr19', 'NC_000020.10': 'chr20',
  3368. 'NC_000021.8': 'chr21', 'NC_000022.10': 'chr22',
  3369. 'NC_000023.10': 'chrX', 'NC_000024.9': 'chrY',
  3370. 'NC_012920.1': 'chrM'}
  3371. dbSNP_awk_array = ''.join(
  3372. f'dbSNP_map["{ID}"] = "{chrom}"; '
  3373. for ID, chrom in dbSNP_chromosome_IDs.items())
  3374. # Memory optimization: seen is deleted after every chromosome
  3375. # (assumes chromosomes are contiguous in dbSNP, which they are)
  3376. run(f'awk \'BEGIN {{while ((getline line) && (line ~ /^##/)); '
  3377. f'print "CHROM", "BP", "REF", "ALT", "RSID"; {dbSNP_awk_array}'
  3378. f'}} $1 in dbSNP_map {{split($5, alts, ","); chrom = '
  3379. f'dbSNP_map[$1]; if (chrom != prev_chrom) delete seen; '
  3380. f'prev_chrom = chrom; for (i in alts) {{ bp = $2; ref = $4; '
  3381. f'alt = alts[i]; {get_minimal_representations_awk()}; '
  3382. f'if (!seen[bp":"ref":"alt":"$3]++) print chrom, bp, ref, '
  3383. f'alt, $3}}}}\' OFS="\t" <(zcat {full_dbSNP_file}) > '
  3384. f'{dbSNP_file_with_multimapping}')
  3385. # Remove multi-mapping variants; this is equivalent (aside from
  3386. # sorting) to dbSNP = dbSNP.unique(['RSID', 'REF', 'ALT'], keep='none')
  3387. dbSNP = pl.read_csv(dbSNP_file_with_multimapping, **read_csv_kwargs)
  3388. dbSNP = dbSNP \
  3389. .cast({'CHROM': pl.Enum([f'chr{i}' for i in range(1, 23)] +
  3390. ['chrX', 'chrY', 'chrM'])}) \
  3391. .sort('RSID', 'REF', 'ALT') \
  3392. .with_columns(rle_id=pl.struct('RSID', 'REF', 'ALT').rle_id()) \
  3393. .filter(pl.col.rle_id.ne(pl.col.rle_id.shift(fill_value=-1)) &
  3394. pl.col.rle_id.ne(pl.col.rle_id.shift(-1, fill_value=-1))) \
  3395. .drop('rle_id') \
  3396. .sort('CHROM', 'BP', 'REF', 'ALT', 'RSID')
  3397. dbSNP.write_csv(dbSNP_file, separator='\t')
  3398. return dbSNP
  3399. def check_valid_dbSNP(dbSNP):
  3400. """
  3401. Checks if a dbSNP instance is valid.
  3402. Args:
  3403. dbSNP: a dbSNP instance
  3404. """
  3405. if not isinstance(dbSNP, pl.DataFrame):
  3406. raise TypeError(f'dbSNP must be a DataFrame returned by '
  3407. f'load_dbSNP(), but has type {type(dbSNP).__name__}')
  3408. if dbSNP.columns != ['CHROM', 'BP', 'REF', 'ALT', 'RSID']:
  3409. raise ValueError(f"dbSNP must be a DataFrame returned by load_dbSNP() "
  3410. f"with columns ['CHROM', 'BP', 'REF', 'ALT', 'RSID']")
  3411. if dbSNP.height < 1_000_000_000:
  3412. raise ValueError(f'dbSNP must be a DataFrame returned by '
  3413. f'load_dbSNP() and should have at least a billion '
  3414. f'rows, but yours has only {dbSNP.height} rows')
  3415. def get_rs_numbers(df, dbSNP, *, chrom_col='CHROM', bp_col='BP', ref_col='REF',
  3416. alt_col='ALT', rs_col='SNP', flip_col='FLIP',
  3417. fall_back_to_old_IDs=False, include_merged_IDs=False,
  3418. verbose=True):
  3419. """
  3420. Given a DataFrame of variants with chrom_col, bp_col, ref_col, and alt_col
  3421. columns, adds a column rs_col to the DataFrame with the rs numbers (joined
  3422. with commas, in the rare case a variant has multiple). Also adds a column
  3423. flip_col, saying which variants needed to have their ref and alt alleles
  3424. flipped to match dbSNP; flipping is only attempted for single-nucleotide
  3425. variants.
  3426. If rs_col is already a column of df, variants present in dbSNP will have
  3427. their IDs in rs_col overwritten with their rs numbers. rs numbers for
  3428. variants not present in dbSNP (including multi-mapping variants; see
  3429. load_dbSNP()) will be set to null, unless fall_back_to_old_IDs=True, in
  3430. which case their original IDs will be retained.
  3431. If rs_col is not already a column of df, variants missing from dbSNP will
  3432. always have their rs numbers set to null, as there are no old IDs to fall
  3433. back to.
  3434. Args:
  3435. df: a DataFrame with chrom_col, bp_col, ref_col, and alt_col columns
  3436. dbSNP: a DataFrame returned by load_dbSNP(); must match the genome
  3437. build of df's bp_col!
  3438. chrom_col: the name of the chromosome column in df
  3439. bp_col: the name of the base-pair column in df
  3440. ref_col: the name of the reference allele column in df
  3441. alt_col: the name of the alternate allele column in df
  3442. rs_col: the name of the rs number column to be added to df
  3443. flip_col: the name of the flip column to be added to df. True where
  3444. alleles had to be flipped to match dbSNP, False where they
  3445. matched without flipping, null if the variant didn't match
  3446. dbSNP either with or without flipping
  3447. fall_back_to_old_IDs: if True, variants missing from dbSNP will retain
  3448. their original variant IDs, instead of having
  3449. them set to null. Requires chrom_col and bp_col
  3450. to already be present in df.
  3451. include_merged_IDs: determines how to handle the case where multiple rs
  3452. numbers have been merged into a single rs number.
  3453. If True, include all of them as a List[String]
  3454. column; if False, only include the merged ID (which
  3455. we assume is the lowest-numbered one).
  3456. verbose: whether to print what's happening at each step
  3457. Returns: df with two additional columns: rs_col, containing the rs numbers,
  3458. and flip_col, containing which variants were flipped.
  3459. """
  3460. if df.is_empty():
  3461. raise ValueError(f'df is empty!')
  3462. if chrom_col not in df:
  3463. raise ValueError(f'"{chrom_col}" not in df; specify chrom_col')
  3464. if bp_col not in df:
  3465. raise ValueError(f'"{bp_col}" not in df; specify bp_col')
  3466. if ref_col not in df:
  3467. raise ValueError(f'"{ref_col}" not in df; specify ref_col')
  3468. if alt_col not in df:
  3469. raise ValueError(f'"{alt_col}" not in df; specify alt_col')
  3470. if flip_col in df:
  3471. raise ValueError(f'"{flip_col}" already in df; rename it or specify '
  3472. f'a different column name for flip_col')
  3473. if fall_back_to_old_IDs and rs_col not in df:
  3474. raise ValueError(f'You specified fall_back_to_old_IDs=True, but '
  3475. f'rs_col "{rs_col}" is not in df; specify it')
  3476. check_valid_dbSNP(dbSNP)
  3477. # Construct the rs number column piecewise by chromosome for efficiency
  3478. df = df.with_row_index()
  3479. rs_numbers = None
  3480. for df_chrom_ID in df[chrom_col].unique(maintain_order=True):
  3481. try:
  3482. chrom = standardize_chromosomes(df_chrom_ID)
  3483. except ValueError:
  3484. raise ValueError(f'df contains non-standard chromosome '
  3485. f'"{df_chrom_ID}"!')
  3486. if verbose:
  3487. print(f'Getting rs numbers for {chrom}...')
  3488. # Subset to chromosome; convert df to minimal representations
  3489. dbSNP_chrom = dbSNP.filter(pl.col.CHROM == chrom) \
  3490. .drop('CHROM') \
  3491. .rename({'BP': bp_col, 'REF': ref_col, 'ALT': alt_col,
  3492. 'RSID': rs_col})
  3493. df_chrom = df.filter(pl.col(chrom_col) == df_chrom_ID) \
  3494. .select('index', bp_col, ref_col, alt_col) \
  3495. .pipe(get_minimal_representations, bp_col=bp_col, ref_col=ref_col,
  3496. alt_col=alt_col) \
  3497. .with_columns(pl.col(bp_col).cast(pl.Int32),
  3498. pl.col(ref_col, alt_col).cast(pl.Categorical))
  3499. # Allow matches without ref/alt flips...
  3500. matches_without_flips = df_chrom \
  3501. .join(dbSNP_chrom, on=[bp_col, ref_col, alt_col], how='left',
  3502. coalesce=True) \
  3503. .drop_nulls(rs_col)
  3504. # ...or with ref/alt flips, but only for SNVs (since for indels, e.g.
  3505. # "21:15847757:A:AG" and "21:15847757:AG:A" are different variants: the
  3506. # first is an insertion and the second is a deletion)
  3507. # Remove .cast(pl.String) once polars allows Categorical
  3508. # .str.len_bytes(): github.com/pola-rs/polars/issues/9773
  3509. matches_with_flips = df_chrom \
  3510. .filter(pl.col(ref_col).cast(pl.String).str.len_bytes() == 1,
  3511. pl.col(alt_col).cast(pl.String).str.len_bytes() == 1) \
  3512. .join(dbSNP_chrom, left_on=[bp_col, ref_col, alt_col],
  3513. right_on=[bp_col, alt_col, ref_col], how='left') \
  3514. .drop_nulls(rs_col)
  3515. # Ensure no variants match both with and without flips (theoretically
  3516. # possible since dbSNP has lots of edge cases, but we don't support it)
  3517. assert not matches_with_flips['index'].is_in(
  3518. matches_without_flips['index']).any()
  3519. # Merge matches with and without flips; in the rare case that a variant
  3520. # has multiple rs numbers, report all of them as a list column (if
  3521. # include_merged_IDs=True), or take only the lowest-numbered one (
  3522. # if include_merged_IDs=False)
  3523. chrom_rs_numbers = pl.concat([
  3524. matches_without_flips.with_columns(pl.lit(False).alias(flip_col)),
  3525. matches_with_flips.with_columns(pl.lit(True).alias(flip_col))]) \
  3526. .group_by('index') \
  3527. .agg(pl.col(rs_col).sort_by(pl.col(rs_col).str.slice(2).cast(int))
  3528. if include_merged_IDs else
  3529. pl.col(rs_col).sort_by(pl.col(rs_col).str.slice(2).cast(int))
  3530. .first(),
  3531. pl.first(flip_col))
  3532. rs_numbers = chrom_rs_numbers if rs_numbers is None else \
  3533. rs_numbers.extend(chrom_rs_numbers)
  3534. del dbSNP_chrom, df_chrom, matches_without_flips, matches_with_flips, \
  3535. chrom_rs_numbers
  3536. if rs_col in df:
  3537. df = df \
  3538. .lazy() \
  3539. .drop(rs_col) \
  3540. .join(rs_numbers.lazy(), on='index', how='left') \
  3541. .drop('index') \
  3542. .collect()
  3543. else:
  3544. df = df \
  3545. .join(rs_numbers, on='index', how='left') \
  3546. .drop('index')
  3547. # Print how many variants had rs numbers in dbSNP
  3548. if verbose:
  3549. num_mapped = len(df) - df[rs_col].null_count()
  3550. print(f'{num_mapped:,} of {len(df):,} variants '
  3551. f'({100 * num_mapped / len(df):.2f}%) had rs numbers in dbSNP')
  3552. # # Fall back to old IDs, if specified
  3553. if rs_col in df and fall_back_to_old_IDs:
  3554. df = df.with_columns(pl.col(rs_col).fill_null(df[rs_col]))
  3555. return df
  3556. def get_positions(df, dbSNP, *, rs_col='SNP', ref_col='REF', alt_col='ALT',
  3557. chrom_col='CHROM', bp_col='BP', flip_col='FLIP',
  3558. fall_back_to_old_positions=False):
  3559. """
  3560. The reverse of get_rs_numbers(): given a DataFrame of variants with rs_col,
  3561. ref_col, and alt_col columns, adds columns chrom_col and bp_col to the
  3562. DataFrame giving the chromosome and base-pair positions of each variant.
  3563. Also adds a column flip_col, saying which variants needed to have their ref
  3564. and alt alleles flipped to match dbSNP; flipping is only attempted for
  3565. single-nucleotide variants.
  3566. Use this function to remap sumstats to a different genome build!
  3567. If chrom_col and bp_col are already columns of df, variants present in
  3568. dbSNP will have their chromosomes and base-pairs in chrom_col and bp_col
  3569. overwritten with those from dbSNP. Variants not present in dbSNP (including
  3570. multi-mapping variants; see load_dbSNP()) will have their chromosomes and
  3571. base-pair positions set to null, unless fall_back_to_old_positions=True, in
  3572. which case their original chromsomes/base-pair positions wil be retained.
  3573. If chrom_col and bp_col are not already columns of df, variants missing
  3574. from dbSNP will always have their chromosomes and base-pair positions set
  3575. to null, as there are no old chromosomes and base-pair positions to fall
  3576. back to.
  3577. Note: this function does not include defunct rs numbers that have been
  3578. merged into other rs numbers, since they don't appear in the dbSNP files
  3579. used in load_dbSNP(). Unlike for get_rs_numbers(), which also has this
  3580. behavior, here it is a limitation!
  3581. Args:
  3582. df: a DataFrame with rs_col, ref_col, and alt_col columns
  3583. dbSNP: a DataFrame returned by load_dbSNP() for the genome build you
  3584. want to get chrom/bp positions for
  3585. rs_col: the name of the rs number column in df
  3586. ref_col: the name of the reference allele column in df
  3587. alt_col: the name of the alternate allele column in df
  3588. chrom_col: the name of the chromosome column to be added to df
  3589. bp_col: the name of the base-pair column to be added to df
  3590. flip_col: the name of the flip column to be added to df. True where
  3591. alleles had to be flipped to match dbSNP, False where they
  3592. matched without flipping, null if the variant didn't match
  3593. dbSNP either with or without flipping
  3594. fall_back_to_old_positions: if True, variants missing from dbSNP will
  3595. retain their original chromosomes and
  3596. base-pair positions, instead of having them
  3597. set to null. Requires chrom_col and bp_col
  3598. to already be present in df.
  3599. Returns: df with three additional columns: chrom_col and bp_col, containing
  3600. the chromosomes and base-pair positions, and flip_col, containing
  3601. which variants were flipped.
  3602. """
  3603. if df.is_empty():
  3604. raise ValueError(f'df is empty!')
  3605. if rs_col not in df:
  3606. raise ValueError(f'"{rs_col}" not in df; specify rs_col')
  3607. if ref_col not in df:
  3608. raise ValueError(f'"{ref_col}" not in df; specify ref_col')
  3609. if alt_col not in df:
  3610. raise ValueError(f'"{alt_col}" not in df; specify alt_col')
  3611. if chrom_col in df and bp_col not in df:
  3612. raise ValueError(f'chrom_col "{chrom_col}" is present in df but '
  3613. f'bp_col "{bp_col}" is not; either both must be '
  3614. f'present, or neither')
  3615. if chrom_col not in df and bp_col in df:
  3616. raise ValueError(f'bp_col "{bp_col}" is present in df but chrom_col '
  3617. f'"{chrom_col}" is not; either both must be present, '
  3618. f'or neither')
  3619. if flip_col in df:
  3620. raise ValueError(f'"{flip_col}" already in df; rename it or specify '
  3621. f'a different column name for flip_col')
  3622. check_valid_dbSNP(dbSNP)
  3623. # If fall_back_to_old_positions=True, save chromosomes and base-pair
  3624. # positions for later; otherwise, drop them so that they don't mess up the
  3625. # join with dbSNP
  3626. if fall_back_to_old_positions:
  3627. if chrom_col not in df:
  3628. raise ValueError(f'You specified fall_back_to_old_positions=True, '
  3629. f'but chrom_col "{chrom_col}" and bp_col '
  3630. f'"{bp_col}" are not in df; specify them')
  3631. df = df.rename({chrom_col: f'_GET_VARIANT_POSITIONS_{chrom_col}',
  3632. bp_col: f'_GET_VARIANT_POSITIONS_{bp_col}'})
  3633. else:
  3634. df = df.drop(chrom_col, bp_col)
  3635. # Convert df's ref and alt to their minimal representations
  3636. df = df \
  3637. .pipe(get_minimal_representations, ref_col=ref_col, alt_col=alt_col,
  3638. bp_col=None) \
  3639. .with_columns(pl.col(ref_col, alt_col).cast(pl.Categorical))
  3640. # Unlike for get_rs_numbers(), there isn't much of an efficiency gain in
  3641. # constructing the chromosome and base-pair columns piecewise by chromosome
  3642. # because the variant's chromosome isn't known a priori, so it needs to be
  3643. # matched against the entirety of dbSNP. However, as an optimization,
  3644. # subset dbSNP to just the rsIDs in df.
  3645. dbSNP = dbSNP \
  3646. .rename({'CHROM': chrom_col, 'BP': b

fibromyalgia_utils.py at commit de5c2c2, no license · at the source

Overview

Authors: Isabel Kerrebijn1,2, Gyda Bjornsdottir3, Keon Arbabi1,2,4, Lea Urpa5,6,7, Hele Haapaniemi7, Gudmar Thorleifsson3, Lilja Stefansdottir3, Stephan Frangakis8, Jesse Valliere5,7, Lovemore Kunorozva6,7,9,10, Erik Abner11, Caleb Ji1,12, Markus Kangur1, Bitten Aagaard13, Henning Bliddal14, Søren Brunak15,16, Mie T Bruun17, Maria Didriksen18, Christian Erikstrup19,20, Sarah Finer21
and 38 other authorsArni J Geirsson22, Daniel F Gudbjartsson3, Thomas F Hansen23,24,25, David van Heel26,27, Ingileif Jonsdottir3, Stacey Knight28, Kirk U Knowlton28, Christina Mikkelsen18, Lincoln D Nadauld29, Thorunn A Olafsdottir3,30, Sisse R Ostrowski18,31, Ole B V Pedersen31,32, Saedis Saevarsdottir3,30, Astros T Skuladottir3,30, Erik Sørensen18, Hreinn Stefansson3, Patrick Sulem3, Olafur A Sveinsson30,33, Gudny E Thorlacius3, Unnur Thorsteinsdottir3, Henrik Ullum34, Arnor Vikingsson22, Thomas M Werge31,35, Chronic Pain Genomics Consortium, FinnGen, DBDS Genomic Consortium, Estonian Biobank Research Team, Genes & Health Research Team, Richa Saxena6,9,10,36, Kari Stefansson3, Chad M Brummett8,37,38, Bente Glintborg31,39, Daniel J Clauw40, Thorgeir E Thorgeirsson3, Frances M K Williams41, Nasa Sinnott-Armstrong42,43,44,45,46, Hanna M Ollila6,7,9,36, Michael Wainberg1,2,12,47,48
48 affiliations
  1. Lunenfeld-Tanenbaum Research Institute, Mount Sinai Hospital, Toronto, Ontario Canada
  2. Institute of Medical Science, University of Toronto, Toronto, Ontario Canada
  3. Amgen deCODE Genetics, Reykjavik, Iceland
  4. Krembil Centre for Neuroinformatics, Centre for Addiction and Mental Health, Toronto, Ontario Canada
  5. Stanley Center for Psychiatric Research, Broad Institute, Cambridge, MA USA
  6. Center for Genomic Medicine, Massachusetts General Hospital, Boston, MA USA
  7. Institute for Molecular Medicine Finland (FIMM), HiLIFE, University of Helsinki, Helsinki, Finland
  8. Department of Anesthesiology, University of Michigan Medical School, Ann Arbor, MI USA
  9. Molecular and Population Genetics Program, Broad Institute, Cambridge, MA USA
  10. Division of Sleep and Circadian Disorders, Brigham and Women’s Hospital, Boston, MA USA
  11. Institute of Genomics, Estonian Genome Center, University of Tartu, Tartu, Estonia
  12. Department of Computer Science, University of Toronto, Toronto, Ontario Canada
  13. Department of Clinical Immunology, Aalborg University Hospital, Aalborg, Denmark
  14. The Parker Institute, Copenhagen University Hospital Bispebjerg Frederiksberg, Copenhagen, Denmark
  15. Department of Public Health, University of Copenhagen, Copenhagen, Denmark
  16. Novo Nordisk Center for Protein Research, University of Copenhagen, Copenhagen, Denmark
  17. Clinical Immunology Research Unit, Department of Clinical Immunology, Odense University Hospital, Odense, Denmark
  18. Department of Clinical Immunology, Copenhagen University Hospital, Rigshospitalet, Copenhagen, Denmark
  19. Department of Clinical Immunology, Aarhus University Hospital, Aarhus, Denmark
  20. Department of Clinical Medicine, Aarhus University, Aarhus, Denmark
  21. Wolfson Institute of Population Health, Queen Mary University of London, London, UK
  22. Department of Rheumatology, Landspitali University Hospital, Reykjavik, Iceland
  23. Neurogenomics, Translational Research Centre, Copenhagen University Hospital, Glostrup, Denmark
  24. Danish Multiple Sclerosis Center, Copenhagen University Hospital, Glostrup, Denmark
  25. Danish Headache Center, Copenhagen University Hospital, Glostrup, Denmark
  26. Blizard Institute, Queen Mary University of London, London, UK
  27. Precision Healthcare University Research Institute, Queen Mary University of London, London, UK
  28. Intermountain Medical Center, Intermountain Heart Institute, Salt Lake City, UT USA
  29. Intermountain Healthcare, Saint George, UT USA
  30. School of Health Sciences, Faculty of Medicine, University of Iceland, Reykjavik, Iceland
  31. Department of Clinical Medicine, Faculty of Health and Medical Sciences, University of Copenhagen, Copenhagen, Denmark
  32. Department of Clinical Immunology, Zealand University Hospital, Koege, Denmark
  33. Department of Neurology, Landspitali University Hospital, Reykjavik, Iceland
  34. Statens Serum Institut, Copenhagen, Denmark
  35. Institute of Biological Psychiatry, Mental Health Services, Copenhagen University Hospital, Copenhagen, Denmark
  36. Department of Anesthesiology, Mass General Brigham, Harvard Medical School, Boston, MA USA
  37. Opioid Research Institute, Office for the Vice President for Research, University of Michigan, Ann Arbor, MI USA
  38. Overdose Prevention Engagement Network, Institute for Healthcare Policy and Innovation, Michigan Medicine, Ann Arbor, MI USA
  39. Copenhagen Center for Arthritis Research (COPECARE) and DANBIO, Center for Rheumatology and Spine Diseases, Rigshospitalet, Glostrup, Denmark
  40. Chronic Pain and Fatigue Research Center, Department of Anesthesiology, University of Michigan, Ann Arbor, MI USA
  41. Department of Twin Research and Genetic Epidemiology, School of Life Course Sciences, King’s College London, London, UK
  42. Herbold Computational Biology Program, Fred Hutchinson Cancer Center, Seattle, WA USA
  43. Department of Genome Sciences, University of Washington, Seattle, WA USA
  44. Brotman Baty Institute, University of Washington, Seattle, WA USA
  45. Center for Synthetic Biology, University of Washington, Seattle, WA USA
  46. Center for One Health Research, University of Washington, Seattle, WA USA
  47. Department of Psychiatry, University of Toronto, Toronto, Ontario Canada
  48. Division of Biostatistics, Dalla Lana School of Public Health, University of Toronto, Toronto, Ontario Canada
Institutions: Mount Sinai Hospital (Canada); University of Toronto (Canada); Lunenfeld-Tanenbaum Research Institute (Canada); deCODE Genetics (Iceland) (Iceland); Centre for Addiction and Mental Health (Canada); Broad Institute (United States); University of Helsinki (Finland); Massachusetts General Hospital (United States); Institute for Molecular Medicine Finland (Finland); HiLIFE – Elämäntieteiden Instituutti; Stanley Center for Psychiatric Research; University of Michigan (United States); Michigan Medicine (United States); Brigham and Women's Hospital (United States); University of Tartu (Estonia); Aalborg University Hospital (Denmark); Bispebjerg Hospital (Denmark); Copenhagen University Hospital (Denmark); University of Copenhagen (Denmark); Odense University Hospital (Denmark); Rigshospitalet (Denmark); Aarhus University Hospital (Denmark); Aarhus University (Denmark); National University Hospital of Iceland (Iceland); Queen Mary University of London (United Kingdom); Intermountain Medical Center (United States); Danish Multiple Sclerosis Center (Denmark); Blizard Institute (United Kingdom); Intermountain Healthcare (United States); Zealand University Hospital (Denmark); Zealand University Hospital Køge (Denmark); University of Iceland (Iceland); Statens Serum Institut (Denmark); Mental Health Services (Denmark); Harvard University (United States); Mass General Brigham (United States); Glostrup Hospital (Denmark); King's College London (United Kingdom); University of Washington (United States); Fred Hutch Cancer Center (United States); Brotman Baty Institute (United States)
Journal: Nature medicine, volume 32, issue 8, pages 3060-3070
Dates: received 18 September 2025; accepted 28 May 2026; published online 28 July 2026; in print 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.1038/s41591-026-04492-6 · PMID 42521817 · PMCID PMC13472937 · OpenAlex W4414345372
Open access: hybrid, a free copy (OpenAlex)
Status: code verified
Categories: genetics / omics (modality), human (organism), pain (population)
Methods: Statistics, Smoothing, state filtering, decompositions, Machine learning
Keywords: Genome-wide association studies, Fibromyalgia
MeSH: Fibromyalgia*, Genetic Predisposition to Disease*, Female, Genome-Wide Association Study, Humans, Male, Polymorphism, Single Nucleotide, Stress Disorders, Post-Traumatic (* major topic)
Topic: Fibromyalgia and Chronic Fatigue Syndrome Research (Psychiatry and Mental health, Medicine), according to OpenAlex
Funding: Gouvernement du Canada | Instituts de Recherche en Santé du Canada | CIHR Skin Research Training Centre (Skin Research Training Centre) (FBD-199459, MHP-192163); European Commission (EC) (H2020-2020-848099); Novo Nordisk Fonden (Novo Nordisk Foundation) (NNF17OC0027864, NNF23OC0082015, NNF14CC0001, NNF17OC0027594); Oak Foundation (OFIL-24-074); Ministry of Education and Research | Estonian Research Competency Council (Research Competency Council) (PRG1291); NHGRI NIH HHS (RM1 HG010461); EC | Horizon 2020 Framework Programme (EU Framework Programme for Research and Innovation H2020) (894987, 101137201, 101137154); NIAMS NIH HHS (K08 AR082454); Natur og Univers, Det Frie Forskningsråd (Natural Sciences, Danish Council for Independent Research) (09-069412); U.S. Department of Health & Human Services | National Institutes of Health (NIH) (K08AR082454, RM1HG010461)
Citations: not cited yet (Europe PMC); 98 references in the paper

Abstract

Fibromyalgia is a common and debilitating chronic pain syndrome of poorly understood etiology. Here we conduct a multi-ancestry genome-wide association study meta-analysis across 2,563,755 individuals (54,629 cases and 2,509,126 controls) from 11 cohorts, identifying 26 risk loci for fibromyalgia. The strongest association was with a coding variant in HTT, the causal gene for Huntington’s disease. Gene prioritization implicated the HTT regulator GPR52, as well as diverse genes with neural roles, including DCC, DRD2/NCAM1, MDGA2 and CELF4. Fibromyalgia heritability was exclusively enriched within brain tissues and neural cell types. Fibromyalgia showed strong, positive genetic correlation with a wide range of chronic pain, psychiatric and somatic disorders, including genetic correlations above 0.7 with low back pain, post-traumatic stress disorder and irritable bowel syndrome. Despite large sex differences in fibromyalgia prevalence, the genetic architecture of fibromyalgia was nearly identical between males and females. This study provides robust genetic evidence defining fibromyalgia as a central nervous system disorder, thereby establishing a biological framework for its complex pathophysiology and extensive clinical comorbidities.

Reproduced under the paper's license (CC BY), from the paper cited above.

Repositories

Its files are read in the Code ↔ Paper reader above, with 4 matches between paragraphs and lines of code.

FINNGEN/regenie-pipelines

License: MIT
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Commit: 320fd10df35be37d4b62977a03fbc86379cfa93c, 23 September 2026
Languages: Shell (5), R (2)
Size: 101 files, 7 scripts
Software Heritage: not archived
Found in: the text, “FinnGen”
Holds: README, license file, environment (requirements.txt, docker/Dockerfile)
Not found: CITATION.cff, tests, continuous integration, documentation
Tools: data.table (2 files)
Availability: 1 check, the latest on 27 September 2026: the link answers
  • 27 September 2026: the link answers
9 files

i-kerrebijn/fibromyalgia_GWAS_meta-analysis

License: none: the authors keep all their rights
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Commit: de5c2c227a1a2176ca608105b25f1f27a5fdc9b7, 20 January 2026
Languages: Python (4)
Size: 5 files, 4 scripts
Software Heritage: not archived
Found in: “Code availability”
Not found: README, license file, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: Matplotlib (1 file), NumPy (1 file), SciPy (1 file)
Availability: 1 check, the latest on 27 September 2026: the link answers
  • 27 September 2026: the link answers
4 files

Code availability

The code is available via GitHub at https://github.com/i-kerrebijn/fibromyalgia_GWAS_meta-analysis.

Reproduced under the paper's license (CC BY), from the paper cited above.

Tracing map

Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.

What the map holds:

  • 2 repositories of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 11 scripts, each with its path and the digest of its content;
  • 4 matches between paragraphs of the paper and lines of the code (method lexical-v1);
  • neither the text of the paper nor the code itself.

Its JSON (tracing-map.json) is deposited on Zenodo with its DOI once the map is validated.

Data

No dataset and no data link were found in the paper.

Data Availability Statement

GWAS summary statistics are available in the GWAS Catalog (accession codes GCST90838603 (https://www.ebi.ac.uk/gwas/search?query=GCST90838603) to GCST90838628 (https://www.ebi.ac.uk/gwas/search?query=GCST90838628)) and PRSs are available in the PGS catalog (score IDs PGS012560 and PGS012561). All summary statistics and PRSs are also available at https://paingenomics.org.

This study used data from the All of Us Research Program’s Controlled Tier Dataset Curated Data Repository version 8, available to authorized users on the Researcher Workbench.

The code is available via GitHub at https://github.com/i-kerrebijn/fibromyalgia_GWAS_meta-analysis.

Reproduced under the paper's license (CC BY), from the paper cited above.

Versions

The history of this record: each version stored by the harvester or made by a correction of its authors or of the maintainers of its code, and what changed in its facts. The texts of the paper (its abstract, its availability statements) are not part of it; versions that changed only those are not listed.

Version 2, 28 September 2026

  • Publisher: n/a → Nature Portfolio

Version 1, 27 September 2026: the first record

Recorded: type, language, journal, volume, issue, pages, dates, 58 authors, 2 keywords, 8 MeSH terms, 10 funders, 97 references.

Cite

This paper

Kerrebijn, I., Bjornsdottir, G., Arbabi, K., Urpa, L., Haapaniemi, H., Thorleifsson, G., Stefansdottir, L., Frangakis, S., Valliere, J., Kunorozva, L., Abner, E., Ji, C., Kangur, M., Aagaard, B., Bliddal, H., Brunak, S., Bruun, M. T., Didriksen, M., Erikstrup, C., . . . Wainberg, M. (2026). The genetic architecture of fibromyalgia across 2.5 million individuals. Nature medicine, 32(8), 3060-3070. https://doi.org/10.1038/s41591-026-04492-6

BibTeX

@article{kerrebijn2026genetic,
author = {Kerrebijn, Isabel and Bjornsdottir, Gyda and Arbabi, Keon and Urpa, Lea and Haapaniemi, Hele and Thorleifsson, Gudmar and Stefansdottir, Lilja and Frangakis, Stephan and Valliere, Jesse and Kunorozva, Lovemore and Abner, Erik and Ji, Caleb and Kangur, Markus and Aagaard, Bitten and Bliddal, Henning and Brunak, Søren and Bruun, Mie T and Didriksen, Maria and Erikstrup, Christian and Finer, Sarah and Geirsson, Arni J and Gudbjartsson, Daniel F and Hansen, Thomas F and van Heel, David and Jonsdottir, Ingileif and Knight, Stacey and Knowlton, Kirk U and Mikkelsen, Christina and Nadauld, Lincoln D and Olafsdottir, Thorunn A and Ostrowski, Sisse R and Pedersen, Ole B V and Saevarsdottir, Saedis and Skuladottir, Astros T and Sørensen, Erik and Stefansson, Hreinn and Sulem, Patrick and Sveinsson, Olafur A and Thorlacius, Gudny E and Thorsteinsdottir, Unnur and Ullum, Henrik and Vikingsson, Arnor and Werge, Thomas M and {Chronic Pain Genomics Consortium} and {FinnGen} and {DBDS Genomic Consortium} and {Estonian Biobank Research Team} and {Genes \& Health Research Team} and Saxena, Richa and Stefansson, Kari and Brummett, Chad M and Glintborg, Bente and Clauw, Daniel J and Thorgeirsson, Thorgeir E and Williams, Frances M K and Sinnott-Armstrong, Nasa and Ollila, Hanna M and Wainberg, Michael},
title = {{The genetic architecture of fibromyalgia across 2.5 million individuals}},
journal = {Nature medicine},
year = {2026},
month = jul,
volume = {32},
number = {8},
pages = {3060--3070},
publisher = {Nature Portfolio},
issn = {1078-8956},
doi = {10.1038/s41591-026-04492-6},
url = {https://doi.org/10.1038/s41591-026-04492-6},
pmid = {42521817},
pmcid = {PMC13472937}
}

RIS

TY - JOUR
AU - Kerrebijn, Isabel
AU - Bjornsdottir, Gyda
AU - Arbabi, Keon
AU - Urpa, Lea
AU - Haapaniemi, Hele
AU - Thorleifsson, Gudmar
AU - Stefansdottir, Lilja
AU - Frangakis, Stephan
AU - Valliere, Jesse
AU - Kunorozva, Lovemore
AU - Abner, Erik
AU - Ji, Caleb
AU - Kangur, Markus
AU - Aagaard, Bitten
AU - Bliddal, Henning
AU - Brunak, Søren
AU - Bruun, Mie T
AU - Didriksen, Maria
AU - Erikstrup, Christian
AU - Finer, Sarah
AU - Geirsson, Arni J
AU - Gudbjartsson, Daniel F
AU - Hansen, Thomas F
AU - van Heel, David
AU - Jonsdottir, Ingileif
AU - Knight, Stacey
AU - Knowlton, Kirk U
AU - Mikkelsen, Christina
AU - Nadauld, Lincoln D
AU - Olafsdottir, Thorunn A
AU - Ostrowski, Sisse R
AU - Pedersen, Ole B V
AU - Saevarsdottir, Saedis
AU - Skuladottir, Astros T
AU - Sørensen, Erik
AU - Stefansson, Hreinn
AU - Sulem, Patrick
AU - Sveinsson, Olafur A
AU - Thorlacius, Gudny E
AU - Thorsteinsdottir, Unnur
AU - Ullum, Henrik
AU - Vikingsson, Arnor
AU - Werge, Thomas M
AU - Chronic Pain Genomics Consortium
AU - FinnGen
AU - DBDS Genomic Consortium
AU - Estonian Biobank Research Team
AU - Genes & Health Research Team
AU - Saxena, Richa
AU - Stefansson, Kari
AU - Brummett, Chad M
AU - Glintborg, Bente
AU - Clauw, Daniel J
AU - Thorgeirsson, Thorgeir E
AU - Williams, Frances M K
AU - Sinnott-Armstrong, Nasa
AU - Ollila, Hanna M
AU - Wainberg, Michael
TI - The genetic architecture of fibromyalgia across 2.5 million individuals
T2 - Nature medicine
J2 - Nat Med
PY - 2026
DA - 2026/07/28
VL - 32
IS - 8
SP - 3060
EP - 3070
SN - 1078-8956
PB - Nature Portfolio
DO - 10.1038/s41591-026-04492-6
UR - https://doi.org/10.1038/s41591-026-04492-6
LA - en
ER -

CSL-JSON

{
"id": "10.1038/s41591-026-04492-6",
"type": "article-journal",
"title": "The genetic architecture of fibromyalgia across 2.5 million individuals",
"container-title": "Nature medicine",
"author": [
{
"family": "Kerrebijn",
"given": "Isabel"
},
{
"family": "Bjornsdottir",
"given": "Gyda"
},
{
"family": "Arbabi",
"given": "Keon"
},
{
"family": "Urpa",
"given": "Lea"
},
{
"family": "Haapaniemi",
"given": "Hele"
},
{
"family": "Thorleifsson",
"given": "Gudmar"
},
{
"family": "Stefansdottir",
"given": "Lilja"
},
{
"family": "Frangakis",
"given": "Stephan"
},
{
"family": "Valliere",
"given": "Jesse"
},
{
"family": "Kunorozva",
"given": "Lovemore"
},
{
"family": "Abner",
"given": "Erik"
},
{
"family": "Ji",
"given": "Caleb"
},
{
"family": "Kangur",
"given": "Markus"
},
{
"family": "Aagaard",
"given": "Bitten"
},
{
"family": "Bliddal",
"given": "Henning"
},
{
"family": "Brunak",
"given": "Søren"
},
{
"family": "Bruun",
"given": "Mie T"
},
{
"family": "Didriksen",
"given": "Maria"
},
{
"family": "Erikstrup",
"given": "Christian"
},
{
"family": "Finer",
"given": "Sarah"
},
{
"family": "Geirsson",
"given": "Arni J"
},
{
"family": "Gudbjartsson",
"given": "Daniel F"
},
{
"family": "Hansen",
"given": "Thomas F"
},
{
"family": "van Heel",
"given": "David"
},
{
"family": "Jonsdottir",
"given": "Ingileif"
},
{
"family": "Knight",
"given": "Stacey"
},
{
"family": "Knowlton",
"given": "Kirk U"
},
{
"family": "Mikkelsen",
"given": "Christina"
},
{
"family": "Nadauld",
"given": "Lincoln D"
},
{
"family": "Olafsdottir",
"given": "Thorunn A"
},
{
"family": "Ostrowski",
"given": "Sisse R"
},
{
"family": "Pedersen",
"given": "Ole B V"
},
{
"family": "Saevarsdottir",
"given": "Saedis"
},
{
"family": "Skuladottir",
"given": "Astros T"
},
{
"family": "Sørensen",
"given": "Erik"
},
{
"family": "Stefansson",
"given": "Hreinn"
},
{
"family": "Sulem",
"given": "Patrick"
},
{
"family": "Sveinsson",
"given": "Olafur A"
},
{
"family": "Thorlacius",
"given": "Gudny E"
},
{
"family": "Thorsteinsdottir",
"given": "Unnur"
},
{
"family": "Ullum",
"given": "Henrik"
},
{
"family": "Vikingsson",
"given": "Arnor"
},
{
"family": "Werge",
"given": "Thomas M"
},
{
"literal": "Chronic Pain Genomics Consortium"
},
{
"literal": "FinnGen"
},
{
"literal": "DBDS Genomic Consortium"
},
{
"literal": "Estonian Biobank Research Team"
},
{
"literal": "Genes & Health Research Team"
},
{
"family": "Saxena",
"given": "Richa"
},
{
"family": "Stefansson",
"given": "Kari"
},
{
"family": "Brummett",
"given": "Chad M"
},
{
"family": "Glintborg",
"given": "Bente"
},
{
"family": "Clauw",
"given": "Daniel J"
},
{
"family": "Thorgeirsson",
"given": "Thorgeir E"
},
{
"family": "Williams",
"given": "Frances M K"
},
{
"family": "Sinnott-Armstrong",
"given": "Nasa"
},
{
"family": "Ollila",
"given": "Hanna M"
},
{
"family": "Wainberg",
"given": "Michael"
}
],
"container-title-short": "Nat Med",
"volume": "32",
"issue": "8",
"page": "3060-3070",
"DOI": "10.1038/s41591-026-04492-6",
"PMID": "42521817",
"PMCID": "PMC13472937",
"ISSN": "1078-8956",
"publisher": "Nature Portfolio",
"URL": "https://doi.org/10.1038/s41591-026-04492-6",
"language": "en",
"issued": {
"date-parts": [
[
2026,
7,
28
]
]
}
}

The tracing map gets a citation of its own once an author has validated it and it has a DOI.

Similar papers

The papers with a page that share the most with this one: the tools found in their code, their categories, datasets, cited references and authors, the rarest counting most.

[1] doi:10.1073/pnas.2609814123 [code]
Genetic architectures of brain-related traits are shaped by strong selective constraints.
Journal: Proceedings of the National Academy of Sciences of the United States of America
In common: data.table, SciPy, Matplotlib, 1 other tool, genetics / omics, 7 references
[2] doi:10.1038/s41467-026-73428-y [code]
Regional heterogeneity in phenotypic and genetic associations between bone and brain in humans.
Journal: Nature communications
In common: data.table, SciPy, NumPy, genetics / omics, 7 references
[3] doi:10.2147/jpr.s619982 [code]
Spatially Contextualized Integrative Genomics Highlights Neuronal and Glial Regulatory Programs in Low Back Pain.
Journal: Journal of pain research
In common: data.table, SciPy, Matplotlib, 1 other tool, pain, genetics / omics, 5 references
[4] doi:10.1038/s41467-026-72164-7 [code]
Multivariate genetic analysis reveals three distinct pathological dimensions in musculoskeletal disorders.
Journal: Nature communications
In common: data.table, SciPy, NumPy, genetics / omics, 5 references
[5] doi:10.1038/s41562-026-02486-5 [code]
Genome-wide association studies of infant and toddler temperament in European and multi-ancestry populations.
Journal: Nature human behaviour
In common: data.table, SciPy, Matplotlib, 1 other tool, genetics / omics, 4 references
[6] doi:10.1038/s41398-026-04137-9 [code]
An integrative mendelian randomisation and drug mechanism framework for target prioritisation and therapeutic repurposing in major depression.
Journal: Translational psychiatry
In common: data.table, SciPy, Matplotlib, 1 other tool, genetics / omics, 3 references
[7] doi:10.1038/s41467-026-76676-0 [code]
Determinants of functional burden pleiotropy and gene dosage responses across human traits.
Journal: Nature communications
In common: data.table, SciPy, Matplotlib, 1 other tool, genetics / omics, 3 references
[8] doi:10.1038/s41467-026-73996-z [code]
Genetic architecture of white matter microstructure captured by unsupervised deep representation learning of fractional anisotropy maps.
Journal: Nature communications
In common: SciPy, Matplotlib, NumPy, genetics / omics, 4 references
[9] doi:10.1073/pnas.2516601123 [code]
Unveiling the glymphatic system's role in brain aging: A comprehensive biomarker and modifiable intervention target.
Journal: Proceedings of the National Academy of Sciences of the United States of America
In common: data.table, SciPy, Matplotlib, 1 other tool, 3 references
[10] doi:10.1186/s12967-026-08266-z [code]
Single-cell multi-omic integration analysis prioritizes druggable genes and reveals cell-type-specific causal effects in glioblastomagenesis.
Journal: Journal of translational medicine
In common: data.table, genetics / omics, 4 references

Contribute

The authors of this paper can claim it, correct its record and validate its tracing map, and the maintainers of its code (its owner, or a public member of its organization) correct what it says of their repository; anyone signed in can ask for its removal. Every request goes to OSCR's own machine, which answers it; your account page follows them.

Sign in with ORCID to claim this paper as one of its authors, correct its record or validate its tracing map: when the paper's metadata lists your ORCID iD, you are recognized at once. Maintainers of its code: sign in with GitHub, then claim the repository on your account page.

Request its removal

To ask OSCR to remove this record, the copies of its authors' scripts or its tracing map, use the removal request page: signed in, you say who you are, what to remove and why, then review and confirm the request. Published rules decide every request (how).

Discussion, reproductions, activity

Discussion: questions and error reports about this paper and its code, from signed-in readers and its authors. It opens with sign-in.

Reproductions: reports from readers who ran the authors' code: what they reproduced, with which environment, commit and data. It opens with sign-in.

Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.