temporal-genomics/temporal-grn/SKILL.md
Infers directed, time-delayed gene regulatory edges from BULK time-series expression using Granger causality (statsmodels VAR F-test), dynGENIE3 (tree ensembles regressing ODE-derived derivatives; Random Forests by default, Extra-Trees optional), and dynamic Bayesian networks (bnlearn). Use when the output is a RANKED HYPOTHESIS list for perturbation validation, not validated causal edges; deciding Granger vs dynGENIE3 vs DBN by timepoint count and linearity; sizing maxlag against the n>3*maxlag+1 degrees-of-freedom floor; handling stationarity/differencing before Granger; restricting regulators to known TFs; and comparing network rewiring across conditions at matched edge density. Not for single-cell pseudotime GRNs (see gene-regulatory-networks/scenic-regulons) or static co-expression (see gene-regulatory-networks/coexpression-networks).
npx skillsauth add GPTomics/bioSkills bio-temporal-genomics-temporal-grnInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Reference examples tested with: statsmodels 0.14+, numpy 1.26+, pandas 2.2+, dynGENIE3 (GitHub vahuynh/dynGENIE3), bnlearn 4.9+, R 4.x
Before using code patterns, verify installed versions match. If versions differ:
pip show <package> then help(module.function) to check signaturespackageVersion('<pkg>') then ?function_name to verify parametersIf code throws ImportError, AttributeError, or TypeError, introspect the installed package and adapt the example to match the actual API rather than retrying.
Note: bulk time-series GRN inference is low-precision and assumption-heavy. Every edge is a HYPOTHESIS. Results are dominated by the sampling design (interval vs the minutes-scale of transcription, number of timepoints, replicate count), not by the algorithm. A tiny p-value from 6-12 timepoints is not evidence of regulation.
"Infer causal regulatory relationships from my time-series expression data" -> Rank directed, time-delayed TF->target edges from bulk temporal expression, to prioritize perturbation experiments.
statsmodels.tsa.stattools.grangercausalitytests() (VAR F-test on predictive precedence)dynGENIE3::dynGENIE3() (tree ensembles on ODE-derived derivatives); bnlearn::hc() + boot.strength() (dynamic Bayesian network)Bulk temporal GRN inference turns a time course into a ranked list of candidate directed edges whose only honest downstream use is prioritizing perturbation experiments (knockdown / overexpression + re-measure). Two hard facts set the ceiling and must be stated up front, not buried.
Operational consequence: restrict regulators to annotated TFs, run more than one method, keep edges recovered by >=2 methods and stable across replicate series, match density before comparing conditions, and hand the top edges to perturbation. This skill is bounded to BULK real-clock-time data; single-cell pseudotime GRN is a different problem (gene-regulatory-networks/scenic-regulons).
| Method | Models | Best when | Fails when | |--------|--------|-----------|------------| | Granger (statsmodels) | Bivariate VAR; F-test restricted vs unrestricted | Enough timepoints (n comfortably > 3maxlag+1); a small a-priori TF->target set; roughly linear, stationary-after-differencing series | 6-12 timepoints (no residual DoF -> no power); genome-wide pairwise (confounding + O(TFtarget) tests); saturating/switch-like regulation (linear only) | | dynGENIE3 (R) | Semi-ODE: trees regress dx/dt on regulator expression | Non-linear / combinatorial regulation; multiple replicates and reasonably dense sampling; a curated regulator list | Sparse or unevenly-spaced timepoints (finite-difference derivative is garbage); calibrated significance is required (it gives a RANKING, no p-values) | | DBN (bnlearn) | Unrolled first-order Markov Bayesian network across slices | Feedback loops matter (autoregulation, negative feedback); a pre-filtered set of tens-to-low-hundreds of nodes; edge-confidence needed | Genome-wide (super-exponential DAG search); delays longer than one sampling interval (first-order Markov); tiny samples (CI/score tests underpowered) |
Methodology evolves; verify current best practice against each tool's latest documentation before committing to one. The defensible default is to run more than one and intersect.
Goal: Rank TF->target pairs by whether past TF expression improves prediction of future target expression, with honest multiple-testing control.
Approach: Difference all genes uniformly to approach stationarity, select a single lag per pair by BIC (so the reported p-value is not the best-of-several), run ONE F-test at that lag, then BH-correct across pairs. Test only TF->target pairs to shrink the family and encode the TF prior.
The F-test compares an unrestricted VAR (Y on its own lags AND X's lags) to a restricted model (Y on its own lags only); statsmodels reports it as ssr_ftest, matching R's lmtest::grangertest. Two constraints dominate:
n_eff = n - maxlag rows fit 2*maxlag+1 parameters, so the test is only defined for n > 3*maxlag + 1, and barely-defined means no power. With n=8 and maxlag=2 the F-test has ~1 residual DoF: a coin flip. This, not compute, is why genome-wide pairwise Granger fails. Prefer maxlag=1 on short courses.import numpy as np
import pandas as pd
from statsmodels.tsa.api import VAR
from statsmodels.tsa.stattools import grangercausalitytests
from statsmodels.stats.multitest import multipletests
# expr_df: genes x timepoints DataFrame; columns MUST be in temporal order.
# Difference uniformly to approach stationarity. Uniform (not per-gene) differencing
# keeps every series on the same footing: mixing I(0) and differenced I(1) series in one
# VAR corrupts the F-test reference distribution. Cost: over-differencing already-stationary
# genes. The deeper tradeoff: differencing removes the trend that CARRIES the regulatory
# signal, so on short courses prefer maxlag=1 over aggressive differencing.
expr_diff = expr_df.diff(axis=1).iloc[:, 1:]
tf_genes = ['TF1', 'TF2', 'TF3']
target_genes = ['geneA', 'geneB', 'geneC']
maxlag = 1 # short courses have ~no DoF beyond lag 1 (need n > 3*maxlag+1)
def granger_pvalue(pair_data, maxlag):
# column 0 = response Y (target), column 1 = predictor X (TF): tests X -> Y.
# Select ONE lag by BIC, then run a SINGLE test at it -> avoids the min-p-over-lags
# multiple test. Guard BIC=0 (no lag structure) up to 1.
lag = max(1, int(VAR(pair_data).select_order(maxlag).bic))
res = grangercausalitytests(pair_data, maxlag=[lag]) # list -> tests only this lag
return res[lag][0]['ssr_ftest'][1], lag # (p_value, lag)
records = []
for tf in tf_genes:
for target in target_genes:
if tf == target:
continue
pair = np.column_stack([expr_diff.loc[target].values, expr_diff.loc[tf].values])
p, lag = granger_pvalue(pair, maxlag)
records.append({'tf': tf, 'target': target, 'p_value': p, 'lag': lag})
results_df = pd.DataFrame(records)
# multipletests default is Holm-Sidak, NOT BH; force fdr_bh explicitly.
results_df['q_value'] = multipletests(results_df['p_value'], method='fdr_bh')[1]
significant = results_df[results_df['q_value'] < 0.05].sort_values('q_value')
Pairwise Granger cannot separate direct regulation from a chain X->Z->Y or a fork Z->{X,Y}. The correct fix is conditional (multivariate) Granger, conditioning on all other regulators' lags, but that explodes the parameter count and is infeasible at transcriptomic sample sizes. Label pairwise output as a CONFOUNDED candidate set, not direct interactions.
Goal: Rank regulator->target edges non-linearly by how much a regulator's current expression predicts a target's temporal derivative.
Approach: dynGENIE3 models each gene as dx_i/dt = f_i(x) - alpha_i * x_i, estimates dx_i/dt by finite differences between consecutive timepoints, and trains a tree ensemble to regress that derivative-plus-decay target on candidate-regulator expression; summed variable importance becomes the edge weight.
library(dynGENIE3)
# TS.data: list of genes x timepoints matrices (one per replicate/series).
# time.points: matching list of time vectors (real deltas -> handles uneven spacing).
expr_list <- list(as.matrix(expr_series1), as.matrix(expr_series2), as.matrix(expr_series3))
time_list <- list(c(0, 4, 8, 12, 24, 48), c(0, 4, 8, 12, 24, 48), c(0, 4, 8, 12, 24, 48))
# Restrict regulators to known TFs (AnimalTFDB / PlantTFDB). This helps TWICE: fewer
# features searched per split (faster) AND a non-TF can never be reported as a regulator
# (higher precision). Single highest-yield precision lever.
tf_indices <- which(rownames(expr_list[[1]]) %in% tf_names)
# tree.method DEFAULTS to 'RF' (Random Forests). Extra-Trees is opt-in: tree.method='ET'
# (the config GENIE3 used to win DREAM4). alpha='from.data' (default) estimates per-gene
# mRNA decay from the data; pass a numeric vector to inject measured half-lives (4sU/BRIC-seq).
res <- dynGENIE3(TS.data = expr_list, time.points = time_list, regulators = tf_indices)
# get.link.list (DOT form) is the dynGENIE3 function. The camelCase getLinkList belongs to
# the separate Bioconductor GENIE3 package -- do not swap them.
link_list <- get.link.list(res$weight.matrix, report.max = 1000)
Two properties gate interpretation:
dx/dt rests on one noisy pair and late wide intervals blur short-timescale regulation into a single slope. More REPLICATES (independent derivative samples averaging the noise down) help far more than adding one or two timepoints.Goal: Learn a directed network that can represent feedback, with bootstrap edge confidence, over a pre-filtered gene set.
Approach: Unroll time into t-1 and t slices and allow edges only from t-1 to t; because A_{t-1}->B_t and B_{t-1}->A_t both point forward, the unrolled graph is acyclic even though the biology has an A<->B feedback loop. So DBNs represent feedback that static Bayesian networks (which must be DAGs) structurally cannot -- the main reason to reach for one. The cost: it is first-order Markov (state at t depends only on t-1; longer delays need t-2/t-3 slices) and the super-exponential DAG search caps realistic inference at tens-to-low-hundreds of nodes, never genome-wide.
library(bnlearn)
# Build the 2-slice frame: columns _t1 (predictors at t-1) and _t (response at t).
n_t <- ncol(expr_mat)
lagged_df <- data.frame(
t(expr_mat[, 2:n_t]), # response slice t
t(expr_mat[, 1:(n_t - 1)]) # predictor slice t-1
)
colnames(lagged_df) <- c(paste0(rownames(expr_mat), '_t'),
paste0(rownames(expr_mat), '_t1'))
# Constrain edges to t-1 -> t so the learned graph is a proper DBN transition model.
nodes_t <- paste0(rownames(expr_mat), '_t')
nodes_t1 <- paste0(rownames(expr_mat), '_t1')
blacklist <- rbind(
expand.grid(from = nodes_t, to = nodes_t1), # forbid t -> t-1 (backward in time)
expand.grid(from = nodes_t1, to = nodes_t1) # forbid within-slice t-1 edges
)
# score='bic-g': Gaussian BIC; penalizes parameters, guarding the tiny sample against
# overfit. Gaussian assumes linear-Gaussian dependencies (misses threshold logic, like
# Granger); discretizing captures nonlinearity but needs data you do not have on short
# courses. hc is greedy -> trust boot.strength, not one DAG.
boot_res <- boot.strength(lagged_df, R = 200, algorithm = 'hc',
algorithm.args = list(score = 'bic-g', blacklist = blacklist))
# strength = fraction of bootstraps containing the arc; direction = fraction of those
# oriented the stated way. direction >= 0.5 is a COIN FLIP -- require >= 0.8 for a
# confidently oriented edge. bnlearn can also compute a data-driven strength threshold:
thr <- attr(boot_res, 'threshold') # data-driven threshold lives on the bn.strength object, a principled alternative to hand-picked 0.7
confident <- boot_res[boot_res$strength >= max(0.7, thr) & boot_res$direction >= 0.8, ]
Goal: Identify genuine rewiring between two conditions, not artifacts of threshold choice.
Approach: Edge-set differences are dominated by density mismatch and near-threshold flips unless controlled. Compare at MATCHED edge density (top-K from each, same K), and only call an edge gained/lost if it is present-and-bootstrap-stable in one condition and absent-and-stable in the other.
def top_k_edges(edge_df, k):
return set(map(tuple, edge_df.sort_values('weight', ascending=False)
.head(k)[['tf', 'target']].values))
k = min(len(edges_a), len(edges_b)) # density-match BEFORE comparing
set_a, set_b = top_k_edges(edges_a, k), top_k_edges(edges_b, k)
jaccard = len(set_a & set_b) / len(set_a | set_b) if (set_a | set_b) else 0.0
gained, lost = set_b - set_a, set_a - set_b # keep only bootstrap-stable ones
Jaccard heuristics (< 0.3 rewired, > 0.7 conserved) are uncalibrated and, without density-matching, mostly measure the threshold rather than biology -- present them as rough anchors only after matching.
regulators=; Granger test only TF->target; DBN whitelist/blacklist). Highest-yield, cheapest precision lever.| Symptom | Cause | Fix |
|---------|-------|-----|
| grangercausalitytests(..., verbose=False) raises FutureWarning | verbose deprecated since statsmodels 0.14, slated for removal | Drop the argument; index the returned dict (res[lag][0]['ssr_ftest'][1]) |
| Granger q-values suspiciously optimistic | min_p across lags then BH is an uncorrected within-pair multiple test | Fix one lag a priori, or BIC-select one lag then run a single test, or Bonferroni across lags before BH |
| Granger has no power / errors on few timepoints | n > 3*maxlag+1 barely met -> ~1 residual DoF | Use maxlag=1 on short courses; get more timepoints/replicates before trusting any q-value |
| "dynGENIE3 uses Extra-Trees" | dynGENIE3 defaults to tree.method='RF' (Random Forests); ET is opt-in | Pass tree.method='ET' if ET is wanted, else describe it as RF |
| dynGENIE3 edges read as calibrated | Importances have no null / no p-value | Threshold by rank explicitly; validate top edges by cross-method agreement + perturbation |
| dynGENIE3 gives garbage on sparse/uneven series | Finite-difference dx/dt amplifies noise | Add replicates (independent derivative samples), not just one more timepoint |
| DBN direction >= 0.5 admits reversed edges | 0.5 = "more often than not" = coin-flip orientation | Require direction >= 0.8; consider bnlearn's data-driven strength threshold over a hand-picked 0.7 |
| Pairwise Granger reported as direct regulation | Blind to common drivers / chains; circadian oscillation fabricates dense edges | Label as confounded candidates; restrict to TF->target; intersect methods |
| Jaccard swings wildly between conditions | Density mismatch + near-threshold flips, not biology | Match edge density (top-K each); require bootstrap-stable presence/absence |
| Lag structure vanishes silently | Expression columns not in temporal order | Assert timepoint ordering before any lagging |
hc / boot.strength API.tools
End-to-end CLIP-seq pipeline from FASTQ to ENCODE-compliant binding sites, single-nucleotide crosslink maps, annotation, motifs, and (optionally) differential binding. Use when running the full Yeo lab eCLIP / iCLIP / iCLIP2 / iCLIP3 / irCLIP / PAR-CLIP analysis with SMInput control, protocol-specific UMI extraction, ENCODE STAR parameters, CLIPper or Skipper peak calling with stringent log2 FC and -log10 p thresholds, IDR rescue and self-consistency QC, and downstream motif registration with mCross or PEKA.
development
Detect, date, and contextualize whole-genome duplication (WGD / paleopolyploidy) events using wgd v2 (Chen et al 2024), KsRates (Sensalari 2022 substitution-rate-corrected Ks dating), DupGen_finder (Qiao 2019), MAPS (Li 2018 phylogenomic), POInT (Conant 2008 ordered-block), SLEDGe (2024 ML-based), Whale.jl (Bayesian DL+WGD), and synteny-anchored paranome construction. Use when identifying ancient polyploidy from Ks distributions and synteny block analysis, positioning WGD events relative to speciation, distinguishing tandem from segmental from WGD duplications, dating the 2R/3R vertebrate / fish / salmonid WGDs, building paranome and Ks-age mixture models, applying KsRates substitution-rate correction across lineages, or testing alternative biased-fractionation / dosage-balance models post-WGD.
tools
Build whole-genome alignments using Progressive Cactus (Armstrong 2020 reference-free clade-level WGA), Minigraph-Cactus (Hickey 2024 pangenome-aware), LASTZ chain/net (UCSC pipeline), MUMmer4 (Marçais 2018 pairwise), minimap2 -x asm5/10/20 (Li 2018 fast pairwise), AnchorWave (Song 2022 WGD-aware), and Mauve / progressiveMauve (bacterial). Operates the HAL toolkit (Hickey 2013) for downstream extraction including halSynteny, halLiftover, halBranchMutations, and hal2maf. Use when constructing multi-species alignments for comparative-annotation projection (TOGA), synteny detection, conservation analyses (phyloP / PhastCons), or pangenome graph construction; selecting between reference-free (Cactus) and reference-anchored (LASTZ chains/nets) approaches; tuning sensitivity for closely vs distantly related genomes; or producing HAL files for genome-wide downstream tools.
development
Detect syntenic blocks and structural rearrangements between genomes using MCScanX (Wang 2012), JCVI/MCScan (Tang 2008 Python), GENESPACE (Lovell 2022) for orthology-anchored riparian visualization, SyRI for structural variation, AnchorWave for sequence-level synteny, i-ADHoRe 3.0 for highly diverged species, SynNet for synteny networks, and ntSynt for multi-genome macrosynteny. Use when identifying collinear gene blocks across species, distinguishing macrosynteny from microsynteny, detecting inversions/translocations/duplications, anchoring orthology in WGD lineages, producing publication riparian plots, computing synteny block age via Ks (cross-references whole-genome-duplication), or running synteny-aware ortholog inference in polyploids.