Skip to content

Commit 2514237

Browse files
added chromosome labels to the copykat heatmap and the copykat + annotation heatmap
supporting importing custom annotations to the copykat
1 parent a3c0130 commit 2514237

6 files changed

Lines changed: 668 additions & 27 deletions

File tree

‎README.md‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,106 @@ CopyKAT-Python reports tumor/normal predictions alongside confidence-related out
130130

131131
---
132132

133+
## Annotated Heatmap with Metadata
134+
135+
After a CopyKAT-Py run you can re-plot the CNA heatmap with per-cell metadata
136+
annotations (cell type, cluster labels, etc.) using either the CLI or the
137+
Python API. Rows are split into labelled groups by a chosen metadata column,
138+
and cells within each group are ordered by hierarchical or K-means clustering
139+
so intra-group CNA structure is preserved.
140+
141+
### CLI — main `copykat-py` run
142+
143+
Pass `--meta` (and optionally `--row-split`) to the standard `copykat-py`
144+
command. The annotated heatmap is produced automatically after the main run,
145+
alongside the standard heatmap.
146+
147+
```bash
148+
copykat-py \
149+
--input sample.csv \
150+
--sample-name xenium_all_cells \
151+
--n-cores 40 \
152+
--meta xenium_ft_full_meta_celltype_leiden.csv \
153+
--row-split inferred_CellType
154+
```
155+
156+
| Flag | Default | Description |
157+
| --- | --- | --- |
158+
| `--meta` | *(optional)* | Annotation CSV — first column = cell name, rest = metadata |
159+
| `--row-split` | second column | Column used to split and label row groups |
160+
161+
### CLI — standalone `copykat-py-plot`
162+
163+
For re-plotting from an existing CNA results file without re-running the
164+
full analysis:
165+
166+
```bash
167+
copykat-py-plot \
168+
--cna sample_copykat_CNA_results.txt \
169+
--meta xenium_ft_full_meta_celltype_leiden.csv \
170+
--row-split inferred_CellType \
171+
--sample-name xenium_all_cells \
172+
--n-cores 40 \
173+
--output xenium_annotated_heatmap.png
174+
```
175+
176+
| Flag | Default | Description |
177+
| --- | --- | --- |
178+
| `--cna` / `-c` | *(required)* | `*_copykat_CNA_results.txt` from a copykat-py run |
179+
| `--meta` / `-m` | *(required)* | Annotation CSV — first column = cell name, rest = metadata |
180+
| `--row-split` | second column | Column used to split and label row groups |
181+
| `--sample-name` | `""` | Figure title prefix and default filename stem |
182+
| `--distance` | `euclidean` | Distance metric: `euclidean`, `pearson`, or `spearman` |
183+
| `--n-cores` | `1` | Parallel threads for clustering |
184+
| `--output` / `-o` | auto | PNG output path |
185+
186+
**Meta CSV format** — header row is auto-detected:
187+
188+
```csv
189+
cell_name,leiden_cluster,inferred_CellType
190+
aaaajgij-1,5,lumhr
191+
aaaandia-1,5,lumhr
192+
...
193+
```
194+
195+
Cells present in the CNA results but absent from the CSV are labelled
196+
`"unknown"` and shown in grey. All remaining metadata columns are drawn as
197+
coloured annotation sidebars.
198+
199+
### Python API — `plot_heatmap_annotated`
200+
201+
```python
202+
import pandas as pd
203+
import numpy as np
204+
from copykat_py.plotting import plot_heatmap_annotated
205+
206+
cna = pd.read_csv("sample_copykat_CNA_results.txt", sep="\t")
207+
plot_heatmap_annotated(
208+
mat = cna.iloc[:, 3:].values.astype("float32"), # bins × cells
209+
cell_names = cna.columns[3:].tolist(),
210+
chrom_info = cna.iloc[:, 0].values,
211+
meta_csv = "xenium_ft_full_meta_celltype_leiden.csv",
212+
row_split_col = "inferred_CellType", # None → auto-picks second column
213+
sample_name = "xenium_all_cells",
214+
n_cores = 40,
215+
output_path = "xenium_annotated_heatmap.png",
216+
)
217+
```
218+
219+
**Layout produced:**
220+
221+
```text
222+
[cell-type labels] | [sidebar col 1] | [sidebar col 2] | [heatmap] | [colorbar] | [legend]
223+
```
224+
225+
- Each metadata column gets a narrow coloured sidebar (tab10/tab20 palette,
226+
auto-assigned)
227+
- White horizontal lines separate row groups
228+
- Within-group ordering: Ward linkage for ≤ 3 000 cells, K-means block
229+
ordering for larger groups
230+
231+
---
232+
133233
## Why Results May Differ from CopyKAT-R
134234

135235
CopyKAT-Python results may not be identical to CopyKAT-R due to differences in:

‎copykat_py/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from copykat_py.copykat import copykat
44
from copykat_py.data_loader import load_example_data
5+
from copykat_py.plotting import plot_heatmap_annotated
56

67
__version__ = "1.0.0"
7-
__all__ = ["copykat", "load_example_data"]
8+
__all__ = ["copykat", "load_example_data", "plot_heatmap_annotated"]

‎copykat_py/cli.py‎

Lines changed: 156 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -35,50 +35,63 @@ def main():
3535

3636
# Input
3737
parser.add_argument("--input", "-i", required=True,
38-
help="Input UMI count matrix (.csv, .tsv, .txt, or .mtx)")
38+
help="[required] Input UMI count matrix (.csv, .tsv, .txt, or .mtx)")
3939
parser.add_argument("--genes", default=None,
40-
help="Gene names file (required for .mtx input)")
40+
help="[optional] Gene names file (required for .mtx input)")
4141
parser.add_argument("--barcodes", default=None,
42-
help="Barcode names file (required for .mtx input)")
43-
42+
help="[optional] Barcode names file (required for .mtx input)")
43+
4444
# Parameters
4545
parser.add_argument("--id-type", default="S", choices=["S", "E"],
46-
help="Gene ID type: S=Symbol, E=Ensembl (default: S)")
46+
help="[optional] Gene ID type: S=Symbol, E=Ensembl (default: S)")
4747
parser.add_argument("--cell-line", default="no", choices=["yes", "no"],
48-
help="Pure cell line mode (default: no)")
48+
help="[optional] Pure cell line mode (default: no)")
4949
parser.add_argument("--ngene-chr", type=int, default=5,
50-
help="Min genes per chromosome (default: 5)")
50+
help="[optional] Min genes per chromosome (default: 5)")
5151
parser.add_argument("--min-genes", type=int, default=200,
52-
help="Min genes per cell (default: 200)")
52+
help="[optional] Min genes per cell (default: 200)")
5353
parser.add_argument("--low-dr", type=float, default=0.05,
54-
help="Min detection rate for smoothing (default: 0.05)")
54+
help="[optional] Min detection rate for smoothing (default: 0.05)")
5555
parser.add_argument("--up-dr", type=float, default=0.1,
56-
help="Min detection rate for segmentation (default: 0.1)")
56+
help="[optional] Min detection rate for segmentation (default: 0.1)")
5757
parser.add_argument("--win-size", type=int, default=25,
58-
help="Window size for segmentation (default: 25)")
58+
help="[optional] Window size for segmentation (default: 25)")
5959
parser.add_argument("--norm-cells", default="",
60-
help="File with known normal cell barcodes (one per line)")
60+
help="[optional] File with known normal cell barcodes (one per line)")
6161
parser.add_argument("--ks-cut", type=float, default=0.1,
62-
help="KS test cutoff for breakpoints (default: 0.1)")
62+
help="[optional] KS test cutoff for breakpoints (default: 0.1)")
6363
parser.add_argument("--sample-name", default="",
64-
help="Sample name prefix for output files")
64+
help="[optional] Sample name prefix for output files")
6565
parser.add_argument("--distance", default="euclidean",
6666
choices=["euclidean", "pearson", "spearman"],
67-
help="Distance metric for clustering (default: euclidean)")
67+
help="[optional] Distance metric for clustering (default: euclidean)")
6868
parser.add_argument("--output-seg", action="store_true",
69-
help="Output .seg file for IGV")
69+
help="[optional] Output .seg file for IGV")
7070
parser.add_argument("--plot-genes", dest="plot_genes", action="store_true", default=True,
71-
help="Generate the heatmap plot (default: enabled)")
71+
help="[optional] Generate the heatmap plot (default: enabled)")
7272
parser.add_argument("--no-plot-genes", dest="plot_genes", action="store_false",
73-
help="Skip heatmap plotting for faster runs")
73+
help="[optional] Skip heatmap plotting for faster runs")
7474
parser.add_argument("--genome", default="hg20", choices=["hg20", "mm10"],
75-
help="Genome assembly (default: hg20)")
75+
help="[optional] Genome assembly (default: hg20)")
7676
parser.add_argument("--n-cores", type=int, default=1,
77-
help="Number of CPU cores (default: 1)")
77+
help="[optional] Number of CPU cores (default: 1)")
7878
parser.add_argument("--pca-components", type=int, default=None,
79-
help="Adaptive PCA component cap for large clustering steps (default: automatic by cell count)")
79+
help="[optional] Adaptive PCA component cap for large clustering steps (default: automatic by cell count)")
8080
parser.add_argument("--output-dir", "-o", default=".",
81-
help="Output directory (default: current)")
81+
help="[optional] Output directory (default: current)")
82+
83+
# Annotated heatmap options
84+
parser.add_argument("--meta", default=None,
85+
metavar="CSV",
86+
help="[optional] Per-cell annotation CSV for the annotated heatmap. "
87+
"First column = cell name; remaining columns are drawn "
88+
"as coloured sidebars. Header is auto-detected. "
89+
"When provided, an additional annotated heatmap is saved "
90+
"alongside the standard one.")
91+
parser.add_argument("--row-split", default=None,
92+
metavar="COLUMN",
93+
help="[optional] Column in --meta used to split and label heatmap rows. "
94+
"Defaults to the second column of the CSV when not given.")
8295

8396
args = parser.parse_args()
8497

@@ -178,6 +191,26 @@ def main():
178191
pred = result["prediction"]["copykat.pred"].value_counts()
179192
for k, v in pred.items():
180193
print(f" {k}: {v} cells")
194+
195+
if args.meta and args.plot_genes and "CNAmat" in result:
196+
print("\nGenerating annotated heatmap...")
197+
from copykat_py.plotting import plot_heatmap_annotated
198+
cna_df = result["CNAmat"]
199+
ann_mat = cna_df.iloc[:, 3:].values.astype(np.float32)
200+
ann_cell_names = cna_df.columns[3:].tolist()
201+
ann_chrom_info = cna_df.iloc[:, 0].values
202+
ann_output = f"{args.sample_name}_copykat_annotated_heatmap.png"
203+
plot_heatmap_annotated(
204+
mat=ann_mat,
205+
cell_names=ann_cell_names,
206+
chrom_info=ann_chrom_info,
207+
meta_csv=args.meta,
208+
row_split_col=args.row_split,
209+
sample_name=args.sample_name,
210+
distance=args.distance,
211+
n_cores=args.n_cores,
212+
output_path=ann_output,
213+
)
181214
finally:
182215
print(f"CopyKAT-Py run finished: {time.strftime('%Y-%m-%d %H:%M:%S')}")
183216
print(f"Detailed log saved to: {log_path}")
@@ -188,5 +221,106 @@ def main():
188221
log_handle.close()
189222

190223

224+
def plot_main():
225+
"""Entry point for ``copykat-py-plot``: annotated heatmap from CNA results."""
226+
parser = argparse.ArgumentParser(
227+
prog="copykat-py-plot",
228+
description=(
229+
"Draw an annotated CNA heatmap from a copykat-py CNA results file "
230+
"and a per-cell metadata CSV. Rows are split and colour-labelled by "
231+
"a chosen metadata column; cells within each group are ordered by "
232+
"hierarchical or K-means clustering so intra-group CNA structure is "
233+
"preserved."
234+
),
235+
formatter_class=argparse.RawDescriptionHelpFormatter,
236+
epilog="""
237+
Examples
238+
--------
239+
# Use second column (leiden_cluster) as row-split (default):
240+
copykat-py-plot \\
241+
--cna sample_copykat_CNA_results.txt \\
242+
--meta meta.csv
243+
244+
# Explicitly split rows by inferred_CellType and also annotate leiden_cluster:
245+
copykat-py-plot \\
246+
--cna sample_copykat_CNA_results.txt \\
247+
--meta xenium_ft_full_meta_celltype_leiden.csv \\
248+
--row-split inferred_CellType \\
249+
--sample-name xenium_all_cells \\
250+
--n-cores 40 \\
251+
--output xenium_annotated_heatmap.png
252+
253+
Meta CSV format
254+
---------------
255+
First column : cell name (must match column names in the CNA results file)
256+
Remaining : any metadata (cell type, cluster, condition, …)
257+
258+
With header: cell_name,leiden_cluster,inferred_CellType
259+
Without header: aaaajgij-1,5,lumhr (first row treated as data)
260+
""",
261+
)
262+
263+
parser.add_argument(
264+
"--cna", "-c", required=True,
265+
help="[required] CNA results file produced by copykat-py "
266+
"(*_copykat_CNA_results.txt, tab-separated). "
267+
"First three columns must be chrom / chrompos / abspos; "
268+
"remaining columns are cells.",
269+
)
270+
parser.add_argument(
271+
"--meta", "-m", required=True,
272+
help="[required] Annotation CSV. First column = cell name; remaining columns are "
273+
"drawn as coloured sidebars. Header row is auto-detected.",
274+
)
275+
parser.add_argument(
276+
"--row-split", default=None,
277+
metavar="COLUMN",
278+
help="[optional] Metadata column used to split and label rows. "
279+
"Defaults to the second column of the CSV when not supplied.",
280+
)
281+
parser.add_argument(
282+
"--sample-name", default="",
283+
help="[optional] Label shown in the figure title and used as the output filename "
284+
"prefix when --output is not given.",
285+
)
286+
parser.add_argument(
287+
"--distance", default="euclidean",
288+
choices=["euclidean", "pearson", "spearman"],
289+
help="[optional] Distance metric for within-group cell clustering (default: euclidean).",
290+
)
291+
parser.add_argument(
292+
"--n-cores", type=int, default=1,
293+
help="[optional] Parallel threads for clustering (default: 1).",
294+
)
295+
parser.add_argument(
296+
"--output", "-o", default=None,
297+
metavar="PATH",
298+
help="[optional] Output PNG path. Defaults to "
299+
"{sample_name}_copykat_annotated_heatmap.png.",
300+
)
301+
302+
args = parser.parse_args()
303+
304+
print(f"Loading CNA results: {args.cna}")
305+
cna_df = pd.read_csv(args.cna, sep="\t", index_col=False)
306+
cell_names = cna_df.columns[3:].tolist()
307+
chrom_info = cna_df.iloc[:, 0].values
308+
mat = cna_df.iloc[:, 3:].values.astype(np.float32)
309+
print(f" {mat.shape[1]} cells × {mat.shape[0]} bins")
310+
311+
from copykat_py.plotting import plot_heatmap_annotated
312+
plot_heatmap_annotated(
313+
mat=mat,
314+
cell_names=cell_names,
315+
chrom_info=chrom_info,
316+
meta_csv=args.meta,
317+
row_split_col=args.row_split,
318+
sample_name=args.sample_name,
319+
distance=args.distance,
320+
n_cores=args.n_cores,
321+
output_path=args.output,
322+
)
323+
324+
191325
if __name__ == "__main__":
192326
main()

0 commit comments

Comments
 (0)