"""NucDynamics on the native engine (``uchrom_recon``, Rust; CPU and GPU).
The engine is a separate package (``pip install u-chrom[recon]`` or
``pip install ./packages/uchrom-recon``); this module turns contacts into a
:class:`~chromdata.ChromData` ensemble.
"""
from __future__ import annotations
import os
from typing import Any, Dict, Mapping, Optional, Sequence, Union
import numpy as np
import pandas as pd
__all__ = ["reconstruct_nucdyn", "native_available", "engine_info", "load_contacts", "is_phased_file",
"phasing_stats", "imputed_phase_table"]
#: Default hierarchical schedule (bp), the original's ``-s 8 4 2 0.4 0.2 0.1``.
DEFAULT_PARTICLE_SIZES = (8e6, 4e6, 2e6, 4e5, 2e5, 1e5)
#: Public names of engine protocols: EMber is protocol "robust" of the native engine.
PROTOCOL_ALIASES = {"ember": "robust"}
def _engine():
try:
import uchrom_recon
except ImportError:
try: # a build from before the rename (u-chrom-nucdyn)
import uchrom_nucdyn as uchrom_recon
except ImportError:
raise ImportError("the native engine is not installed: pip install ./packages/uchrom-recon") from None
return uchrom_recon
[docs]
def native_available() -> bool:
"""True when the native engine (``uchrom_recon``) can be imported."""
try:
_engine()
except ImportError:
return False
return True
[docs]
def engine_info() -> Dict[str, Any]:
"""Version of the native engine and the GPU it would use."""
un = _engine()
return {"version": un.__version__, "gpu_build": bool(un.HAS_GPU), "gpu": un.gpu_info()}
def _resolve_device(device: str) -> str:
if device in ("cpu", "gpu"):
return device
if device != "auto":
raise ValueError(f"device must be 'auto', 'cpu' or 'gpu', got {device!r}")
return "gpu" if _engine().gpu_available() else "cpu"
def is_phased_file(path) -> bool:
"""True for phased contact files: Dip-C ``.con`` (``.con.gz``, ``.con.txt[.gz]``) and
``.pairs`` whose ``#columns:`` header has ``phase0`` / ``phase1`` (hickit)."""
import gzip
path = str(path)
name = path[:-3] if path.endswith(".gz") else path
if name.endswith((".con", ".con.txt")):
return True
if not name.endswith(".pairs"):
return False
opener = gzip.open if path.endswith(".gz") else open
with opener(path, "rt") as fh:
for line in fh:
if not line.startswith("#"):
return False
if line.startswith("#columns:"):
cols = line.split()[1:]
return "phase0" in cols and "phase1" in cols
return False
def _hap_column(x) -> np.ndarray:
s = pd.Series(x)
if s.dtype == object:
s = s.astype(str).replace({".": "-1", "nan": "-1", "": "-1"})
return s.astype(float).fillna(-1).to_numpy().astype(np.int8)
def phasing_stats(hap_a, hap_b, chrom_a=None, chrom_b=None, ploidy=None) -> Dict[str, Any]:
"""Phasing of the input contacts: fraction of phased ends; contacts with both / one /
no end phased. With ``ploidy``, ends on single-copy chromosomes count as phased."""
ha = np.asarray(hap_a) >= 0
hb = np.asarray(hap_b) >= 0
if ploidy is not None and chrom_a is not None:
single = {c for c, k in ploidy.items() if k == 1}
ha = ha | np.isin(np.asarray(chrom_a).astype(str), list(single))
hb = hb | np.isin(np.asarray(chrom_b).astype(str), list(single))
n = len(ha)
return {"n_contacts": int(n), "frac_ends_phased": float((ha.sum() + hb.sum()) / max(2 * n, 1)),
"n_both_phased": int((ha & hb).sum()), "n_one_phased": int((ha ^ hb).sum()),
"n_none_phased": int((~ha & ~hb).sum())}
def imputed_phase_table(hap_a, hap_b, phase_probs) -> pd.DataFrame:
"""Per input contact: input haplotypes, the most probable (hap_a, hap_b) and its
probability (given that the contact is true); -1 / NaN when the contact was filtered out."""
pp = np.asarray(phase_probs, np.float64)
ok = np.isfinite(pp).all(1)
best = np.where(ok, np.argmax(np.nan_to_num(pp, nan=-1.0), axis=1), -1)
return pd.DataFrame({
"hap_a": np.asarray(hap_a, np.int8), "hap_b": np.asarray(hap_b, np.int8),
"imputed_a": np.where(ok, best >> 1, -1).astype(np.int8),
"imputed_b": np.where(ok, best & 1, -1).astype(np.int8),
"prob": np.where(ok, np.nan_to_num(pp).max(1), np.nan),
})
[docs]
def reconstruct_nucdyn(
contacts: Union[str, os.PathLike, pd.DataFrame],
*,
n_models: int = 10,
device: str = "auto",
n_threads: int = 0,
seed: int = 0,
protocol: str = "nuc_dynamics_2017",
particle_sizes: Sequence[float] = DEFAULT_PARTICLE_SIZES,
genome_ranges=None,
cell_id: Optional[str] = None,
key_added: Optional[str] = "nucdyn",
params: Optional[Mapping[str, Any]] = None,
verbose: bool = False,
ploidy: Any = None,
hap_names: Sequence[str] = ("pat", "mat"),
phase_prior: bool = False,
_method: str = "nucdyn",
**engine_params: Any,
):
"""Single-cell Hi-C genome structure ensemble with NucDynamics.
Runs the hierarchical simulated-annealing protocol of NucDynamics
(Stevens et al. 2017, *Nature* 544:59) on the native engine and returns
the ensemble as a new :class:`~chromdata.ChromData`.
Parameters
----------
contacts
Path (``.ncc``, ``.pairs[.gz]``, GEO ``chr_A pos_A chr_B pos_B``
table) or DataFrame of contacts, see :func:`load_contacts`.
n_models
Number of structures (models) in the ensemble. All models are
computed at once (in parallel on the CPU, in one batch on the GPU).
device
``"auto"`` (GPU when available, else CPU), ``"cpu"`` (f64, multi-core)
or ``"gpu"`` (f32, wgpu: Metal / Vulkan / DX12).
n_threads
CPU worker threads (0 = all cores).
seed
Random seed (start coordinates and velocities); on the CPU a seed
gives bitwise-identical results.
protocol
``"nuc_dynamics_2017"`` (default): the code that produced the
published 2017 structures (tjs23/nuc_dynamics ``master``).
``"release_1.3"``: the later bead-size-scaled version with
ambiguity resolution and model selection (2 x ``n_models`` above 1 Mb,
the ``n_models`` closest to the mean kept).
``"robust"`` (public name **EMber**, alias ``"ember"``; see
:func:`uchrom.recon.sc.ember.reconstruct_ember`): the 2017 protocol with a noise-aware observation model
(contact restraints re-weighted by their posterior probability of
being true contacts; false-contact rate and contact-kernel width
learned by EM; ``robust_*`` engine parameters). Adds the results
``<key_added>.em`` (EM log) and ``<key_added>.contact_weights``
(final weight of every input contact, NaN = filtered out).
particle_sizes
Hierarchical particle sizes in bp (coarse to fine).
genome_ranges
Restrict the contacts to these ranges (e.g. ``"chr19"``).
cell_id
Written to ``spots['cell_id']``.
key_added
Key of the provenance record in ``cd.results`` (per-stage log) and
of ``cd.uns[key_added]``; ``None`` stores neither.
params
Further engine parameters (see ``uchrom_recon.default_params()``:
``temp_steps``, ``dynamics_steps``, ``temp_start``, ``temp_end``,
``time_step``, ``contact_dist_lower``, ...); keyword arguments are
merged into it.
ploidy
Phased (homolog-resolved) contacts — hickit ``.pairs`` with ``phase0`` / ``phase1``,
Dip-C ``.con``, a DataFrame with ``hap1`` / ``hap2`` (see :func:`load_contacts`) — are
reconstructed as a diploid genome (protocols ``"nuc_dynamics_2017"`` and
``"robust"`` / EMber): one particle chain per chromosome copy, contacts with an
unphased end ambiguous between the candidate copies (2017: NucDynamics ambiguous
restraint; EMber: the copy pair is inferred by EM from the structure). ``ploidy``
gives the copies per chromosome: ``None`` / ``"auto"`` (male — X and Y single copy —
when a Y chromosome is present, as hickit), ``"diploid"``, ``"male"``, a dict
``{chrom: 1 | 2}``, or ``"haploid"`` (ignore the phases: one chain per chromosome).
hap_names
Names of haplotypes 0 and 1 in the trace ids (default Dip-C's ``("pat", "mat")``:
traces ``chr1(pat)`` / ``chr1(mat)``; single-copy chromosomes keep the plain name).
phase_prior
Also use hickit's imputed ``phase_prob00 .. phase_prob11`` (when present) as a prior over
the candidate copy pairs (default ``False``, the frozen diploid EMber configuration:
EMber's own 2-D neighbourhood prior ``robust_dip_nbr_prior`` only, no external prior).
Returns
-------
ChromData
One spot per particle; one trace per chromosome (diploid: per chromosome copy, spot
column ``haplotype`` = 0 / 1; both copies share the bins). ``coords`` holds
model 0 and ``layers['model_<k>']`` holds every model ``k`` (model 0
included), so the whole ensemble travels with the object. Units are
particle radii. Bins are the particles' genomic intervals: in the
2017 protocol a particle at sequence position ``p`` collects the
contacts in ``(p - size, p]`` (``bins.start = p - size``,
``bins.end = p``); in release_1.3 ``[p, p + size)``. ``uns[key_added]``
records the engine, protocol, parameters and per-stage log.
"""
from chromdata import ChromData
un = _engine()
protocol = PROTOCOL_ALIASES.get(protocol, protocol)
func = f"uchrom.recon.sc.{_method}.reconstruct_{_method}"
c = load_contacts(contacts, genome_ranges)
dev = _resolve_device(device)
p = dict(params or {})
p.update(engine_params)
diploid = "hap_a" in c and ploidy != "haploid"
dip_kw = {}
if diploid:
if protocol not in ("nuc_dynamics_2017", "robust"):
raise ValueError(f"phased (diploid) contacts need protocol nuc_dynamics_2017 or robust (EMber), not {protocol!r}")
if len(hap_names) != 2 or hap_names[0] == hap_names[1]:
raise ValueError("hap_names: two different names")
dip_kw = dict(hap_a=c["hap_a"], hap_b=c["hap_b"], ploidy="auto" if ploidy is None else ploidy,
phase_prior=c.get("phase_prob") if phase_prior else None)
res = un.calc_genome_structure(
c["chrom_a"], c["pos_a"], c["chrom_b"], c["pos_b"],
ambiguity=c["ambiguity"], active=c["active"],
n_models=int(n_models), device=dev, n_threads=int(n_threads), seed=int(seed),
protocol=protocol, particle_sizes=[float(s) for s in particle_sizes], verbose=verbose,
**dip_kw, **p)
size = int(res["particle_size"])
pos = np.asarray(res["position"], np.int64)
if res["position_convention"] == "end":
start = np.maximum(pos - size, 0)
end = np.maximum(pos, start + 1)
else:
start, end = pos, pos + size
coords = np.asarray(res["coords"], np.float64)
if res.get("diploid"):
chrom = res["chrom"].astype(str)
copy = np.asarray(res["copy"], np.int8)
k = res["ploidy"]
trace = [f"{ch}({hap_names[h]})" if k[ch] == 2 else ch for ch, h in zip(chrom, copy)]
spots = pd.DataFrame({"chrom": chrom, "start": start, "end": end, "trace_id": trace, "haplotype": copy})
if cell_id is not None:
spots["cell_id"] = cell_id
cd = ChromData(coords[0].copy(), spots)
else:
df = pd.DataFrame({"chrom": res["chrom"].astype(str), "start": start, "end": end,
"x": coords[0, :, 0], "y": coords[0, :, 1], "z": coords[0, :, 2]})
cd = ChromData.from_dataframe(df, cell_id=cell_id)
# from_dataframe keeps the row order of df; every model in the same order
width = max(1, len(str(len(coords) - 1)))
for k in range(len(coords)):
cd.layers[f"model_{k:0{width}d}"] = coords[k].copy()
cd.uns.setdefault("xyz_unit", "particle radii (NucDynamics)")
if key_added is not None:
stages = pd.DataFrame([{k: v for k, v in s.items() if k not in ("removed_models", "robust")}
for s in res["stages"]])
info = {
"method": _method, "engine": "uchrom_recon", "engine_version": un.__version__, "device": dev,
"gpu": un.gpu_info() if dev == "gpu" else None, "protocol": protocol,
"n_models": int(len(coords)), "seed": int(seed), "particle_size": size,
"particle_position": res["position_convention"],
"model_layers": [f"model_{k:0{width}d}" for k in range(len(coords))],
"seconds": float(res["seconds_total"]),
"citation": ("U-Chrom EMber (engine protocol \"robust\"), built on " if protocol == "robust" else "")
+ "NucDynamics: Stevens et al. 2017, Nature 544:59, doi:10.1038/nature21429",
}
cd.uns[key_added] = info
cd.results.set(f"{key_added}.stages", stages, kind="table",
function=func,
params={k: v for k, v in res["params"].items() if k != "verbose"},
inputs={"contacts": str(contacts) if not isinstance(contacts, pd.DataFrame) else "DataFrame",
"n_contacts": int(len(c["pos_a"]))})
if res.get("diploid"):
phasing = phasing_stats(c["hap_a"], c["hap_b"], c["chrom_a"], c["chrom_b"], res["ploidy"])
info["diploid"] = {"ploidy": res["ploidy"], "hap_names": list(hap_names),
"phase_prior": bool(phase_prior and "phase_prob" in c), **phasing}
cd.uns[key_added] = info
pp =np.asarray(res["phase_probs"], np.float64)
cd.results.set(f"{key_added}.phase_probs", pp, kind="array", function=func,
params={"protocol": protocol, "diploid_cis_sep": res["params"]["diploid_cis_sep"]},
inputs={"rows": "input contacts in load_contacts order; columns P(hap_a, hap_b | true) "
"for (0,0), (0,1), (1,0), (1,1); NaN = filtered out",
"method": "EM posterior of the last update" if protocol == "robust"
else "closest candidate copy pair in the final structure"})
cd.results.set(f"{key_added}.imputed_phase", imputed_phase_table(c["hap_a"], c["hap_b"], pp), kind="table",
function=func, params={"protocol": protocol},
inputs={"rows": "input contacts in load_contacts order"})
if protocol == "robust":
# EM log (one row per update) and the final weight of every input contact
em = pd.DataFrame([{"stage": s["stage"], "particle_size": s["particle_size"], **u}
for s in res["stages"] for u in s.get("robust", [])])
cd.results.set(f"{key_added}.em", em, kind="table",
function=func, params={"protocol": "robust"})
cd.results.set(f"{key_added}.contact_weights", np.asarray(res["contact_weights"], np.float64),
kind="array", function=func,
params={"protocol": "robust"},
inputs={"rows": "input contacts in load_contacts order; NaN = filtered out"})
return cd