genome-spy-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. genome_spy/__init__.py +199 -0
  2. genome_spy/_chart_authoring.py +231 -0
  3. genome_spy/_conditions.py +72 -0
  4. genome_spy/_embed.py +87 -0
  5. genome_spy/_expressions.py +271 -0
  6. genome_spy/_parameters.py +267 -0
  7. genome_spy/_render.py +207 -0
  8. genome_spy/_utils.py +75 -0
  9. genome_spy/_widget.py +262 -0
  10. genome_spy/api.py +198 -0
  11. genome_spy/arrow.py +155 -0
  12. genome_spy/channels.py +193 -0
  13. genome_spy/chart.py +1240 -0
  14. genome_spy/data.py +56 -0
  15. genome_spy/data_transformers.py +267 -0
  16. genome_spy/datasets/__init__.py +189 -0
  17. genome_spy/datasets/_airway.py +219 -0
  18. genome_spy/datasets/_annotations.py +37 -0
  19. genome_spy/datasets/_gistic.py +43 -0
  20. genome_spy/datasets/_grammar.py +66 -0
  21. genome_spy/datasets/_hapmap.py +180 -0
  22. genome_spy/datasets/_mutation.py +289 -0
  23. genome_spy/datasets/_oncoprint.py +523 -0
  24. genome_spy/datasets/data/airway_metadata.csv +9 -0
  25. genome_spy/datasets/data/airway_scaledcounts.csv +38695 -0
  26. genome_spy/datasets/data/brca.maf.gz +0 -0
  27. genome_spy/datasets/data/hapmap_gwas.csv +14413 -0
  28. genome_spy/datasets/data/mutation_impact_reference.json +27 -0
  29. genome_spy/datasets/data/oncoprint_dataset3.json +266 -0
  30. genome_spy/datasets/data/p53_sequence_comparison.json.gz +0 -0
  31. genome_spy/datasets/data/pik3ca_mutations.json +1 -0
  32. genome_spy/datasets/data/pik3ca_tcga_brca_lollipop.json +38 -0
  33. genome_spy/datasets/data/refseq_gene_bodies.csv.gz +0 -0
  34. genome_spy/datasets/data/tal1_alphagenome_reference.json.gz +0 -0
  35. genome_spy/datasets/data/tcga.tsv +146 -0
  36. genome_spy/datasets/data/tcga_laml.maf.gz +0 -0
  37. genome_spy/datasets/data/tcga_laml_annot.tsv +201 -0
  38. genome_spy/datasets/data/tcga_laml_combined_oncoplot.json.gz +0 -0
  39. genome_spy/datasets/data/tcga_ov_gistic_lesions.tsv.gz +0 -0
  40. genome_spy/datasets/data/tcga_ov_gistic_scores.tsv.gz +0 -0
  41. genome_spy/helpers.py +185 -0
  42. genome_spy/jupyter.py +5 -0
  43. genome_spy/py.typed +0 -0
  44. genome_spy/schema/__init__.py +784 -0
  45. genome_spy/schema/_kwds.py +1394 -0
  46. genome_spy/schema/_typing.py +186 -0
  47. genome_spy/schema/capabilities.json +593 -0
  48. genome_spy/schema/channels.py +8943 -0
  49. genome_spy/schema/composition.py +1064 -0
  50. genome_spy/schema/core.py +51821 -0
  51. genome_spy/schema/ergonomics.py +2056 -0
  52. genome_spy/schema/expressions.py +476 -0
  53. genome_spy/schema/genome-spy-schema.json +33657 -0
  54. genome_spy/schema/lazy.py +326 -0
  55. genome_spy/schema/mixins.py +11684 -0
  56. genome_spy/schemapi.py +264 -0
  57. genome_spy/static/widget.js +345 -0
  58. genome_spy_python-0.1.0.dist-info/METADATA +185 -0
  59. genome_spy_python-0.1.0.dist-info/RECORD +64 -0
  60. genome_spy_python-0.1.0.dist-info/WHEEL +4 -0
  61. genome_spy_python-0.1.0.dist-info/licenses/LICENSE +21 -0
  62. genome_spy_python-0.1.0.dist-info/licenses/LICENSES/ALTAIR-BSD-3-Clause.txt +27 -0
  63. genome_spy_python-0.1.0.dist-info/licenses/LICENSES/GALLERY-DATA-MIT.txt +22 -0
  64. genome_spy_python-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +42 -0
@@ -0,0 +1,219 @@
1
+ """Internal helpers for the packaged airway RNA-seq examples."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import numpy as np
8
+
9
+ from genome_spy.datasets import load_dataset
10
+
11
+ if TYPE_CHECKING:
12
+ import pandas as pd
13
+
14
+
15
+ _AIRWAY_GENE_SYMBOLS = {
16
+ "ENSG00000109906": "ZBTB16",
17
+ "ENSG00000116711": "PLA2G4A",
18
+ "ENSG00000145777": "TSLP",
19
+ "ENSG00000152583": "SPARCL1",
20
+ }
21
+
22
+
23
+ def airway_paired_logcounts(
24
+ *, min_base_mean: float = 10.0
25
+ ) -> tuple[pd.Series, pd.DataFrame, pd.DataFrame]:
26
+ """Load packaged airway counts as paired treated/control log-counts.
27
+
28
+ Description:
29
+ Reads the scaled-count table and sample metadata, filters genes by
30
+ mean count, transforms counts with ``log2(count + 1)``, and pivots the
31
+ eight samples into matching treated and control matrices by cell type.
32
+
33
+ Args:
34
+ min_base_mean: Minimum mean count required to retain a gene.
35
+
36
+ Returns:
37
+ The retained base means, treated log-count matrix, and control
38
+ log-count matrix. All three share the same gene index.
39
+
40
+ Raises:
41
+ ImportError: If pandas is not installed.
42
+
43
+ Example:
44
+ >>> base_mean, treated, control = airway_paired_logcounts()
45
+ >>> treated.shape == control.shape
46
+ True
47
+ """
48
+ counts = load_dataset("airway_scaledcounts", as_format="dataframe").set_index(
49
+ "ensgene"
50
+ )
51
+ metadata = load_dataset("airway_metadata", as_format="dataframe").set_index("id")
52
+
53
+ sample_ids = metadata.index.to_list()
54
+ count_matrix = counts.loc[:, sample_ids].copy()
55
+ base_mean = count_matrix.mean(axis=1)
56
+ count_matrix = count_matrix.loc[base_mean >= min_base_mean]
57
+ base_mean = base_mean.loc[count_matrix.index]
58
+
59
+ long_counts = (
60
+ np.log2(count_matrix + 1.0)
61
+ .reset_index()
62
+ .melt(id_vars="ensgene", var_name="id", value_name="log_count")
63
+ .merge(
64
+ metadata.loc[:, ["celltype", "dex"]].reset_index(),
65
+ on="id",
66
+ how="left",
67
+ validate="many_to_one",
68
+ )
69
+ )
70
+ paired = long_counts.pivot(
71
+ index="ensgene",
72
+ columns=["celltype", "dex"],
73
+ values="log_count",
74
+ )
75
+
76
+ celltype_order = metadata["celltype"].drop_duplicates().to_list()
77
+ control = paired.xs("control", axis=1, level="dex").loc[:, celltype_order]
78
+ treated = paired.xs("treated", axis=1, level="dex").loc[:, celltype_order]
79
+ return base_mean.loc[paired.index], treated, control
80
+
81
+
82
+ def airway_differential_expression(
83
+ *,
84
+ min_base_mean: float = 10.0,
85
+ max_genes: int = 12_000,
86
+ log2fc_cutoff: float = 1.0,
87
+ pvalue_cutoff: float = 0.01,
88
+ padj_alpha: float = 0.1,
89
+ ) -> tuple[pd.DataFrame, dict[str, list[float]]]:
90
+ """Build the chart-ready airway differential-expression table.
91
+
92
+ Description:
93
+ Uses the paired treated/control log-count matrices from
94
+ :func:`airway_paired_logcounts`, computes paired t-tests and Benjamini-
95
+ Hochberg adjusted p-values, then adds the transformed fields and
96
+ significance classification shared by the MA and volcano examples.
97
+ A small curated set of genes also receives chart-ready callout labels
98
+ and label offsets.
99
+
100
+ Args:
101
+ min_base_mean: Minimum mean count required before testing.
102
+ max_genes: Keep at most this many genes by base mean for plotting.
103
+ log2fc_cutoff: Absolute fold-change threshold for significance labels.
104
+ pvalue_cutoff: Raw p-value threshold for significance labels.
105
+ padj_alpha: FDR level passed to the multiple-testing correction.
106
+
107
+ Returns:
108
+ A chart-ready result table and plotting domains.
109
+
110
+ Raises:
111
+ ImportError: If pandas, SciPy, or statsmodels is not installed.
112
+
113
+ Example:
114
+ >>> data, domains = airway_differential_expression(max_genes=200)
115
+ >>> {"log2fc", "pvalue", "padj"} <= set(data)
116
+ True
117
+ """
118
+ import pandas as pd
119
+ from scipy.stats import ttest_rel
120
+ from statsmodels.stats.multitest import fdrcorrection
121
+
122
+ base_mean, treated, control = airway_paired_logcounts(min_base_mean=min_base_mean)
123
+ log2fc = treated.subtract(control).mean(axis=1)
124
+ test = ttest_rel(treated.to_numpy(), control.to_numpy(), axis=1, nan_policy="omit")
125
+ pvalue = np.asarray(test.pvalue, dtype=float)
126
+ pvalue = np.where(
127
+ ~np.isfinite(pvalue) & np.isclose(log2fc.to_numpy(), 0.0), 1.0, pvalue
128
+ )
129
+ pvalue = np.where(~np.isfinite(pvalue), 0.0, pvalue)
130
+ _rejected, padj = fdrcorrection(pvalue, alpha=padj_alpha)
131
+
132
+ data = pd.DataFrame(
133
+ {
134
+ "ensgene": treated.index,
135
+ "base_mean": base_mean.loc[treated.index].to_numpy(),
136
+ "log2fc": log2fc.to_numpy(),
137
+ "pvalue": pvalue,
138
+ "padj": padj,
139
+ }
140
+ )
141
+ data["neglog10_pvalue"] = -np.log10(np.clip(data["pvalue"], 1e-300, 1.0))
142
+ data["neglog10_padj"] = -np.log10(np.clip(data["padj"], 1e-300, 1.0))
143
+ data["log10_base_mean"] = np.log10(data["base_mean"])
144
+ passes = (data["pvalue"] < pvalue_cutoff) & (data["log2fc"].abs() >= log2fc_cutoff)
145
+ data["direction"] = np.where(
146
+ passes & (data["log2fc"] > 0),
147
+ "up in dex",
148
+ np.where(passes & (data["log2fc"] < 0), "down in dex", "n.s."),
149
+ )
150
+ data = data.nlargest(max_genes, "base_mean").sort_values("log10_base_mean")
151
+
152
+ log2fc_extent = float(np.ceil(data["log2fc"].abs().max() * 2) / 2)
153
+ volcano_y_max = float(np.ceil(data["neglog10_pvalue"].quantile(0.995) / 5) * 5)
154
+ data["neglog10_pvalue_plot"] = np.minimum(data["neglog10_pvalue"], volcano_y_max)
155
+ volcano_x_extent = float(np.ceil(data["log2fc"].abs().max() * 2) / 2)
156
+ domains = {
157
+ "ma_x": [
158
+ float(np.floor(data["log10_base_mean"].min() * 2) / 2),
159
+ float(np.ceil(data["log10_base_mean"].max() * 2) / 2),
160
+ ],
161
+ "ma_y": [-log2fc_extent, log2fc_extent],
162
+ "volcano_x": [-volcano_x_extent, volcano_x_extent],
163
+ "volcano_y": [0.0, volcano_y_max],
164
+ "pvalue_cutoff": [-float(np.log10(pvalue_cutoff))],
165
+ }
166
+ data["gene_symbol"] = data["ensgene"].map(_AIRWAY_GENE_SYMBOLS)
167
+ _add_airway_annotation_positions(data)
168
+ return data, domains
169
+
170
+
171
+ def _add_airway_annotation_positions(data: pd.DataFrame) -> None:
172
+ """Add sparse label endpoints used by the airway gallery examples."""
173
+ # Pixel offsets keep annotation spacing stable while the reader zooms. The
174
+ # side tells the text mark to extend away from the leader endpoint.
175
+ volcano_offsets = {
176
+ "ZBTB16": (-30, -32),
177
+ "PLA2G4A": (-30, -38),
178
+ "TSLP": (-36, 28),
179
+ }
180
+ ma_offsets = {
181
+ "ZBTB16": (48, 16),
182
+ "PLA2G4A": (-36, 16),
183
+ "SPARCL1": (48, 15),
184
+ }
185
+
186
+ data["volcano_label"] = data["gene_symbol"].where(
187
+ data["gene_symbol"].isin(volcano_offsets)
188
+ )
189
+ data["ma_label"] = data["gene_symbol"].where(data["gene_symbol"].isin(ma_offsets))
190
+
191
+ volcano_dx = data["gene_symbol"].map(
192
+ {symbol: offset[0] for symbol, offset in volcano_offsets.items()}
193
+ )
194
+ volcano_dy = data["gene_symbol"].map(
195
+ {symbol: offset[1] for symbol, offset in volcano_offsets.items()}
196
+ )
197
+ ma_dx = data["gene_symbol"].map(
198
+ {symbol: offset[0] for symbol, offset in ma_offsets.items()}
199
+ )
200
+ ma_dy = data["gene_symbol"].map(
201
+ {symbol: offset[1] for symbol, offset in ma_offsets.items()}
202
+ )
203
+
204
+ data["volcano_x_offset"] = volcano_dx
205
+ data["volcano_y_offset"] = volcano_dy
206
+ data["volcano_label_side"] = data["gene_symbol"].map(
207
+ {
208
+ symbol: "left" if offset[0] < 0 else "right"
209
+ for symbol, offset in volcano_offsets.items()
210
+ }
211
+ )
212
+ data["ma_x_offset"] = ma_dx
213
+ data["ma_y_offset"] = ma_dy
214
+ data["ma_label_side"] = data["gene_symbol"].map(
215
+ {
216
+ symbol: "left" if offset[0] < 0 else "right"
217
+ for symbol, offset in ma_offsets.items()
218
+ }
219
+ )
@@ -0,0 +1,37 @@
1
+ """Internal helpers for the packaged RefSeq gene-body annotations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Literal
6
+
7
+ from genome_spy.datasets import load_dataset
8
+
9
+ if TYPE_CHECKING:
10
+ import pandas as pd
11
+
12
+
13
+ def refseq_gene_bodies(assembly: Literal["hg19", "hg38"]) -> pd.DataFrame:
14
+ """Load an assembly-wide RefSeq gene-body annotation table.
15
+
16
+ Description:
17
+ Loads gene bodies independently prepared from assembly-matched UCSC
18
+ RefSeq records. Coordinates are zero-based and half-open. The number
19
+ of contributing transcript records is intended only for label
20
+ prioritization.
21
+
22
+ Args:
23
+ assembly: UCSC assembly identifier.
24
+ Returns:
25
+ A chart-ready table containing every packaged gene body for the
26
+ requested assembly.
27
+
28
+ Raises:
29
+ ImportError: If pandas is not installed.
30
+
31
+ Example:
32
+ >>> genes = refseq_gene_bodies("hg38")
33
+ >>> "STK3" in set(genes["symbol"])
34
+ True
35
+ """
36
+ genes = load_dataset("refseq_gene_bodies", as_format="dataframe")
37
+ return genes.loc[genes["assembly"].eq(assembly)].reset_index(drop=True)
@@ -0,0 +1,43 @@
1
+ """Load the complete packaged TCGA OV GISTIC2 example results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, TypedDict
6
+
7
+ from genome_spy.datasets import load_dataset
8
+
9
+ if TYPE_CHECKING:
10
+ import pandas as pd
11
+
12
+
13
+ class TcgaOvGisticData(TypedDict):
14
+ """Chart tables returned for the TCGA OV GISTIC landscape."""
15
+
16
+ scores: pd.DataFrame
17
+ lesions: pd.DataFrame
18
+
19
+
20
+ def tcga_ov_gistic_data() -> TcgaOvGisticData:
21
+ """Load the TCGA OV GISTIC2 data displayed by the gallery example.
22
+
23
+ Description:
24
+ The tables are the complete ``scores.gistic`` and
25
+ ``all_lesions.conf_99.txt`` files used by GenomeSpy's TCGA OV example.
26
+ They originate from the TCGA OV-TP GISTIC2 Level 4 archive published by
27
+ the Broad GDAC Firehose on 2016-01-28 and use hg19 coordinates. The
28
+ copies are retained locally so rendering does not depend on
29
+ GenomeSpy's external data host.
30
+
31
+ Returns:
32
+ Score and lesion tables for the displayed genomic interval.
33
+
34
+ Raises:
35
+ ImportError: If pandas is not installed.
36
+
37
+ Example:
38
+ >>> tcga_ov_gistic_data()["scores"].head()
39
+ """
40
+ return {
41
+ "scores": load_dataset("tcga_ov_gistic_scores", as_format="dataframe"),
42
+ "lesions": load_dataset("tcga_ov_gistic_lesions", as_format="dataframe"),
43
+ }
@@ -0,0 +1,66 @@
1
+ """Small deterministic tables used by grammar gallery examples."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ import pandas as pd
8
+
9
+
10
+ def sincos_data() -> pd.DataFrame:
11
+ """Return a compact table for point and composition examples."""
12
+ return pd.DataFrame({"x": range(31)})
13
+
14
+
15
+ def heatmap_data() -> pd.DataFrame:
16
+ """Return a deterministic grid with a smooth quantitative value."""
17
+ rows = [
18
+ {"x": x, "y": y, "z": math.sin(x / 8) + math.cos(y / 10 - 0.5)}
19
+ for y in range(20)
20
+ for x in range(40)
21
+ ]
22
+ return pd.DataFrame(rows)
23
+
24
+
25
+ def ranged_rule_data() -> pd.DataFrame:
26
+ """Return intervals for the ranged-rule example."""
27
+ return pd.DataFrame(
28
+ [
29
+ {"y": "A", "x": 2, "x2": 7},
30
+ {"y": "B", "x": 0, "x2": 3},
31
+ {"y": "B", "x": 5, "x2": 6},
32
+ {"y": "C", "x": 4, "x2": 8},
33
+ {"y": "D", "x": 1, "x2": 5},
34
+ ]
35
+ )
36
+
37
+
38
+ def link_data() -> pd.DataFrame:
39
+ """Return interval pairs for the link-mark example."""
40
+ return pd.DataFrame(
41
+ [
42
+ {"x": 1, "x2": 5, "y": 2},
43
+ {"x": 3, "x2": 8, "y": 4},
44
+ {"x": 5, "x2": 11, "y": 6},
45
+ {"x": 7, "x2": 13, "y": 8},
46
+ {"x": 9, "x2": 16, "y": 10},
47
+ ]
48
+ )
49
+
50
+
51
+ def sequence_logo_data() -> pd.DataFrame:
52
+ """Return base counts for a small sequence-logo example."""
53
+ return pd.DataFrame(
54
+ [
55
+ {"pos": 1, "base": "A", "count": 2},
56
+ {"pos": 1, "base": "C", "count": 3},
57
+ {"pos": 1, "base": "T", "count": 5},
58
+ {"pos": 2, "base": "A", "count": 7},
59
+ {"pos": 2, "base": "C", "count": 3},
60
+ {"pos": 3, "base": "A", "count": 10},
61
+ {"pos": 4, "base": "T", "count": 9},
62
+ {"pos": 4, "base": "G", "count": 1},
63
+ {"pos": 5, "base": "G", "count": 8},
64
+ {"pos": 6, "base": "G", "count": 7},
65
+ ]
66
+ )
@@ -0,0 +1,180 @@
1
+ """Prepared HapMap tables used by association-plot examples."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import numpy as np
8
+
9
+ from genome_spy.datasets import load_dataset
10
+
11
+ if TYPE_CHECKING:
12
+ import pandas as pd
13
+
14
+
15
+ def hapmap_manhattan_data(
16
+ *, genome_wide_p: float = 5e-8, suggestive_p: float = 1e-5
17
+ ) -> tuple[pd.DataFrame, pd.DataFrame, dict[str, float | list[float]]]:
18
+ """Build chart tables for the packaged HapMap Manhattan example.
19
+
20
+ Description:
21
+ Loads ``CHR``, ``BP``, ``P``, and annotation columns from the HapMap
22
+ GWAS table, removes invalid p-values, adds chromosome labels and
23
+ ``-log10(P)``, and selects the eight smallest p-values for labels.
24
+
25
+ Args:
26
+ genome_wide_p: P-value used for the genome-wide guide line.
27
+ suggestive_p: P-value used for the suggestive guide line.
28
+
29
+ Returns:
30
+ The transformed point table, top-hit table, and y-axis metadata.
31
+
32
+ Raises:
33
+ ImportError: If pandas is not installed.
34
+
35
+ Example:
36
+ >>> points, top_hits, domains = hapmap_manhattan_data()
37
+ >>> "neglog" in points
38
+ True
39
+ """
40
+ data = load_dataset("hapmap_gwas", as_format="dataframe")
41
+ data = data[data["P"] > 0].copy()
42
+ data["chrom"] = np.where(
43
+ data["CHR"] == 23,
44
+ "chrX",
45
+ "chr" + data["CHR"].astype(str),
46
+ )
47
+ data["neglog"] = -np.log10(data["P"])
48
+ data["chrom_group"] = np.where(data["CHR"] % 2 == 0, "even", "odd")
49
+ top_hits = data.nsmallest(8, "P")
50
+ y_domain = [0.0, float(np.ceil(data["neglog"].max()))]
51
+ return (
52
+ data,
53
+ top_hits,
54
+ {
55
+ "y_domain": y_domain,
56
+ "genome_wide_y": float(-np.log10(genome_wide_p)),
57
+ "suggestive_y": float(-np.log10(suggestive_p)),
58
+ },
59
+ )
60
+
61
+
62
+ def hapmap_volcano_data(
63
+ *, effect_cutoff: float = 0.5, pvalue_cutoff: float = 1e-5
64
+ ) -> tuple[pd.DataFrame, dict[str, float | list[float]]]:
65
+ """Build the chart-ready HapMap volcano table and domains.
66
+
67
+ Description:
68
+ Loads the HapMap association table, transforms p-values to ``-log10(P)``,
69
+ and classifies points by effect direction and the supplied thresholds.
70
+
71
+ Args:
72
+ effect_cutoff: Minimum absolute effect size for a labelled point.
73
+ pvalue_cutoff: Maximum p-value for a labelled point.
74
+
75
+ Returns:
76
+ The transformed point table and plotting domains.
77
+
78
+ Raises:
79
+ ImportError: If pandas is not installed.
80
+
81
+ Example:
82
+ >>> data, domains = hapmap_volcano_data()
83
+ >>> "association" in data
84
+ True
85
+ """
86
+ data = load_dataset("hapmap_gwas", as_format="dataframe")
87
+ data = data[data["P"] > 0].copy()
88
+ data["neglog"] = -np.log10(data["P"])
89
+ passes = (data["P"] < pvalue_cutoff) & (data["EFFECTSIZE"].abs() >= effect_cutoff)
90
+ data["association"] = np.where(
91
+ passes & (data["EFFECTSIZE"] > 0),
92
+ "risk",
93
+ np.where(passes & (data["EFFECTSIZE"] < 0), "protective", "n.s."),
94
+ )
95
+ x_extent = float(np.ceil(data["EFFECTSIZE"].abs().max() * 10) / 10)
96
+ return data, {
97
+ "x_domain": [-x_extent, x_extent],
98
+ "y_domain": [0.0, float(np.ceil(data["neglog"].max()))],
99
+ "effect_cutoff": effect_cutoff,
100
+ "neglog_pvalue_cutoff": float(-np.log10(pvalue_cutoff)),
101
+ }
102
+
103
+
104
+ def hapmap_qq_data(
105
+ *, bins: int = 45
106
+ ) -> tuple[pd.DataFrame, pd.DataFrame, dict[str, float]]:
107
+ """Build the chart-ready HapMap QQ and deviation tables.
108
+
109
+ Description:
110
+ Sorts valid HapMap p-values, pairs observed and expected quantiles, and
111
+ bins their deviation for the lower summary strip.
112
+
113
+ Args:
114
+ bins: Number of expected-quantile bins used for the deviation summary.
115
+
116
+ Returns:
117
+ The QQ table, binned deviation table, and plotting limits.
118
+
119
+ Raises:
120
+ ImportError: If pandas is not installed.
121
+
122
+ Example:
123
+ >>> qq, deviation, domains = hapmap_qq_data()
124
+ >>> {"expected", "observed"} <= set(qq)
125
+ True
126
+ """
127
+ import pandas as pd
128
+
129
+ pvals = np.sort(
130
+ load_dataset("hapmap_gwas", as_format="dataframe")
131
+ .query("P > 0")["P"]
132
+ .to_numpy()
133
+ )
134
+ ranks = np.arange(1, len(pvals) + 1)
135
+ data = pd.DataFrame(
136
+ {
137
+ "expected": -np.log10((ranks - 0.5) / len(pvals)),
138
+ "observed": -np.log10(pvals),
139
+ }
140
+ )
141
+ data["delta"] = data["observed"] - data["expected"]
142
+ data["pattern"] = np.where(
143
+ data["delta"] > 0.45, "Tail enrichment", "Null-like bulk"
144
+ )
145
+
146
+ edges = np.linspace(0.0, float(data["expected"].max()), bins + 1)
147
+ deviation = (
148
+ data.assign(
149
+ bin=pd.cut(
150
+ data["expected"],
151
+ bins=edges,
152
+ include_lowest=True,
153
+ duplicates="drop",
154
+ )
155
+ )
156
+ .groupby("bin", observed=True, as_index=False)
157
+ .agg(delta_mean=("delta", "mean"))
158
+ )
159
+ intervals = deviation["bin"]
160
+ deviation["x0"] = intervals.map(lambda interval: float(interval.left))
161
+ deviation["x1"] = intervals.map(lambda interval: float(interval.right))
162
+ deviation["zero"] = 0.0
163
+ deviation["direction"] = np.where(
164
+ deviation["delta_mean"] >= 0,
165
+ "Observed > expected",
166
+ "Observed < expected",
167
+ )
168
+ deviation = deviation[["x0", "x1", "zero", "delta_mean", "direction"]]
169
+ limit = float(max(data["expected"].max(), data["observed"].max())) * 1.02
170
+ delta_limit = float(max(abs(deviation["delta_mean"]).max(), 0.25)) * 1.1
171
+ return (
172
+ data,
173
+ deviation,
174
+ {
175
+ "limit": limit,
176
+ "delta_limit": delta_limit,
177
+ "annotation_x": round(limit * 0.54, 2),
178
+ "tail_y": round(limit * 0.93, 2),
179
+ },
180
+ )