diff --git a/CHANGELOG.md b/CHANGELOG.md
index d0d65ab..3301954 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,40 +4,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [v0.0.7]
+## [0.1.0]
+## Added
+- Added the denovo pipeline
+## Changed
+- Updates to the report plots
+
+## [0.0.1]
### Added
-- Fastqingress module for common handling of (possibly
- multiplexed) inputs.
-- Optimized container size through removal of various
- conda cruft.
-### Changed
-- Use mamba by default for building conda environments.
-- Cut down README to items specific to workflow.
-### Fixed
-- Incorrect specification of conda environment file in Nextflow config.
+- First release
+- Initial port of Snakemake WF from https://github.com/nanoporetech/pipeline-nanopore-ref-isoforms
-## [v0.0.6]
-### Changed
-- Explicitely install into base conda env
-## [v0.0.5]
-### Added
-- Software versioning report example.
-
-## [v0.0.4]
-### Changed
-- Version bump to test CI.
-
-## [v0.0.3]
-### Changed
-- Moved all CI to templates.
-- Use canned aplanat report components.
-
-## [v0.0.2]
-### Added
-- CI release checks.
-- Create pre-releases in CI from dev branch.
-
-## [v0.0.1]
-
-First release.
diff --git a/Dockerfile b/Dockerfile
index 86fbfcb..49c9148 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -17,3 +17,4 @@ RUN \
USER $WF_UID
WORKDIR $HOME
+
diff --git a/README.md b/README.md
index 8810e9f..7347de7 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,40 @@
-# Pipeline for annotating genomes using long read transcriptomics
+# wf-isoforms
+
This repository contains a [nextflow](https://www.nextflow.io/) workflow
-for assembly and annotation of transcripts from Oxford Nanopore cDNA or direct RNA reads.
-
+for assembly and annotation of transcripts from Oxford Nanopore cDNA or direct RNA reads.
+It has been adapted from two existing Snakemake pipelines:
+* https://github.com/nanoporetech/pipeline-nanopore-ref-isoforms
+* https://github.com/nanoporetech/pipeline-nanopore-denovo-isoforms
+---
## Overview
-* cDNA or direct RNA reads are optionally preprocessed by [pychopper](https://github.com/nanoporetech/pychopper) for trimming and orientation.
-* Reads are then mapped to a supplied reference genome using [minimap2](https://github.com/lh3/minimap2)
-* Transcripts are assembled by[stringtie](http://ccb.jhu.edu/software/stringtie) in long read mode (with or without a guide reference annotation) to generate the GFF annotation.
-* The annotation generated by the pipeline is compared to the reference annotation (if supplied) using [gffcompare](http://ccb.jhu.edu/software/stringtie/gffcompare.shtml)
-* An html report is generated, which contains various summary statistics and plots of the data.
+* cDNA or direct RNA reads are initially and optionally preprocessed by [pychopper](https://github.com/nanoporetech/pychopper)
+for identification of full-length reads, as well as trimming and orientation correction.
+
+Reference-based approach
+* Full length reads are mapped to a supplied reference genome using [minimap2](https://github.com/lh3/minimap2)
+* Transcripts are assembled by [stringtie](http://ccb.jhu.edu/software/stringtie)
+in long read mode (with or without a guide reference annotation) to generate the GFF annotation.
+* The annotation generated by the pipeline is compared to the reference annotation
+using [gffcompare](http://ccb.jhu.edu/software/stringtie/gffcompare.shtml)
+
+de novo-based approach (experimental!)
+* Sequence clusters are generated using [isONclust2](https://github.com/nanoporetech/isONclust2)
+ * If a reference genome is supplied, cluster quality metrics are determined by comparing
+ with clusters generated from a minimap2 alignment
+* A consensus sequence for each cluster is generated using [spoa](https://github.com/rvaser/spoa)
+* Three rounds of polishing using racon and minimap2 to give a final polished CDS for each gene.
+* Full-length reads are then mapped to these polished CDS
+* Transcripts are assembled by stringtie as for the reference-based approach
+
+
+For both approaches:
+* An html report is generated, which contains various summary statistics and plots of the data.
+---
## Quickstart
+
+
The workflow uses [nextflow](https://www.nextflow.io/) to manage compute and
software resources, as such nextflow will need to be installed before attempting
to run the workflow.
@@ -25,7 +49,8 @@ either docker, singularity or conda is installed.
It is not required to clone or download the git repository in order to run the workflow.
For more information on running EPI2ME Labs workflows [visit out website](https://labs.epi2me.io/wfindex).
-**Workflow options**
+
+### Workflow options
To obtain the workflow, having installed `nextflow`, users can run:
@@ -36,41 +61,91 @@ nextflow run epi2me-labs/wf-isoforms --help
to see the options for the workflow.
**Workflow inputs**
-- Directory containing cDNA/direct RNA reads (or path to single file) in fastq/fastq.gz format
-- Reference genome in fast format
+- Directory containing cDNA/direct RNA reads. Or a directory containing subdirectories each with reads from different samples
+ (in fastq/fastq.gz format)
+- Reference genome in fasta format (required for reference-based assembly)
- Optional reference annotation in GFF2/3 format
-**Example workflow run**
+**Example execution of a workflow for reference-based transcript assembly**
+This uses a synthetic SIRV dataset so we need to tell minimap2 about the non-canonical spplice junctions with
+--minimap2_opts '-uf --splice-flank=no'
```
-# To run a small and quick example using synthetic data
-nextflow run wf-isoforms/ --fastq test_data/fastq --ref_genome genome.fasta --ref_annotation reference.gff
---out_dir outdir/ -profile conda
+OUTPUT=~/output;
+nextflow run wf-isoforms/ --fastq test_data/fastq --ref_genome test_data/SIRV_150601a.fasta --ref_annotation test_data/SIRV_isofroms.gtf
+--minimap2_opts '-uf --splice-flank=no' --out_dir outdir -w workspace_dir -profile conda -resume
```
```
# To evaluate the workflow on a larger Drosophila dataset
-chmod u+x ./run_evaluation_dmel.sh outdir
+./evaluation/run_evaluation_dmel.sh outdir
```
-**Workflow outputs**
+**Example workflow for denovo transcript assembly**
+```
+OUTPUT=~/output
+nextflow run . --fastq test_data/fastq --denovo --ref_genome test_data/SIRV_150601a.fasta -profile local --out_dir ${OUTPUT} -w ${OUTPUT}/workspace \
+--sample sample_id -resume
+```
+A full list of options can be seen in nextflow_schema.json. Below are some commonly used ones.
+They can be set in the config like this:
+ opt = 50
+or at the command line:
+ --opt 50
+
+- Threshold for including isoforms into interactive table `transcript_table_cov_thresh = 50`
+- Run the denovo pipeline `denovo = true` (default false)
+
+Pychopper and minimap2 can take options via `minimap2_opts` and `pychopper_opts`
+
+
+For example:
+- When using the SIRV synthetic test data
+ - `minimap2_opts = '-uf --splice-flank=no'`
+- pychopper needs to know which cDNA synthesis kit used
+ - SQK-PCS109: use `pychopper_opts = '-k PCS109'` (default)
+ - SQK-PCS110: use `pychopper_opts = '-k PCS110'`
+- pychopper can use one of two available backends for identifying primers in the raw reads
+ - nhmmscan `pychopper opts = '-m phmm'`
+ - edlib `pychopper opts = '-m edlib'`
+
Note: edlib is used in the configs as it's quite a lot faster. However it may be less sensitive than nhmmscan.
+
+---
+## Workflow outputs
* wf-isoforms-report.html
- Summary and plots of reads, alignments and the transcript assembly and annotation
-* str_merged.gff
- - The stringtie-generated transcript annotations
-* str_merged.stats
- - gffcomare file with statistics regarding the accuracy of the assembled query transcripts in relation to the reference annotation
-* str_merged.annotated.gtf
- - A gffcomapre output file with extra columns relating to comparison with the reference annotation
-* str_transcriptome.fas
- - A transcriptome made from the query reads
+ - One file per run with results from each sample merged
+
+####Transcript isoform table
+Note: Currently only available for the reference-guilded assembly
+The report includes a searchable filterable table of transcript isoforms
+Note: If using a large dataset with 10s of thousands of predicted isoforms, to speed up searching, the table can be limited
+by read coverage, which can be set with `transcript_table_cov_thresh = 50` (default 50x)
+
+Each sample will also have it's own directory containing the following (dependent on assembly approach and input options)
+* {sampleid}_gffcompare
+ * Directory containing all output from gffcompare
+* transcripts_{sample_id}.gff
+ * Annotation of query transcripts
+* {sample_id}_transcriptome.fas
+ - A transcriptome derived from the query reads
+* {sample_id}_merged_transcriptome.fas
+ - A transcriptome derived from the query reads + reference annotation
* merged_transcriptome.fas
-A transcriptome made from the combined query reads and reference annotation
-
+* final_polished_cds.fas (de novo only)
+ * CDS sequences derived from reads
+
+
## Useful links
* [nextflow](https://www.nextflow.io/)
* [docker](https://www.docker.com/products/docker-desktop)
* [Singularity](https://sylabs.io/singularity/)
* [conda](https://docs.conda.io/en/latest/miniconda.html)
+* [racon](https://github.com/isovic/racon)
+* [spoa](https://github.com/rvaser/spoa)
+* [inONclust](https://github.com/ksahlin/isONclust)
+* [isONclust2](https://github.com/nanoporetech/isONclust2)
+
diff --git a/bin/compute_cluster_quality.py b/bin/compute_cluster_quality.py
new file mode 100755
index 0000000..acd40fc
--- /dev/null
+++ b/bin/compute_cluster_quality.py
@@ -0,0 +1,638 @@
+#!/usr/bin/env python
+
+"""Generate cluster quality data."""
+
+# Adapted form script by Kristoffer Sahlin for
+# isONclust: https://github.com/ksahlin/isONclust
+
+import argparse
+from collections import defaultdict
+import math
+from pathlib import Path
+
+import matplotlib
+from matplotlib import pyplot as plt
+from matplotlib.backends.backend_pdf import PdfPages
+import pandas as pd
+import pysam
+from sklearn.metrics.cluster import adjusted_rand_score, completeness_score,\
+ homogeneity_score, v_measure_score
+matplotlib.use('Agg')
+
+
+def parse_inferred_clusters_tsv(tsv_file, args):
+ """parse_inferred_clusters_tsv."""
+ infile = open(tsv_file, "r")
+ infile.readline()
+ clusters = {}
+ for line in infile:
+ cluster_id, _, read_acc = line.strip().split("\t")
+ if args.simulated:
+ read_acc = "_".join([item for item in read_acc.split("_")[:-1]])
+ elif args.ont:
+ read_acc = read_acc.split(" ")[0]
+ elif args.modified_ont:
+ read_acc = read_acc
+ else:
+ read_acc = read_acc.split("_strand")[0]
+
+ clusters[read_acc] = int(cluster_id)
+ return clusters
+
+
+def parse_true_clusters(ref_file):
+ """parse_true_clusters."""
+ classes = defaultdict(dict)
+ ref_id_to_chrom = {}
+ alignment_counter = defaultdict(int)
+
+ prev_chrom = -1
+ curr_class_id = -1
+ prev_class_stop = -1
+ prev_read_id = ""
+ unique_reads = set()
+ unclassified = 0
+ for read in ref_file.fetch(until_eof=True):
+ unique_reads.add(read.query_name)
+ if read.is_unmapped:
+ unclassified += 1
+ continue
+ # deal with supplementary alignments!!
+ if read.is_secondary or read.is_supplementary:
+ continue
+ # print(read.query_name, read.flag)
+ assert prev_read_id != read.query_name
+
+ chrom = read.reference_name
+ if chrom != prev_chrom:
+ curr_class_id += 1
+ classes[read.query_name] = curr_class_id
+ prev_chrom = chrom
+ prev_class_stop = read.reference_end
+
+ else:
+ read_ref_start = read.reference_start
+ if read_ref_start > prev_class_stop:
+ curr_class_id += 1
+ classes[read.query_name] = curr_class_id
+ prev_class_stop = read.reference_end
+ else:
+ classes[read.query_name] = curr_class_id
+
+ prev_class_stop = max(read.reference_end, prev_class_stop)
+ prev_read_id = read.query_name
+
+ # classes[read.query_name] = int(read.reference_id) # chrom
+ ref_id_to_chrom[int(read.reference_id)] = chrom
+ alignment_counter[int(read.reference_id)] += 1
+ # if chrom not in class_ranges:
+ # class_ranges[chrom] = {}
+
+ # print(chrom, read_ref_start, read_ref_end)
+ # for start, stop in class_ranges[chrom]:
+ # if start <= read_ref_start and read_ref_end <= stop:
+ # # entirly within
+ # elif
+
+ # classes[read.query_name] = #read.reference_name.split("|")[0]
+ # #"|".join(read.reference_name.split("|")[:2])
+ return classes, len(unique_reads), unclassified
+
+
+def parse_true_clusters_simulated(ref_file):
+ """parse_true_clusters_simulated."""
+ classes = defaultdict(dict)
+ for read in ref_file.fetch(until_eof=True):
+ # by gene id
+ classes[read.query_name] = read.reference_name.split("|")[0]
+ # by transcript id
+ # classes[read.query_name] = read.reference_name.split("|")[1]
+
+ return classes
+
+
+def compute_V_measure(clusters, classes):
+ """compute_V_measure."""
+ class_list, cluster_list = [], []
+ # not_found_id = 1000000
+ clustered_but_unaligned = 0
+ for read in clusters:
+ if read in classes:
+ class_list.append(classes[read])
+ cluster_list.append(clusters[read])
+ else:
+ # print("Read was clustered but unaligned:", read)
+ clustered_but_unaligned += 1
+
+ # added the unprocessed reads to the measure
+ not_clustered = set(classes.keys()) - set(clusters.keys())
+ highest_cluster_id = max(clusters.values())
+ highest_cluster_id += 1
+ for read in not_clustered:
+ class_list.append(classes[read])
+ cluster_list.append(highest_cluster_id)
+ highest_cluster_id += 1
+
+ v_score = v_measure_score(class_list, cluster_list)
+ compl_score = completeness_score(class_list, cluster_list)
+ homog_score = homogeneity_score(class_list, cluster_list)
+ ari = adjusted_rand_score(class_list, cluster_list)
+
+ print("Not included in clustering but aligned:", len(not_clustered))
+ print(
+ "V:",
+ v_score,
+ "Completeness:",
+ compl_score,
+ "Homogeneity:",
+ homog_score)
+ print(
+ "Nr reads clustered but unaligned "
+ "(i.e., no class and excluded from V-measure): ",
+ clustered_but_unaligned)
+ return v_score, compl_score, homog_score, clustered_but_unaligned, ari
+
+
+def compute_V_measure_non_singleton_classes(clusters, classes):
+ """V measure for non-singleton classes."""
+ max_cluster_id = max(clusters.values())
+ new_id = max_cluster_id + 1
+ classes_dict = {}
+ for read_acc, cl_id in classes.items():
+ if cl_id not in classes_dict:
+ classes_dict[cl_id] = [read_acc]
+ else:
+ classes_dict[cl_id].append(read_acc)
+
+ nontrivial_classes_reads = []
+ for cl_id in classes_dict:
+ if len(classes_dict[cl_id]) < 5:
+ continue
+ else:
+ for read in classes_dict[cl_id]:
+ nontrivial_classes_reads.append(read)
+
+ class_list, cluster_list = [], []
+ for read in nontrivial_classes_reads:
+ if read in clusters:
+ class_list.append(classes[read])
+ cluster_list.append(clusters[read])
+ else:
+ class_list.append(classes[read])
+ cluster_list.append(new_id)
+ new_id += 1
+
+ v_score = v_measure_score(class_list, cluster_list)
+ compl_score = completeness_score(class_list, cluster_list)
+ homog_score = homogeneity_score(class_list, cluster_list)
+ nr_filtered_classes = len(
+ [1 for cl_id in classes_dict if len(classes_dict[cl_id]) >= 5])
+ print(
+ "NONTRIVIAL CLASSES: V:",
+ v_score,
+ "Completeness:",
+ compl_score,
+ "Homogeneity:",
+ homog_score)
+ print("NUMBER OF CLASSES (FILTERED):", len(
+ [1 for cl_id in classes_dict if len(classes_dict[cl_id]) >= 5]))
+ return v_score, compl_score, homog_score, nr_filtered_classes
+
+
+def compute_V_measure_non_singletons(clusters, classes):
+ """V measure for non-singletons."""
+ cluster_dict = {}
+ for read_acc, cl_id in clusters.items():
+ if cl_id not in cluster_dict:
+ cluster_dict[cl_id] = [read_acc]
+ else:
+ cluster_dict[cl_id].append(read_acc)
+
+ nontrivial_clustered_reads = []
+ for cl_id in cluster_dict:
+ if len(cluster_dict[cl_id]) <= 1:
+ continue
+ else:
+ for read in cluster_dict[cl_id]:
+ nontrivial_clustered_reads.append(read)
+
+ class_list, cluster_list = [], []
+ # not_found_id = 1000000
+ clustered_but_unaligned = 0
+ for read in nontrivial_clustered_reads:
+ if read in classes:
+ class_list.append(classes[read])
+ cluster_list.append(clusters[read])
+ else:
+ # print("Read was clustered but unaligned:", read)
+ clustered_but_unaligned += 1
+
+ v_score = v_measure_score(class_list, cluster_list)
+ compl_score = completeness_score(class_list, cluster_list)
+ homog_score = homogeneity_score(class_list, cluster_list)
+ print(
+ "NONTRIVIAL CLUSTERS: V:",
+ v_score,
+ "Completeness:",
+ compl_score,
+ "Homogeneity:",
+ homog_score)
+ print(
+ "NONTRIVIAL CLUSTERS: Nr reads clustered but unaligned "
+ "(i.e., no class and excluded from V-veasure): ",
+ clustered_but_unaligned)
+ return v_score, compl_score, homog_score, clustered_but_unaligned
+
+
+def percentile(N, percent, key=lambda x: x):
+ """
+ Find the percentile of a list of values.
+
+ @parameter N - is a list of values. Note N MUST BE already sorted.
+ @parameter percent - a float value from 0.0 to 1.0.
+ @parameter key - optional key function to compute value
+ from each element of N.
+
+ @return - the percentile of the values
+ """
+ if not N:
+ return None
+ k = (len(N) - 1) * percent
+ f = math.floor(k)
+ c = math.ceil(k)
+ if f == c:
+ return key(N[int(k)])
+ d0 = key(N[int(f)]) * (c - k)
+ d1 = key(N[int(c)]) * (k - f)
+ return d0 + d1
+
+# end of http://code.activestate.com/recipes/511478/ }}}
+
+
+def get_cluster_information(clusters, classes):
+ """Get cluster info."""
+ # class distribution
+ class_dict = {}
+ for read_acc, class_id in classes.items():
+ if class_id not in class_dict:
+ class_dict[class_id] = [read_acc]
+ else:
+ class_dict[class_id].append(read_acc)
+
+ total_nr_classes = len(class_dict)
+ class_distribution = sorted([len(cl) for cl in class_dict.values()])
+ singleton_classes = set(
+ [acc_list[0] for cl_id, acc_list in class_dict.items()
+ if len(acc_list) == 1])
+ min_class_size = min(class_distribution)
+ max_class_size = max(class_distribution)
+ mean_class_size = sum(class_distribution) / float(len(class_distribution))
+ median_class_size = class_distribution[int(len(class_distribution) / 2)] \
+ if len(class_distribution) % 2 == 1 else (
+ class_distribution[int(len(class_distribution) / 2)] +
+ class_distribution[int(len(class_distribution) / 2) - 1]) / 2.0
+
+ upper_75_class_size = percentile(class_distribution, 0.75)
+ median_class_size = percentile(class_distribution, 0.5)
+
+ tot_size = sum(class_distribution)
+ e_class_size = sum(
+ [c_s**2 for c_s in class_distribution]) / float(tot_size)
+ tot_iterated_size = 0
+ for c_s in class_distribution[::-1]:
+ tot_iterated_size += c_s
+ if tot_iterated_size >= tot_size / 2.0:
+ n50_class_size = c_s
+ break
+
+ # cluster distribution
+ cluster_dict = {}
+ for read_acc, cl_id in clusters.items():
+ if cl_id not in cluster_dict:
+ cluster_dict[cl_id] = [read_acc]
+ else:
+ cluster_dict[cl_id].append(read_acc)
+
+ cluster_distribution = sorted([len(cl) for cl in cluster_dict.values()])
+
+ # in case unclustered reads are missing from output (as for isoseq3)
+ omitted_from_output_singletons = set(classes.keys()) - set(clusters.keys())
+ cluster_distribution = [
+ 1 for i in range(
+ len(omitted_from_output_singletons))] + cluster_distribution
+ total_nr_clusters = len(cluster_distribution)
+
+ singleton_clusters = set(
+ [acc_list[0] for cl_id, acc_list in cluster_dict.items()
+ if len(acc_list) == 1])
+ min_cluster_size = min(cluster_distribution)
+ max_cluster_size = max(cluster_distribution)
+ mean_cluster_size = sum(cluster_distribution) / \
+ float(len(cluster_distribution))
+
+ upper_75_cluster_size = percentile(cluster_distribution, 0.75)
+ median_cluster_size = percentile(cluster_distribution, 0.5)
+
+ tot_size = sum(cluster_distribution)
+ e_cluster_size = sum(
+ [c_s**2 for c_s in cluster_distribution]) / float(tot_size)
+ tot_iterated_size = 0
+ for c_s in cluster_distribution[::-1]:
+ tot_iterated_size += c_s
+ if tot_iterated_size >= tot_size / 2.0:
+ n50_cluster_size = c_s
+ break
+
+ unaligned_but_nontrivially_clustered = set(
+ clusters.keys()) - singleton_clusters - set(classes.keys())
+
+ # not_considered = set([read for read in classes if read not in clusters ])
+
+ not_clustered_classes = defaultdict(int)
+ clustered_classes = defaultdict(int)
+ reads_not_clustered = defaultdict(list)
+ for read in clusters:
+ if read in classes:
+ class_id = classes[read]
+ else:
+ class_id = "unaligned"
+
+ if read in singleton_clusters:
+ not_clustered_classes[class_id] += 1
+ reads_not_clustered[class_id].append(read)
+ else:
+ clustered_classes[class_id] += 1
+
+ print("UNCLUSTERED:", "Tot classes:", len(not_clustered_classes))
+ print("CLUSTERED:", "Tot classes:", len(clustered_classes))
+ print("MIXED:", "Tot classes containing both:", len(
+ set(clustered_classes.keys()) & set(not_clustered_classes.keys())))
+ print("Total number of classes (unique gene ID):", total_nr_classes)
+ return (total_nr_classes - len(singleton_classes), len(singleton_classes),
+ min_class_size, max_class_size, mean_class_size, median_class_size,
+ total_nr_clusters, len(singleton_clusters)
+ + len(omitted_from_output_singletons),
+ min_cluster_size, max_cluster_size, mean_cluster_size,
+ median_cluster_size, len(unaligned_but_nontrivially_clustered),
+ upper_75_class_size, upper_75_cluster_size, e_class_size,
+ n50_class_size, e_cluster_size, n50_cluster_size)
+
+
+def main(args):
+ """Entry point."""
+ clusters = parse_inferred_clusters_tsv(args.clusters, args)
+ if not clusters:
+ outfile = open(args.outfile, "w")
+ outfile.write("No clusters created\n")
+ outfile.close()
+ return
+
+ if args.simulated:
+ ref_file = pysam.AlignmentFile(args.classes, "r", check_sq=False)
+ classes = parse_true_clusters_simulated(ref_file)
+ # by simulation we know classes of all reads, they are therefore the
+ # same number.
+ tot_nr_reads = len(classes)
+ else:
+ ref_file = pysam.AlignmentFile(args.classes, "rb", check_sq=False)
+ classes, tot_nr_reads, unclassified = parse_true_clusters(ref_file)
+
+ v_score, compl_score, homog_score, clustered_but_unaligned, ari = \
+ compute_V_measure(clusters, classes)
+
+ nr_non_singleton_classes, singleton_classes, min_class_size, \
+ max_class_size, mean_class_size, median_class_size, total_nr_clusters,\
+ singleton_clusters, min_cluster_size, max_cluster_size, \
+ mean_cluster_size, median_cluster_size, \
+ unaligned_but_nontrivially_clustered, \
+ upper_75_class_size, upper_75_cluster_size, e_class_size, \
+ n50_class_size, e_cluster_size, n50_cluster_size = \
+ get_cluster_information(clusters, classes)
+
+ outfile = open(args.outfile, "w")
+
+ outfile.write("CLASSES\n")
+
+ # reads, unaligned, classes, singleton, min,max, mean,median
+
+ outfile.write(
+ "{0},{1},{2},{3},{4},{5},{6},{7}\n".format(
+ "tot_nr_reads",
+ "unclassified",
+ "nr_non_singleton_classes",
+ "singleton_classes",
+ "upper_75_class_size",
+ "median_class_size",
+ "e_class_size",
+ "n50_class_size"))
+ outfile.write(
+ "{0},{1},{2},{3},{4},{5},{6},{7}\n".format(
+ tot_nr_reads,
+ unclassified,
+ nr_non_singleton_classes,
+ singleton_classes,
+ upper_75_class_size,
+ median_class_size,
+ e_class_size,
+ n50_class_size))
+
+ # Reads_nontrivially_clustered_(%), Singletons_(%),
+ # Reads_Nontrivially_clustered_but_unaligned, V, c,h ,V_nt, c_nt,h_nt,
+ # non_singleton_clusters, min, max, median, mean
+
+ Reads_nontrivially_clustered_percent = round(
+ 100 * (float(tot_nr_reads - singleton_clusters) / tot_nr_reads), 1)
+ # round(1.0 - Reads_nontrivially_clustered_percent, 2)
+ Reads_Nontrivially_clustered_but_unaligned = \
+ unaligned_but_nontrivially_clustered
+ V, c, h = round(v_score, 3), round(compl_score, 3), round(homog_score, 3)
+
+ non_singleton_clusters = total_nr_clusters - singleton_clusters
+
+ print("NONTRIVIAL CLUSTERS: ", (total_nr_clusters - singleton_clusters))
+
+ outfile.write("CLUSTERS\n")
+ outfile.write(
+ "{0},{1},{2},{3},{4},{5},{6},{7},{8},{9},{10},{11}\n".format(
+ "V",
+ "c",
+ "h",
+ "ARI",
+ "Reads_nontrivially_clustered_percent",
+ "Reads_Nontrivially_clustered_but_unaligned",
+ "non_singleton_clusters",
+ "singleton_clusters",
+ "upper_75_cluster_size",
+ "median",
+ "e_cluster_size",
+ "n50_cluster_size"))
+ outfile.write(
+ "{0},{1},{2},{3},{4},{5},{6},{7},{8},{9},{10},{11}\n".format(
+ V,
+ c,
+ h,
+ ari,
+ Reads_nontrivially_clustered_percent,
+ Reads_Nontrivially_clustered_but_unaligned,
+ non_singleton_clusters,
+ singleton_clusters,
+ upper_75_cluster_size,
+ median_cluster_size,
+ e_cluster_size,
+ n50_cluster_size))
+ outfile.close()
+
+ if args.ctsv is not None:
+ cfh = open(args.ctsv, "w")
+ cfh.write("Read\tCluster\n")
+ for r, c in classes.items():
+ cfh.write("{}\t{}\n".format(r, c))
+ cfh.flush()
+ cfh.close()
+
+ dfc = pd.DataFrame(
+ {
+ 'Statistic': [
+ 'V-measure',
+ 'ARI',
+ 'Completeness',
+ 'Homogeneity'],
+ 'Value': [
+ V,
+ ari,
+ c,
+ h]}).set_index('Statistic')
+ dfn = pd.DataFrame(
+ {'Statistic': ['NonSingleton',
+ 'Singletons'],
+ 'Value': [non_singleton_clusters,
+ singleton_clusters]}).set_index('Statistic')
+ dfs = pd.DataFrame(
+ {
+ 'Statistic': [
+ 'Upper75ClsSize',
+ 'Upper75ClassSize',
+ 'MedianClsSize',
+ 'MedianClassSize'],
+ 'Value': [
+ upper_75_cluster_size,
+ upper_75_class_size,
+ median_cluster_size,
+ median_class_size]}).set_index('Statistic')
+ dfs2 = pd.DataFrame({'Statistic': ['N50ClsSize', 'N50ClassSize'],
+ 'Value': [
+ n50_cluster_size,
+ n50_class_size]}).set_index('Statistic')
+
+ rdo = Path(args.raw_data_out)
+ dfc.to_csv(rdo / 'v_ari_com_hom.csv')
+ dfn.to_csv(rdo / 'sing_nonsing.csv')
+ dfs.to_csv(rdo / 'class_sizes1.csv')
+ dfs2.to_csv(rdo / 'class_sizes2.csv')
+
+ pages = PdfPages(args.report)
+
+ yd = 7 * 2.5
+
+ ax = dfc.plot(kind='bar', fontsize=7, rot=0)
+ for p in ax.patches:
+ ax.annotate(
+ "{:.2f}".format(
+ p.get_height()),
+ (p.get_x() +
+ p.get_width() /
+ 2.0,
+ p.get_height()),
+ ha='center')
+ pages.savefig()
+ plt.clf()
+
+ ax = dfn.plot(kind='bar', fontsize=7, rot=0)
+ for p in ax.patches:
+ ax.annotate(
+ "{:.2f}".format(
+ p.get_height()),
+ (p.get_x() +
+ p.get_width() /
+ 2.0,
+ p.get_height()),
+ ha='center')
+ pages.savefig()
+ plt.clf()
+
+ ax = dfs.plot(kind='bar', fontsize=7, rot=0)
+ for p in ax.patches:
+ ax.annotate(
+ "{:.2f}".format(
+ p.get_height()),
+ (p.get_x() +
+ p.get_width() /
+ 2.0,
+ p.get_height() +
+ yd),
+ ha='center')
+ pages.savefig()
+ plt.clf()
+ ax = dfs2.plot(kind='bar', fontsize=7, rot=0)
+ for p in ax.patches:
+ ax.annotate(
+ "{:.2f}".format(
+ p.get_height()),
+ (p.get_x() +
+ p.get_width() /
+ 2.0,
+ p.get_height() +
+ yd),
+ ha='center')
+ pages.savefig()
+ plt.clf()
+
+ pages.close()
+
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser(
+ description="Align predicted transcripts to transcripts in ensembl "
+ "reference data base.")
+ parser.add_argument(
+ '--clusters',
+ type=str,
+ help='Inferred clusters (tsv file)')
+ parser.add_argument(
+ '--classes',
+ type=str,
+ help='A sorted and indexed bam file.')
+ parser.add_argument(
+ '--ctsv',
+ default=None,
+ type=str,
+ help='Write true classes in this TSV file.')
+ parser.add_argument(
+ '--simulated',
+ action="store_true",
+ help='Simulated data, we can simply read correct classes '
+ 'from the ref field.')
+ parser.add_argument(
+ '--ont',
+ action="store_true",
+ help='ONT data, parsing accessions differently.')
+ parser.add_argument(
+ '--modified_ont',
+ action="store_true",
+ help='ONT data preprocessed accessions, parsing '
+ 'accessions differently.')
+ parser.add_argument('--outfile', type=str, help='Output file with results')
+ parser.add_argument(
+ '--report',
+ type=str,
+ help='Output PDF file with report')
+ parser.add_argument('--sizes', type=str, help='Cluster sizes')
+ parser.add_argument(
+ '--raw_data_out',
+ type=str,
+ help='dir to save raw data for plotting')
+ args = parser.parse_args()
+
+ print("------------------------------------------------------------")
+ main(args)
+ print("------------------------------------------------------------")
diff --git a/bin/plot_aln_stats.py b/bin/plot_aln_stats.py
deleted file mode 100755
index 97cda66..0000000
--- a/bin/plot_aln_stats.py
+++ /dev/null
@@ -1,186 +0,0 @@
-#!/usr/bin/env python
-"""Plot a seqkit alignment stats file."""
-
-import argparse
-import warnings
-
-import matplotlib
-from matplotlib.backends.backend_pdf import PdfPages
-import matplotlib.pyplot as plt
-import numpy as np
-import pandas as pd
-import six
-
-matplotlib.use('Agg')
-
-with warnings.catch_warnings():
- warnings.simplefilter("ignore")
- import seaborn as sns
-
-warnings.resetwarnings()
-_ = sns
-
-# Parse command line arguments:
-parser = argparse.ArgumentParser(
- description="""Plot a seqkit alignment stats file.""")
-parser.add_argument(
- '-r', metavar='report_pdf', type=str, help="Report PDF (stats.pdf).",
- default="stats.pdf")
-parser.add_argument(
- 'input', metavar='input_tsv', type=str, help="Input TSV.")
-
-
-class Report:
- """Class for plotting utilities on the top of matplotlib.
-
- Plots are saved in the specified file through the PDF backend.
- """
-
- def __init__(self, pdf):
- """
- Init Report with a matplotlib PdfPages instance.
-
- :param self: object.
- :param pdf: Output pdf.
- :returns: The report object.
- :rtype: Report
- """
- self.pdf = pdf
- self.plt = plt
- self.pages = PdfPages(pdf)
-
- def _set_properties_and_close(self, fig, title, xlab, ylab):
- """Set title, axis labels and close the figure.
-
- :param self: object.
- :param fig: The current figure.
- :param title: Figure title.
- :param xlab: X axis label.
- :param ylab: Y axis label.
- :returns: None
- :rtype: object
- """
- plt.xlabel(xlab)
- plt.ylabel(ylab)
- plt.title(title)
- self.pages.savefig(fig)
- plt.close(fig)
-
- def plot_boxplots(self, data_map, title="", xlab="", ylab="",
- xticks_rotation=0, xticks_fontsize=5):
- """Plot multiple pairs of data arrays.
-
- :param self: object.
- :param data_map:
- A dictionary with labels as keys and lists as data values.
- :param title: Figure title.
- :param xlab: X axis label.
- :param ylab: Y axis label.
- :param xticks_rotation: Rotation value for x tick labels.
- :param xticks_fontsize: Fontsize for x tick labels.
- :returns: None
- :rtype: object
- """
- fig = plt.figure()
- plt.boxplot(list(data_map.values()))
- plt.xticks(np.arange(len(data_map)) + 1, data_map.keys(),
- rotation=xticks_rotation, fontsize=xticks_fontsize)
- self._set_properties_and_close(fig, title, xlab, ylab)
-
- def plot_bars_simple(self, data_map, title="", xlab="", ylab="", alpha=0.6,
- xticks_rotation=0, auto_limit=False):
- """Plot simple bar chart from input dictionary.
-
- :param self: object.
- :param data_map: A dictionary with labels as keys and data as values.
- :param title: Figure title.
- :param xlab: X axis label.
- :param ylab: Y axis label.
- :param alpha: Alpha value.
- :param xticks_rotation: Rotation value for x tick labels.
- :param auto_limit: Set y axis limits automatically.
- :returns: None
- :rtype: object
- """
- fig = plt.figure()
-
- labels = list(data_map.keys())
- data = list(data_map.values())
- positions = np.arange(len(labels))
- plt.bar(positions, data, align='center', alpha=alpha)
- plt.xticks(positions, labels, rotation=xticks_rotation)
-
- if auto_limit:
- low, high = min(data), max(data)
- plt.ylim([(low - 0.5 * (high - low)), (high + 0.5 * (high - low))])
-
- self._set_properties_and_close(fig, title, xlab, ylab)
-
- def plot_histograms(self, data_map, title="", xlab="", ylab="", bins=50,
- alpha=0.7, legend_loc='best', legend=True,
- vlines=None):
- """Plot histograms of multiple data arrays.
-
- :param self: object.
- :param data_map:
- A dictionary with labels as keys and data arrays as values.
- :param title: Figure title.
- :param xlab: X axis label.
- :param ylab: Y axis label.
- :param bins: Number of bins.
- :param alpha: Transparency value for histograms.
- :param legend_loc: Location of legend.
- :param legend: Plot legend if True.
- :param vlines:
- Dictionary with labels and positions of vertical lines to draw.
- :returns: None
- :rtype: object
- """
- fig = plt.figure()
-
- for label, data in six.iteritems(data_map):
- if len(data) > 0:
- plt.hist(data, bins=bins, label=label, alpha=alpha)
- if vlines is not None:
- for label, pos in six.iteritems(vlines):
- plt.axvline(x=pos, label=label)
- if legend:
- plt.legend(loc=legend_loc)
-
- self._set_properties_and_close(fig, title, xlab, ylab)
-
- def close(self):
- """Close PDF backend.
-
- Do not forget to call this at the end of your
- script or your output will be damaged!
-
- :param self: object
- :returns: None
- :rtype: object
- """
- self.pages.close()
-
-
-if __name__ == '__main__':
- args = parser.parse_args()
-
- plotter = Report(args.r)
-
- # Plot overview panel:
- stats = pd.read_csv(args.input, sep="\t")
- perc = stats[["PrimAlnPerc", "MultimapPerc"]].copy()
- num = stats[["PrimAln", "SecAln", "SupAln", "Unmapped", "TotalReads",
- "TotalRecords"]].copy()
-
- perc.plot(kind='bar')
- plt.title("Percent primary and multimapping reads")
- plt.tight_layout()
- plotter.pages.savefig()
-
- num.plot(kind='bar')
- plt.title("Number of alignment records")
- plt.tight_layout()
- plotter.pages.savefig()
-
- plotter.close()
diff --git a/bin/plot_gffcmp_stats.py b/bin/plot_gffcmp_stats.py
deleted file mode 100755
index 408eda7..0000000
--- a/bin/plot_gffcmp_stats.py
+++ /dev/null
@@ -1,471 +0,0 @@
-#!/usr/bin/env python
-
-"""Plot a gffcompare stats file."""
-
-import argparse
-from collections import OrderedDict
-import warnings
-
-import matplotlib
-from matplotlib.backends.backend_pdf import PdfPages
-import matplotlib.pyplot as plt
-import numpy as np
-import pandas as pd
-import six
-
-
-matplotlib.use('Agg')
-
-with warnings.catch_warnings():
- warnings.simplefilter("ignore")
- import seaborn as sns
-
-warnings.resetwarnings()
-_ = sns
-
-# Parse command line arguments:
-parser = argparse.ArgumentParser(
- description="""Plot a gffcompare stats file.""")
-parser.add_argument(
- '-r', metavar='report_pdf', type=str,
- help="Report PDF (plot_gffcmp_stats.pdf).",
- default="plot_gffcmp_stats.pdf")
-parser.add_argument(
- '-t', metavar='tracking_tsv', type=str,
- help="Tracking file produced by gffcompare.", default=None)
-parser.add_argument(
- 'input', metavar='input_txt', type=str,
- help="Input gffcompare stats file.")
-
-
-class Report:
- """Matplotlib plotting utilities."""
-
- def __init__(self, pdf):
- """Init class with PdfPahges instance.
-
- Plots are saved in the specified file through the PDF backend.
-
- :param self: object.
- :param pdf: Output pdf.
- :returns: The report object.
- :rtype: Report
-
- """
- self.pdf = pdf
- self.plt = plt
- self.pages = PdfPages(pdf)
-
- def _set_properties_and_close(self, fig, title, xlab, ylab):
- """Set title, axis labels and close the figure.
-
- :param self: object.
- :param fig: The current figure.
- :param title: Figure title.
- :param xlab: X axis label.
- :param ylab: Y axis label.
- :returns: None
- :rtype: object
- """
- plt.xlabel(xlab)
- plt.ylabel(ylab)
- plt.title(title)
- self.pages.savefig(fig)
- plt.close(fig)
-
- def plot_boxplots(self, data_map, title="", xlab="", ylab="",
- xticks_rotation=0, xticks_fontsize=5):
- """Plot multiple pairs of data arrays.
-
- :param self: object.
- :param data_map: A dictionary with labels as keys and lists as data
- values.
- :param title: Figure title.
- :param xlab: X axis label.
- :param ylab: Y axis label.
- :param xticks_rotation: Rotation value for x tick labels.
- :param xticks_fontsize: Fontsize for x tick labels.
- :returns: None
- :rtype: object
- """
- fig = plt.figure()
- plt.boxplot(list(data_map.values()))
- plt.xticks(np.arange(len(data_map)) + 1, data_map.keys(),
- rotation=xticks_rotation, fontsize=xticks_fontsize)
- self._set_properties_and_close(fig, title, xlab, ylab)
-
- def plot_bars_simple(self, data_map, title="", xlab="", ylab="", alpha=0.6,
- xticks_rotation=0, auto_limit=False):
- """Plot simple bar chart from input dictionary.
-
- :param self: object.
- :param data_map: A dictionary with labels as keys and data as values.
- :param title: Figure title.
- :param xlab: X axis label.
- :param ylab: Y axis label.
- :param alpha: Alpha value.
- :param xticks_rotation: Rotation value for x tick labels.
- :param auto_limit: Set y axis limits automatically.
- :returns: None
- :rtype: object
- """
- fig = plt.figure()
-
- labels = list(data_map.keys())
- data = list(data_map.values())
- positions = np.arange(len(labels))
- plt.bar(positions, data, align='center', alpha=alpha)
- plt.xticks(positions, labels, rotation=xticks_rotation)
-
- if auto_limit:
- low, high = min(data), max(data)
- plt.ylim([(low - 0.5 * (high - low)), (high + 0.5 * (high - low))])
-
- self._set_properties_and_close(fig, title, xlab, ylab)
-
- def plot_histograms(self, data_map, title="", xlab="", ylab="", bins=50,
- alpha=0.7, legend_loc='best', legend=True,
- vlines=None):
- """Plot histograms of multiple data arrays.
-
- :param self: object.
- :param data_map: A dictionary with labels as keys and data arrays
- as values.
- :param title: Figure title.
- :param xlab: X axis label.
- :param ylab: Y axis label.
- :param bins: Number of bins.
- :param alpha: Transparency value for histograms.
- :param legend_loc: Location of legend.
- :param legend: Plot legend if True.
- :param vlines: Dictionary with labels and positions of vertical lines
- to draw.
- :returns: None
- :rtype: object
- """
- fig = plt.figure()
-
- for label, data in six.iteritems(data_map):
- if len(data) > 0:
- plt.hist(data, bins=bins, label=label, alpha=alpha)
- if vlines is not None:
- for label, pos in six.iteritems(vlines):
- plt.axvline(x=pos, label=label)
- if legend:
- plt.legend(loc=legend_loc)
-
- self._set_properties_and_close(fig, title, xlab, ylab)
-
- def close(self):
- """Close PDF backend.
-
- Do not forget to call this at the end of your
- script or your output will be damaged!
-
- :param self: object
- :returns: None
- :rtype: object
- """
- self.pages.close()
-
-
-def _parse_stat_line(sl):
- """Parse a stats line."""
- res = {}
- tmp = sl.split(':')[1]
- tmp = tmp.split('|')
- res['sensitivity'] = float(tmp[0].strip())
- res['precision'] = float(tmp[1].strip())
- return res
-
-
-def _parse_matching_line(line):
- """Parse a metching line."""
- tmp = line.split(':')[1].strip()
- return int(tmp)
-
-
-def _parse_mn_line(line):
- """Parse a miss or novel line."""
- res = {}
- tmp = line.split(':')[1].strip()
- tmp = tmp.split('/')
- res['value'] = int(tmp[0])
- tmp = tmp[1].split('(')
- res['value_total'] = int(tmp[0].strip())
- res['percent'] = float(tmp[1].split('%)')[0])
- return res
-
-
-def _parse_total_line(line):
- """Parse a total line."""
- res = {}
- tmp = line.split(':')[1].strip()
- tmp = tmp.split('in')
- res['transcripts'] = int(tmp[0].strip())
- tmp = tmp[1].split('loci')
- res['loci'] = int(tmp[0].strip())
- tmp = int(tmp[1].split('(')[1].split(' ')[0])
- res['me_transcripts'] = tmp
- return res
-
-
-def parse_gffcmp_stats(txt):
- """Parse a gffcompare stats file.
-
- :param txt: Path to the gffcompare stats file.
- :returns: Return as tuple of dataframes containing:
- perfromance statistics, match statistics, miss statistics,
- novel statistics, total statistics.
- :rtype: tuple
- """
- sensitivity = []
- precision = []
- level = []
-
- matching = OrderedDict()
-
- missed_level = []
- missed = []
- missed_total = []
- missed_percent = []
-
- novel_level = []
- novel = []
- novel_total = []
- novel_percent = []
-
- total_target = []
- total_loci = []
- total_transcripts = []
- total_multiexonic = []
-
- fh = open(txt, 'r')
- for line in fh:
- line = line.strip()
- if len(line) == 0:
- continue
- # Parse totals:
- if line.startswith('# Query mRNAs'):
- total_target.append('Query')
- r = _parse_total_line(line)
- total_loci.append(r['loci'])
- total_transcripts.append(r['transcripts'])
- total_multiexonic.append(r['me_transcripts'])
-
- if line.startswith('# Reference mRNAs '):
- total_target.append('Reference')
- r = _parse_total_line(line)
- total_loci.append(r['loci'])
- total_transcripts.append(r['transcripts'])
- total_multiexonic.append(r['me_transcripts'])
-
- # Parse basic statistics:
- if line.startswith('Base level'):
- st = _parse_stat_line(line)
- level.append('Base')
- sensitivity.append(st['sensitivity'])
- precision.append(st['precision'])
- if line.startswith('Exon level'):
- st = _parse_stat_line(line)
- level.append('Exon')
- sensitivity.append(st['sensitivity'])
- precision.append(st['precision'])
- if line.startswith('Intron level'):
- st = _parse_stat_line(line)
- level.append('Intron')
- sensitivity.append(st['sensitivity'])
- precision.append(st['precision'])
- if line.startswith('Intron chain level'):
- st = _parse_stat_line(line)
- level.append('Intron chain')
- sensitivity.append(st['sensitivity'])
- precision.append(st['precision'])
- if line.startswith('Transcript level'):
- st = _parse_stat_line(line)
- level.append('Transcript')
- sensitivity.append(st['sensitivity'])
- precision.append(st['precision'])
- if line.startswith('Locus level'):
- st = _parse_stat_line(line)
- level.append('Locus')
- sensitivity.append(st['sensitivity'])
- precision.append(st['precision'])
-
- # Parse match statistics:
- if line.startswith('Matching intron chains'):
- m = _parse_matching_line(line)
- matching['Intron chains'] = [m]
- if line.startswith('Matching transcripts'):
- m = _parse_matching_line(line)
- matching['Transcripts'] = [m]
- if line.startswith('Matching loci'):
- m = _parse_matching_line(line)
- matching['Loci'] = [m]
-
- # Parse missing statistics:
- if line.startswith('Missed exons'):
- missed_level.append('Exons')
- r = _parse_mn_line(line)
- missed.append(r['value'])
- missed_total.append(r['value_total'])
- missed_percent.append(r['percent'])
- if line.startswith('Missed introns'):
- missed_level.append('Introns')
- r = _parse_mn_line(line)
- missed.append(r['value'])
- missed_total.append(r['value_total'])
- missed_percent.append(r['percent'])
- if line.startswith('Missed loci'):
- missed_level.append('Loci')
- r = _parse_mn_line(line)
- missed.append(r['value'])
- missed_total.append(r['value_total'])
- missed_percent.append(r['percent'])
-
- # Parse novel statistics:
- if line.startswith('Novel exons'):
- novel_level.append('Exons')
- r = _parse_mn_line(line)
- novel.append(r['value'])
- novel_total.append(r['value_total'])
- novel_percent.append(r['percent'])
- if line.startswith('Novel introns'):
- novel_level.append('Introns')
- r = _parse_mn_line(line)
- novel.append(r['value'])
- novel_total.append(r['value_total'])
- novel_percent.append(r['percent'])
- if line.startswith('Novel loci'):
- novel_level.append('Loci')
- r = _parse_mn_line(line)
- novel.append(r['value'])
- novel_total.append(r['value_total'])
- novel_percent.append(r['percent'])
-
- fh.close()
-
- df_stats = pd.DataFrame(OrderedDict(
- [('Sensitivity', sensitivity), ('Precision', precision)]), index=level)
- df_match = pd.DataFrame(matching, index=['Matching'])
-
- df_miss = pd.DataFrame(
- OrderedDict(
- [('Total', missed_total),
- ('Missed', missed),
- ('Percent missed', missed_percent)]), index=missed_level)
-
- df_novel = pd.DataFrame(
- OrderedDict(
- [('Total', novel_total),
- ('Novel', novel),
- ('Percent novel', novel_percent)]), index=novel_level)
-
- df_total = pd.DataFrame(OrderedDict(
- [('Loci', total_loci), ('Transcripts', total_transcripts),
- ('Multiexonic', total_multiexonic)]), index=total_target)
-
- return df_stats, df_match, df_miss, df_novel, df_total
-
-
-if __name__ == '__main__':
- args = parser.parse_args()
-
- stats, match, miss, novel, total = parse_gffcmp_stats(args.input)
- tracking = pd.read_csv(args.t, sep="\t", header=None, usecols=[0, 3],
- names=['Count', 'Overlaps'])
-
- tracking = tracking.groupby("Overlaps").count().reset_index()
- tracking = tracking.sort_values("Overlaps")
-
- plotter = Report(args.r)
-
- # Plot overview panel:
-
- plt.figure(1)
- plt.subplot(2, 2, 1)
- total.plot(ax=plt.gca(), kind='barh', sharex=False, title='Totals')
- plt.tight_layout()
-
- plt.subplot(2, 2, 2)
- stats.plot(ax=plt.gca(), kind='barh', legend=True, sharex=False,
- title='Performance').legend(loc='best')
- plt.tight_layout()
-
- plt.subplot(2, 2, 3)
-
- miss.copy().drop(
- 'Percent missed', axis=1).plot(
- ax=plt.gca(), kind='barh',
- legend=True, sharex=False,
- title='Missed')
-
- plt.tight_layout()
-
- plt.subplot(2, 2, 4)
-
- novel.copy().drop(
- 'Percent novel', axis=1).plot(
- ax=plt.gca(), kind='barh', legend=True, sharex=False,
- title='Novel')
-
- plt.tight_layout()
- plotter.pages.savefig()
-
- # Plot individual panels:
-
- total.plot(kind='barh', subplots=True, legend=False, sharex=False)
- plt.tight_layout()
- plotter.pages.savefig()
-
- stats.plot(kind='barh', subplots=True, legend=False, sharex=False)
- plt.tight_layout()
- plotter.pages.savefig()
-
- match.plot(kind='barh', subplots=True, legend=False)
- plt.tight_layout()
- plotter.pages.savefig()
-
- miss.plot(kind='barh', subplots=True, legend=False, sharex=False)
- plt.tight_layout()
- plotter.pages.savefig()
-
- novel.plot(kind='barh', subplots=True, legend=False, sharex=False)
- plt.tight_layout()
- plotter.pages.savefig()
-
- def fix_names(s):
- """Map trancript classification codes."""
- names = {
- '=': 'ExactMatch:=',
- 'c': 'Contained:c',
- 'k': 'ReverseContained:k',
- 'm': 'RetainedIntron:m',
- 'n': 'PartRetainedIntron:n',
- 'j': 'PartialMatch:j',
- 'e': 'TransFragMatch:e',
- 's': 'OppositeMatch:s',
- 'o': 'OtherSameStrand:o',
- 'x': 'ExonicOpposite:o',
- 'y': 'RefInIntrons:y',
- 'p': 'PolymeraseRunon:p',
- 'r': 'Repeat:r',
- 'u': 'Intergenic:u',
- 'i': 'FullyIntronic:i',
- }
- return names[s]
-
- # Plot overlaps panel:
- tracking.Overlaps = tracking.Overlaps.apply(fix_names)
- tracking = tracking.set_index("Overlaps")
- tracking.plot(kind='bar', title="Overlaps detected by gffcompare",
- colormap='Paired')
- plt.tight_layout()
- plotter.pages.savefig()
- tracking["Percent"] = tracking.Count * 100 / tracking.Count.sum()
- tracking[["Percent"]].plot(
- kind='bar', title="Overlaps detected by gffcompare", colormap='Paired')
- plt.tight_layout()
- plotter.pages.savefig()
-
- plotter.close()
diff --git a/bin/report.py b/bin/report.py
index 163a25b..227eb1c 100755
--- a/bin/report.py
+++ b/bin/report.py
@@ -2,7 +2,10 @@
"""Create workflow report."""
import argparse
-from collections import OrderedDict
+from collections import Counter, defaultdict, OrderedDict
+from functools import reduce
+import math
+from pathlib import Path
from aplanat import bars, lines
from aplanat.components import fastcat
@@ -10,12 +13,65 @@ from aplanat.components import simple as scomponents
from aplanat.report import WFReport
from aplanat.util import Colors
from bokeh.layouts import gridplot
-from bokeh.models import ColumnDataSource
+from bokeh.models import ColumnDataSource, Panel, Tabs
+from bokeh.models.widgets import DataTable, TableColumn
from bokeh.palettes import Category10_10
from bokeh.plotting import figure
from bokeh.transform import dodge
+import gffutils
+from jinja2 import Template
import numpy as np
import pandas as pd
+import sigfig
+
+
+def _vbar(x, top, title, **kwargs):
+ """Vertical bar chart."""
+ fig = figure(title=title)
+
+ fig.vbar(x, top=top, **kwargs)
+ return fig
+
+
+def _hbar(y, right, title='', fig_height=300, fig_width=300,
+ bar_height=0.1, **kwargs):
+ """Horizontal bar chart."""
+ fig = figure(title=title, height=fig_height, width=fig_width)
+ yn = list(range(len(y)))
+ fig.hbar(yn,
+ right=right,
+ height=bar_height,
+ **kwargs)
+ # Overide the numerical labels with cate
+ fig.yaxis.ticker = yn
+ mapper = {k: v for (k, v) in zip(yn, y)}
+ fig.yaxis.major_label_overrides = mapper
+
+ return fig
+
+
+class Table:
+ """A table report component.
+
+ Adapted from aplanat
+ """
+
+ def __init__(self, template, data_frame, index, table_id, **kwargs):
+ """Initialize table component.
+
+ :param dataframe: dataframe to turn in to simple table.
+ """
+ template = Template(template)
+
+ for key, val in kwargs.items():
+ if isinstance(val, bool):
+ kwargs[key] = str(val).lower()
+
+ self.div = template.render(dataframe=data_frame.to_html(
+ table_id=table_id,
+ index=index),
+ table_id=table_id,
+ kwargs=kwargs)
def simple_hbar(df, y, right, title="", color=Colors.cerulean,
@@ -25,14 +81,11 @@ def simple_hbar(df, y, right, title="", color=Colors.cerulean,
:param groups: the grouping variable (the x-axis values).
:param values: the data for bars are drawn (the y-axis values).
:param kwargs: kwargs for bokeh figure.
-
- Move to planat when it's working?
"""
defaults = {
'output_backend': 'webgl',
- 'plot_height': 300, 'plot_width': 600}
+ 'height': 300, 'width': 600}
defaults.update(fig_kwargs)
-
p = figure(y_range=df[y], height=250, title=title,
toolbar_location=None, tools="")
@@ -224,15 +277,15 @@ def parse_gffcmp_stats(txt):
df_miss = pd.DataFrame(
OrderedDict(
- [('Total', missed_total),
- ('Missed', missed),
- ('Percent missed', missed_percent)]), index=missed_level)
+ [('Total', missed_total),
+ ('Missed', missed),
+ ('Percent missed', missed_percent)]), index=missed_level)
df_novel = pd.DataFrame(
OrderedDict(
- [('Total', novel_total),
- ('Novel', novel),
- ('Percent novel', novel_percent)]), index=novel_level)
+ [('Total', novel_total),
+ ('Novel', novel),
+ ('Percent novel', novel_percent)]), index=novel_level)
df_total = pd.DataFrame(OrderedDict(
[('Loci', total_loci), ('Transcripts', total_transcripts),
@@ -241,7 +294,7 @@ def parse_gffcmp_stats(txt):
return df_stats, df_match, df_miss, df_novel, df_total
-def grouped_bar(df, title=""):
+def grouped_bar(df, title="", tilted_xlabs=False):
"""Create grouped bar plot from pandas dataframe.
:param pandas.DataFrame
@@ -268,14 +321,17 @@ def grouped_bar(df, title=""):
# https://docs.bokeh.org/en/latest/docs/user_guide/categorical.html
dodge_range = (-0.25, 0.25)
current_dodge = dodge_range[0]
- dodge_increment = abs(dodge_range[0] - dodge_range[1]) \
- / (len(df.columns) - 1)
+ dodge_increment = abs(dodge_range[0] - dodge_range[1]) / \
+ (len(df.columns) - 1)
+
+ if tilted_xlabs:
+ p.xaxis.major_label_orientation = math.pi / 4
for col in df.columns:
num_colors = df.shape[1] - 1
colors = list(zip(*[[Category10_10[x]] * (len(df.columns) - 1)
- for x in range(num_colors)]))
+ for x in range(num_colors)]))
colors = [item for sublist in colors for item in sublist]
if col == 'x_groups':
@@ -285,7 +341,8 @@ def grouped_bar(df, title=""):
width = df.size / 60
p.vbar(x=dodge('x_groups', current_dodge, range=p.x_range), top=col,
- width=width, source=source, color=color, legend_label=col)
+ width=width, source=source, color=color,
+ legend_label=col)
current_dodge += dodge_increment
p.x_range.range_padding = 0.1
@@ -295,53 +352,22 @@ def grouped_bar(df, title=""):
return p
-def workflow_plots(report, df_aln_stats_file,
- gff_cmp_stats_file, gff_cmp_tracking_file):
+def gff_compare_plots(report, gffcompare_outdirs: Path, sample_ids):
"""Create various sections and plots in a WfReport.
:param report: aplanat WFReport
- :param df_aln_stats_file: alignment stats. Output of `seqkit bam -s`
- :param gff_cmp_stats_file: gffcompare stats file
- :param gff_cmp_tracking_file: gffcompare tracking file
+ :param gffcompare_outdirs: List of output directories from run_gffcompare
:return: None
+
+ TODO: split this into separate functions
"""
- df_aln_stats = pd.read_csv(df_aln_stats_file, sep='\t')
- df_aln_stats = df_aln_stats.select_dtypes([np.number]).dropna(axis=1)
-
- section = report.add_section()
- section.markdown('''
- ### Read mapping summary
-
- Output of [seqkit](https://bioinf.shenwei.me/seqkit/) bam -s''')
-
- section.table(df_aln_stats)
-
- # Percentage primary and secondary mapping
- df_perc = df_aln_stats[['PrimAlnPerc', 'MultimapPerc']]
- bar_perc = bars.simple_bar(
- df_perc.columns.values, df_perc.iloc[0].values,
- title='% primary and multimapping reads', colors=Colors.cerulean)
-
- # Counts of read mapping class
- df_counts = df_aln_stats.drop(columns=['PrimAlnPerc', 'MultimapPerc'])
- bar_counts = bars.simple_bar(
- df_counts.columns.values, df_counts.iloc[0].values,
- title='Number of alignment records', colors=Colors.cerulean
- )
-
- grid = gridplot([bar_perc, bar_counts], ncols=2,
- plot_width=400, plot_height=400)
- section.plot(grid)
-
- # If gffcompare has not been run, finish report here
- if not gff_cmp_stats_file or not gff_cmp_tracking_file:
+ # If any of the gffcompare dirs are empty, skip this section
+ if not all([any(Path(x).iterdir()) for x in gffcompare_outdirs]):
return
- stats, _, miss, novel, total = \
- parse_gffcmp_stats(gff_cmp_stats_file)
-
# Plot overview panel:
section = report.add_section()
+ # TODO: update this based on current version
section.markdown('''
### Annotation summary
@@ -365,42 +391,47 @@ def workflow_plots(report, df_aln_stats_file,
Features present in the query transcripts, but absent in the reference
''')
- bar_totals = grouped_bar(total, title="Totals")
- bar_performance = grouped_bar(stats, title="Performance")
- bar_missed = grouped_bar(miss, title="Missed")
- bar_novel = grouped_bar(novel, title="Novel")
+ for id_, dir_ in zip(sample_ids, gffcompare_outdirs):
+ stats, _, miss, novel, total = \
+ parse_gffcmp_stats(dir_ / 'str_merged.stats')
- grid = gridplot([bar_totals, bar_performance, bar_missed, bar_novel],
- ncols=2, plot_width=400, plot_height=400)
- section.plot(grid)
+ bar_totals = grouped_bar(total, title="Totals")
+ bar_performance = grouped_bar(stats, title="Performance",
+ tilted_xlabs=True)
+ bar_missed = grouped_bar(miss, title="Missed")
+ bar_novel = grouped_bar(novel, title="Novel")
- def fix_names(s):
- """Map trancript classification codes."""
- names = {
- '=': 'ExactMatch:=',
- 'c': 'Contained:c',
- 'k': 'ReverseContained:k',
- 'm': 'RetainedIntron:m',
- 'n': 'PartRetainedIntron:n',
- 'j': 'PartialMatch:j',
- 'e': 'TransFragMatch:e',
- 's': 'OppositeMatch:s',
- 'o': 'OtherSameStrand:o',
- 'x': 'ExonicOpposite:o',
- 'y': 'RefInIntrons:y',
- 'p': 'PolymeraseRunon:p',
- 'r': 'Repeat:r',
- 'u': 'Intergenic:u',
- 'i': 'FullyIntronic:i',
- }
- return names[s]
+ grid = gridplot([bar_totals, bar_performance, bar_missed, bar_novel],
+ ncols=4, width=270, height=280)
+ section.markdown("""
+ #### Sample_id: {}
+ """.format(id_))
+ section.plot(grid)
+
+ names = {
+ '=': 'ExactMatch:=',
+ 'c': 'Contained:c',
+ 'k': 'ReverseContained:k',
+ 'm': 'RetainedIntron:m',
+ 'n': 'PartRetainedIntron:n',
+ 'j': 'PartialMatch:j',
+ 'e': 'TransFragMatch:e',
+ 's': 'OppositeMatch:s',
+ 'o': 'OtherSameStrand:o',
+ 'x': 'ExonicOpposite:o',
+ 'y': 'RefInIntrons:y',
+ 'p': 'PolymeraseRunon:p',
+ 'r': 'Repeat:r',
+ 'u': 'Intergenic:u',
+ 'i': 'FullyIntronic:i',
+ }
# Plot overlaps panel:
section = report.add_section()
section.markdown('''
- ## Query transfrag class assignments
+ ### Query transfrag class assignments
- The classes that are assinged by
+ The classes that are assigned by
[gffcompare](https://ccb.jhu.edu/software/stringtie/gffcompare.shtml),
which describe the relationship between query transfrag and the most
similar reference transcript.
@@ -409,112 +440,406 @@ def workflow_plots(report, df_aln_stats_file,
gffcompare_codes.png) illustrates the different classes.
''')
- tracking = pd.read_csv(gff_cmp_tracking_file, sep="\t", header=None,
- usecols=[0, 3], names=['Count', 'Overlaps'])
+ tracking_dfs = []
- tracking = tracking.groupby("Overlaps").count().reset_index()
- tracking = tracking.sort_values("Overlaps")
- tracking.Overlaps = tracking.Overlaps.apply(fix_names)
- tracking["Percent"] = tracking.Count * 100 / tracking.Count.sum()
+ print(gffcompare_outdirs)
+ track_files = [x / 'str_merged.tracking' for x in gffcompare_outdirs]
- tracking_bar = simple_hbar(
- tracking, 'Overlaps', 'Count', title="totals")
- tracking_bar_perc = simple_hbar(
- tracking, 'Overlaps', 'Percent', title='percent'
- )
+ df_tracking = load_sample_data(track_files, sample_ids,
+ read_func=lambda x:
+ pd.read_csv(x, sep="\t", header=None,
+ usecols=[0, 3],
+ names=['Count', 'Overlaps']))
- grid = gridplot([tracking_bar, tracking_bar_perc], ncols=2,
- plot_width=400, plot_height=400)
- section.plot(grid)
+ tabs = []
+ for id_, df_track in df_tracking.groupby('sample_id'):
+ tracking = df_track.groupby("Overlaps").count().reset_index()
+ tracking.Overlaps = tracking.Overlaps.map(names)
+ tracking['Percent'] = tracking.Count * 100 / tracking.Count.sum()
+ tracking = tracking.sort_values("Overlaps")
+ track_bar = _hbar(
+ tracking['Overlaps'].values.tolist(),
+ tracking['Percent'].values.tolist(), title="{}".format(id_))
+
+ # Edit for creating unified table
+ tracking.rename(columns={'sample_id': id_ + ' count'}, inplace=True)
+ tracking.drop(columns=['Count'], inplace=True)
+ tracking_dfs.append(tracking)
+
+ # section.plot(grid)
+
+ # Tracking table
+ df_class_table = reduce(
+ lambda left, right: pd.merge(left, right), tracking_dfs)
+
+ desc = pd.Series(df_class_table.Overlaps.apply(
+ lambda x: x.split(':')[0]))
+ df_class_table.insert(0, 'description', desc)
+
+ code = pd.Series(df_class_table.Overlaps.apply(
+ lambda x: x.split(':')[1]))
+ df_class_table.insert(0, 'code', code)
+
+ cols = [TableColumn(field=Ci, title=Ci, width=100)
+ for Ci in df_class_table.columns]
+ track_table = DataTable(columns=cols,
+ source=ColumnDataSource(df_class_table),
+ index_position=None,
+ width=500)
+
+ df_class_table.drop(columns=['Overlaps'], inplace=True)
+ tabs.append(Panel(
+ child=gridplot([track_bar, track_table], ncols=2), title=id_)
+ )
+
+ cover_panel = Tabs(tabs=tabs)
+ section.plot(cover_panel)
+
+ def plot_isoforms_per_tpm_bin(df_code, class_code, sample_id,
+ geomspace=False):
+ """Make plots of number of isoforms per TPM coverage bin."""
+ max_ = int(sigfig.round(df_code.TPM.max(), 2))
+ if geomspace:
+ bins = [math.ceil(x) for x in
+ np.geomspace(10, max_, num=15)]
+ else:
+ bins = np.linspace(10, max_, 15)
+ bins = np.unique(bins) # Low max_ can end up with duplicated bins
+
+ groups = pd.cut(df_code.TPM, bins).value_counts()
+ df_code.to_csv('dfcode.csv')
+ df_temp = pd.DataFrame.from_dict(dict(
+ x=[x.mid for x in groups.index], y=groups.values
+ ))
+ df_temp.sort_values(by='x', inplace=True)
+
+ x = [str(math.ceil(x)) for x in df_temp.x]
+ y = df_temp.y
+
+ reads_per_iso_geom = \
+ bars.simple_bar(x, y,
+ title="{} - Num isoforms/TPM bin - "
+ "gffcompare class code - '{}'".format(
+ sample_id, class_code),
+ colors=Colors.cerulean,
+ x_axis_label='TPM',
+ y_axis_label='Number of isoforms')
+
+ reads_per_iso_geom.xaxis.major_label_orientation = math.pi / 2.8
+ return reads_per_iso_geom
+
+ log_plots = defaultdict(list)
+
+ try:
+ tmap_files = [next(x.glob('*.tmap')) for x in gffcompare_outdirs]
+ except StopIteration:
+ print("Cannot find .tmap files in {}".format(gffcompare_outdirs))
+ return
+
+ df_tmap = load_sample_data(tmap_files, sample_ids)
+
+ for id_, df in df_tmap.groupby('sample_id'):
+
+ log_plots[id_].append(plot_isoforms_per_tpm_bin(
+ df, 'all', id_, geomspace=True))
+
+ for class_code, df_code in df.groupby('class_code'):
+ log_plots[id_].append(plot_isoforms_per_tpm_bin(
+ df_code, class_code, id_, geomspace=True))
+
+ tabs = []
+ for id_, sample_plots in log_plots.items():
+ tabs.append(Panel(
+ child=gridplot(sample_plots, ncols=2), title=id_))
+
+ section.markdown('''
+ ### Read coverage by gffcompare transfrag class''')
+ cover_panel = Tabs(tabs=tabs)
+ section.plot(cover_panel)
+
+ return df_tmap
def pychopper_plots(report, df):
"""Make plots from pychopper.cdna_classifier.py.
:param report: aplanat WFReport
- :param df: result DataFrame
+ :param df: DataFrame of Pychopper stats
"""
section = report.add_section()
section.markdown('''
- ### pychopper summary statisitcs
+ ### Pychopper summary statisitcs
The following plots summarize the output of [cdna_classifier.py]
(https://github.com/nanoporetech/pychopper)
- * **Classification of output reads**:
- * Primers_found: Reads with primers found in correct orientation at
- both ends.
- * Rescue: Reads 'rescued' from fused reads
- * Unusable: Read with missing or incorrect primer orientation
- * **Strand of oriented reads**:
- * Strand of read relative to the mRNA
- * **Strand of rescued read**:
- * Strand of read that were rescued from fused reads
- * **Number of primer alignment hits in unclassified reads**:
- * Note: Need to look into what this means
- * **Number of primer alignment hits in rescued reads**:
- * Note: Need to look into what this means
- * **Number of usable segments per rescued read**:
- * Number of usable segments (primer-flanked, correctly oriented
- regions) per fused read.
- * **Usable bases as a function of cutoff**:
- * The cutoff value supplied to the primer alignment tool.
- Note: What are usabel bases in this conext
- * ** Log10 length distribution of trimmed away sequences**:
- * todo
+ * **Pr.found**: Reads with primers found in correct orientation at
+ both ends.
+ * **Resc**: Reads 'rescued' from fused reads
+ * **Unusable**: Read with missing or incorrect primer orientation
+ * **+/-**: Orientation of reads relative to the mRNA
''')
- def g(df, index, title):
- df_ = df[df.index == index]
- groups = df_.Name.values
- bar_ = bars.simple_bar(
- groups, df_['Value'].values,
- title=title, colors=Colors.cerulean)
- return bar_
+ plots = []
+ for id_, df in df.groupby('sample_id'):
+ df1 = df.set_index('Name', drop=True)
+ df1 = df1.T[['Primers_found', 'Rescue', 'Unusable']]
+ df1.rename(columns={'Primers_found': 'Pr.found',
+ 'Rescue': 'Resc',
+ 'Unsable': 'Un'}, inplace=True)
+ df2 = df[df.index == 'Strand']
+ bar_chop = bars.simple_bar(
+ df1.columns.values.tolist() + df2.Name.values.tolist(),
+ df1.iloc[0].values.tolist() + df2.Value.values.tolist(),
+ title='{} - Pychopper stats'.format(id_),
+ colors=Colors.cerulean)
- df1 = df.set_index('Name', drop=True)
- df1 = df1.T[['Primers_found', 'Rescue', 'Unusable']]
- bar_class = bars.simple_bar(
- df1.columns.values, df1.iloc[0].values,
- title='Classification of output reads', colors=Colors.cerulean)
-
- plots = [
- bar_class,
- g(df, 'Strand', 'Strand of oriented read'),
- g(df, 'RescueStrand', 'Strand of rescued reads'),
- g(df, 'UnclassHitNr', 'Number of hits in unclassified reads'),
- g(df, 'RescueHitNr', 'Number of hits in rescued reads'),
- g(df, 'RescueSegmentNr', 'Number of usable segments per rescued read')
- ]
-
- q = round(df.loc['Parameter', 'Value'], 4)
- df_at = df[df.index == 'AutotuneSample'].astype('float')
-
- # Add vertical line at x=q
- ymin, ymax = df_at['Value'].min(), df_at['Value'].max()
- plots.append(lines.line([df_at['Name'].values.tolist(), [q, q]],
- [df_at['Value'].values.tolist(), [ymin, ymax]],
- title=("Usable bases as function of cutoff(q).Best"
- " q={}").format(q), colors=['blue', 'red']
- ))
- df_unusable = df[df.index == 'Unusable'].astype('float')
-
- plots.append(lines.line([np.log10(1 + df_unusable['Name'])],
- [df_unusable['Value']],
- title=("Log10 length distribution of trimmed away"
- " sequences.")
- ))
-
- grid = gridplot(plots, ncols=2,
- plot_width=400, plot_height=400)
+ plots.extend([bar_chop])
+ grid = gridplot(plots, ncols=4,
+ width=300, height=300)
section.plot(grid)
+def cluster_quality(cluster_qc_dir, report, sample_ids):
+ """Make cluster quality section."""
+ section = report.add_section()
+ section.markdown('''
+ ### De novo clustering quality
+
+ This section shows plots relating to the clustering quality performed
+ by isONclust2. The full length reads are mapped to a reference genome
+ to create a ground truth of reads mapped to clusters. This is then compared
+ to the de novo-generated clusters, and the following statistics are
+ generated.
+
+ * [Homogeneity](https://scikit-learn.org/stable/modules/generated/
+ sklearn.metrics.homogeneity_score.html): Penalises over-clustering.
+
+ * [Completeness](https://scikit-learn.org/stable/modules/generated/
+ sklearn.metrics.completeness_score.html): Penalises under-clustering.
+
+ * [V-measure](https://clusteringjl.readthedocs.io/en/latest/vmeasure.html):
+ The harmonic mean of the homogeneity and completeness
+
+ * [Adjusted Rand Index](https://scikit-learn.org/stable/modules/generated/
+ sklearn.metrics.adjusted_rand_score.html): Intuitively, measures the
+ percentage of read pairs correctly clustered, normalized so that a perfect
+ clustering = 1 and a random cluster assignment achieves = 0
+
+ * NonSingleton: Clusters with multiple reads
+ * Singleton: Clusters consisting of a single read (These do not contribute
+ to the final transcript calling - I need to check this!)
+
+ ''')
+
+ tabs = []
+ for id_, cluster_dir in zip(sample_ids, cluster_qc_dir):
+ plots = []
+ for fn in [
+ 'v_ari_com_hom.csv', 'sing_nonsing.csv']:
+ # Skip the next two plots for now
+ # 'class_sizes1.csv', 'class_sizes2.csv']:
+ df = pd.read_csv(Path(cluster_dir) / fn)
+ bar = bars.simple_bar(
+ df.Statistic.values.tolist(), df.Value.values.tolist(),
+ colors=Colors.cerulean
+ )
+ bar.xaxis.major_label_orientation = math.pi / 2.8
+ plots.append(bar)
+ tabs.append(Panel(
+ child=gridplot(plots, ncols=4,
+ width=300, height=300), title=id_))
+
+ cover_panel = Tabs(tabs=tabs)
+ section.plot(cover_panel)
+
+
+def transcript_table(report, df_tmaps, covr_threshold, table_template):
+ """Create searchable table of transcripts."""
+ section = report.add_section()
+
+ # Should we put data from each sample into it's own table or have it
+ # all in single table and sample_id column? Currently it's the latter
+
+ # drop some columns for the big table and do some filtering
+ df = df_tmaps.drop(columns=['FPKM', 'qry_gene_id', 'major_iso_id',
+ 'ref_match_len', 'TPM'])
+ df.sort_values('cov', ascending=True, inplace=True)
+ counts = list(range(len(df)))
+
+ # Keep Isoforms with coverage > threshold
+ vline_x = np.argmax(df['cov'] > covr_threshold)
+ vline_y = [0, df['cov'].max()]
+
+ cov_plt = lines.line(
+ [counts, [vline_x, vline_x]], # x-values
+ [df['cov'].values.tolist(), vline_y], # y-values
+ title=(
+ "Read Coverage. Threshold = {}x coverage".format(
+ covr_threshold)
+ ), x_axis_label='Num Isoforms',
+ y_axis_label='Coverage',
+ colors=['blue', 'red'])
+
+ section.markdown('''
+ ### Query transcript table
+
+ Low coverage transcripts are removed to speed up the table viewing.
+ This can be set with the parameter `args.min_isoform_cov` in the config.
+ ''')
+ section.plot(cov_plt)
+ # Filter on converge threshold
+ df = df[df['cov'] >= covr_threshold]
+
+ # Make a column of number of isoforms in parent gene
+ gb = df.groupby(['ref_gene_id', 'sample_id']).count()
+ # gb = gb.set_index(['ref_gene_id', 'sample_id'])
+ gb.rename(columns={'ref_id': 'num_isoforms'}, inplace=True)
+
+ df['parent gene iso num'] = df.apply(
+ lambda x: gb.loc[(x.ref_gene_id, x.sample_id), 'num_isoforms'], axis=1)
+
+ df.sort_values('parent gene iso num', inplace=True, ascending=True)
+
+ with open(table_template, 'r') as fh:
+ tabletempl = fh.read()
+
+ bigtable = Table(tabletempl, df, index=False, table_id='bigtable')
+ section._add_item(bigtable.div)
+
+
+def tanscriptome_summary(report, gffs, sample_ids, denovo=False):
+ """
+ Plot transcriptome summaries.
+
+ Some of this data is available via gffcompare output, but the de novo
+ pipeline skips that, so we do it al here.
+
+ We do not report exon number for the denovo assembly yet. This is because
+ in this case, the gff annotation is generated by aligning to the CDS not
+ the genome.
+
+ :param report: aplanat WFReport
+ :param gffs: list of paths to gff transcriptome annotations
+ :param sample_ids: list of sample ids
+ :param denovo: whether annotation was generated by de novo pipeline or not
+ """
+ # test.db gets written to the git repo.
+ section = report.add_section()
+ section.markdown('''
+ ### Transcriptome summary
+ ''')
+
+ tabs = []
+ for id_, gff in zip(sample_ids, gffs):
+
+ plots = []
+
+ db = gffutils.create_db(
+ gff, dbfn=':memory:', force=True, keep_order=True,
+ merge_strategy='merge', sort_attribute_values=True
+ )
+
+ num_transcripts = db.count_features_of_type('transcript')
+ num_genes = db.count_features_of_type('gene')
+
+ transcript_lens = []
+ exons_per_transcript = Counter()
+ isoforms_per_gene = []
+
+ for g in db.features_of_type('gene'):
+
+ n_isos = len(list(db.children(g, featuretype='transcript')))
+ isoforms_per_gene.append(n_isos)
+
+ for t in db.children(
+ g, featuretype='transcript', order_by='start'):
+ tr_len = 0
+ exons = list(enumerate(db.children(t, featuretype='exon')))
+ if len(exons) == 0:
+ continue
+ for nx, ex in exons:
+ tr_len += abs(ex.end - ex.start)
+
+ exons_per_transcript[nx] += 1
+
+ transcript_lens.append(tr_len)
+
+ num_bins = max(isoforms_per_gene)
+ if num_bins > 12:
+ num_bins = 12
+
+ hist_, bins = np.histogram(isoforms_per_gene, bins=num_bins)
+
+ bar_isos = bars.simple_bar([str(round(x_, 1)) for x_ in bins], hist_,
+ title="Isoforms per gene",
+ colors=Colors.cerulean,
+ x_axis_label='Num. isoforms',
+ y_axis_label='Num. genes')
+
+ bar_isos.xaxis.major_label_orientation = math.pi / 2.8
+ plots.append(bar_isos)
+
+ box = bars.boxplot_series(
+ [id_] * len(transcript_lens), transcript_lens,
+ width=70, ylim=(min(transcript_lens), max(transcript_lens)),
+ title='transcript lengths')
+ plots.append(box)
+
+ if not denovo:
+ x, y = zip(*sorted(exons_per_transcript.items()))
+
+ ept_bar = _vbar(x, list(y),
+ title="Exons per transcript",
+ color=Colors.cerulean)
+
+ ept_bar.xaxis.major_label_orientation = math.pi / 2.8
+ plots.append(ept_bar)
+
+ df_sum = pd.DataFrame.from_dict(
+ {'Total genes': [num_genes],
+ 'Total transcripts': [num_transcripts],
+ 'Max trans. len': max(transcript_lens),
+ 'Min trans. len': min(transcript_lens)}).T
+ df_sum.reset_index(drop=False, inplace=True)
+
+ df_sum.columns = [' ', 'count']
+ cols = [TableColumn(field=Ci, title=Ci, width=80)
+ for Ci in df_sum.columns]
+ data_table = DataTable(columns=cols,
+ source=ColumnDataSource(df_sum),
+ index_position=None,
+ width=180)
+ plots.append(data_table)
+
+ tabs.append(Panel(
+ child=gridplot(plots, ncols=4,
+ width=300, height=300), title=id_))
+
+ cover_panel = Tabs(tabs=tabs)
+ section.plot(cover_panel)
+
+
+def load_sample_data(files, sample_ids, read_func=None):
+ """Load CSVs into dataframe, and assign sample_id column."""
+ df_ = pd.DataFrame()
+ if not files:
+ return None
+ for id_, x in zip(sample_ids, files):
+ if read_func:
+ d = read_func(x)
+ else:
+ d = pd.read_csv(x, sep='\t+')
+ d['sample_id'] = id_
+ df_ = pd.concat([df_, d])
+ return df_
+
+
def main():
"""Run the entry point."""
parser = argparse.ArgumentParser()
- parser.add_argument("report", help="Report output file")
- parser.add_argument("summaries", nargs='+', help="Read summary file.")
+ parser.add_argument("--report", help="Report output file")
+ parser.add_argument("--summaries", nargs='+', help="Read summary file.")
parser.add_argument(
"--versions", required=True,
help="directory containing CSVs containing name,version.")
@@ -529,36 +854,95 @@ def main():
help="git commit of the executed workflow")
parser.add_argument(
- "--alignment_stats", required=True,
- help="TSV summary file of alignment statistics")
-
- parser.add_argument(
- "--gffcompare_tracking", required=False, default=None,
+ "--alignment_stats", required=False, default=None, nargs='*',
help="TSV summary file of alignment statistics")
parser.add_argument(
- "--gffcompare_stats", required=False, default=None,
- help="TSV summary file of alignment statistics")
-
+ "--gff_annotation", required=True, nargs='+',
+ help="transcriptome annotation gff file")
parser.add_argument(
- "--pychop_report", required=True,
+ "--gffcompare_dir", required=False, default=None, nargs='*',
+ help="gffcompare outout dir")
+ parser.add_argument(
+ "--pychop_report", required=True, nargs='+',
help="TSV summary file of pychopper statistics")
+ parser.add_argument(
+ "--sample_ids", required=True, nargs='+',
+ help="List of sample ids")
+ parser.add_argument(
+ "--report_template", required=True,
+ help="Jinja template")
+ parser.add_argument(
+ "--table_template", required=True,
+ help="Template for big transcript table")
+ parser.add_argument(
+ "--transcript_table_cov_thresh", required=False, type=int, default=50,
+ help="Isoforms without this support will be excluded from the table")
+ parser.add_argument(
+ "--cluster_qc_dirs", required=False, type=str, default=None, nargs='*',
+ help="Directory with various cluster quality csvs")
+ parser.add_argument('--denovo', dest='denovo', action='store_true')
+
args = parser.parse_args()
+ print('denovo', args.denovo)
+
+ sample_ids = args.sample_ids
report = WFReport(
- "Workflow for assembling transcript isoforms", "wf-isoforms",
+ "Transcript isoform report", "wf-isoforms",
revision=args.revision, commit=args.commit)
# Add reads summary section
- report.add_section(
- section=fastcat.full_report(args.summaries))
+ for id_, summ in zip(sample_ids, args.summaries):
+ report.add_section(
+ section=fastcat.full_report(
+ [summ],
+ header='#### Read stats: {}'.format(id_)
+ ))
+
+ if args.alignment_stats is not None:
+ df_aln_stats = load_sample_data(args.alignment_stats, sample_ids)
+ section = report.add_section()
+ section.markdown('''
+ ### Read mapping summary
+
+ Output of [seqkit](https://bioinf.shenwei.me/seqkit/) bam -s''')
+
+ section.table(df_aln_stats)
# workflow-specific plotting
- workflow_plots(report, args.alignment_stats, args.gffcompare_stats,
- args.gffcompare_tracking)
+ tanscriptome_summary(report, args.gff_annotation, sample_ids,
+ denovo=args.denovo)
- if args.pychop_report:
- df_chop_stats = pd.read_csv(args.pychop_report, sep='\t', index_col=0)
- pychopper_plots(report, df_chop_stats)
+ df_tmaps = gff_compare_plots(
+ report,
+ [Path(x) for x in args.gffcompare_dir],
+ sample_ids)
+
+ report.write(args.report)
+
+ pc_df = pd.DataFrame()
+ for id_, pyc in zip(sample_ids, args.pychop_report):
+ try:
+ p = pd.read_csv(pyc, sep='\t', index_col=0)
+ except pd.errors.EmptyDataError:
+ continue
+ p['sample_id'] = id_
+ pc_df = pd.concat([pc_df, p])
+
+ if len(pc_df) > 0:
+ pychopper_plots(report, pc_df)
+
+ with open(args.report_template, "r") as fh:
+ reptempl = fh.read()
+
+ report.template = Template(reptempl)
+
+ if df_tmaps is not None:
+ transcript_table(report, df_tmaps, args.transcript_table_cov_thresh,
+ args.table_template)
+
+ if args.cluster_qc_dirs is not None:
+ cluster_quality(args.cluster_qc_dirs, report, sample_ids)
# Arguments and software versions
report.add_section(
@@ -566,7 +950,6 @@ def main():
report.add_section(
section=scomponents.params_table(args.params))
- # write report
report.write(args.report)
diff --git a/bin/report_template.html b/bin/report_template.html
new file mode 100755
index 0000000..57fe286
--- /dev/null
+++ b/bin/report_template.html
@@ -0,0 +1,42 @@
+
+
+
{{ lead }} + {{ div }} +