Merge branch 'bokehreport_CW-3077' into 'dev'
Update report CW-3077 See merge request epi2melabs/workflows/wf-transcriptomes!168
This commit is contained in:
commit
45a717aaf3
@ -4,6 +4,14 @@ 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.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
### Updated
|
||||
- Workflow report updated to use `ezcharts`.
|
||||
### Fixed
|
||||
- Exons per isoforms histogram reporting incorrect numbers.
|
||||
### Removed
|
||||
- per-class gffcompare tracking files as there exists a combine tracking file.
|
||||
|
||||
## [v1.4.0]
|
||||
## Added
|
||||
- `--igv` parameter (default: false) for outputting IGV config allowing visualisation of read alignments in the EPI2ME App.
|
||||
|
||||
14
README.md
14
README.md
@ -210,13 +210,13 @@ Output files may be aggregated including information for all samples or provided
|
||||
| Title | File path | Description | Per sample or aggregated |
|
||||
|-------|-----------|-------------|--------------------------|
|
||||
| workflow report | wf-transcriptomes-report.html | a HTML report document detailing the primary findings of the workflow | aggregated |
|
||||
| Per file read stats | fastq_ingress_results/reads/fastcat_stats/per-file-stats.tsv | A TSV with per file read stats, including all samples. | aggregated |
|
||||
| Read stats | fastq_ingress_results/reads/fastcat_stats/per-read-stats.tsv | A TSV with per read stats, including all samples. | aggregated |
|
||||
| Run ID's | fastq_ingress_results/reads/fastcat_stats/run_ids | List of run IDs present in reads. | aggregated |
|
||||
| Meta map json | fastq_ingress_results/reads/metamap.json | Metadata used in workflow presented in a JSON. | aggregated |
|
||||
| Concatenated sequence data | fastq_ingress_results/reads/{{ alias }}.fastq.gz | Per sample reads concatenated in to one FASTQ file. | per-sample |
|
||||
| Assembled transcriptome | {{ alias }}_transcriptome.fas | Per sample assembled transcriptome. | per-sample |
|
||||
| Annotated assembled transcriptome | {{ alias }}_merged_transcriptome.fas | Per sample annotated assembled transcriptome. | per-sample |
|
||||
| Per file read stats | fastq_ingress_results/{{ alias }}//reads/fastcat_stats/per-file-stats.tsv | A TSV with per file read stats, including all samples. | aggregated |
|
||||
| Read stats | fastq_ingress_results/{{ alias }}//reads/fastcat_stats/per-read-stats.tsv | A TSV with per read stats, including all samples. | aggregated |
|
||||
| Run ID's | fastq_ingress_results/{{ alias }}//reads/fastcat_stats/run_ids | List of run IDs present in reads. | aggregated |
|
||||
| Meta map json | fastq_ingress_results/{{ alias }}//reads/metamap.json | Metadata used in workflow presented in a JSON. | aggregated |
|
||||
| Concatenated sequence data | fastq_ingress_results/{{ alias }}//reads/{{ alias }}.fastq.gz | Per sample reads concatenated in to one FASTQ file. | per-sample |
|
||||
| Assembled transcriptome | {{ alias }}_transcriptome.fas | Per sample assembled transcriptome. Not output if a reference annotation was supplied | per-sample |
|
||||
| Annotated assembled transcriptome | {{ alias }}_merged_transcriptome.fas | Per sample annotated assembled transcriptome. Only output if a reference annotation was supplied | per-sample |
|
||||
| Alignment summary statistics | {{ alias }}_read_aln_stats.tsv | Per sample alignment summary statistics. | per-sample |
|
||||
| GFF compare results. | {{ alias }}_gffcompare | All GFF compare output files. | per-sample |
|
||||
| Differential gene expression results | de_analysis/results_dge.tsv | This is a gene-level result file that describes genes and their probability of showing differential expression between experimental conditions. | aggregated |
|
||||
|
||||
@ -1,649 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""Generate cluster quality data."""
|
||||
|
||||
# Adapted form script by Kristoffer Sahlin for
|
||||
# isONclust: https://github.com/ksahlin/isONclust
|
||||
|
||||
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)
|
||||
|
||||
from .util import wf_parser, get_named_logger # noqa: ABS101
|
||||
|
||||
matplotlib.use('Agg')
|
||||
|
||||
logger = get_named_logger("clustqual")
|
||||
|
||||
|
||||
def argparser():
|
||||
"""Argument parser for entrypoint."""
|
||||
parser = wf_parser("compute_cluster_quality")
|
||||
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')
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
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
|
||||
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] = {}
|
||||
|
||||
# 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:
|
||||
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)
|
||||
logger.info("Not included in clustering but aligned:", len(not_clustered))
|
||||
logger.info(
|
||||
"v:",
|
||||
v_score,
|
||||
"Completeness:",
|
||||
compl_score,
|
||||
"Homogeneity:",
|
||||
homog_score)
|
||||
logger.info(
|
||||
"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])
|
||||
logger.info(
|
||||
"NONTRIvIAL CLASSES: v:",
|
||||
v_score,
|
||||
"Completeness:",
|
||||
compl_score,
|
||||
"Homogeneity:",
|
||||
homog_score)
|
||||
logger.info("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:
|
||||
logger.debug("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)
|
||||
logger.info(
|
||||
"NONTRIvIAL CLUSTERS: v:",
|
||||
v_score,
|
||||
"Completeness:",
|
||||
compl_score,
|
||||
"Homogeneity:",
|
||||
homog_score)
|
||||
logger.info(
|
||||
"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
|
||||
|
||||
logger.info("UNCLUSTERED:", "Tot classes:", len(not_clustered_classes))
|
||||
logger.info("CLUSTERED:", "Tot classes:", len(clustered_classes))
|
||||
logger.info("MIXED:", "Tot classes containing both:", len(
|
||||
set(clustered_classes.keys()) & set(not_clustered_classes.keys())))
|
||||
logger.info("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
|
||||
|
||||
logger.info(
|
||||
"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()
|
||||
@ -1,135 +1,313 @@
|
||||
"""Create an IGV config file."""
|
||||
|
||||
from itertools import zip_longest
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from .util import get_named_logger, wf_parser # noqa: ABS101
|
||||
|
||||
|
||||
def parse_fnames(fofn):
|
||||
"""Parse list with filenames and return them grouped as ref-, XAM-, or VCF-related.
|
||||
# Common variables
|
||||
REF_EXTENSIONS = [".fasta", ".fasta.gz", ".fa", ".fa.gz", ".fna", ".fna.gz"]
|
||||
DATA_TYPES_LISTS = {
|
||||
"bam": ["bam"],
|
||||
"bam_idx": ["bam.bai"],
|
||||
"cram": ["cram"],
|
||||
"cram_idx": ["cram.crai"],
|
||||
"vcf": ["vcf", "vcf.gz"],
|
||||
"vcf_idx": ["vcf.gz.tbi", "vcf.gz.csi"],
|
||||
"bcf": ["bcf"],
|
||||
"bcf_idx": ["bcf.csi"],
|
||||
"gtf": ["gtf", "gtf.gz"],
|
||||
"gtf_idx": ["gtf.gz.tbi"],
|
||||
"gff": ["gff", "gff.gz", "gff3", "gff3.gz"],
|
||||
"gff_idx": ["gff.gz.tbi", "gff3.gz.tbi"],
|
||||
"bed": ["bed", "bed.gz"],
|
||||
"bed_idx": ["bed.gz.tbi"],
|
||||
"bedmethyl": ["bedmethyl", "bedmethyl.gz"],
|
||||
"bedmethyl_idx": ["bedmethyl.gz.tbi"],
|
||||
"ref": REF_EXTENSIONS,
|
||||
}
|
||||
DATA_TYPES = {
|
||||
ext: ftype for ftype, extlist in DATA_TYPES_LISTS.items() for ext in extlist
|
||||
}
|
||||
|
||||
:param fofn: File with list of file names (one per line)
|
||||
:return: dict of reference-related filenames (with keys 'ref', 'fai', and '.gzi' and
|
||||
`None` as default values); lists of XAM- and VCF-related filenames
|
||||
"""
|
||||
ref_extensions = [".fasta", ".fasta.gz", ".fa", ".fa.gz", ".fna", ".fna.gz"]
|
||||
ref_dict = {}
|
||||
xams = []
|
||||
xam_indices = []
|
||||
vcfs = []
|
||||
vcf_indices = []
|
||||
with open(fofn, "r") as f:
|
||||
for line in f:
|
||||
fname = line.strip()
|
||||
if any(fname.endswith(ext) for ext in ref_extensions):
|
||||
ref_dict["ref"] = fname
|
||||
elif fname.endswith(".fai"):
|
||||
ref_dict["fai"] = fname
|
||||
elif fname.endswith(".gzi"):
|
||||
ref_dict["gzi"] = fname
|
||||
elif fname.endswith(".bam") or fname.endswith(".cram"):
|
||||
xams.append(fname)
|
||||
elif fname.endswith(".bai") or fname.endswith(".crai"):
|
||||
xam_indices.append(fname)
|
||||
elif fname.endswith(".vcf") or fname.endswith(".vcf.gz"):
|
||||
vcfs.append(fname)
|
||||
elif fname.endswith(".csi") or fname.endswith(".tbi"):
|
||||
vcf_indices.append(fname)
|
||||
# do some sanity checks
|
||||
if "ref" not in ref_dict:
|
||||
raise ValueError(
|
||||
"No reference file (i.e. file ending in one of "
|
||||
f"{ref_extensions} was found)."
|
||||
)
|
||||
ref = ref_dict["ref"]
|
||||
if (gzi := ref_dict.get("gzi")) is not None:
|
||||
# since we got a '.gzi' index, make sure that the reference is actually
|
||||
# compressed
|
||||
if not ref_dict["ref"].endswith(".gz"):
|
||||
raise ValueError(
|
||||
f"Found GZI reference index '{gzi}', but the reference file "
|
||||
f"'{ref}' appears not to be compressed."
|
||||
# Data by idx
|
||||
DATA_INDEXES_FMT = {
|
||||
fmt: f"{fmt}_idx" for fmt, dtype in DATA_TYPES.items() if "_idx" not in dtype
|
||||
}
|
||||
|
||||
# Assign each format to its index
|
||||
INDEX_PAIRS = {
|
||||
"bam": ("bai",),
|
||||
"cram": ("crai",),
|
||||
"vcf": ("tbi", "csi"),
|
||||
"bcf": ("csi",),
|
||||
"bed": ("tbi",),
|
||||
"bedmethyl": ("tbi",),
|
||||
"gff": ("tbi",),
|
||||
"gtf": ("tbi",),
|
||||
}
|
||||
|
||||
|
||||
class TrackBuilder:
|
||||
"""Class that builds an IGV track."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize properties for interval track."""
|
||||
# Reference properties
|
||||
self.ref = None
|
||||
self.fai = None
|
||||
self.gzi = None
|
||||
# Samples info
|
||||
self.samples = {}
|
||||
# Track properties
|
||||
self.igv_json = {"reference": {}, "tracks": []}
|
||||
self.track_type = {
|
||||
"bam": "alignment",
|
||||
"cram": "alignment",
|
||||
"bcf": "variant",
|
||||
"vcf": "variant",
|
||||
"bedmethyl": "annotation",
|
||||
"bed": "annotation",
|
||||
"gtf": "annotation",
|
||||
"gff": "annotation",
|
||||
}
|
||||
# Here we save aliases of file formats that IGV.js
|
||||
# wants and that do not match the input file extension.
|
||||
self.igv_fmt_alias = {"gff": "gff3"}
|
||||
# lookup of extra options for each data type
|
||||
self.extra_opts_lookups = {
|
||||
"bam": {},
|
||||
"cram": {},
|
||||
"bcf": {},
|
||||
"vcf": {},
|
||||
"bed": {},
|
||||
"bedmethyl": {},
|
||||
"gtf": {},
|
||||
"gff": {},
|
||||
}
|
||||
|
||||
def add_ref(self, ref=None):
|
||||
"""Add reference file, unless already defined."""
|
||||
if self.ref:
|
||||
raise Exception(
|
||||
f"Reference genome has already been set to {self.ref}.\n"
|
||||
"Only one reference FASTA file is expected."
|
||||
)
|
||||
if xam_indices:
|
||||
if len(xams) != len(xam_indices):
|
||||
raise ValueError("Got different number of XAM and XAM index files.")
|
||||
if vcf_indices:
|
||||
if len(vcfs) != len(vcf_indices):
|
||||
raise ValueError("Got different number of VCF and VCF index files.")
|
||||
if xams and vcfs:
|
||||
if len(xams) != len(vcfs):
|
||||
raise ValueError("Got different number of XAM and VCF files.")
|
||||
# if we got XAM or VCF indices, pair them up with their corresponding files (and
|
||||
# otherwise with `None`)
|
||||
xams_with_indices = zip_longest(xams, xam_indices)
|
||||
vcfs_with_indices = zip_longest(vcfs, vcf_indices)
|
||||
return ref_dict, xams_with_indices, vcfs_with_indices
|
||||
else:
|
||||
self.ref = ref
|
||||
|
||||
def add_ref_index(self, ref_index=None):
|
||||
"""Add reference index if valid."""
|
||||
basename = Path(self.ref).name
|
||||
idx_basename = Path(ref_index).name
|
||||
if idx_basename == f"{basename}.fai":
|
||||
self.fai = ref_index
|
||||
if idx_basename == f"{basename}.gzi" and basename.endswith(".gz"):
|
||||
self.gzi = ref_index
|
||||
|
||||
def parse_fnames(self, fofn):
|
||||
"""Parse list with filenames and return them grouped.
|
||||
|
||||
:param fofn: File with list of file names (one per line)
|
||||
"""
|
||||
tmp_samples = {}
|
||||
with open(fofn, "r") as f:
|
||||
for line in f:
|
||||
# If the line contains the sample name, prepare the data structure
|
||||
if "," in line:
|
||||
sample, fname = line.strip().split(",")
|
||||
if sample not in tmp_samples:
|
||||
tmp_samples[sample] = SampleBundle(sample=sample)
|
||||
tmp_samples[sample].append(fname)
|
||||
else:
|
||||
# Otherwise, assign everything to NO_SAMPLE
|
||||
# Files will still be displayed, but in no specific order.
|
||||
fname = line.strip()
|
||||
if any(fname.endswith(ext) for ext in REF_EXTENSIONS):
|
||||
self.add_ref(ref=fname)
|
||||
elif fname.endswith(".fai") or fname.endswith(".gzi"):
|
||||
self.add_ref_index(ref_index=fname)
|
||||
else:
|
||||
if "NO_SAMPLE" not in tmp_samples.keys():
|
||||
tmp_samples["NO_SAMPLE"] = SampleBundle(sample="NO_SAMPLE")
|
||||
tmp_samples["NO_SAMPLE"].append(fname)
|
||||
# Re-order samples in dict and add them to the list, leaving
|
||||
# NO_SAMPLE as last
|
||||
sorted_samples = (
|
||||
sorted([sample for sample in tmp_samples.keys() if sample != 'NO_SAMPLE'])
|
||||
)
|
||||
if 'NO_SAMPLE' in tmp_samples.keys():
|
||||
sorted_samples += ['NO_SAMPLE']
|
||||
for sample in sorted_samples:
|
||||
self.samples[sample] = tmp_samples[sample]
|
||||
|
||||
def build_igv_json(self):
|
||||
"""Ensure there is a reference genome."""
|
||||
if not self.ref:
|
||||
raise ValueError(
|
||||
"No reference file (i.e. file ending in one of "
|
||||
f"{REF_EXTENSIONS} was found)."
|
||||
)
|
||||
# Evaluate that a bgzipped reference has the appropriate index.
|
||||
if self.ref.endswith(".gz") and not self.gzi:
|
||||
raise ValueError(f"GZI reference index for {self.ref} not found.")
|
||||
|
||||
# Create the base track if there is a reference genome.
|
||||
self.igv_json["reference"] = {
|
||||
"id": "ref",
|
||||
"name": "ref",
|
||||
"wholeGenomeView": False,
|
||||
"fastaURL": self.ref,
|
||||
}
|
||||
if self.fai:
|
||||
self.igv_json["reference"]["indexURL"] = self.fai
|
||||
if self.gzi:
|
||||
self.igv_json["reference"]["compressedIndexURL"] = self.gzi
|
||||
|
||||
# Add samples data now
|
||||
for sample, bundle in self.samples.items():
|
||||
bundle.process_data()
|
||||
# Add the bundled data to the tracks
|
||||
for fname, index, file_fmt in bundle.data_bundles:
|
||||
self.add_track(
|
||||
fname,
|
||||
file_fmt,
|
||||
sample_name=sample if sample != "NO_SAMPLE" else None,
|
||||
index=index,
|
||||
extra_opts=self.extra_opts_lookups[file_fmt],
|
||||
)
|
||||
|
||||
def add_track(self, infile, file_fmt, sample_name=None, index=None, extra_opts={}):
|
||||
"""Add a track to an IGV json.
|
||||
|
||||
This function takes an input file, an optional index file, its
|
||||
file format and additional extra options for the track.
|
||||
|
||||
:param infile: input file to create a track for
|
||||
:param file_fmt: input file track type
|
||||
:param sample_name: Name of the sample to display in the track name
|
||||
:param index: index for the input file
|
||||
:param extra_opts: dict of extra options for the track
|
||||
:return: dict with track options
|
||||
"""
|
||||
# Define track name depending on whether the sample ID is provided
|
||||
track_name = Path(infile).name
|
||||
if sample_name:
|
||||
track_name = f"{sample_name}: {Path(infile).name}"
|
||||
track_dict = {
|
||||
"name": track_name,
|
||||
"type": self.track_type[file_fmt],
|
||||
"format": self.igv_fmt_alias.get(file_fmt, file_fmt),
|
||||
"url": infile,
|
||||
}
|
||||
# add the index, if present
|
||||
if index:
|
||||
track_dict["indexURL"] = index
|
||||
track_dict.update(extra_opts)
|
||||
self.igv_json["tracks"] += [track_dict]
|
||||
|
||||
def add_locus(self, locus):
|
||||
"""Add target locus to the json."""
|
||||
self.igv_json["locus"] = locus
|
||||
|
||||
def add_extra_opts(
|
||||
self,
|
||||
extra_alignment_opts=None,
|
||||
extra_variant_opts=None,
|
||||
extra_interval_opts=None,
|
||||
):
|
||||
"""Import extra options from json files."""
|
||||
if extra_alignment_opts is not None:
|
||||
with open(extra_alignment_opts, "r") as f:
|
||||
extra_alignment_opts_json = json.load(f)
|
||||
for ftype in ["bam", "cram"]:
|
||||
self.extra_opts_lookups[ftype] = extra_alignment_opts_json
|
||||
if extra_variant_opts is not None:
|
||||
with open(extra_variant_opts, "r") as f:
|
||||
extra_variant_opts_json = json.load(f)
|
||||
for ftype in ["vcf", "bcf"]:
|
||||
self.extra_opts_lookups[ftype] = extra_variant_opts_json
|
||||
if extra_interval_opts is not None:
|
||||
with open(extra_interval_opts, "r") as f:
|
||||
extra_interval_opts_json = json.load(f)
|
||||
for ftype in ["bed", "bedmethyl", "gff", "gtf"]:
|
||||
self.extra_opts_lookups[ftype] = extra_interval_opts_json
|
||||
|
||||
|
||||
def get_reference_options(ref, fai=None, gzi=None):
|
||||
"""Create dict with IGV reference options.
|
||||
class SampleBundle:
|
||||
"""Sample data class.
|
||||
|
||||
:param ref: reference file name
|
||||
:param fai: name reference `.fai` index file
|
||||
:param gzi: name of `.gzi` index file for a compressed reference
|
||||
:return: dict with reference options
|
||||
This class stores the data for multiple tracks for a
|
||||
single sample, then is used to generate a collection of
|
||||
IGV.js tracks.
|
||||
"""
|
||||
# initialise the options dict and add the index attributes later
|
||||
ref_opts = {
|
||||
"id": "ref",
|
||||
"name": "ref",
|
||||
"wholeGenomeView": False,
|
||||
"fastaURL": ref,
|
||||
}
|
||||
if fai is not None:
|
||||
ref_opts["indexURL"] = fai
|
||||
if gzi is not None:
|
||||
ref_opts["compressedIndexURL"] = gzi
|
||||
return ref_opts
|
||||
|
||||
def __init__(self, sample):
|
||||
"""Initialize properties for a sample."""
|
||||
self.sample = sample
|
||||
self.infiles = []
|
||||
self.data_bundles = []
|
||||
|
||||
def get_alignment_track(xam, xai=None, extra_opts=None):
|
||||
"""Create dict with options for IGV alignment track.
|
||||
def append(self, fname):
|
||||
"""Add a new raw file to the bundle."""
|
||||
self.infiles.append(fname)
|
||||
|
||||
:param xam: name of XAM file to be displayed
|
||||
:param xai: name of XAM index file
|
||||
:param extra_opts: dict of extra options for the alignment track
|
||||
:return: dict with alignment track options
|
||||
"""
|
||||
alignment_track_dict = {
|
||||
"name": xam,
|
||||
"type": "alignment",
|
||||
"format": xam.split(".")[-1],
|
||||
"url": xam,
|
||||
}
|
||||
# add the XAM index if present
|
||||
if xai is not None:
|
||||
alignment_track_dict["indexURL"] = xai
|
||||
alignment_track_dict.update(extra_opts or {})
|
||||
return alignment_track_dict
|
||||
def process_data(self):
|
||||
"""Process input files."""
|
||||
fbasenames = [Path(fname).name for fname in self.infiles]
|
||||
ftypes = [self.classify_files(bname) for bname in fbasenames]
|
||||
self.data_bundles = self.pair_file_with_index(self.infiles, fbasenames, ftypes)
|
||||
|
||||
@staticmethod
|
||||
def classify_files(fname):
|
||||
"""Classify inputs."""
|
||||
for extension, ftype in DATA_TYPES.items():
|
||||
if fname.endswith(f".{extension}"):
|
||||
return ftype
|
||||
|
||||
def get_variant_track(vcf, index=None, extra_opts=None):
|
||||
"""Create dict with options for IGV variant track.
|
||||
@staticmethod
|
||||
def pair_file_with_index(infiles, fbasenames, ftypes):
|
||||
"""Clump files with their indexes."""
|
||||
# Collect data by group type
|
||||
groups = {ftype: {"basenames": [], "paths": []} for ftype in set(ftypes)}
|
||||
# Group each file by its type and base name
|
||||
for ftype, fbasename, fname in zip(ftypes, fbasenames, infiles):
|
||||
groups[ftype]["basenames"] += [fbasename]
|
||||
groups[ftype]["paths"] += [fname]
|
||||
|
||||
:param vcf: name of VCF file to be displayed
|
||||
:param index: name of VCF index file (ending in `.csi` or `.tbi`)
|
||||
:param extra_opts: dict of extra options for the variant track
|
||||
:return: dict with variant track options
|
||||
"""
|
||||
variant_track_dict = {
|
||||
"name": vcf,
|
||||
"type": "variant",
|
||||
"format": "vcf",
|
||||
"url": vcf,
|
||||
}
|
||||
# add the VCF index if we got an index extension
|
||||
if index is not None:
|
||||
variant_track_dict["indexURL"] = index
|
||||
variant_track_dict.update(extra_opts or {})
|
||||
return variant_track_dict
|
||||
# Output bundles
|
||||
outputs = []
|
||||
# Start matching the variant files
|
||||
for ftype, itype in DATA_INDEXES_FMT.items():
|
||||
# Ignore file formats that are not present in the bundle.
|
||||
if ftype not in groups:
|
||||
continue
|
||||
# Make pairs of files.
|
||||
for fbasename, fpath in zip(
|
||||
groups[ftype]["basenames"], groups[ftype]["paths"]
|
||||
):
|
||||
# Construct potential index file names based on basename of input files
|
||||
idx_basenames = set(
|
||||
[f"{fbasename}.{idx}" for idx in INDEX_PAIRS[ftype]]
|
||||
)
|
||||
# Find which indexes are available
|
||||
if itype in groups.keys():
|
||||
idx_basenames = list(
|
||||
idx_basenames.intersection(set(groups[itype]["basenames"]))
|
||||
)
|
||||
# Get the first index (if there are more than one,
|
||||
# it doesn't matter)
|
||||
bname = idx_basenames[0]
|
||||
idx_fn = groups[itype]["paths"][
|
||||
groups[itype]["basenames"].index(bname)
|
||||
]
|
||||
outputs.append([fpath, idx_fn, ftype])
|
||||
# Otherwise, return only the simple file.
|
||||
else:
|
||||
outputs.append([fpath, None, ftype])
|
||||
return outputs
|
||||
|
||||
|
||||
def main(args):
|
||||
@ -137,44 +315,26 @@ def main(args):
|
||||
logger = get_named_logger("configIGV")
|
||||
|
||||
# parse the FOFN
|
||||
ref_dict, xams_with_indices, vcfs_with_indices = parse_fnames(args.fofn)
|
||||
igv_builder = TrackBuilder()
|
||||
|
||||
# Add the additional track configurations
|
||||
igv_builder.add_extra_opts(
|
||||
extra_alignment_opts=args.extra_alignment_opts,
|
||||
extra_variant_opts=args.extra_variant_opts,
|
||||
extra_interval_opts=args.extra_interval_opts
|
||||
)
|
||||
|
||||
# Import files
|
||||
igv_builder.parse_fnames(args.fofn)
|
||||
|
||||
# initialise the IGV options dict with the reference options
|
||||
json_dict = {"reference": get_reference_options(**ref_dict)}
|
||||
|
||||
# if we got JSON files with extra options for the alignment / variant tracks, read
|
||||
# them
|
||||
extra_alignment_opts = {}
|
||||
if args.extra_alignment_opts is not None:
|
||||
with open(args.extra_alignment_opts, "r") as f:
|
||||
extra_alignment_opts = json.load(f)
|
||||
extra_variant_opts = {}
|
||||
if args.extra_variant_opts is not None:
|
||||
with open(args.extra_variant_opts, "r") as f:
|
||||
extra_variant_opts = json.load(f)
|
||||
|
||||
# now add the alignment and variant tracks
|
||||
json_dict["tracks"] = []
|
||||
# we use `zip_longest` to make sure that variant and alignment tracks from the same
|
||||
# sample are added after each other
|
||||
for (vcf, vcf_index), (xam, xam_index) in zip_longest(
|
||||
vcfs_with_indices, xams_with_indices, fillvalue=(None, None)
|
||||
):
|
||||
if vcf is not None:
|
||||
# add a variant track for the VCF
|
||||
json_dict["tracks"].append(
|
||||
get_variant_track(vcf, vcf_index, extra_variant_opts)
|
||||
)
|
||||
if xam is not None:
|
||||
# add an alignment track for the XAM
|
||||
json_dict["tracks"].append(
|
||||
get_alignment_track(xam, xam_index, extra_alignment_opts)
|
||||
)
|
||||
igv_builder.build_igv_json()
|
||||
|
||||
# Add locus information
|
||||
if args.locus is not None:
|
||||
json_dict["locus"] = args.locus
|
||||
igv_builder.add_locus(args.locus)
|
||||
|
||||
json.dump(json_dict, sys.stdout, indent=4)
|
||||
json.dump(igv_builder.igv_json, sys.stdout, indent=4)
|
||||
|
||||
logger.info("Printed IGV config JSON to STDOUT.")
|
||||
|
||||
@ -202,4 +362,8 @@ def argparser():
|
||||
"--extra-variant-opts",
|
||||
help="JSON file with extra options for variant tracks",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--extra_interval_opts",
|
||||
help="JSON file with extra options for interval tracks",
|
||||
)
|
||||
return parser
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
#!/usr/bin/env python
|
||||
"""Create de report section."""
|
||||
|
||||
from glob import glob
|
||||
import os
|
||||
|
||||
from aplanat import hist, points
|
||||
from aplanat.bars import boxplot_series
|
||||
from aplanat.util import Colors
|
||||
from dominate.tags import h5, p
|
||||
from dominate.util import raw
|
||||
from ezcharts import scatterplot
|
||||
from ezcharts.components.ezchart import EZChart
|
||||
from ezcharts.layout.snippets import DataTable
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
@ -51,174 +52,55 @@ def create_summary_table(df):
|
||||
avg_acc, avg_mapq])
|
||||
|
||||
|
||||
def dtu_table(gene_id, dtu_file, alignment_stats, condition_sheet):
|
||||
"""Create DTU table and plot."""
|
||||
dtu_results = pd.read_csv(dtu_file, sep='\t')
|
||||
f = open(condition_sheet)
|
||||
df = pd.read_csv(f, sep='\t')
|
||||
treated_df = df.loc[df['condition'] == "treated"]
|
||||
untreated_df = df.loc[df['condition'] == "untreated"]
|
||||
treated_samples = treated_df['sample'].tolist()
|
||||
control_samples = untreated_df['sample'].tolist()
|
||||
# parmaterise these
|
||||
control_name = "condition1"
|
||||
treated_name = "condition2"
|
||||
table = dtu_results.loc[(dtu_results["geneID"] == gene_id)]
|
||||
msg = "Gene ID \"{}\" does not exist in the dataset, please select another"
|
||||
assert not table.empty, msg.format(gene_id)
|
||||
alignment_stats[alignment_stats["Ref"].isin([gene_id])]
|
||||
alignment_stats["Transcript"] = alignment_stats["Ref"].apply(
|
||||
lambda x: x.split(".")[0])
|
||||
gene_alignments = alignment_stats[alignment_stats["Transcript"].isin(
|
||||
table["txID"])]
|
||||
gene_alignments = gene_alignments.loc[(
|
||||
gene_alignments["Type"] == "Primary")]
|
||||
gene_alignments["condition"] = gene_alignments.apply(
|
||||
lambda x: control_name if x["fname"] in
|
||||
control_samples else treated_name, axis=1)
|
||||
groups = gene_alignments.groupby(
|
||||
["Transcript", "fname", "condition"]).agg(
|
||||
**{"Transcript_count": ("Read", "size")})
|
||||
df = None
|
||||
for gene_id, group in gene_alignments.groupby("Transcript"):
|
||||
temp = group.groupby("fname").agg(**{
|
||||
gene_id: ("Read", "size")
|
||||
}).transpose()
|
||||
if df is None:
|
||||
df = temp
|
||||
else:
|
||||
df = pd.concat([df, temp])
|
||||
df = df.reset_index().rename(columns={"index": "Transcript_ID"})
|
||||
table = table.rename(columns={
|
||||
"txID": "Transcript_ID",
|
||||
"gene": "p_gene",
|
||||
"transcript": "p_transcript"
|
||||
})
|
||||
gene_table = pd.merge(df, table)
|
||||
gene_table = gene_table.set_index(["geneID", "Transcript_ID"])
|
||||
file_names = set(control_samples.keys())
|
||||
file_names.update(treated_samples.keys())
|
||||
gene_table = gene_table.fillna(0)
|
||||
table = groups.reset_index()[
|
||||
["condition", "Transcript", "Transcript_count"]]
|
||||
table['transcript, condition'] = \
|
||||
table['Transcript'].astype(str) + ', ' + table['condition'].astype(str)
|
||||
repeats = groups.groupby(level=['Transcript', 'condition']).size()
|
||||
min_rep, max_rep = min(repeats), max(repeats)
|
||||
plot = boxplot_series(
|
||||
table['transcript, condition'], table['Transcript_count'],
|
||||
x_axis_label='Transcript, condition',
|
||||
y_axis_label='Transcript count',
|
||||
height=200, width=200,
|
||||
title="Transcript counts (from {}-{} replicates)".format(
|
||||
min_rep, max_rep))
|
||||
plot.xaxis.major_label_orientation = 3.1452/2
|
||||
if min_rep < 7:
|
||||
for renderer in plot.renderers:
|
||||
renderer.glyph.line_alpha = 0.2
|
||||
try:
|
||||
renderer.glyph.fill_alpha = 0.2
|
||||
except Exception:
|
||||
pass
|
||||
plot.circle(
|
||||
table['transcript, condition'], table['Transcript_count'],
|
||||
fill_color='black', line_color='black')
|
||||
return (table, plot)
|
||||
|
||||
|
||||
def pool_csvs(folder):
|
||||
"""Concat seqkit stats."""
|
||||
files = glob(folder + "/*.seqkit.stats")
|
||||
dfs = [parse_seqkit(f) for f in files]
|
||||
return pd.concat(dfs)
|
||||
|
||||
|
||||
def abundance_histogram(filtered_counts, gene_counts, section):
|
||||
"""Create plot for abundance of transcripts across all samples."""
|
||||
section.markdown("""
|
||||
Histogram showing the abundance of transcript
|
||||
counts for genes identified in the analysis.
|
||||
""")
|
||||
filtered_count_file = filtered_counts
|
||||
gene_count_file = gene_counts
|
||||
transcripts_per_gene = pd.read_csv(
|
||||
filtered_count_file, sep='\t',
|
||||
usecols=['gene_id', 'feature_id']).groupby(['gene_id']).agg(['count'])
|
||||
transcripts_per_gene.columns = transcripts_per_gene.columns.droplevel()
|
||||
gene_ids = pd.read_csv(
|
||||
gene_count_file, sep='\t',
|
||||
usecols=[0]).index.values.tolist()
|
||||
singletons = [
|
||||
gene_id for gene_id in gene_ids if gene_id not in transcripts_per_gene.index.values.tolist()] # noqa
|
||||
singletons = pd.DataFrame(index=singletons, columns=['count']).fillna(1)
|
||||
transcripts_per_gene = pd.concat([singletons, transcripts_per_gene])
|
||||
transcript_plot = hist.histogram(
|
||||
[transcripts_per_gene['count'].tolist()],
|
||||
binwidth=1, colors=[Colors.cerulean])
|
||||
transcript_plot.xaxis.axis_label = "Number of isoforms per gene (n)"
|
||||
transcript_plot.yaxis.axis_label = "Number of occurences"
|
||||
section.markdown("### Transcripts per gene")
|
||||
section.plot(transcript_plot)
|
||||
|
||||
|
||||
def dexseq_section(dexseq_file, section, id_dic):
|
||||
def dexseq_section(dexseq_file, id_dic, pval_thresh):
|
||||
"""Add gene isoforms table and plot."""
|
||||
section.markdown("### Differential Isoform usage")
|
||||
dexseq_caption = '''Table showing gene isoforms, ranked by adjusted
|
||||
h5("Differential Isoform usage")
|
||||
p("""Table showing gene isoforms, ranked by adjusted
|
||||
p-value, from the DEXSeq analysis. Information shown includes the log2 fold
|
||||
change between experimental conditions, the log-scaled transcript
|
||||
abundance and the false discovery corrected p-value (FDR).
|
||||
abundance and the false discovery corrected p-value (FDR - Benjamini-Hochberg) .
|
||||
This table has not been filtered
|
||||
for genes that satisfy statistical or magnitudinal thresholds'''
|
||||
section.markdown(dexseq_caption)
|
||||
for genes that satisfy statistical or magnitudinal thresholds""")
|
||||
|
||||
dexseq_results = pd.read_csv(dexseq_file, sep='\t')
|
||||
dexseq_results.index.name = "gene_id:trancript_id"
|
||||
dexseq_results.index.name = "gene_id:transcript_id"
|
||||
# Replace gene id with more useful gene name where possible
|
||||
dexseq_results.index = dexseq_results.index.map(
|
||||
lambda x: str(id_dic.get(x.split(':')[0])) + ':' + str(x.split(':')[1]))
|
||||
dexseq_pvals = dexseq_results.sort_values(by='pvalue', ascending=True)
|
||||
section.table(dexseq_results.loc[dexseq_pvals.index], index=True)
|
||||
section.markdown("""
|
||||
The figure below presents the MA plot from the DEXSeq analysis.
|
||||
M is the log2 ratio of isoform transcript abundance between conditions.
|
||||
A is the log2 transformed mean abundance value.
|
||||
Transcripts that satisfy the logFC and FDR corrected p-value
|
||||
thresholds defined are shaded as 'Up-' or 'Down-' regulated.""")
|
||||
pval_limit = 0.01
|
||||
up = dexseq_results.loc[
|
||||
(dexseq_results["Log2FC"] > 0) & (
|
||||
dexseq_results['pvalue'] < pval_limit)]
|
||||
down = dexseq_results.loc[
|
||||
(dexseq_results["Log2FC"] <= 0) & (
|
||||
dexseq_results['pvalue'] < pval_limit)]
|
||||
not_sig = dexseq_results.loc[(dexseq_results["pvalue"] >= pval_limit)]
|
||||
|
||||
dexseq_plot = points.points(
|
||||
x_datas=[
|
||||
up["Log2MeanExon"],
|
||||
down["Log2MeanExon"],
|
||||
not_sig["Log2MeanExon"],
|
||||
],
|
||||
y_datas=[
|
||||
up["Log2FC"],
|
||||
down["Log2FC"],
|
||||
not_sig["Log2FC"],
|
||||
],
|
||||
title="Average copy per million (CPM) vs Log-fold change (LFC)",
|
||||
colors=["red", "blue", "black"],
|
||||
names=["Up", "Down", "NotSig"]
|
||||
)
|
||||
DataTable.from_pandas(
|
||||
dexseq_results.sort_values(by='pvalue', ascending=True), use_index=True)
|
||||
|
||||
dexseq_plot.xaxis.axis_label = "A (log2 transformed mean exon read counts)"
|
||||
dexseq_plot.yaxis.axis_label = """
|
||||
M (log2 transformed differential abundance)
|
||||
"""
|
||||
dexseq_results_caption = "### Dexseq results"
|
||||
section.markdown(dexseq_results_caption)
|
||||
section.plot(dexseq_plot)
|
||||
p(
|
||||
"""The figure below presents the MA plot from the DEXSeq analysis.
|
||||
M is the log2 ratio of isoform transcript abundance between conditions.
|
||||
A is the log2 transformed mean abundance value.
|
||||
Transcripts that satisfy the logFC and FDR-corrected
|
||||
(False discovery rate - Benjamini-Hochberg) p-value
|
||||
thresholds defined are shaded as 'Up-' or 'Down-' regulated.""")
|
||||
|
||||
dexseq_results['direction'] = 'not_sig'
|
||||
|
||||
dexseq_results.loc[
|
||||
(dexseq_results["Log2FC"] > 0) & (dexseq_results['pvalue'] < pval_thresh),
|
||||
'direction'] = 'up'
|
||||
|
||||
dexseq_results.loc[
|
||||
(dexseq_results["Log2FC"] <= 0) & (dexseq_results['pvalue'] < pval_thresh),
|
||||
'direction'] = 'down'
|
||||
|
||||
plot = scatterplot(
|
||||
data=dexseq_results, x='Log2MeanExon', y='Log2FC', hue='direction',
|
||||
palette=['#E32636', '#7E8896', '#0A22DE'],
|
||||
hue_order=['up', 'down', 'not_sig'], marker='circle')
|
||||
plot._fig.xaxis.axis_label = "A (log2 transformed mean exon read counts)"
|
||||
plot._fig.yaxis.axis_label = "M (log2 transformed differential abundance)"
|
||||
plot.legend = dict(orient='horizontal', top=30)
|
||||
plot._fig.title = "Average copy per million (CPM) vs Log-fold change (LFC)"
|
||||
EZChart(plot)
|
||||
|
||||
|
||||
def dtu_section(dtu_file, section, gt_dic, ge_dic):
|
||||
def dtu_section(dtu_file, gt_dic, ge_dic):
|
||||
"""Plot dtu section."""
|
||||
dtu_results = pd.read_csv(dtu_file, sep='\t')
|
||||
dtu_results["gene_name"] = dtu_results["txID"].apply(
|
||||
@ -226,198 +108,187 @@ def dtu_section(dtu_file, section, gt_dic, ge_dic):
|
||||
dtu_results["geneID"] = dtu_results["geneID"].apply(
|
||||
lambda x: ge_dic.get(x))
|
||||
dtu_pvals = dtu_results.sort_values(by='gene', ascending=True)
|
||||
dtu_caption = '''Table showing gene and transcript identifiers
|
||||
and their FDR corrected probabilities
|
||||
raw("""Table showing gene and transcript identifiers
|
||||
and their FDR-corrected (False discovery rate - Benjamini-Hochberg) probabilities
|
||||
for the genes and their isoforms that have been
|
||||
identified as showing DTU using the R packages DEXSeq and StageR.
|
||||
This list has been shortened requiring that both gene and transcript
|
||||
must satisfy the p-value
|
||||
threshold'''
|
||||
section.markdown(dtu_caption)
|
||||
section.table(dtu_results.loc[dtu_pvals.index])
|
||||
threshold""")
|
||||
DataTable.from_pandas(dtu_results.loc[dtu_pvals.index], use_index=False)
|
||||
|
||||
raw("""View dtu_plots.pdf file to see plots of differential isoform usage""")
|
||||
|
||||
|
||||
def copy_dge_add_gene_names(dge_file, geid_gname, newfile_name):
|
||||
"""Add gene name column to DGE TSV with gene ID to gene Name dict."""
|
||||
def dge_section(dge_file, ids_dic, pval_thresh):
|
||||
"""Create DGE table and MA plot."""
|
||||
h5("Differential gene expression")
|
||||
dge_results = pd.read_csv(dge_file, sep='\t')
|
||||
column_to_move = dge_results.index.map(
|
||||
lambda x: geid_gname.get(x))
|
||||
dge_results.insert(0, "gene_name", column_to_move)
|
||||
dge_results.to_csv(newfile_name, index=True, index_label="gene_id", sep="\t")
|
||||
|
||||
|
||||
def copy_filtered_add_gene_names(unfiltered_file, geid_gname, newfile_name):
|
||||
"""Use gene id to gene name dict to add name column to counts TSV."""
|
||||
unfiltered = pd.read_csv(unfiltered_file, sep='\t')
|
||||
unfiltered.insert(1, "gene_name", unfiltered.gene_id.map(
|
||||
lambda x: geid_gname.get(x)))
|
||||
unfiltered.to_csv(newfile_name, index=False, sep='\t')
|
||||
|
||||
|
||||
def copy_tpm_add_gene_names(tpm_file, txid_gname, newfile_name):
|
||||
"""Use transcript id to gene name dict to add name column to TPM TSV."""
|
||||
tpm = pd.read_csv(tpm_file, sep='\t')
|
||||
tpm.insert(1, "gene_name", tpm.Reference.map(
|
||||
lambda x: txid_gname.get(x)))
|
||||
tpm.to_csv(newfile_name, index=False, sep='\t')
|
||||
|
||||
|
||||
def dge_section(dge_file, section, ids_dic):
|
||||
"""Create DGE table and plot."""
|
||||
section.markdown('### Differential gene expression')
|
||||
dge_results = pd.read_csv(dge_file, sep='\t')
|
||||
dge_pvals = dge_results.sort_values(by='FDR', ascending=True)
|
||||
dge_results[['logFC', 'logCPM', 'F']] = dge_results[
|
||||
['logFC', 'logCPM', 'F']].round(2)
|
||||
dge_caption = """
|
||||
Table showing the genes from the edgeR analysis.
|
||||
Information shown includes the log2 fold change between
|
||||
experimental conditions, the log-scaled counts per million measure of abundance
|
||||
and the false discovery corrected p-value (FDR). This table has not been
|
||||
filtered for genes that satisfy statistical or magnitudinal thresholds"""
|
||||
section.markdown(dge_caption)
|
||||
|
||||
p("""Table showing the genes from the edgeR analysis.
|
||||
Information shown includes the log2 fold change between
|
||||
experimental conditions, the log-scaled counts per million measure of abundance
|
||||
and the FDR-corrected p-value (False discovery rate - Benjamini-Hochberg).
|
||||
This table has not been
|
||||
filtered for genes that satisfy statistical or magnitudinal thresholds""")
|
||||
|
||||
dge_results.index = dge_results.index.map(lambda x: ids_dic.get(x))
|
||||
dge_pvals.index = dge_pvals.index.map(lambda x: ids_dic.get(x))
|
||||
section.table(dge_results.loc[dge_pvals.index], index=True)
|
||||
dge = pd.read_csv(dge_file, sep="\t")
|
||||
section.markdown("""
|
||||
This plot visualises differences in measurements between the
|
||||
two experimental conditions. M is the log2 ratio of gene expression
|
||||
calculated between the conditions.
|
||||
A is a log2 transformed mean expression value.
|
||||
The figure below presents the MA figure from this edgeR analysis.
|
||||
Genes that satisfy the logFC and FDR corrected p-value thresholds
|
||||
defined are shaded as 'Up-' or 'Down-' regulated.
|
||||
dge_results = dge_results.sort_values('FDR', ascending=True)
|
||||
dge_results.index.name = 'Transcript'
|
||||
DataTable.from_pandas(dge_results, use_index=True)
|
||||
|
||||
h5("Results of the edgeR Analysis.")
|
||||
|
||||
p("""This plot visualises differences in measurements between the
|
||||
two experimental conditions. M is the log2 ratio of gene expression
|
||||
calculated between the conditions.
|
||||
A is a log2 transformed mean expression value.
|
||||
The figure below presents the MA figure from this edgeR analysis.
|
||||
Genes that satisfy the logFC and FDR-corrected
|
||||
(False discovery rate - Benjamini-Hochberg) p-value thresholds
|
||||
defined are shaded as 'Up-' or 'Down-' regulated.
|
||||
""")
|
||||
pval_limit = 0.01
|
||||
up = dge.loc[(dge["logFC"] > 0) & (dge['PValue'] < pval_limit)]
|
||||
down = dge.loc[(dge["logFC"] <= 0) & (dge['PValue'] < pval_limit)]
|
||||
not_sig = dge.loc[(dge["PValue"] >= pval_limit)]
|
||||
logcpm_vs_logfc = points.points(
|
||||
x_datas=[
|
||||
up["logCPM"],
|
||||
down["logCPM"],
|
||||
not_sig["logCPM"],
|
||||
],
|
||||
y_datas=[
|
||||
up["logFC"],
|
||||
down["logFC"],
|
||||
not_sig["logFC"],
|
||||
],
|
||||
title="Average copy per million (CPM) vs Log-fold change (LFC)",
|
||||
colors=["red", "blue", "black"],
|
||||
names=["Up", "Down", "NotSig"]
|
||||
)
|
||||
logcpm_vs_logfc.xaxis.axis_label = "Average log CPM"
|
||||
logcpm_vs_logfc.yaxis.axis_label = "Log-fold change"
|
||||
logcpm_caption = """### Results of the edgeR Analysis."""
|
||||
section.markdown(logcpm_caption)
|
||||
section.plot(logcpm_vs_logfc)
|
||||
|
||||
dge = pd.read_csv(dge_file, sep="\t")
|
||||
dge['sig'] = None
|
||||
dge.loc[(dge["logFC"] > 0) & (dge['PValue'] < pval_thresh), 'sig'] = 'up'
|
||||
dge.loc[(dge["logFC"] <= 0) & (dge['PValue'] < pval_thresh), 'sig'] = 'down'
|
||||
dge.loc[(dge["PValue"] >= pval_thresh), 'sig'] = 'not_sig'
|
||||
|
||||
plot = scatterplot(
|
||||
data=dge, x='logCPM', y='logFC', hue='sig',
|
||||
palette=['#E32636', '#7E8896', '#0A22DE'],
|
||||
hue_order=['up', 'not_sig', 'down'], marker='circle')
|
||||
plot._fig.x_range.start = 10
|
||||
plot._fig.xaxis.axis_label = "Average log CPM"
|
||||
plot._fig.yaxis.axis_label = "Log-fold change"
|
||||
plot.legend = dict(orient='horizontal', top=30)
|
||||
# Should opacity of the symbols be lowered?
|
||||
plot._fig.title = "Average copy per million (CPM) vs Log-fold change (LFC)"
|
||||
EZChart(plot)
|
||||
|
||||
|
||||
def salmon_table(salmon_counts, section):
|
||||
def salmon_table(salmon_counts):
|
||||
"""Create salmon counts summary table."""
|
||||
salmon_counts = pd.read_csv(salmon_counts, sep='\t')
|
||||
salmon_counts.set_index("Reference", drop=True, append=False, inplace=True)
|
||||
salmon_size_top = salmon_counts.sum(axis=1).sort_values(ascending=False)
|
||||
salmon_counts = salmon_counts.applymap(np.int64)
|
||||
salmon_count_caption = """
|
||||
Table showing the annotated Transcripts Per Million
|
||||
h5("Transcripts Per Million")
|
||||
p("""Table showing the annotated Transcripts Per Million
|
||||
identified by Minimap2 mapping and Salmon transcript
|
||||
detection with the highest
|
||||
number of mapped reads"""
|
||||
section.markdown("### Transcripts Per Million ")
|
||||
section.markdown(salmon_count_caption, "salmon-head-caption")
|
||||
section.table(
|
||||
salmon_counts.loc[salmon_size_top.index].head(n=100), index=True)
|
||||
detection. Displaying the top 100 transcripts with the highest
|
||||
number of mapped reads""")
|
||||
|
||||
salmon_counts = salmon_counts[sorted(salmon_counts.columns)]
|
||||
DataTable.from_pandas(
|
||||
salmon_counts.loc[salmon_size_top.index].head(n=100), use_index=True)
|
||||
|
||||
|
||||
def get_translations(gtf):
|
||||
"""Create dict with gene_name and gene_references."""
|
||||
fn = open(gtf).readlines()
|
||||
gene_txid = {}
|
||||
gene_geid = {}
|
||||
geid_gname = {}
|
||||
with open(gtf) as fh:
|
||||
gene_txid = {}
|
||||
gene_geid = {}
|
||||
geid_gname = {}
|
||||
|
||||
def get_feature(row, feature):
|
||||
return row.split(feature)[1].split(
|
||||
";")[0].replace('=', '').replace("\"", "").strip()
|
||||
def get_feature(row, feature):
|
||||
return row.split(feature)[1].split(
|
||||
";")[0].replace('=', '').replace("\"", "").strip()
|
||||
|
||||
for i in fn:
|
||||
if i.startswith("#"):
|
||||
continue
|
||||
# Different gtf/gff formats contain different attributes
|
||||
# and different formating (eg. gene_name="xyz" or gene_name "xyz")
|
||||
if 'gene_name' in i:
|
||||
gene_name = get_feature(i, "gene_name")
|
||||
elif 'gene_id' in i:
|
||||
gene_name = get_feature(i, 'gene_id')
|
||||
elif 'gene' in i:
|
||||
gene_name = get_feature(i, "gene")
|
||||
else:
|
||||
continue
|
||||
for i in fh:
|
||||
if i.startswith("#"):
|
||||
continue
|
||||
# Different gtf/gff formats contain different attributes
|
||||
# and different formating (eg. gene_name="xyz" or gene_name "xyz")
|
||||
gene_name = None
|
||||
for var_name in ["gene_name", "gene_id", "gene"]:
|
||||
if var_name in i:
|
||||
gene_name = get_feature(i, var_name)
|
||||
break
|
||||
|
||||
if 'ref_gene_id' in i:
|
||||
gene_reference = get_feature(i, 'ref_gene_id')
|
||||
elif 'gene_id' in i:
|
||||
gene_reference = get_feature(i, 'gene_id')
|
||||
else:
|
||||
gene_reference = gene_name
|
||||
if 'transcript_id' in i:
|
||||
transcript_id = get_feature(i, 'transcript_id')
|
||||
else:
|
||||
transcript_id = "unknown"
|
||||
if 'gene_id' in i:
|
||||
gene_id = get_feature(i, 'gene_id')
|
||||
else:
|
||||
gene_id = gene_name
|
||||
gene_txid[transcript_id] = gene_name
|
||||
gene_geid[gene_id] = gene_reference
|
||||
geid_gname[gene_reference] = gene_name
|
||||
if 'ref_gene_id' in i:
|
||||
gene_reference = get_feature(i, 'ref_gene_id')
|
||||
elif 'gene_id' in i:
|
||||
gene_reference = get_feature(i, 'gene_id')
|
||||
else:
|
||||
gene_reference = gene_name
|
||||
if 'transcript_id' in i:
|
||||
transcript_id = get_feature(i, 'transcript_id')
|
||||
else:
|
||||
transcript_id = "unknown"
|
||||
if 'gene_id' in i:
|
||||
gene_id = get_feature(i, 'gene_id')
|
||||
else:
|
||||
gene_id = gene_name
|
||||
gene_txid[transcript_id] = gene_name
|
||||
gene_geid[gene_id] = gene_reference
|
||||
geid_gname[gene_reference] = gene_name
|
||||
return gene_txid, gene_geid, geid_gname
|
||||
|
||||
|
||||
def de_section(
|
||||
stringtie, dge, dexseq, dtu,
|
||||
tpm, report, filtered, unfiltered,
|
||||
gene_counts):
|
||||
gene_counts, aln_stats_dir, pval_threshold=0.01):
|
||||
"""Differential expression sections."""
|
||||
section = report.add_section()
|
||||
section.markdown("# Differential expression.")
|
||||
section.markdown("""
|
||||
This section shows differential gene expression
|
||||
and differential isoform usage. Salmon was used to
|
||||
assign reads to individual annotated isoforms defined by
|
||||
the GTF-format annotation.
|
||||
These counts were used to perform a statistical analysis to identify
|
||||
the genes and isoforms that show differences in abundance between
|
||||
the experimental conditions.
|
||||
Any novel genes or transcripts that do not have relevant gene or transcript IDs
|
||||
are prefixed with MSTRG for use in differential expression analysis.
|
||||
Find the full sequences of any transcripts in the
|
||||
`final_non_redundant_transcriptome.fasta` file.
|
||||
""")
|
||||
section.markdown("### Alignment summary stats")
|
||||
alignment_stats = pool_csvs("seqkit")
|
||||
alignment_summary_df = create_summary_table(alignment_stats)
|
||||
alignment_summary_df = alignment_summary_df.fillna(0).applymap(np.int64)
|
||||
section.table(alignment_summary_df, key='alignment-stats', index=True)
|
||||
salmon_table(tpm, section)
|
||||
gene_txid, gene_name, geid_gname = get_translations(stringtie)
|
||||
# Use dictionaries to add gene names to the counts tsv files to help users
|
||||
copy_dge_add_gene_names(dge, geid_gname, "results_dge.tsv")
|
||||
copy_dge_add_gene_names(gene_counts, geid_gname, "all_gene_counts.tsv")
|
||||
copy_filtered_add_gene_names(
|
||||
filtered, geid_gname, "filtered_transcript_counts_with_genes.tsv")
|
||||
copy_filtered_add_gene_names(
|
||||
unfiltered, geid_gname, "unfiltered_transcript_counts_with_genes.tsv")
|
||||
copy_tpm_add_gene_names(tpm, gene_txid, "unfiltered_tpm_transcript_counts.tsv")
|
||||
with report.add_section("Differential expression", "DE"):
|
||||
|
||||
# Add tables to report
|
||||
dge_section(dge, section, gene_name)
|
||||
dexseq_section(dexseq, section, gene_name)
|
||||
dtu_section(dtu, section, gene_txid, gene_name)
|
||||
# missing dtu plots at the moment as too many
|
||||
section.markdown("""
|
||||
### View dtu_plots.pdf file to see plots of differential isoform usage
|
||||
""")
|
||||
p("""This section shows differential gene expression
|
||||
and differential isoform usage. Salmon was used to
|
||||
assign reads to individual annotated isoforms defined by
|
||||
the GTF-format annotation.
|
||||
These counts were used to perform a statistical analysis to identify
|
||||
the genes and isoforms that show differences in abundance between
|
||||
the experimental conditions.
|
||||
Any novel genes or transcripts that do not have relevant gene or transcript IDs
|
||||
are prefixed with MSTRG for use in differential expression analysis.
|
||||
Find the full sequences of any transcripts in the
|
||||
final_non_redundant_transcriptome.fasta file.
|
||||
""")
|
||||
alignment_stats = pd.concat([parse_seqkit(f) for f in aln_stats_dir.iterdir()])
|
||||
alignment_summary_df = create_summary_table(alignment_stats)
|
||||
alignment_summary_df = alignment_summary_df.fillna(0).applymap(np.int64)
|
||||
h5("Alignment summary stats")
|
||||
alignment_summary_df.index.name = "statistic"
|
||||
DataTable.from_pandas(alignment_summary_df, use_index=True)
|
||||
|
||||
salmon_table(tpm)
|
||||
gene_txid, gene_name, geid_gname = get_translations(stringtie)
|
||||
|
||||
# Add gene names columns to counts files and write out
|
||||
# for publishing to user dir.
|
||||
df_dge = pd.read_csv(dge, sep='\t')
|
||||
df_dge.insert(0, 'gene_name', df_dge.index.map(lambda x: geid_gname.get(x)))
|
||||
df_dge.to_csv('results_dge.tsv', index=True, index_label="gene_id", sep="\t")
|
||||
|
||||
# write_dge(gene_counts, geid_gname, "all_gene_counts.tsv")
|
||||
df_gene_counts = pd.read_csv(gene_counts, sep='\t')
|
||||
df_gene_counts.insert(
|
||||
0, 'gene_name', df_gene_counts.index.map(lambda x: geid_gname.get(x)))
|
||||
df_gene_counts.to_csv(
|
||||
'results_dge.tsv', index=True, index_label="gene_id", sep="\t")
|
||||
|
||||
df_filtered = pd.read_csv(filtered, sep='\t')
|
||||
df_filtered.insert(1, "gene_name", df_filtered.gene_id.map(
|
||||
lambda x: geid_gname.get(x)))
|
||||
df_filtered.to_csv(
|
||||
'filtered_transcript_counts_with_genes.tsv', index=False, sep='\t')
|
||||
|
||||
df_unfiltered = pd.read_csv(unfiltered, sep='\t')
|
||||
df_unfiltered.insert(1, "gene_name", df_unfiltered.gene_id.map(
|
||||
lambda x: geid_gname.get(x)))
|
||||
df_unfiltered.to_csv(
|
||||
'unfiltered_transcript_counts_with_genes.tsv', index=False, sep='\t')
|
||||
|
||||
df_tpm = pd.read_csv(tpm, sep='\t')
|
||||
df_tpm.insert(1, "gene_name", df_tpm.Reference.map(
|
||||
lambda x: gene_txid.get(x)))
|
||||
df_tpm.to_csv("unfiltered_tpm_transcript_counts.tsv", index=False, sep='\t')
|
||||
|
||||
# Add tables to report
|
||||
dge_section(dge, gene_name, pval_threshold)
|
||||
dexseq_section(dexseq, gene_name, pval_threshold)
|
||||
dtu_section(dtu, gene_txid, gene_name)
|
||||
|
||||
@ -1,66 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""Generate per-transcript class sumarrarry files from gffcompare."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from .util import wf_parser # noqa: ABS101
|
||||
|
||||
|
||||
def argparser():
|
||||
"""Argument parser for entrypoint."""
|
||||
parser = wf_parser("generate_tracking_summary")
|
||||
parser.add_argument("--tracking", required=True, help="")
|
||||
parser.add_argument("--output_dir", required=True, help="")
|
||||
parser.add_argument("--annotation", required=False, default=None, help="")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def generate_tracking_summary(tracking_file, output_dir, annotations=None):
|
||||
"""Write per transcript class gffcompare tracking files."""
|
||||
# results/gffcompare/gff_comparestringtie.tracking
|
||||
write_empty_tsvs = False
|
||||
tracking_headings = [
|
||||
"query_transfrag_id", "query_locus_id", "ref_gene_id",
|
||||
"class", "details"]
|
||||
nice_names = {
|
||||
'=': 'complete', 'c': 'contained', 'k': 'containment',
|
||||
'm': 'retained', 'n': 'retained (partial)', 'j': 'multi',
|
||||
'e': 'single', 'o': 'overlap', 's': 'opposite',
|
||||
'x': 'exonic', 'i': 'intron', 'y': 'contains', 'p': 'runon',
|
||||
'r': 'repeat', 'u': 'unknown'}
|
||||
|
||||
if os.path.exists(annotations):
|
||||
tracking = pd.read_csv(
|
||||
tracking_file, sep="\t", names=tracking_headings[1:],
|
||||
index_col=0)
|
||||
|
||||
d = pd.DataFrame(tracking['class'].value_counts()) \
|
||||
.reset_index().rename(columns={'index': 'class', 'class': 'count'})
|
||||
d['description'] = [nice_names[x] for x in d['class']]
|
||||
|
||||
# write a separate table for each class
|
||||
for class_code, table in tracking.groupby('class'):
|
||||
if not write_empty_tsvs and table.empty:
|
||||
sys.stdout("Skipping: No transcripts found for: {}".format(
|
||||
class_code))
|
||||
continue
|
||||
|
||||
path = tracking_file + ".{}.tsv".format(class_code)
|
||||
table.to_csv(path)
|
||||
else:
|
||||
sys.stdout(
|
||||
"Skipping classification summary as no annotation provided.")
|
||||
|
||||
|
||||
def main(args):
|
||||
"""Run entry point."""
|
||||
assert os.path.isfile(args.tracking)
|
||||
assert os.path.isdir(args.output_dir)
|
||||
if args.annotation:
|
||||
os.path.isfile(args.annotation)
|
||||
generate_tracking_summary(
|
||||
args.tracking, output_dir=args.output_dir, annotations=args.annotation)
|
||||
@ -1,68 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""Make report tables and data for plotting."""
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from .util import wf_parser # noqa: ABS101
|
||||
|
||||
|
||||
def argparser():
|
||||
"""Argument parser for entrypoint."""
|
||||
parser = wf_parser("Prepare report data")
|
||||
parser.add_argument(
|
||||
'--sample_id', help="Sample ID", required=True)
|
||||
parser.add_argument(
|
||||
'--gffcompare_dir',
|
||||
help="The gffcompare output directory",
|
||||
required=False,
|
||||
type=Path)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def make_isoform_table(gffcompare_dir, sample_id):
|
||||
"""Make an isoform summary table."""
|
||||
try:
|
||||
tmap_file = next(gffcompare_dir.glob('*.tmap'))
|
||||
except StopIteration:
|
||||
raise ValueError("Cannot find .tmap file in {}".format(gffcompare_dir))
|
||||
dtypes = {
|
||||
'ref_gene_id': str,
|
||||
'ref_id': str,
|
||||
'class_code': str,
|
||||
'qry_id': str,
|
||||
'num_exons': np.uint16,
|
||||
'cov': np.uint32,
|
||||
'len': np.uint32
|
||||
}
|
||||
df = pd.read_csv(
|
||||
tmap_file, sep='\t+',
|
||||
index_col=None,
|
||||
usecols=list(dtypes.keys()),
|
||||
dtype=dtypes)
|
||||
|
||||
if len(df) == 0: # No transcripts. Write a header only result file
|
||||
df = pd.DataFrame(
|
||||
columns=list(dtypes.keys()) + ['sample_id', 'parent gene iso num'])
|
||||
df.to_csv(f'{sample_id}_transcripts_table.tsv', sep='\t', index=False)
|
||||
else:
|
||||
df = df.assign(sample_id=sample_id)
|
||||
|
||||
# Make a column of number of isoforms in parent gene
|
||||
gb = df.groupby(['ref_gene_id']).count()
|
||||
gb.rename(columns={'ref_id': 'num_isoforms'}, inplace=True)
|
||||
|
||||
df['parent gene iso num'] = df.apply(
|
||||
lambda x: gb.loc[(x.ref_gene_id), 'num_isoforms'], axis=1)
|
||||
|
||||
# Unclassified transcripts should not be lumped together
|
||||
df.loc[df.class_code == 'u', 'parent gene iso num'] = None
|
||||
|
||||
df.to_csv(f'{sample_id}_transcripts_table.tsv', sep='\t', index=False)
|
||||
|
||||
|
||||
def main(args):
|
||||
"""Entry point."""
|
||||
if args.gffcompare_dir:
|
||||
make_isoform_table(args.gffcompare_dir, args.sample_id)
|
||||
262
bin/workflow_glue/parse_gffcompare.py
Executable file
262
bin/workflow_glue/parse_gffcompare.py
Executable file
@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env python
|
||||
"""Make report tables and data for plotting."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from .util import get_named_logger, wf_parser # noqa: ABS101
|
||||
|
||||
|
||||
def argparser():
|
||||
"""Argument parser for entrypoint."""
|
||||
parser = wf_parser("Parse gffcompare")
|
||||
parser.add_argument(
|
||||
'--sample_id', help="Sample ID", required=True)
|
||||
parser.add_argument(
|
||||
'--gffcompare_dir',
|
||||
help="The gffcompare output directory",
|
||||
required=False,
|
||||
type=Path)
|
||||
parser.add_argument(
|
||||
'--isoform_table_out',
|
||||
help="Output path for per-isoform table",
|
||||
type=Path)
|
||||
parser.add_argument(
|
||||
'--tracking',
|
||||
help="gffcompare tracking file",
|
||||
type=Path)
|
||||
parser.add_argument(
|
||||
"--annotation",
|
||||
required=False,
|
||||
default=None, help="Reference annotation GFF file")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def _parse_stat_line(sl):
|
||||
"""Parse a stats line."""
|
||||
res = {}
|
||||
tmp = sl.split(':')[1].split('|')
|
||||
res['sensitivity'] = float(tmp[0].strip())
|
||||
res['precision'] = float(tmp[1].strip())
|
||||
return res
|
||||
|
||||
|
||||
def _parse_matching_line(line):
|
||||
"""Parse a matching 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(gffcompare_stats, sample_id, outpath):
|
||||
"""Parse a gffcompare stats file.
|
||||
|
||||
Gffcompare stats file
|
||||
:param gffcompare_stats: 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
|
||||
"""
|
||||
performance = []
|
||||
missed = []
|
||||
novel = []
|
||||
total = []
|
||||
|
||||
with open(gffcompare_stats, 'r') as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if len(line) == 0:
|
||||
continue
|
||||
|
||||
# Parse totals:
|
||||
if line.startswith('# Query mRNAs'):
|
||||
r = _parse_total_line(line)
|
||||
total.append([r['loci'], 'loci', 'query'])
|
||||
total.append([r['transcripts'], 'transcripts', 'query'])
|
||||
total.append([r['me_transcripts'], 'multexonic', 'query'])
|
||||
if line.startswith('# Reference mRNAs '):
|
||||
r = _parse_total_line(line)
|
||||
total.append([r['loci'], 'loci', 'reference'])
|
||||
total.append([r['transcripts'], 'transcripts', 'reference'])
|
||||
total.append([r['me_transcripts'], 'multexonic', 'reference'])
|
||||
|
||||
# Parse basic statistics:
|
||||
if line.startswith('Base level'):
|
||||
st = _parse_stat_line(line)
|
||||
performance.append((st['sensitivity'], 'Sensitivity', 'Base'))
|
||||
performance.append((st['precision'], 'Precision', 'Base'))
|
||||
if line.startswith('Exon level'):
|
||||
st = _parse_stat_line(line)
|
||||
performance.append((st['sensitivity'], 'Sensitivity', 'Exon'))
|
||||
performance.append((st['precision'], 'Precision', 'Exon'))
|
||||
if line.startswith('Intron level'):
|
||||
st = _parse_stat_line(line)
|
||||
performance.append((st['sensitivity'], 'Sensitivity', 'Intron'))
|
||||
performance.append((st['precision'], 'Precision', 'Intron'))
|
||||
if line.startswith('Intron chain level'):
|
||||
st = _parse_stat_line(line)
|
||||
performance.append((st['sensitivity'], 'Sensitivity', 'Intron_chain'))
|
||||
performance.append((st['precision'], 'Precision', 'Intron_chain'))
|
||||
if line.startswith('Transcript level'):
|
||||
st = _parse_stat_line(line)
|
||||
performance.append((st['sensitivity'], 'Sensitivity', 'Transcript'))
|
||||
performance.append((st['precision'], 'Precision', 'Transcript'))
|
||||
if line.startswith('Locus level'):
|
||||
st = _parse_stat_line(line)
|
||||
performance.append((st['sensitivity'], 'Sensitivity', 'Locus'))
|
||||
performance.append((st['precision'], 'Precision', 'Locus'))
|
||||
|
||||
# Parse missing statistics:
|
||||
if line.startswith('Missed exons'):
|
||||
r = _parse_mn_line(line)
|
||||
missed.append((r['value'], 'Missed', 'Exons'))
|
||||
missed.append((r['value_total'], 'total', 'Exons'))
|
||||
missed.append((r['percent'], 'Percent', 'Exons'))
|
||||
if line.startswith('Missed introns'):
|
||||
r = _parse_mn_line(line)
|
||||
missed.append((r['value'], 'Missed', 'Introns'))
|
||||
missed.append((r['value_total'], 'total', 'Introns'))
|
||||
missed.append((r['percent'], 'Percent', 'Introns'))
|
||||
if line.startswith('Missed loci'):
|
||||
r = _parse_mn_line(line)
|
||||
missed.append((r['value'], 'Missed', 'Loci'))
|
||||
missed.append((r['value_total'], 'total', 'Loci'))
|
||||
missed.append((r['percent'], 'Percent', 'Loci'))
|
||||
|
||||
# Parse novel statistics:
|
||||
if line.startswith('Novel exons'):
|
||||
r = _parse_mn_line(line)
|
||||
novel.append((r['value'], 'Novel', 'Exons'))
|
||||
novel.append((r['value_total'], 'Total', 'Exons'))
|
||||
novel.append((r['percent'], 'Percent_novel', 'Exons'))
|
||||
if line.startswith('Novel introns'):
|
||||
r = _parse_mn_line(line)
|
||||
novel.append((r['value'], 'Novel', 'Introns'))
|
||||
novel.append((r['value_total'], 'Total', 'Introns'))
|
||||
novel.append((r['percent'], 'Percent_novel', 'Introns'))
|
||||
if line.startswith('Novel loci'):
|
||||
r = _parse_mn_line(line)
|
||||
novel.append((r['value'], 'Novel', 'Loci'))
|
||||
novel.append((r['value_total'], 'Total', 'Loci'))
|
||||
novel.append((r['percent'], 'Percent_novel', 'Loci'))
|
||||
|
||||
def write_records(records, fn):
|
||||
pd.DataFrame.from_records(records, columns=['counts', 'type', 'source']) \
|
||||
.to_csv(outpath / fn, sep='\t')
|
||||
|
||||
write_records(total, 'Totals.tsv')
|
||||
write_records(missed, 'Missed.tsv')
|
||||
write_records(performance, 'Performance.tsv')
|
||||
write_records(novel, 'Novel.tsv')
|
||||
|
||||
|
||||
def tracking_summary(tracking_file, output_dir, annotations=None):
|
||||
"""Write per transcript class gffcompare tracking files."""
|
||||
tracking_headings = [
|
||||
"query_transfrag_id", "query_locus_id", "ref_gene_id",
|
||||
"class", "details"]
|
||||
nice_names = {
|
||||
'=': 'complete', 'c': 'contained', 'k': 'containment',
|
||||
'm': 'retained', 'n': 'retained (partial)', 'j': 'multi',
|
||||
'e': 'single', 'o': 'overlap', 's': 'opposite',
|
||||
'x': 'exonic', 'i': 'intron', 'y': 'contains', 'p': 'runon',
|
||||
'r': 'repeat', 'u': 'unknown'}
|
||||
|
||||
if os.path.exists(annotations):
|
||||
tracking = pd.read_csv(
|
||||
tracking_file, sep="\t", names=tracking_headings[1:],
|
||||
index_col=0)
|
||||
|
||||
df = (
|
||||
pd.DataFrame(tracking['class'].value_counts())
|
||||
.reset_index()
|
||||
.rename(columns={'index': 'class', 'class': 'Count'})
|
||||
)
|
||||
|
||||
df['Percent'] = round(df['Count'] * 100 / df['Count'].sum(), 2)
|
||||
df['description'] = [nice_names[x] for x in df['class']]
|
||||
|
||||
df = df.sort_values('Count', ascending=True)
|
||||
df.to_csv(output_dir / 'tracking_summary.tsv', sep='\t')
|
||||
|
||||
else:
|
||||
logger = get_named_logger('trackingSum')
|
||||
logger.info("Skipping classification summary as no annotation provided.")
|
||||
|
||||
|
||||
def make_isoform_table(gffcompare_dir, sample_id, outpath):
|
||||
"""Make an isoform summary table."""
|
||||
try:
|
||||
tmap_file = next(gffcompare_dir.glob('*.tmap'))
|
||||
except StopIteration:
|
||||
raise ValueError("Cannot find .tmap file in {}".format(gffcompare_dir))
|
||||
dtypes = {
|
||||
'ref_gene_id': str,
|
||||
'ref_id': str,
|
||||
'class_code': str,
|
||||
'qry_id': str,
|
||||
'num_exons': np.uint16,
|
||||
'cov': np.uint32,
|
||||
'len': np.uint32
|
||||
}
|
||||
df = pd.read_csv(
|
||||
tmap_file, sep='\t+',
|
||||
index_col=None,
|
||||
usecols=list(dtypes.keys()),
|
||||
dtype=dtypes)
|
||||
|
||||
if df.empty: # No transcripts. Write a header only result file
|
||||
df = pd.DataFrame(
|
||||
columns=list(dtypes.keys()) + ['sample_id', 'parent gene iso num'])
|
||||
df.to_csv(f'{sample_id}_transcripts_table.tsv', sep='\t', index=False)
|
||||
else:
|
||||
df = df.assign(sample_id=sample_id)
|
||||
|
||||
# Make a column of number of isoforms in parent gene
|
||||
gb = df.groupby(['ref_gene_id']).count()
|
||||
gb.rename(columns={'ref_id': 'num_isoforms'}, inplace=True)
|
||||
|
||||
df['parent gene iso num'] = df.apply(
|
||||
lambda x: gb.loc[(x.ref_gene_id), 'num_isoforms'], axis=1)
|
||||
|
||||
# Unclassified transcripts should not be lumped together
|
||||
df.loc[df.class_code == 'u', 'parent gene iso num'] = None
|
||||
|
||||
df.to_csv(outpath, sep='\t', index=False)
|
||||
|
||||
|
||||
def main(args):
|
||||
"""Entry point."""
|
||||
if args.gffcompare_dir: # TODO: should this every be optional?
|
||||
stats = args.gffcompare_dir / 'str_merged.stats'
|
||||
parse_gffcmp_stats(stats, args.sample_id, args.gffcompare_dir)
|
||||
make_isoform_table(args.gffcompare_dir, args.sample_id, args.isoform_table_out)
|
||||
tracking_summary(
|
||||
args.tracking, args.gffcompare_dir, args.annotation)
|
||||
296
bin/workflow_glue/reheader_samstream.py
Executable file
296
bin/workflow_glue/reheader_samstream.py
Executable file
@ -0,0 +1,296 @@
|
||||
"""Reheader a SAM in a stream.
|
||||
|
||||
When using the bam2fq -> minimap2 pattern for (re)aligning BAM data, we
|
||||
lose any existing RG and PG headers. This is particularly egregious when
|
||||
handling basecalled data as lines related to dorado basecalling settings
|
||||
as well as dorado RG headers are lost; orphaning RG tags in the reads.
|
||||
This is problematic for downstream anaylses that would like to read the
|
||||
XAM header to intelligently determine how to handle the reads based on
|
||||
the basecaller model and basecaller configuration.
|
||||
|
||||
This script handles:
|
||||
- Inserting RG, PG and CO lines from an existing XAM header into the
|
||||
header of the SAM emitted from minimap2's alignment stream
|
||||
- Inserting a PG header to indicate that a call to bam2fq was made
|
||||
- Updating the first streamed PG.PP parent tag with the last PG.ID
|
||||
of the existing XAM header to maintain a chain of custody
|
||||
- Updating any streamed PG.ID (and PG.PP) tags to avoid collisions
|
||||
with inserted PG.ID
|
||||
|
||||
Handling collisions may seem like overkill but it is anticipated that
|
||||
this script will be called immediately after minimap2, any previous
|
||||
attempt to use minimap2 will lead to ambiguity. This would be the
|
||||
expected case where users have used wf-basecalling or wf-alignment to
|
||||
align a set of reads, only to realign them to another reference (eg.
|
||||
via wf-human-variation). Arguably, we should remove older references to
|
||||
minimap2 as they will have been invalidated by the call to bam2fq but
|
||||
removing PG records and sticking the PG chain back together seems more
|
||||
fraught with annoying future bugs than simply resolving conflicts.
|
||||
|
||||
This script will explode on a stream that contains:
|
||||
- PG lines in the original header where the last PG in the chain is
|
||||
ambiguous, or where the parent PP IDs are not injective
|
||||
- PG lines in the stream that do not appear in the order of their
|
||||
chain (that is if a PG.PP refers to a PG.ID that has not been
|
||||
encountered yet)
|
||||
|
||||
SQ lines are retained after an HD line. That is to say, the most recent
|
||||
set of SQ lines observed after an HD will appear in the final output.
|
||||
SQ, RG, PG and CO lines are emitted as a group together, with elements
|
||||
written out in the order observed.
|
||||
|
||||
PG lines are naively appended to the last PG element in the chain. No
|
||||
attempt is made to keep multiple program chains intact as this can lead
|
||||
to bloated headers. Broken PG metadata is a known problem (see
|
||||
samtools/hts-specs#275) but one that is preferable to headers that
|
||||
become unwieldly large to process: there IS an upper limit to a SAM
|
||||
header's size after all.
|
||||
|
||||
This script takes advantage of minimap2's SAM output to immediately
|
||||
reheader the stream before any downstream calls to other programs pollute
|
||||
the PG header. This script is a little overkill but attempts to be robust
|
||||
with handling PG collisions and more obviously encapsulates reheadering
|
||||
behaviour, and leaves some room to do more clever things as necessary.
|
||||
"""
|
||||
import sys
|
||||
|
||||
from .util import wf_parser # noqa: ABS101
|
||||
|
||||
|
||||
class SamHeader:
|
||||
"""An overkill container to manage merging PG lines in SAM headers.
|
||||
|
||||
Collision handling is simple. If a PG.ID is duplicated by the stream
|
||||
then we add a suffix to its name and keep an eye out for the
|
||||
corresponding PG.PP later. We assume that headers emitted by the
|
||||
stream are chronological because this script should not be called as
|
||||
part of any complicated pipework other than immediately following
|
||||
minimap2.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialise a collision aware PG container."""
|
||||
self.remapped_pgids = {}
|
||||
self.collision_suffix = 0
|
||||
|
||||
# Default HD, in case the new stream does not provide one
|
||||
self.hd = "@HD\tVN:1.6\tSO:unknown"
|
||||
|
||||
# We'll merge RG, CO and PG
|
||||
self.rg_records = []
|
||||
self.co_records = []
|
||||
self.pg_records = []
|
||||
|
||||
# We keep the most recently observed block of SQ records by
|
||||
# resetting SQ on the first SQ seen after non-SQ. We cannot
|
||||
# rely on HD being emitted (as minimap2 does not do this!)
|
||||
self.sq_records = []
|
||||
self.reset_sq = False
|
||||
|
||||
self.observed_rgids = set()
|
||||
self.observed_pgids = set()
|
||||
self.last_pgid = None
|
||||
|
||||
@staticmethod
|
||||
def str_to_record(line):
|
||||
"""Return an appropriate struct for a given string record."""
|
||||
try:
|
||||
record_type, record_data = line.strip().split('\t', 1)
|
||||
except ValueError:
|
||||
raise Exception(f"Record type could not be determined: {line}")
|
||||
|
||||
if len(record_type) > 3:
|
||||
raise Exception(f"Record type malformed: {record_type}")
|
||||
|
||||
record = {}
|
||||
if record_type in ["@HD", "@CO", "@SQ"]:
|
||||
return record_type, record_data
|
||||
elif record_type in ["@RG", "@PG"]:
|
||||
allowed_keys = {
|
||||
"@RG": ["ID", "BC", "CN", "DS", "DT", "FO", "KS", "LB", "PG", "PI", "PL", "PM", "PU", "SM"], # noqa:E501
|
||||
"@PG": ["ID", "PN", "CL", "PP", "DS", "VN"]
|
||||
}
|
||||
for field in record_data.strip().split('\t'):
|
||||
k, v = field.split(':', 1)
|
||||
if k not in allowed_keys[record_type]:
|
||||
raise Exception(f"{record_type} with bad key '{k}': {record_data}")
|
||||
record[k] = v
|
||||
if "ID" not in record:
|
||||
raise Exception(f"{record_type} with no ID: {record_data}")
|
||||
return record_type, record
|
||||
else:
|
||||
raise Exception(f"Unknown record type: {line}")
|
||||
|
||||
@staticmethod
|
||||
def record_to_str(record_type, record_data):
|
||||
"""Form a string from a header record."""
|
||||
if record_type in ["@PG", "@RG"]:
|
||||
tags = [f"{k}:{v}" for k, v in record_data.items()]
|
||||
return f"{record_type}\t" + '\t'.join(tags)
|
||||
elif record_type in ["@SQ", "@CO"]:
|
||||
return f"{record_type}\t{record_data}"
|
||||
|
||||
@staticmethod
|
||||
def resolve_pg_chain(pg_dicts):
|
||||
"""Check links between PG.ID and PP.ID, exploding if inconsistent."""
|
||||
links = {}
|
||||
# Document links between all ID and their PP parent
|
||||
pgids_without_ppid = 0
|
||||
for pgd in pg_dicts:
|
||||
pgid = pgd["ID"]
|
||||
pgpp = pgd.get("PP")
|
||||
links[pgid] = pgpp
|
||||
if pgpp is None:
|
||||
pgids_without_ppid += 1
|
||||
if len(links) > 0:
|
||||
# If there are links, exactly one should have a None parent
|
||||
# to indicate the first PG in the chain. Explode if we see
|
||||
# no head or multiple heads.
|
||||
if pgids_without_ppid == 0:
|
||||
raise Exception("PG chain does not have a head.")
|
||||
elif pgids_without_ppid > 1:
|
||||
raise Exception("PG chain has multiple heads.")
|
||||
for source in links:
|
||||
head = source
|
||||
path = [head]
|
||||
while True:
|
||||
head = links[head]
|
||||
if head is None:
|
||||
break
|
||||
if head in path:
|
||||
path.append(head)
|
||||
raise Exception(f"PG chain appears to contain cycle: {path}")
|
||||
path.append(head)
|
||||
# This function is only really called to catch any explosions
|
||||
# but we'll return the links here as it is useful for testing
|
||||
return links
|
||||
|
||||
def _bump_pg_collider(self):
|
||||
"""Alter the collision suffix after determining a collision."""
|
||||
self.collision_suffix += 1
|
||||
|
||||
def _uncollide_pgid(self, pgid):
|
||||
"""Return an uncollided string for a given PG ID."""
|
||||
new_pgid = f"{pgid}-{self.collision_suffix}"
|
||||
self.remapped_pgids[pgid] = new_pgid
|
||||
self._bump_pg_collider()
|
||||
return new_pgid
|
||||
|
||||
def add_line(self, line):
|
||||
"""Add a header line to the header."""
|
||||
record_type, record = self.str_to_record(line)
|
||||
|
||||
if record_type == "@HD":
|
||||
self.hd = f"@HD\t{record}"
|
||||
elif record_type == "@CO":
|
||||
self.co_records.append(record)
|
||||
elif record_type == "@SQ":
|
||||
if self.reset_sq:
|
||||
self.sq_records = []
|
||||
self.reset_sq = False
|
||||
self.sq_records.append(record)
|
||||
elif record_type == "@RG":
|
||||
rgid = record["ID"]
|
||||
if rgid not in self.observed_rgids:
|
||||
self.observed_rgids.add(rgid)
|
||||
self.rg_records.append(record)
|
||||
elif record not in self.rg_records:
|
||||
# if rgid has been seen before, abort if this record is different
|
||||
raise Exception(
|
||||
f"Duplicate RG with ID '{rgid}' conflicts with previously seen RG with same ID." # noqa:E501
|
||||
)
|
||||
elif record_type == "@PG":
|
||||
pgid = record["ID"]
|
||||
if pgid in self.observed_pgids:
|
||||
# collision, rewrite the pgid
|
||||
pgid = self._uncollide_pgid(pgid)
|
||||
record["ID"] = pgid
|
||||
else:
|
||||
self.observed_pgids.add(pgid)
|
||||
|
||||
# maintain chain
|
||||
ppid = record.get("PP")
|
||||
if not ppid:
|
||||
# record has no parent, this is either
|
||||
# - the first record (last_pgid is None) so is the tail
|
||||
# - an inserted record that needs its parent to be the current tail
|
||||
if not self.last_pgid:
|
||||
self.last_pgid = pgid
|
||||
else:
|
||||
record["PP"] = self.last_pgid
|
||||
self.last_pgid = pgid
|
||||
else:
|
||||
if ppid not in self.observed_pgids:
|
||||
raise Exception(
|
||||
f"Encountered PG.PP '{ppid}' before observing corresponding PG.ID" # noqa:E501
|
||||
)
|
||||
# remap parent id (if needed)
|
||||
record["PP"] = self.remapped_pgids.get(ppid, ppid)
|
||||
# set tail to this record
|
||||
self.last_pgid = pgid
|
||||
|
||||
self.pg_records.append(record)
|
||||
|
||||
if len(self.sq_records) > 0 and record_type != '@SQ':
|
||||
self.reset_sq = True
|
||||
|
||||
return record
|
||||
|
||||
def write_header(self, fh):
|
||||
"""Write this header to a file handle."""
|
||||
self.resolve_pg_chain(self.pg_records) # check PG header
|
||||
fh.write(f"{self.hd}\n")
|
||||
for sq in self.sq_records:
|
||||
fh.write(self.record_to_str("@SQ", sq) + '\n')
|
||||
for rg in self.rg_records:
|
||||
fh.write(self.record_to_str("@RG", rg) + '\n')
|
||||
for pg in self.pg_records:
|
||||
fh.write(self.record_to_str("@PG", pg) + '\n')
|
||||
for co in self.co_records:
|
||||
fh.write(self.record_to_str("@CO", co) + '\n')
|
||||
|
||||
|
||||
def reheader_samstream(header_in, stream_in, stream_out, args):
|
||||
"""Run reheader_samstream."""
|
||||
# read original header into container
|
||||
sh = SamHeader()
|
||||
for line in header_in:
|
||||
sh.add_line(line)
|
||||
|
||||
# append user provided lines to container
|
||||
for line in args.insert:
|
||||
sh.add_line(line)
|
||||
|
||||
# read the header portion of the minimap2 stream
|
||||
wrote_header = False
|
||||
for line in stream_in:
|
||||
if line[0] != '@':
|
||||
# write out header on first alignment
|
||||
sh.write_header(stream_out)
|
||||
wrote_header = True
|
||||
# and actually write the first alignment
|
||||
stream_out.write(line)
|
||||
break
|
||||
sh.add_line(line)
|
||||
|
||||
# Pass through the rest of the alignments
|
||||
for line in stream_in:
|
||||
stream_out.write(line)
|
||||
# If there were no alignments, we won't have hit the != @ case in the first stdin,
|
||||
# and we won't have written the header out. Write a header if we haven't already.
|
||||
if not wrote_header:
|
||||
sh.write_header(stream_out)
|
||||
|
||||
|
||||
def argparser():
|
||||
"""Argument parser for entrypoint."""
|
||||
parser = wf_parser("reheader_samstream")
|
||||
parser.add_argument("header_in")
|
||||
parser.add_argument("--insert", action="append", default=[])
|
||||
return parser
|
||||
|
||||
|
||||
def main(args):
|
||||
"""reheader_samstream default entry point."""
|
||||
with open(args.header_in) as header_in:
|
||||
reheader_samstream(header_in, sys.stdin, sys.stdout, args)
|
||||
File diff suppressed because it is too large
Load Diff
74
bin/workflow_glue/summarise_gff.py
Executable file
74
bin/workflow_glue/summarise_gff.py
Executable file
@ -0,0 +1,74 @@
|
||||
"""Get summary statistics from GFF file."""
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
import pickle
|
||||
|
||||
import gffutils
|
||||
from .util import wf_parser # noqa: ABS101
|
||||
|
||||
|
||||
def argparser():
|
||||
"""Argument parser for entrypoint."""
|
||||
parser = wf_parser("summ_gff")
|
||||
parser.add_argument(
|
||||
"gff",
|
||||
help="Report output file",
|
||||
type=Path)
|
||||
parser.add_argument(
|
||||
"sample_id",
|
||||
help="Output TSV file path")
|
||||
parser.add_argument(
|
||||
"out",
|
||||
default="gff_summary.tsv",
|
||||
help="Output TSV file path",
|
||||
type=Path)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(args):
|
||||
"""Entry point."""
|
||||
db = gffutils.create_db(
|
||||
str(args.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 = Counter()
|
||||
|
||||
for gene in db.features_of_type('gene'):
|
||||
|
||||
n_isos = len(list(db.children(gene, featuretype='transcript')))
|
||||
isoforms_per_gene[n_isos] += 1
|
||||
|
||||
for transcript in db.children(
|
||||
gene, featuretype='transcript', order_by='start'):
|
||||
tr_len = 0
|
||||
exons = list(db.children(transcript, featuretype='exon'))
|
||||
if len(exons) == 0:
|
||||
continue
|
||||
exons_per_transcript[len(exons)] += 1
|
||||
for ex in exons:
|
||||
tr_len += abs(ex.end - ex.start)
|
||||
|
||||
transcript_lens.append(tr_len)
|
||||
|
||||
results = {
|
||||
'sample_id': args.sample_id,
|
||||
'summaries': {
|
||||
'Total genes': [num_genes],
|
||||
'Total transcripts': [num_transcripts],
|
||||
'Max trans. len': max(transcript_lens),
|
||||
'Min trans. len': min(transcript_lens)
|
||||
},
|
||||
'transcript_lengths': transcript_lens,
|
||||
'exons_per_transcript': exons_per_transcript,
|
||||
'isoforms_per_gene': isoforms_per_gene
|
||||
}
|
||||
|
||||
with open(args.out, 'wb') as fh:
|
||||
pickle.dump(results, fh)
|
||||
@ -1,10 +0,0 @@
|
||||
"""A dummy test."""
|
||||
|
||||
import argparse
|
||||
|
||||
from workflow_glue import report
|
||||
|
||||
|
||||
def test():
|
||||
"""Just showing that we can import using the workflow-glue."""
|
||||
assert isinstance(report.argparser(), argparse.ArgumentParser)
|
||||
@ -3,13 +3,13 @@ Output files may be aggregated including information for all samples or provided
|
||||
| Title | File path | Description | Per sample or aggregated |
|
||||
|-------|-----------|-------------|--------------------------|
|
||||
| workflow report | wf-transcriptomes-report.html | a HTML report document detailing the primary findings of the workflow | aggregated |
|
||||
| Per file read stats | fastq_ingress_results/reads/fastcat_stats/per-file-stats.tsv | A TSV with per file read stats, including all samples. | aggregated |
|
||||
| Read stats | fastq_ingress_results/reads/fastcat_stats/per-read-stats.tsv | A TSV with per read stats, including all samples. | aggregated |
|
||||
| Run ID's | fastq_ingress_results/reads/fastcat_stats/run_ids | List of run IDs present in reads. | aggregated |
|
||||
| Meta map json | fastq_ingress_results/reads/metamap.json | Metadata used in workflow presented in a JSON. | aggregated |
|
||||
| Concatenated sequence data | fastq_ingress_results/reads/{{ alias }}.fastq.gz | Per sample reads concatenated in to one FASTQ file. | per-sample |
|
||||
| Assembled transcriptome | {{ alias }}_transcriptome.fas | Per sample assembled transcriptome. | per-sample |
|
||||
| Annotated assembled transcriptome | {{ alias }}_merged_transcriptome.fas | Per sample annotated assembled transcriptome. | per-sample |
|
||||
| Per file read stats | fastq_ingress_results/{{ alias }}//reads/fastcat_stats/per-file-stats.tsv | A TSV with per file read stats, including all samples. | aggregated |
|
||||
| Read stats | fastq_ingress_results/{{ alias }}//reads/fastcat_stats/per-read-stats.tsv | A TSV with per read stats, including all samples. | aggregated |
|
||||
| Run ID's | fastq_ingress_results/{{ alias }}//reads/fastcat_stats/run_ids | List of run IDs present in reads. | aggregated |
|
||||
| Meta map json | fastq_ingress_results/{{ alias }}//reads/metamap.json | Metadata used in workflow presented in a JSON. | aggregated |
|
||||
| Concatenated sequence data | fastq_ingress_results/{{ alias }}//reads/{{ alias }}.fastq.gz | Per sample reads concatenated in to one FASTQ file. | per-sample |
|
||||
| Assembled transcriptome | {{ alias }}_transcriptome.fas | Per sample assembled transcriptome. Not output if a reference annotation was supplied | per-sample |
|
||||
| Annotated assembled transcriptome | {{ alias }}_merged_transcriptome.fas | Per sample annotated assembled transcriptome. Only output if a reference annotation was supplied | per-sample |
|
||||
| Alignment summary statistics | {{ alias }}_read_aln_stats.tsv | Per sample alignment summary statistics. | per-sample |
|
||||
| GFF compare results. | {{ alias }}_gffcompare | All GFF compare output files. | per-sample |
|
||||
| Differential gene expression results | de_analysis/results_dge.tsv | This is a gene-level result file that describes genes and their probability of showing differential expression between experimental conditions. | aggregated |
|
||||
|
||||
@ -329,15 +329,19 @@ def xam_ingress(Map arguments)
|
||||
// - too many aligned files to safely and quickly merge (`samtools merge` opens
|
||||
// all files at the same time and some machines might have low limits for
|
||||
// open file descriptors)
|
||||
// * to_merge: flatMap > sort > group > merge
|
||||
// * to_sortmerge: flatMap > sort > group > merge
|
||||
// * to_merge: flatMap > group > merge
|
||||
// - between 1 and `N_OPEN_FILES_LIMIT` aligned files
|
||||
no_files: n_files == 0
|
||||
no_files: \
|
||||
n_files == 0
|
||||
indexed: \
|
||||
n_files == 1 && (meta["is_unaligned"] || meta["is_sorted"]) && meta["src_xai"]
|
||||
to_index: \
|
||||
n_files == 1 && (meta["is_unaligned"] || meta["is_sorted"]) && !meta["src_xai"]
|
||||
to_catsort: \
|
||||
(n_files == 1) || (n_files > N_OPEN_FILES_LIMIT) || meta["is_unaligned"]
|
||||
to_sortmerge: \
|
||||
!meta["is_sorted"]
|
||||
to_merge: true
|
||||
}
|
||||
|
||||
@ -345,6 +349,7 @@ def xam_ingress(Map arguments)
|
||||
// only run samtools fastq on samples with at least one file
|
||||
ch_to_fastq = ch_result.indexed.mix(
|
||||
ch_result.to_index,
|
||||
ch_result.to_sortmerge,
|
||||
ch_result.to_merge,
|
||||
ch_result.to_catsort
|
||||
)
|
||||
@ -385,10 +390,13 @@ def xam_ingress(Map arguments)
|
||||
}
|
||||
|
||||
// deal with samples with few-enough files for `samtools merge` first
|
||||
ch_merged = ch_result.to_merge
|
||||
// we'll sort any unsorted files before merge
|
||||
ch_merged = ch_result.to_sortmerge
|
||||
| flatMap { meta, paths -> paths.collect { [meta, it] } }
|
||||
| sortBam
|
||||
| map { meta, bam, bai -> [meta, bam] } // drop index as merge does not need it
|
||||
| groupTuple
|
||||
| mix(ch_result.to_merge)
|
||||
| mergeBams
|
||||
| map{
|
||||
meta, bam, bai ->
|
||||
@ -650,12 +658,13 @@ process validateIndex {
|
||||
|
||||
// Sort FOFN for samtools merge to ensure samtools sort breaks ties deterministically.
|
||||
// Uses -c to ensure matching RG.IDs across multiple inputs are not unnecessarily modified to avoid collisions.
|
||||
// Note that samtools merge does not use the indexes so we do not provide them
|
||||
process mergeBams {
|
||||
label "ingress"
|
||||
label "wf_common"
|
||||
cpus 3
|
||||
memory "4 GB"
|
||||
input: tuple val(meta), path("input_bams/reads*.bam"), path("input_bams/reads*.bam.bai")
|
||||
input: tuple val(meta), path("input_bams/reads*.bam")
|
||||
output: tuple val(meta), path("reads.bam"), path("reads.bam.bai")
|
||||
script:
|
||||
def merge_threads = Math.max(1, task.cpus - 1)
|
||||
|
||||
170
main.nf
170
main.nf
@ -264,7 +264,7 @@ process assemble_transcripts{
|
||||
|
||||
process merge_gff_bundles{
|
||||
/*
|
||||
Merge gff bundles into a single gff file per sample.
|
||||
Merge gff bundles into a single gff file per sample, and get summary statistics
|
||||
*/
|
||||
label 'isoforms'
|
||||
cpus params.threads
|
||||
@ -274,6 +274,7 @@ process merge_gff_bundles{
|
||||
tuple val(sample_id), path (gff_bundle)
|
||||
output:
|
||||
tuple val(sample_id), path("${sample_id}.gff"), emit: gff
|
||||
tuple val(sample_id), path("transcriptome_summary.pickle"), emit: summary
|
||||
script:
|
||||
def merged_gff = "${sample_id}.gff"
|
||||
"""
|
||||
@ -285,6 +286,11 @@ process merge_gff_bundles{
|
||||
grep -v '#' \$fn >> $merged_gff
|
||||
|
||||
done
|
||||
|
||||
workflow-glue summarise_gff \
|
||||
$merged_gff \
|
||||
$sample_id \
|
||||
transcriptome_summary.pickle
|
||||
"""
|
||||
}
|
||||
|
||||
@ -315,17 +321,16 @@ process run_gffcompare{
|
||||
gffcompare -o ${out_dir}/str_merged -r ${ref_annotation} \
|
||||
${params.gffcompare_opts} ${query_annotation}
|
||||
|
||||
workflow-glue generate_tracking_summary --tracking $out_dir/str_merged.tracking \
|
||||
--output_dir ${out_dir} --annotation ${ref_annotation}
|
||||
|
||||
mv *.tmap "${out_dir}"
|
||||
mv *.refmap "${out_dir}"
|
||||
cp "${out_dir}/str_merged.annotated.gtf" "${sample_id}_annotated.gtf"
|
||||
|
||||
# Make an isoform table for report and user output.
|
||||
workflow-glue make_isoform_table \
|
||||
workflow-glue parse_gffcompare \
|
||||
--sample_id "${sample_id}" \
|
||||
--gffcompare_dir "${out_dir}"
|
||||
--gffcompare_dir "${out_dir}" \
|
||||
--isoform_table_out "${sample_id}_transcripts_table.tsv" \
|
||||
--tracking $out_dir/str_merged.tracking \
|
||||
--annotation ${ref_annotation}
|
||||
"""
|
||||
}
|
||||
|
||||
@ -337,10 +342,9 @@ process get_transcriptome{
|
||||
cpus 1
|
||||
memory "2 GB"
|
||||
input:
|
||||
tuple val(sample_id), path(transcripts_gff), path(gffcmp_dir), path(reference_seq)
|
||||
tuple val(sample_id), path("transcripts.gff"), path(gffcompare_dir), path("reference.fa")
|
||||
output:
|
||||
tuple val(sample_id), path("*.fas"), emit: transcriptome
|
||||
|
||||
tuple val(sample_id), path("*transcriptome.fas"), emit: transcriptome
|
||||
script:
|
||||
def transcriptome = "${sample_id}_transcriptome.fas"
|
||||
def merged_transcriptome = "${sample_id}_merged_transcriptome.fas"
|
||||
@ -348,15 +352,11 @@ process get_transcriptome{
|
||||
// so skip getting transcriptome FASTA from the annotated files.
|
||||
if (params.ref_annotation){
|
||||
"""
|
||||
gffread -g ${reference_seq} -w ${transcriptome} ${transcripts_gff}
|
||||
if [ "\$(ls -A $gffcmp_dir)" ];
|
||||
then
|
||||
gffread -F -g ${reference_seq} -w ${merged_transcriptome} $gffcmp_dir/str_merged.annotated.gtf
|
||||
fi
|
||||
gffread -F -g reference.fa -w ${merged_transcriptome} $gffcompare_dir/str_merged.annotated.gtf
|
||||
"""
|
||||
} else {
|
||||
"""
|
||||
gffread -g ${reference_seq} -w ${transcriptome} ${transcripts_gff}
|
||||
gffread -g reference.fa -w ${transcriptome} "transcripts.gff"
|
||||
"""
|
||||
}
|
||||
}
|
||||
@ -387,75 +387,59 @@ process merge_transcriptomes {
|
||||
|
||||
process makeReport {
|
||||
|
||||
label "isoforms"
|
||||
label "wf_common"
|
||||
cpus 2
|
||||
memory "4 GB"
|
||||
|
||||
publishDir "${params.out_dir}", mode: 'copy', pattern: "wf-transcriptomes-report.html"
|
||||
input:
|
||||
val metadata
|
||||
path stats, stageAs: "stats_*"
|
||||
path versions
|
||||
val wf_version
|
||||
path "params.json"
|
||||
path "pychopper_report/*"
|
||||
path "per_read_stats/?.gz"
|
||||
path "aln_stats/*"
|
||||
path "gffcmp_dir/*"
|
||||
path "gff_annotation/*"
|
||||
path "de_report/*"
|
||||
path "seqkit/*"
|
||||
path "isoforms_table/*"
|
||||
path "transcriptome_aln_stats/*"
|
||||
path pychopper, stageAs: "pychopper_report/*"
|
||||
path aln_stats, stageAs: "aln_stats/*"
|
||||
path gffcmp_dir, stageAs: "gffcmp_dir/*"
|
||||
path gff_annotation, stageAs: "gff_annotation/*"
|
||||
path de_report, stageAs: "de_report/*"
|
||||
path isoforms_table, stageAs: "isoforms_table/*"
|
||||
path "transcriptome_summary/summary_*.tsv"
|
||||
|
||||
output:
|
||||
path ("wf-transcriptomes-*.html"), emit: report
|
||||
// If de analysis has been run output the counts files with gene name added.
|
||||
path ("results_dge.tsv"), emit: results_dge, optional: true
|
||||
path ("unfiltered_tpm_transcript_counts.tsv"), emit: tpm, optional: true
|
||||
path ("unfiltered_transcript_counts_with_genes.tsv"), emit: unfiltered, optional: true
|
||||
path ("filtered_transcript_counts_with_genes.tsv"), emit: filtered, optional: true
|
||||
path ("all_gene_counts.tsv"), emit: gene_counts, optional: true
|
||||
shell:
|
||||
report_name = "wf-transcriptomes-report.html"
|
||||
'''
|
||||
if [ -f "de_report/OPTIONAL_FILE" ]; then
|
||||
dereport=""
|
||||
else
|
||||
dereport="--de_report true --de_stats "seqkit/*""
|
||||
mv de_report/*.g*f* de_report/stringtie_merged.gtf
|
||||
fi
|
||||
if [ -f "gff_annotation/OPTIONAL_FILE" ]; then
|
||||
OPT_GFF_ANNOTATION=""
|
||||
else
|
||||
OPT_GFF_ANNOTATION="--gff_annotation gff_annotation/*"
|
||||
fi
|
||||
if [ -f "gffcmp_dir/OPTIONAL_FILE" ]; then
|
||||
OPT_GFFCMP_DIR=""
|
||||
else
|
||||
OPT_GFFCMP_DIR="--gffcompare_dir gffcmp_dir/"
|
||||
fi
|
||||
if [ -f "aln_stats/OPTIONAL_FILE" ]; then
|
||||
OPT_ALN=""
|
||||
else
|
||||
OPT_ALN="--alignment_stats aln_stats/*"
|
||||
fi
|
||||
if [ -f "pychopper_report/OPTIONAL_FILE" ]; then
|
||||
OPT_PC_REPORT=""
|
||||
else
|
||||
OPT_PC_REPORT="--pychop_report pychopper_report/*"
|
||||
fi
|
||||
if [ -f "isoforms_table/OPTIONAL_FILE" ]; then
|
||||
OPT_ISO_TABLE=""
|
||||
else
|
||||
OPT_ISO_TABLE="--isoform_table isoforms_table"
|
||||
fi
|
||||
workflow-glue report --report !{report_name} \
|
||||
--versions !{versions} \
|
||||
--params params.json \
|
||||
${OPT_ALN} \
|
||||
${OPT_PC_REPORT} \
|
||||
--stats per_read_stats/* \
|
||||
${OPT_GFF_ANNOTATION} \
|
||||
${OPT_ISO_TABLE} \
|
||||
${OPT_GFFCMP_DIR} \
|
||||
--isoform_table_nrows !{params.isoform_table_nrows} \
|
||||
${dereport}
|
||||
'''
|
||||
script:
|
||||
String report_name = "wf-transcriptomes-report.html"
|
||||
String metadata = new JsonBuilder(metadata).toPrettyString()
|
||||
String gff_opts = gff_annotation.fileName.name == OPTIONAL_FILE.name ? "" : "--gff_annotation gff_annotation/"
|
||||
String de_report_opts = de_report.fileName.name == OPTIONAL_FILE.name ? "" : "--de_report de_report/ --de_stats transcriptome_aln_stats/"
|
||||
String gffcmp_opts = gffcmp_dir.fileName.name == OPTIONAL_FILE.name ? "" : "--gffcompare_dir gffcmp_dir/"
|
||||
String aln_stats_opts = aln_stats.fileName.name == OPTIONAL_FILE.name ? "" : "--alignment_stats aln_stats/"
|
||||
String pychop_opts = pychopper.fileName.name == OPTIONAL_FILE.name ? "" : "--pychop_report pychopper_report/"
|
||||
String iso_table_opts = isoforms_table.fileName.name == OPTIONAL_FILE.name ? "" : "--isoform_table isoforms_table/"
|
||||
"""
|
||||
echo '${metadata}' > metadata.json
|
||||
workflow-glue report \
|
||||
--report $report_name \
|
||||
--versions $versions \
|
||||
--wf_version $wf_version \
|
||||
--params params.json \
|
||||
$aln_stats_opts \
|
||||
$pychop_opts \
|
||||
--stats $stats \
|
||||
--metadata metadata.json \
|
||||
$gff_opts \
|
||||
$iso_table_opts \
|
||||
$gffcmp_opts \
|
||||
--isoform_table_nrows ${params.isoform_table_nrows} \
|
||||
$de_report_opts \
|
||||
--transcriptome_summary transcriptome_summary/
|
||||
"""
|
||||
}
|
||||
|
||||
|
||||
@ -598,9 +582,15 @@ workflow pipeline {
|
||||
}
|
||||
|
||||
fastq_ingress_results = reads
|
||||
// replace `null` with path to optional file
|
||||
| map { [ it[0], it[1] ?: OPTIONAL_FILE, it[2] ?: OPTIONAL_FILE ] }
|
||||
| collectFastqIngressResultsInDir
|
||||
// fastq_ingress doesn't have the index; add one extra null for compatibility.
|
||||
// We do not use variable name as assigning variable name with a tuple
|
||||
// not matching (e.g. meta, bam, bai, stats <- [meta, bam, stats]) causes
|
||||
// the workflow to crash.
|
||||
reads = reads
|
||||
.map{
|
||||
it.size() == 4 ? it : [it[0], it[1], null, it[2]]
|
||||
}
|
||||
map_sample_ids_cls = {it ->
|
||||
/* Harmonize tuples
|
||||
output:
|
||||
@ -629,9 +619,8 @@ workflow pipeline {
|
||||
String publish_bams = "BAMS"
|
||||
software_versions = getVersions()
|
||||
workflow_params = getParams()
|
||||
input_reads = reads.map{ meta, samples, stats -> [meta, samples]}
|
||||
input_reads = reads.map{ meta, samples, index, stats -> [meta, samples]}
|
||||
sample_ids = input_reads.flatMap({meta,samples -> meta.alias})
|
||||
per_read_stats = reads.map{ meta, samples, stats -> stats.resolve("per-read-stats.tsv.gz") }.toList()
|
||||
|
||||
if (!params.direct_rna){
|
||||
preprocess_reads(input_reads)
|
||||
@ -691,7 +680,6 @@ workflow pipeline {
|
||||
merge_gff = OPTIONAL_FILE
|
||||
assembly_stats = OPTIONAL_FILE
|
||||
use_ref_ann = false
|
||||
|
||||
}
|
||||
if (params.de_analysis){
|
||||
sample_sheet = file(params.sample_sheet, type:"file")
|
||||
@ -716,24 +704,35 @@ workflow pipeline {
|
||||
}
|
||||
de = differential_expression(transcriptome, input_reads, sample_sheet, gtf)
|
||||
de_report = de.all_de
|
||||
count_transcripts_file = de.count_transcripts
|
||||
de_outputs = de.de_outputs
|
||||
count_transcripts_file = de.count_transcripts
|
||||
} else{
|
||||
de_report = OPTIONAL_FILE
|
||||
count_transcripts_file = OPTIONAL_FILE
|
||||
}
|
||||
|
||||
// get metadata and stats files, keeping them ordered (could do with transpose I suppose)
|
||||
reads.multiMap{ meta, path, index, stats ->
|
||||
meta: meta
|
||||
stats: stats
|
||||
}.set { for_report }
|
||||
metadata = for_report.meta.collect()
|
||||
stats = for_report.stats.collect()
|
||||
|
||||
makeReport(
|
||||
metadata,
|
||||
stats,
|
||||
software_versions,
|
||||
workflow.manifest.version,
|
||||
workflow_params,
|
||||
count_transcripts_file,
|
||||
pychopper_report,
|
||||
per_read_stats,
|
||||
assembly_stats,
|
||||
gff_compare,
|
||||
merge_gff,
|
||||
de_report,
|
||||
count_transcripts_file,
|
||||
isoforms_table)
|
||||
isoforms_table,
|
||||
merge_gff_bundles.out.summary.map {it[1]}.collect())
|
||||
|
||||
report = makeReport.out.report
|
||||
|
||||
@ -769,6 +768,7 @@ workflow pipeline {
|
||||
results = results.concat(de_results.map{ [it, "de_analysis"] })
|
||||
}
|
||||
|
||||
|
||||
results.concat(workflow_params.map{ [it, null]})
|
||||
// IGV config
|
||||
if (params.transcriptome_source == "precomputed" && params.igv){
|
||||
@ -822,7 +822,7 @@ workflow pipeline {
|
||||
// get list of file names
|
||||
// Absolute paths required for directories
|
||||
igv_files = reads
|
||||
| map { meta, sample, stats -> meta.alias }
|
||||
| map { meta, sample, index, stats -> meta.alias }
|
||||
| toSortedList
|
||||
| map { list -> list.collect{
|
||||
[
|
||||
@ -830,9 +830,9 @@ workflow pipeline {
|
||||
"$publish_bams/${it}_reads_aln_sorted.bam.bai"
|
||||
]
|
||||
} }
|
||||
| concat ( igv_index)
|
||||
| flatten
|
||||
| concat (igv_ref)
|
||||
| flatten
|
||||
| concat ( igv_index)
|
||||
| concat (gz_igv)
|
||||
| flatten
|
||||
| collectFile(name: "file-names.txt", newLine: true, sort: false)
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
"type": "aggregated"
|
||||
},
|
||||
"read-stats-per-file": {
|
||||
"filepath": "fastq_ingress_results/reads/fastcat_stats/per-file-stats.tsv",
|
||||
"filepath": "fastq_ingress_results/{{ alias }}//reads/fastcat_stats/per-file-stats.tsv",
|
||||
"title": "Per file read stats",
|
||||
"description": "A TSV with per file read stats, including all samples.",
|
||||
"mime-type": "text/tab-separated-values",
|
||||
@ -17,7 +17,7 @@
|
||||
"type": "aggregated"
|
||||
},
|
||||
"read-stats-per-read": {
|
||||
"filepath": "fastq_ingress_results/reads/fastcat_stats/per-read-stats.tsv",
|
||||
"filepath": "fastq_ingress_results/{{ alias }}//reads/fastcat_stats/per-read-stats.tsv",
|
||||
"title": "Read stats",
|
||||
"description": "A TSV with per read stats, including all samples.",
|
||||
"mime-type": "text/tab-separated-values",
|
||||
@ -25,7 +25,7 @@
|
||||
"type": "aggregated"
|
||||
},
|
||||
"run-ids": {
|
||||
"filepath": "fastq_ingress_results/reads/fastcat_stats/run_ids",
|
||||
"filepath": "fastq_ingress_results/{{ alias }}//reads/fastcat_stats/run_ids",
|
||||
"title": "Run ID's",
|
||||
"description": "List of run IDs present in reads.",
|
||||
"mime-type": "text/txt",
|
||||
@ -33,7 +33,7 @@
|
||||
"type": "aggregated"
|
||||
},
|
||||
"metamap": {
|
||||
"filepath": "fastq_ingress_results/reads/metamap.json",
|
||||
"filepath": "fastq_ingress_results/{{ alias }}//reads/metamap.json",
|
||||
"title": "Meta map json",
|
||||
"description": "Metadata used in workflow presented in a JSON.",
|
||||
"mime-type": "text/json",
|
||||
@ -41,7 +41,7 @@
|
||||
"type": "aggregated"
|
||||
},
|
||||
"sample-data": {
|
||||
"filepath": "fastq_ingress_results/reads/{{ alias }}.fastq.gz",
|
||||
"filepath": "fastq_ingress_results/{{ alias }}//reads/{{ alias }}.fastq.gz",
|
||||
"title": "Concatenated sequence data",
|
||||
"description": "Per sample reads concatenated in to one FASTQ file.",
|
||||
"mime-type": "text/json",
|
||||
@ -51,7 +51,7 @@
|
||||
"transcriptome": {
|
||||
"filepath": "{{ alias }}_transcriptome.fas",
|
||||
"title": "Assembled transcriptome",
|
||||
"description": "Per sample assembled transcriptome.",
|
||||
"description": "Per sample assembled transcriptome. Not output if a reference annotation was supplied",
|
||||
"mime-type": "text/x-fasta",
|
||||
"optional": true,
|
||||
"type": "per-sample"
|
||||
@ -59,7 +59,7 @@
|
||||
"merged_transcriptome": {
|
||||
"filepath": "{{ alias }}_merged_transcriptome.fas",
|
||||
"title": "Annotated assembled transcriptome",
|
||||
"description": "Per sample annotated assembled transcriptome.",
|
||||
"description": "Per sample annotated assembled transcriptome. Only output if a reference annotation was supplied",
|
||||
"mime-type": "text/x-fasta",
|
||||
"optional": true,
|
||||
"type": "per-sample"
|
||||
|
||||
@ -28,11 +28,14 @@ process map_reads{
|
||||
| samtools view -q ${params.minimum_mapping_quality} -F 2304 -Sb -\
|
||||
| seqkit bam -j 1 -x -T '${ContextFilter}' -\
|
||||
| samtools sort --write-index -@ 1 -o "${sample_id}_reads_aln_sorted.bam##idx##${sample_id}_reads_aln_sorted.bam.bai" - ;
|
||||
((cat "${sample_id}_reads_aln_sorted.bam" | seqkit bam -s -j 1 - 2>&1) | tee ${sample_id}_read_aln_stats.tsv ) || true
|
||||
((cat "${sample_id}_reads_aln_sorted.bam" | seqkit bam -s -j 1 - 2>&1) | tee "${sample_id}_read_aln_stats.tsv" ) || true
|
||||
|
||||
# Add sample id header and column
|
||||
sed "s/\$/${sample_id}/" "${sample_id}_read_aln_stats.tsv" \
|
||||
| sed "1 s/${sample_id}/sample_id/" > tmp
|
||||
# Add sample id header and column; remove last column (File)
|
||||
cat "${sample_id}_read_aln_stats.tsv" \
|
||||
| sed "s/^/${sample_id} /" \
|
||||
| sed "1 s/^${sample_id}/sample_id/" \
|
||||
| awk 'NF{NF-=1};1' \
|
||||
> tmp
|
||||
mv tmp "${sample_id}_read_aln_stats.tsv"
|
||||
|
||||
if [[ -s "internal_priming_fail.tsv" ]];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user