Skip to content

Commit 7558cd9

Browse files
committed
allow sgb_to_gtdb to take merged profiles
1 parent 95a80f2 commit 7558cd9

7 files changed

Lines changed: 175 additions & 58 deletions

metaphlan/utils/merge_metaphlan_tables.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,11 @@ def merge(aaastrIn, ostm, gtdb):
2727
if not headers:
2828
print(f"merge_metaphlan_tables: file {f} has no headers with metaphlan version or is improperly formatted.")
2929
return
30-
listmpaVersion.add(headers[0])
30+
listmpaVersion.add(headers[0].split('\t')[0])
3131
names = headers[-1].split('#')[1].strip().split('\t')
3232

3333
if len(listmpaVersion) > 1:
34-
print('merge_metaphlan_tables: profiles from differrent versions of MetaPhlAn, please profile your '
34+
print('merge_metaphlan_tables: profiles from different versions of MetaPhlAn, please profile your '
3535
'samples using the same MetaPhlAn version.\n')
3636
return
3737

@@ -40,6 +40,8 @@ def merge(aaastrIn, ostm, gtdb):
4040
name=os.path.splitext(os.path.basename(f))[0].replace('_profile', '')))
4141

4242
merged_tables = pd.concat([merged_tables, pd.concat(profiles_list, axis=1).fillna(0)], axis=1).fillna(0)
43+
separator = '|' if not gtdb else ';'
44+
merged_tables = merged_tables.sort_index(key=lambda idx: idx.str.split(separator).str.len())
4345
ostm.write(list(listmpaVersion)[0]+'\n')
4446
merged_tables.to_csv(ostm, sep='\t')
4547

metaphlan/utils/mpa_vJan21_CHOCOPhlAnSGB_202103_SGB2GTDB.tsv renamed to metaphlan/utils/mpa_vJan21_CHOCOPhlAnSGB_202103_SGB2GTDB_r207.tsv

File renamed without changes.

metaphlan/utils/mpa_vJan25_CHOCOPhlAnSGB_202503_SGB2GTDB.tsv renamed to metaphlan/utils/mpa_vJan25_CHOCOPhlAnSGB_202503_SGB2GTDB_r220.tsv

File renamed without changes.

metaphlan/utils/mpa_vJun23_CHOCOPhlAnSGB_202307_SGB2GTDB.tsv renamed to metaphlan/utils/mpa_vJun23_CHOCOPhlAnSGB_202307_SGB2GTDB_r207.tsv

File renamed without changes.

metaphlan/utils/mpa_vJun23_CHOCOPhlAnSGB_202403_SGB2GTDB.tsv renamed to metaphlan/utils/mpa_vJun23_CHOCOPhlAnSGB_202403_SGB2GTDB_r207.tsv

File renamed without changes.

metaphlan/utils/mpa_vOct22_CHOCOPhlAnSGB_202212_SGB2GTDB.tsv renamed to metaphlan/utils/mpa_vOct22_CHOCOPhlAnSGB_202212_SGB2GTDB_r207.tsv

File renamed without changes.

metaphlan/utils/sgb_to_gtdb_profile.py

100755100644
Lines changed: 171 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,107 +1,222 @@
11
#!/usr/bin/env python
22
__author__ = 'Aitor Blanco (aitor.blancomiguez@unitn.it'
33
__version__ = '4.2.4'
4-
__date__ = '21 Oct 2025'
4+
__date__ = '21 Oct 2025'
55

66
import os
7+
import glob
78
import time
89
import argparse as ap
10+
911
try:
1012
from .util_fun import info, error
1113
except ImportError:
1214
from util_fun import info, error
1315

14-
GTDB_ASSIGNMENT_FILE = os.path.join(os.path.dirname(os.path.abspath(
15-
__file__)), "mpa_vJan25_CHOCOPhlAnSGB_202503_SGB2GTDB.tsv")
16-
1716

1817
def read_params():
19-
""" Reads and parses the command line arguments of the script
20-
21-
Returns:
22-
namespace: The populated namespace with the command line arguments
23-
"""
24-
p = ap.ArgumentParser(
25-
description="", formatter_class=ap.ArgumentDefaultsHelpFormatter)
26-
p.add_argument('-i', '--input', type=str,
27-
default=None, help="The input profile")
28-
p.add_argument('-o', '--output', type=str,
29-
default=None, help="The output profile")
18+
"""Reads and parses the command line arguments of the script."""
19+
p = ap.ArgumentParser(description="", formatter_class=ap.ArgumentDefaultsHelpFormatter)
20+
p.add_argument('-i', '--input', type=str, default=None, help='The input profile')
21+
p.add_argument('-o', '--output', type=str, default=None, help='The output profile')
22+
p.add_argument('--gtdb_assignment_file', type=str, default=None,
23+
help='Optional GTDB assignment TSV to use instead of auto-detecting it')
24+
p.add_argument('--merged_profiles', action='store_true', default=False,
25+
help='Specify this when the input is a merged MetaPhlAn profile table')
3026
return p.parse_args()
3127

3228

3329
def check_params(args):
34-
"""Checks the mandatory command line arguments of the script
35-
36-
Args:
37-
args (namespace): the arguments to check
38-
"""
30+
"""Checks the mandatory command line arguments of the script."""
3931
if not args.input:
4032
error('-i (or --input) must be specified', exit=True)
4133
if not args.output:
4234
error('-o (or --output) must be specified', exit=True)
4335

4436

45-
def get_gtdb_profile(mpa_profile, gtdb_profile):
46-
"""Creates the GDTB profile from a MPA one
37+
def get_gtdb_assignment_file(mpa_profile, gtdb_assignment_file=None):
38+
"""Resolve the GTDB assignment TSV for the MetaPhlAn index used by a profile."""
39+
if gtdb_assignment_file:
40+
return gtdb_assignment_file
4741

48-
Args:
49-
mpa_profile (str): the path to the input MPA profile
50-
gtdb_profile (str): the path to the output GTDB profile
51-
"""
52-
tax_levels = ['d', 'p', 'c', 'o', 'f', 'g', 's']
53-
def get_parent_taxon(taxon, level):
54-
parts = taxon.split(';')
55-
if len(parts) > 1:
56-
return ';'.join(parts[:-1])
57-
else:
58-
return parts[0]
59-
60-
sgb2gtdb = dict()
61-
with open(GTDB_ASSIGNMENT_FILE, 'r') as read_file:
42+
with open(mpa_profile, 'r') as rf:
43+
header = rf.readline().strip()
44+
45+
if not header.startswith('#mpa_'):
46+
error('Could not infer the MetaPhlAn index from the first header line', exit=True)
47+
48+
mpa_index = header[1:]
49+
search_dir = os.path.dirname(os.path.abspath(__file__))
50+
matches = sorted(glob.glob(os.path.join(search_dir, '{}_SGB2GTDB*.tsv'.format(mpa_index))), reverse=True)
51+
52+
if not matches:
53+
error('Could not find an SGB2GTDB assignment file for MetaPhlAn index "{}" in {}'
54+
.format(mpa_index, search_dir), exit=True)
55+
56+
return matches[0]
57+
58+
59+
def load_sgb2gtdb(gtdb_assignment_file):
60+
"""Loads the SGB-to-GTDB mapping."""
61+
sgb2gtdb = {}
62+
with open(gtdb_assignment_file, 'r') as read_file:
6263
for line in read_file:
63-
line = line.strip().split('\t')
64-
sgb2gtdb[line[0]] = line[1]
64+
parts = line.strip().split('\t')
65+
if len(parts) >= 2:
66+
sgb2gtdb[parts[0]] = parts[1]
67+
return sgb2gtdb
68+
69+
70+
def get_parent_taxon(taxon):
71+
"""Gets the direct parent taxon in a semicolon-separated taxonomy."""
72+
parts = taxon.split(';')
73+
if len(parts) > 1:
74+
return ';'.join(parts[:-1])
75+
return parts[0]
76+
77+
78+
def add_abundance(current_value, new_value, merged_profiles):
79+
"""Adds scalar or vector abundances depending on input profile type."""
80+
if not merged_profiles:
81+
return current_value + new_value
82+
return [a + b for a, b in zip(current_value, new_value)]
83+
84+
85+
def format_abundance(value, merged_profiles):
86+
"""Formats abundance values for output writing."""
87+
if not merged_profiles:
88+
return '{:.5f}'.format(value)
89+
return '\t'.join('{:.5f}'.format(x) for x in value)
90+
91+
92+
def parse_abundance(fields, merged_profiles):
93+
"""Parses abundance fields from a profile line."""
94+
if not merged_profiles:
95+
return float(fields[2])
96+
return [float(x) for x in fields[1:]]
97+
98+
99+
def normalize_abundances(abundances, merged_profiles, unclassified_fraction=0):
100+
"""Normalize abundances to sum to 100 using the input profile shape."""
101+
if not abundances:
102+
return abundances
103+
104+
def normalize_vector(values, target):
105+
total = sum(values)
106+
rounded = [round(target * value / total, 5) for value in values] if total else list(values)
107+
correction = round(target - sum(rounded), 5)
108+
if rounded:
109+
rounded[-1] = round(rounded[-1] + correction, 5)
110+
return rounded
111+
112+
if merged_profiles:
113+
if not unclassified_fraction:
114+
unclassified_fraction = [0.0] * len(next(iter(abundances.values())))
115+
116+
targets = [100 - x for x in unclassified_fraction]
117+
columns = list(zip(*abundances.values()))
118+
normalized_columns = [normalize_vector(list(column), target) for column, target in zip(columns, targets)]
119+
return {
120+
taxon: [column[i] for column in normalized_columns]
121+
for i, taxon in enumerate(abundances)
122+
}
123+
124+
if isinstance(unclassified_fraction, list):
125+
unclassified_fraction = unclassified_fraction[0] if unclassified_fraction else 0
126+
127+
items = list(abundances.items())
128+
target = 100 - unclassified_fraction
129+
normalized_values = normalize_vector([value for _, value in items], target)
130+
return {taxon: value for (taxon, _), value in zip(items, normalized_values)}
131+
132+
133+
def get_gtdb_profile(mpa_profile, gtdb_profile, merged_profiles=False, gtdb_assignment_file=None):
134+
"""Creates the GTDB profile from a MetaPhlAn one."""
135+
tax_levels = ['d', 'p', 'c', 'o', 'f', 'g', 's']
136+
137+
gtdb_assignment_file = get_gtdb_assignment_file(mpa_profile, gtdb_assignment_file)
138+
info('Using GTDB assignment file: {}'.format(gtdb_assignment_file))
139+
sgb2gtdb = load_sgb2gtdb(gtdb_assignment_file)
140+
65141
with open(gtdb_profile, 'w') as wf:
66142
with open(mpa_profile, 'r') as rf:
67-
unclassified = 0
68-
abundances = {x: dict() for x in tax_levels}
143+
abundances = {x: {} for x in tax_levels}
144+
unclassified_fraction = 0
69145
for line in rf:
70146
if line.startswith('#mpa_'):
71-
wf.write(line)
72-
wf.write('#clade_name\trelative_abundance\n')
147+
line = line.strip()
148+
wf.write(line + '\t' + gtdb_assignment_file.split('_')[-1].split('.')[0] + '\n')
149+
if not merged_profiles:
150+
wf.write('#clade_name\trelative_abundance\n')
151+
elif line.startswith('#'):
152+
# For merged profiles preserve additional headers
153+
if merged_profiles:
154+
wf.write(line)
155+
elif line.startswith('clade_name'):
156+
if merged_profiles:
157+
wf.write(line)
73158
elif line.startswith('UNCLASSIFIED'):
74-
unclassified = float(line.strip().split('\t')[2])
75-
wf.write('UNCLASSIFIED\t{}\n'.format(unclassified))
159+
fields = line.strip().split('\t')
160+
if not merged_profiles:
161+
unclassified_fraction = float(fields[2])
162+
wf.write('UNCLASSIFIED\t{}\n'.format(float(fields[2])))
163+
else:
164+
unclassified_fraction = [float(x) for x in fields[1:]]
165+
wf.write('UNCLASSIFIED\t{}\n'.format('\t'.join(fields[1:])))
76166
elif 't__SGB' in line:
77-
line = line.strip().split('\t')
78-
abundance = float(line[2])
79-
sgb_id = line[0].split('|')[-1][3:]
167+
fields = line.strip().split('\t')
168+
sgb_id = fields[0].split('|')[-1][3:]
80169
gtdb_tax = sgb2gtdb.get(sgb_id, None)
81170
if gtdb_tax is None:
82171
continue
172+
173+
abundance = parse_abundance(fields, merged_profiles)
83174
if gtdb_tax not in abundances['s']:
84-
abundances['s'][gtdb_tax] = 0
85-
abundances['s'][gtdb_tax] += abundance
175+
if not merged_profiles:
176+
abundances['s'][gtdb_tax] = 0.0
177+
else:
178+
abundances['s'][gtdb_tax] = [0.0] * len(abundance)
179+
abundances['s'][gtdb_tax] = add_abundance(
180+
abundances['s'][gtdb_tax], abundance, merged_profiles)
181+
182+
abundances['s'] = normalize_abundances(abundances['s'], merged_profiles, unclassified_fraction)
183+
86184
tax_levels_rev = list(reversed(tax_levels))
87185
for i, tax_level in enumerate(tax_levels_rev[:-1]):
88186
for tax in abundances[tax_level]:
89-
parent_tax = get_parent_taxon(tax, tax_level)
90-
new_level = tax_levels_rev[i+1]
91-
if parent_tax not in abundances[new_level]:
92-
abundances[new_level][parent_tax] = 0
93-
abundances[new_level][parent_tax] += abundances[tax_level][tax]
187+
parent_tax = get_parent_taxon(tax)
188+
parent_level = tax_levels_rev[i + 1]
189+
if parent_tax not in abundances[parent_level]:
190+
if not merged_profiles:
191+
abundances[parent_level][parent_tax] = 0.0
192+
else:
193+
n_cols = len(abundances[tax_level][tax])
194+
abundances[parent_level][parent_tax] = [0.0] * n_cols
195+
abundances[parent_level][parent_tax] = add_abundance(
196+
abundances[parent_level][parent_tax],
197+
abundances[tax_level][tax],
198+
merged_profiles
199+
)
200+
94201
for tax_level in tax_levels:
95202
for tax in abundances[tax_level]:
96-
wf.write('{}\t{}\n'.format(tax, abundances[tax_level][tax]))
203+
wf.write('{}\t{}\n'.format(
204+
tax,
205+
format_abundance(abundances[tax_level][tax], merged_profiles)
206+
))
97207

98208

99209
def main():
100210
t0 = time.time()
101211
args = read_params()
102212
info("Start execution")
103213
check_params(args)
104-
get_gtdb_profile(args.input, args.output)
214+
get_gtdb_profile(
215+
args.input,
216+
args.output,
217+
merged_profiles=args.merged_profiles,
218+
gtdb_assignment_file=args.gtdb_assignment_file
219+
)
105220
exec_time = time.time() - t0
106221
info("Finish execution ({} seconds)".format(round(exec_time, 2)))
107222

0 commit comments

Comments
 (0)