msfiddle 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- msfiddle/__init__.py +44 -0
- msfiddle/config/fiddle_tcn_casmi.yml +113 -0
- msfiddle/config/fiddle_tcn_demo.yml +121 -0
- msfiddle/config/fiddle_tcn_demo_wo_da.yml +121 -0
- msfiddle/config/fiddle_tcn_embl.yml +100 -0
- msfiddle/config/fiddle_tcn_orbitrap.yml +163 -0
- msfiddle/config/fiddle_tcn_qtof.yml +196 -0
- msfiddle/dataset.py +152 -0
- msfiddle/demo/input_msms.mgf +491 -0
- msfiddle/demo/output_buddy.csv +22 -0
- msfiddle/demo/output_fiddle.csv +22 -0
- msfiddle/demo/output_fiddle_all.csv +22 -0
- msfiddle/demo/output_sirius.csv +22 -0
- msfiddle/download.py +194 -0
- msfiddle/main.py +356 -0
- msfiddle/model_tcn.py +222 -0
- msfiddle/utils/__init__.py +40 -0
- msfiddle/utils/mol_utils.py +138 -0
- msfiddle/utils/msms_utils.py +312 -0
- msfiddle/utils/pkl_utils.py +372 -0
- msfiddle/utils/refine_utils.py +246 -0
- msfiddle-0.1.0.dist-info/METADATA +151 -0
- msfiddle-0.1.0.dist-info/RECORD +27 -0
- msfiddle-0.1.0.dist-info/WHEEL +5 -0
- msfiddle-0.1.0.dist-info/entry_points.txt +4 -0
- msfiddle-0.1.0.dist-info/licenses/LICENSE +661 -0
- msfiddle-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import re
|
|
3
|
+
from tqdm import tqdm
|
|
4
|
+
|
|
5
|
+
from rdkit import Chem
|
|
6
|
+
# ignore the warning
|
|
7
|
+
from rdkit import RDLogger
|
|
8
|
+
RDLogger.DisableLog('rdApp.*')
|
|
9
|
+
from rdkit.Chem import AllChem
|
|
10
|
+
from rdkit.Chem.rdMolDescriptors import CalcMolFormula
|
|
11
|
+
|
|
12
|
+
from .mol_utils import ATOMS_WEIGHT, monoisotopic_mass_calculator
|
|
13
|
+
|
|
14
|
+
MSLEVEL_MAP = {
|
|
15
|
+
'MS2': 2,
|
|
16
|
+
'MS1': 1,
|
|
17
|
+
'2': 2,
|
|
18
|
+
'1': 1,
|
|
19
|
+
}
|
|
20
|
+
# this is the superset of spectra keys in our data
|
|
21
|
+
mgf_key_order = ['title', 'precursor_type', 'precursor_mz',
|
|
22
|
+
'molmass', 'ms_level', 'ionmode',
|
|
23
|
+
'source_instrument', 'instrument_type', 'collision_energy',
|
|
24
|
+
'smiles', 'inchi_key']
|
|
25
|
+
|
|
26
|
+
def sdf2mgf(path, prefix):
|
|
27
|
+
'''mgf format
|
|
28
|
+
[{
|
|
29
|
+
'params': {
|
|
30
|
+
'title': prefix_<index>,
|
|
31
|
+
'precursor_type': <precursor_type (e.g. [M+NH4]+ and [M+H]+)>,
|
|
32
|
+
'precursor_mz': <precursor m/z>,
|
|
33
|
+
'molmass': <isotopic mass>,
|
|
34
|
+
'ms_level': <ms_level>,
|
|
35
|
+
'ionmode': <POSITIVE|NEGATIVE>,
|
|
36
|
+
'source_instrument': <source_instrument>,
|
|
37
|
+
'instrument_type': <instrument_type>,
|
|
38
|
+
'collision_energy': <collision energe>,
|
|
39
|
+
'smiles': <smiles>,
|
|
40
|
+
'inchi_key': <inchi_key>,
|
|
41
|
+
},
|
|
42
|
+
'm/z array': mz_array,
|
|
43
|
+
'intensity array': intensity_array
|
|
44
|
+
}, ...]
|
|
45
|
+
'''
|
|
46
|
+
supp = Chem.SDMolSupplier(path)
|
|
47
|
+
print('Read {} data from {}'.format(len(supp), path))
|
|
48
|
+
|
|
49
|
+
spectra = []
|
|
50
|
+
for idx, mol in enumerate(tqdm(supp)):
|
|
51
|
+
if mol == None or \
|
|
52
|
+
not mol.HasProp('MASS SPECTRAL PEAKS') or \
|
|
53
|
+
not mol.HasProp('PRECURSOR TYPE') or \
|
|
54
|
+
not mol.HasProp('PRECURSOR M/Z') or \
|
|
55
|
+
not mol.HasProp('SPECTRUM TYPE') or \
|
|
56
|
+
not mol.HasProp('COLLISION ENERGY') or \
|
|
57
|
+
not mol.HasProp('ION MODE'):
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
mz_array = []
|
|
61
|
+
intensity_array = []
|
|
62
|
+
raw_ms = mol.GetProp('MASS SPECTRAL PEAKS').split('\n')
|
|
63
|
+
for line in raw_ms:
|
|
64
|
+
mz_array.append(float(line.split()[0]))
|
|
65
|
+
intensity_array.append(float(line.split()[1]))
|
|
66
|
+
mz_array = np.array(mz_array)
|
|
67
|
+
intensity_array = np.array(intensity_array)
|
|
68
|
+
|
|
69
|
+
inchi_key = 'Unknown' if not mol.HasProp('INCHIKEY') else mol.GetProp('INCHIKEY')
|
|
70
|
+
instrument = 'Unknown' if not mol.HasProp('INSTRUMENT') else mol.GetProp('INSTRUMENT')
|
|
71
|
+
spectrum = {
|
|
72
|
+
'params': {
|
|
73
|
+
'title': prefix+'_'+str(idx),
|
|
74
|
+
'precursor_type': mol.GetProp('PRECURSOR TYPE'),
|
|
75
|
+
'precursor_mz': mol.GetProp('PRECURSOR M/Z'),
|
|
76
|
+
'molmass': mol.GetProp('EXACT MASS'),
|
|
77
|
+
'ms_level': mol.GetProp('SPECTRUM TYPE'),
|
|
78
|
+
'ionmode': mol.GetProp('ION MODE'),
|
|
79
|
+
'source_instrument': instrument,
|
|
80
|
+
'instrument_type': mol.GetProp('INSTRUMENT TYPE'),
|
|
81
|
+
'collision_energy': mol.GetProp('COLLISION ENERGY'),
|
|
82
|
+
'smiles': Chem.MolToSmiles(mol, isomericSmiles=True),
|
|
83
|
+
'inchi_key': inchi_key,
|
|
84
|
+
},
|
|
85
|
+
'm/z array': mz_array,
|
|
86
|
+
'intensity array': intensity_array
|
|
87
|
+
}
|
|
88
|
+
spectra.append(spectrum)
|
|
89
|
+
return spectra
|
|
90
|
+
|
|
91
|
+
def filter_spec(spectra, config, type2charge):
|
|
92
|
+
clean_spectra = []
|
|
93
|
+
smiles_list = []
|
|
94
|
+
|
|
95
|
+
records = {'instrument_type': [], 'instrument': [], 'ms_level': [],
|
|
96
|
+
'atom_type': [], 'precursor_type': [], 'peak_num': [],
|
|
97
|
+
'max_mz': [], 'invalud_formula': [], 'ppm_tolerance': [],
|
|
98
|
+
'invalid_intensity_array': []}
|
|
99
|
+
for idx, spectrum in enumerate(tqdm(spectra)):
|
|
100
|
+
smiles = spectrum['params']['smiles']
|
|
101
|
+
mol = Chem.AddHs(Chem.MolFromSmiles(smiles))
|
|
102
|
+
if mol == None: continue
|
|
103
|
+
|
|
104
|
+
# Unify the smiles format
|
|
105
|
+
smiles = Chem.MolToSmiles(mol, isomericSmiles=False, canonical=True)
|
|
106
|
+
spectrum['params']['smiles'] = smiles
|
|
107
|
+
|
|
108
|
+
# Filter by instrument type
|
|
109
|
+
if 'instrument_type' in config.keys():
|
|
110
|
+
instrument_type = spectrum['params']['instrument_type']
|
|
111
|
+
if instrument_type not in config['instrument_type']:
|
|
112
|
+
records['instrument_type'].append(spectrum['params']['title'])
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
# Filter by instrument (MoNA contains too many intrument names to filter out)
|
|
116
|
+
if 'instrument' in config.keys():
|
|
117
|
+
instrument = spectrum['params']['source_instrument']
|
|
118
|
+
if instrument not in config['instrument']:
|
|
119
|
+
records['instrument'].append(spectrum['params']['title'])
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
# Filter by mslevel
|
|
123
|
+
if 'ms_level' in config.keys():
|
|
124
|
+
mslevel = spectrum['params']['ms_level']
|
|
125
|
+
if mslevel != config['ms_level']:
|
|
126
|
+
records['ms_level'].append(spectrum['params']['title'])
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
# Filter by atom number and atom type
|
|
130
|
+
if len(mol.GetAtoms()) > config['max_atom_num'] or len(mol.GetAtoms()) < config['min_atom_num']: continue
|
|
131
|
+
is_compound_countain_rare_atom = False
|
|
132
|
+
for atom in mol.GetAtoms():
|
|
133
|
+
if atom.GetSymbol() not in config['atom_type']:
|
|
134
|
+
is_compound_countain_rare_atom = True
|
|
135
|
+
break
|
|
136
|
+
if is_compound_countain_rare_atom:
|
|
137
|
+
records['atom_type'].append(spectrum['params']['title'])
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
# Filter by precursor type
|
|
141
|
+
precursor_type = spectrum['params']['precursor_type']
|
|
142
|
+
if precursor_type not in config['precursor_type']:
|
|
143
|
+
records['precursor_type'].append(spectrum['params']['title'])
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
# Filt by peak number
|
|
147
|
+
if len(spectrum['m/z array']) < config['min_peak_num']:
|
|
148
|
+
records['peak_num'].append(spectrum['params']['title'])
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
# Filter by max m/z
|
|
152
|
+
if np.max(spectrum['m/z array']) < config['min_mz'] or np.max(spectrum['m/z array']) > config['max_mz']:
|
|
153
|
+
records['max_mz'].append(spectrum['params']['title'])
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
# Filter by ppm (mass error)
|
|
157
|
+
if 'ppm_tolerance' in config.keys():
|
|
158
|
+
f = CalcMolFormula(mol)
|
|
159
|
+
# try:
|
|
160
|
+
# f = Formula(f)
|
|
161
|
+
# except: # invalud formula
|
|
162
|
+
# records['invalud_formula'].append(spectrum['params']['title'])
|
|
163
|
+
# continue
|
|
164
|
+
molmass = monoisotopic_mass_calculator(f, 'f')
|
|
165
|
+
theo_mz = precursor_mz_calculator(precursor_type, molmass)
|
|
166
|
+
ppm = abs(theo_mz - float(spectrum['params']['precursor_mz'])) / theo_mz * 10**6
|
|
167
|
+
|
|
168
|
+
if ppm > config['ppm_tolerance']:
|
|
169
|
+
records['ppm_tolerance'].append(spectrum['params']['title'])
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
# Remove the invalid spectra
|
|
173
|
+
if spectrum['intensity array'].max() == spectrum['intensity array'].min():
|
|
174
|
+
records['invalid_intensity_array'].append(spectrum['params']['title'])
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
spectrum['params']['theoretical_precursor_mz'] = theo_mz
|
|
178
|
+
spectrum['params']['ppm'] = ppm
|
|
179
|
+
spectrum['params']['simulated_precursor_mz'] = simulate_experimental_mz(theo_mz, config['ppm_tolerance'])
|
|
180
|
+
spectrum['params']['charge'] = type2charge[precursor_type] # add charge
|
|
181
|
+
clean_spectra.append(spectrum)
|
|
182
|
+
smiles_list.append(smiles)
|
|
183
|
+
|
|
184
|
+
print('Filtering records: ')
|
|
185
|
+
for k, v in records.items():
|
|
186
|
+
print('\t{}: {}'.format(k, len(v)))
|
|
187
|
+
|
|
188
|
+
return clean_spectra, smiles_list
|
|
189
|
+
|
|
190
|
+
def simulate_experimental_mz(theoretical_mz, relative_mass_tolerance_ppm):
|
|
191
|
+
"""
|
|
192
|
+
Simulate experimental precursor m/z value by shifting the theoretical value
|
|
193
|
+
within a Gaussian distribution of mass deviations.
|
|
194
|
+
|
|
195
|
+
:param theoretical_mz: Theoretical precursor m/z value.
|
|
196
|
+
:param relative_mass_tolerance_ppm: Relative mass tolerance in parts per million (ppm).
|
|
197
|
+
:return: Simulated experimental precursor m/z value.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
# Calculate the standard deviation of the Gaussian distribution as 1/3 of the relative mass tolerance
|
|
201
|
+
std_dev = (relative_mass_tolerance_ppm / 1e6 * theoretical_mz) / 3
|
|
202
|
+
|
|
203
|
+
# Randomly sample a mass deviation from the Gaussian distribution
|
|
204
|
+
mass_deviation = np.random.normal(0, std_dev)
|
|
205
|
+
|
|
206
|
+
# Shift the theoretical m/z value by the sampled mass deviation to simulate the experimental value
|
|
207
|
+
experimental_mz = theoretical_mz + mass_deviation
|
|
208
|
+
|
|
209
|
+
return experimental_mz
|
|
210
|
+
|
|
211
|
+
def ce2nce(ce, precursor_mz, charge):
|
|
212
|
+
charge_factor = {1: 1, 2: 0.9, 3: 0.85, 4: 0.8, 5: 0.75, 6: 0.75, 7: 0.75, 8: 0.75}
|
|
213
|
+
return ce * 500 * charge_factor[charge] / precursor_mz
|
|
214
|
+
|
|
215
|
+
def precursor_mz_calculator(precursor_type, mass):
|
|
216
|
+
if precursor_type == '[M+H]+':
|
|
217
|
+
return mass + ATOMS_WEIGHT['H']
|
|
218
|
+
elif precursor_type == '[M+2H]2+':
|
|
219
|
+
return (mass + 2 * ATOMS_WEIGHT['H']) / 2
|
|
220
|
+
elif precursor_type == '[M+Na]+':
|
|
221
|
+
return mass + ATOMS_WEIGHT['Na']
|
|
222
|
+
elif precursor_type == '[M-H]-':
|
|
223
|
+
return mass - ATOMS_WEIGHT['H']
|
|
224
|
+
elif precursor_type == '[M+H-H2O]+' or precursor_type == '[M-H2O+H]+':
|
|
225
|
+
return mass + ATOMS_WEIGHT['H'] - (ATOMS_WEIGHT['H'] * 2 + ATOMS_WEIGHT['O'])
|
|
226
|
+
elif precursor_type == '[2M+H]+':
|
|
227
|
+
return 2 * mass + ATOMS_WEIGHT['H']
|
|
228
|
+
elif precursor_type == '[2M-H]-':
|
|
229
|
+
return 2 * mass - ATOMS_WEIGHT['H']
|
|
230
|
+
elif precursor_type == '[M+H-2H2O]+':
|
|
231
|
+
return mass + ATOMS_WEIGHT['H'] - 2 * (ATOMS_WEIGHT['H'] * 2 + ATOMS_WEIGHT['O'])
|
|
232
|
+
elif precursor_type == '[M+H-NH3]+':
|
|
233
|
+
return mass + ATOMS_WEIGHT['H'] - (ATOMS_WEIGHT['N'] + ATOMS_WEIGHT['H'] * 3)
|
|
234
|
+
elif precursor_type == '[M+H+NH3]+' or precursor_type == '[M+NH4]+':
|
|
235
|
+
return mass + ATOMS_WEIGHT['H'] + (ATOMS_WEIGHT['N'] + ATOMS_WEIGHT['H'] * 3)
|
|
236
|
+
elif precursor_type == '[M+H-CH2O2]+':
|
|
237
|
+
return mass + ATOMS_WEIGHT['H'] - (ATOMS_WEIGHT['C'] + ATOMS_WEIGHT['H'] * 2 + ATOMS_WEIGHT['O'] * 2)
|
|
238
|
+
elif precursor_type == '[M+H-CH4O2]+':
|
|
239
|
+
return mass + ATOMS_WEIGHT['H'] - (ATOMS_WEIGHT['C'] + ATOMS_WEIGHT['H'] * 4 + ATOMS_WEIGHT['O'] * 2)
|
|
240
|
+
elif precursor_type == '[M-H-CO2]-' or precursor_type == '[M-CHO2]-':
|
|
241
|
+
return mass - ATOMS_WEIGHT['H'] - (ATOMS_WEIGHT['C'] + ATOMS_WEIGHT['O'] * 2)
|
|
242
|
+
elif precursor_type == '[M-H-H2O]-':
|
|
243
|
+
return mass - ATOMS_WEIGHT['H'] - (ATOMS_WEIGHT['H'] * 2 + ATOMS_WEIGHT['O'])
|
|
244
|
+
else:
|
|
245
|
+
raise ValueError('Unsupported precursor type: {}'.format(precursor_type))
|
|
246
|
+
|
|
247
|
+
def mass_calculator(precursor_type, precursor_mz):
|
|
248
|
+
if precursor_type == '[M+H]+':
|
|
249
|
+
return precursor_mz - ATOMS_WEIGHT['H']
|
|
250
|
+
elif precursor_type == '[M+2H]2+':
|
|
251
|
+
return (precursor_mz * 2 - 2 * ATOMS_WEIGHT['H'])
|
|
252
|
+
elif precursor_type == '[M+Na]+':
|
|
253
|
+
return precursor_mz - ATOMS_WEIGHT['Na']
|
|
254
|
+
elif precursor_type == '[M-H]-':
|
|
255
|
+
return precursor_mz + ATOMS_WEIGHT['H']
|
|
256
|
+
elif precursor_type == '[M+H-H2O]+' or precursor_type == '[M-H2O+H]+':
|
|
257
|
+
return precursor_mz - ATOMS_WEIGHT['H'] + (ATOMS_WEIGHT['H'] * 2 + ATOMS_WEIGHT['O'])
|
|
258
|
+
elif precursor_type == '[2M+H]+':
|
|
259
|
+
return (precursor_mz - ATOMS_WEIGHT['H']) / 2
|
|
260
|
+
elif precursor_type == '[2M-H]-':
|
|
261
|
+
return (precursor_mz + ATOMS_WEIGHT['H']) / 2
|
|
262
|
+
elif precursor_type == '[M+H-2H2O]+':
|
|
263
|
+
return precursor_mz - ATOMS_WEIGHT['H'] + 2 * (ATOMS_WEIGHT['H'] * 2 + ATOMS_WEIGHT['O'])
|
|
264
|
+
elif precursor_type == '[M+H-NH3]+':
|
|
265
|
+
return precursor_mz - ATOMS_WEIGHT['H'] + (ATOMS_WEIGHT['N'] + ATOMS_WEIGHT['H'] * 3)
|
|
266
|
+
elif precursor_type == '[M+H+NH3]+' or precursor_type == '[M+NH4]+':
|
|
267
|
+
return precursor_mz - ATOMS_WEIGHT['H'] - (ATOMS_WEIGHT['N'] + ATOMS_WEIGHT['H'] * 3)
|
|
268
|
+
elif precursor_type == '[M+H-CH2O2]+':
|
|
269
|
+
return precursor_mz - ATOMS_WEIGHT['H'] + (ATOMS_WEIGHT['C'] + ATOMS_WEIGHT['H'] * 2 + ATOMS_WEIGHT['O'] * 2)
|
|
270
|
+
elif precursor_type == '[M+H-CH4O2]+':
|
|
271
|
+
return precursor_mz - ATOMS_WEIGHT['H'] + (ATOMS_WEIGHT['C'] + ATOMS_WEIGHT['H'] * 4 + ATOMS_WEIGHT['O'] * 2)
|
|
272
|
+
elif precursor_type == '[M-H-CO2]-' or precursor_type == '[M-CHO2]-':
|
|
273
|
+
return precursor_mz + ATOMS_WEIGHT['H'] + (ATOMS_WEIGHT['C'] + ATOMS_WEIGHT['O'] * 2)
|
|
274
|
+
elif precursor_type == '[M-H-H2O]-':
|
|
275
|
+
return precursor_mz + ATOMS_WEIGHT['H'] + (ATOMS_WEIGHT['H'] * 2 + ATOMS_WEIGHT['O'])
|
|
276
|
+
else:
|
|
277
|
+
raise ValueError('Unsupported precursor type: {}'.format(precursor_type))
|
|
278
|
+
|
|
279
|
+
if __name__ == '__main__':
|
|
280
|
+
# test sdf2mgf
|
|
281
|
+
spectra = sdf2mgf('../data/origin/Agilent_Combined.sdf', 'agilent_combine')
|
|
282
|
+
print('Number of spectra: {}'.format(len(spectra)))
|
|
283
|
+
for spec in spectra:
|
|
284
|
+
if spec['params']['precursor_type'] == '[M-H]-':
|
|
285
|
+
print(spec)
|
|
286
|
+
break
|
|
287
|
+
|
|
288
|
+
# test filter_spec
|
|
289
|
+
config = {
|
|
290
|
+
'instrument_type': 'ESI-QTOF',
|
|
291
|
+
'instrument': ['Unknown'],
|
|
292
|
+
'ms_level': '2',
|
|
293
|
+
'atom_type': ['C', 'O', 'N', 'H', 'P', 'S', 'F', 'Cl', 'B', 'Br', 'I', 'Na', 'K'],
|
|
294
|
+
'precursor_type': ['[M+H]+', '[M-H]-'],
|
|
295
|
+
'min_mz': 50,
|
|
296
|
+
'max_mz': 1500,
|
|
297
|
+
'min_peak_num': 5,
|
|
298
|
+
'max_atom_num': 300,
|
|
299
|
+
'min_atom_num': 10,
|
|
300
|
+
'ppm_tolerance': 10
|
|
301
|
+
}
|
|
302
|
+
type2charge = {
|
|
303
|
+
'[M+H]+': 1,
|
|
304
|
+
'[M-H]-': -1
|
|
305
|
+
}
|
|
306
|
+
clean_spectra, smiles_list = filter_spec(spectra, config, type2charge)
|
|
307
|
+
print('Number of clean spectra: {}'.format(len(clean_spectra)))
|
|
308
|
+
print('Number of smiles: {}'.format(len(smiles_list)))
|
|
309
|
+
for spec in clean_spectra:
|
|
310
|
+
if spec['params']['precursor_type'] == '[M-H]-':
|
|
311
|
+
print(spec)
|
|
312
|
+
break
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import re
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from tqdm import tqdm
|
|
5
|
+
import pandas as pd
|
|
6
|
+
import random
|
|
7
|
+
import copy
|
|
8
|
+
|
|
9
|
+
from rdkit import Chem
|
|
10
|
+
# ignore the warning
|
|
11
|
+
from rdkit import RDLogger
|
|
12
|
+
RDLogger.DisableLog('rdApp.*')
|
|
13
|
+
from rdkit.Chem import AllChem
|
|
14
|
+
from rdkit.Chem.rdMolDescriptors import CalcMolFormula
|
|
15
|
+
|
|
16
|
+
from .mol_utils import monoisotopic_mass_calculator, formula_to_dict, formula_to_vector, dict_to_formula, ATOMS_INDEX
|
|
17
|
+
|
|
18
|
+
# ------------------------ Main Functions ------------------------
|
|
19
|
+
|
|
20
|
+
def spec2arr(spectra, encoder):
|
|
21
|
+
'''data format
|
|
22
|
+
[
|
|
23
|
+
{'title': <str>, 'formula': <numpy array>, 'env': <numpy array>, 'spec': <numpy array>},
|
|
24
|
+
{'title': <str>, 'formula': <numpy array>, 'env': <numpy array>, 'spec': <numpy array>},
|
|
25
|
+
....
|
|
26
|
+
]
|
|
27
|
+
'''
|
|
28
|
+
data = []
|
|
29
|
+
bad_data = []
|
|
30
|
+
for idx, spectrum in enumerate(tqdm(spectra)):
|
|
31
|
+
data_item = spec2arr_single(spectrum, encoder)
|
|
32
|
+
if data_item != None:
|
|
33
|
+
data.append(data_item)
|
|
34
|
+
else:
|
|
35
|
+
bad_data.append(spectrum['params']['title'])
|
|
36
|
+
|
|
37
|
+
return data, bad_data
|
|
38
|
+
|
|
39
|
+
def spec2pair(data, bad_title, encoder):
|
|
40
|
+
# Extract df of spectra index and smiles
|
|
41
|
+
df = {'index': [], 'smiles': []}
|
|
42
|
+
|
|
43
|
+
for idx, data_item in enumerate(tqdm(data)):
|
|
44
|
+
if data_item['title'] in bad_title: continue
|
|
45
|
+
|
|
46
|
+
df['index'].append(idx)
|
|
47
|
+
df['smiles'].append(data_item['smiles'])
|
|
48
|
+
df = pd.DataFrame(df)
|
|
49
|
+
|
|
50
|
+
# Extract positive pairs
|
|
51
|
+
print('Extracting positive and negative pairs...')
|
|
52
|
+
positive_pairs, negative_pairs = generate_pairs(df)
|
|
53
|
+
|
|
54
|
+
# Ensure balanced samples
|
|
55
|
+
num_samples = min(len(positive_pairs), len(negative_pairs))
|
|
56
|
+
sampled_positive_pairs = random.sample(positive_pairs, num_samples)
|
|
57
|
+
sampled_negative_pairs = random.sample(negative_pairs, num_samples)
|
|
58
|
+
|
|
59
|
+
# Convert pairs to dataframe
|
|
60
|
+
data_pairs = {'idx1': [], 'idx2': [], 'label': []}
|
|
61
|
+
for idx1, idx2 in sampled_positive_pairs:
|
|
62
|
+
data_pairs['idx1'].append(idx1)
|
|
63
|
+
data_pairs['idx2'].append(idx2)
|
|
64
|
+
data_pairs['label'].append(1)
|
|
65
|
+
for idx1, idx2 in sampled_negative_pairs:
|
|
66
|
+
data_pairs['idx1'].append(idx1)
|
|
67
|
+
data_pairs['idx2'].append(idx2)
|
|
68
|
+
data_pairs['label'].append(0)
|
|
69
|
+
|
|
70
|
+
return data_pairs
|
|
71
|
+
|
|
72
|
+
# ------------------------ Helper Functions ------------------------
|
|
73
|
+
|
|
74
|
+
def generate_pairs(df):
|
|
75
|
+
df_group = df.groupby('smiles')
|
|
76
|
+
groups = list(df_group.groups.values())
|
|
77
|
+
|
|
78
|
+
positive_pairs = set()
|
|
79
|
+
negative_pairs = set()
|
|
80
|
+
|
|
81
|
+
for _, row in tqdm(df.iterrows(), total=len(df)):
|
|
82
|
+
group = df_group.get_group(row['smiles'])
|
|
83
|
+
|
|
84
|
+
# Positive pair (from the same group)
|
|
85
|
+
if len(group) > 1:
|
|
86
|
+
positive_pair_candidates = list(set(group.index) - {row.name})
|
|
87
|
+
positive_pair = random.sample(positive_pair_candidates, 1)[0]
|
|
88
|
+
positive_pairs.add(tuple(sorted((row.name, positive_pair))))
|
|
89
|
+
|
|
90
|
+
# Negative pair (from a different group)
|
|
91
|
+
other_groups = [g for g in groups if row.name not in g]
|
|
92
|
+
if other_groups:
|
|
93
|
+
random_group = random.choice(other_groups)
|
|
94
|
+
negative_pair = random.choice(random_group)
|
|
95
|
+
negative_pairs.add(tuple(sorted((row.name, negative_pair))))
|
|
96
|
+
|
|
97
|
+
return positive_pairs, negative_pairs
|
|
98
|
+
|
|
99
|
+
def spec2arr_single(spectrum, encoder):
|
|
100
|
+
# convert molecular object to formula string
|
|
101
|
+
mol = Chem.MolFromSmiles(spectrum['params']['smiles'])
|
|
102
|
+
mol = Chem.AddHs(mol)
|
|
103
|
+
try:
|
|
104
|
+
formula = CalcMolFormula(mol) # formula string
|
|
105
|
+
except:
|
|
106
|
+
print('Failed to convert smiles to formula: {}'.format(spectrum['params']['smiles']))
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
charge = int(encoder['type2charge'][spectrum['params']['precursor_type']])
|
|
110
|
+
|
|
111
|
+
# spec array
|
|
112
|
+
good_spec, mz_array, int_array, spec_arr = generate_ms(x=spectrum['m/z array'],
|
|
113
|
+
y=spectrum['intensity array'],
|
|
114
|
+
precursor_mz=float(spectrum['params']['theoretical_precursor_mz']),
|
|
115
|
+
resolution=encoder['resolution'],
|
|
116
|
+
max_mz=encoder['max_mz'],
|
|
117
|
+
charge=charge)
|
|
118
|
+
if not good_spec:
|
|
119
|
+
print('Failed to generate mass spectra: {}'.format(spectrum['params']['title']))
|
|
120
|
+
return None # invalid spectra
|
|
121
|
+
|
|
122
|
+
# env array
|
|
123
|
+
adjust_formula, adjust_precursor_type = melt_neutral_precursor(formula, spectrum['params']['precursor_type'])
|
|
124
|
+
ce, nce = parse_collision_energy(ce_str=spectrum['params']['collision_energy'],
|
|
125
|
+
precursor_mz=float(spectrum['params']['theoretical_precursor_mz']),
|
|
126
|
+
charge=abs(charge))
|
|
127
|
+
if ce == None and nce == None:
|
|
128
|
+
print('Failed to parse collision energy: \"{}\"'.format(spectrum['params']['collision_energy']))
|
|
129
|
+
return None # can not process '30-50 eV'
|
|
130
|
+
env_arr = np.array([float(spectrum['params']['theoretical_precursor_mz']),
|
|
131
|
+
nce,
|
|
132
|
+
encoder['precursor_type'][adjust_precursor_type]])
|
|
133
|
+
|
|
134
|
+
# mass
|
|
135
|
+
# iso_mass = monoisotopic_mass_calculator(mol, mode='mol')
|
|
136
|
+
iso_mass = monoisotopic_mass_calculator(adjust_formula, mode='f')
|
|
137
|
+
|
|
138
|
+
# formula array
|
|
139
|
+
formula_vec = formula_to_vector(adjust_formula)
|
|
140
|
+
f_arr = np.array(formula_vec)
|
|
141
|
+
|
|
142
|
+
return {'title': spectrum['params']['title'],
|
|
143
|
+
'smiles': spectrum['params']['smiles'],
|
|
144
|
+
'formula': f_arr.astype(np.float32),
|
|
145
|
+
'mass': iso_mass,
|
|
146
|
+
'charge': charge,
|
|
147
|
+
'spec': spec_arr.astype(np.float32),
|
|
148
|
+
'env': env_arr.astype(np.float32),
|
|
149
|
+
'mz': mz_array,
|
|
150
|
+
'intensity': int_array}
|
|
151
|
+
|
|
152
|
+
def generate_ms(x, y, precursor_mz, resolution=1, max_mz=1500, charge=1):
|
|
153
|
+
'''
|
|
154
|
+
Input: x [float list denotes the x-coordinate of peaks]
|
|
155
|
+
y [float list denotes the y-coordinate of peaks]
|
|
156
|
+
precursor_mz [float denotes the parent ion]
|
|
157
|
+
resolution [float denotes the resolution of spectra]
|
|
158
|
+
max_mz [integer denotes the maximum m/z value of spectra]
|
|
159
|
+
charge [float denotes the charge of spectra]
|
|
160
|
+
Return: ms [numpy array denotes the mass spectra]
|
|
161
|
+
'''
|
|
162
|
+
assert len(x) == len(y), 'The length of x and y should be the same.'
|
|
163
|
+
|
|
164
|
+
x, y = remove_precursor_isotopic_peaks(x, y, precursor_mz, charge, tolerance=resolution/10)
|
|
165
|
+
x = np.array(x)
|
|
166
|
+
y = np.array(y)
|
|
167
|
+
if len(x) == 0 or y.max() - y.min() == 0:
|
|
168
|
+
return False, None, None, None
|
|
169
|
+
|
|
170
|
+
# (1) Smooth the MS/MS (no benefit)
|
|
171
|
+
# y = np.sqrt(y)
|
|
172
|
+
# (2) Sharpen the MS/MS
|
|
173
|
+
# y = y ** 2
|
|
174
|
+
|
|
175
|
+
# Prepare parameters using Decimal for precision
|
|
176
|
+
resolution = Decimal(str(resolution))
|
|
177
|
+
max_mz = Decimal(str(max_mz))
|
|
178
|
+
|
|
179
|
+
# Initialize mass spectra vector with zeros
|
|
180
|
+
intensity_val = np.zeros(int(max_mz // resolution))
|
|
181
|
+
mz_val = np.zeros(int(max_mz // resolution)) # Save the max delta m/z falling this bin
|
|
182
|
+
|
|
183
|
+
# Convert x, y to indices and accumulate in the mass spectra vector
|
|
184
|
+
for mz, intensity in zip(x, y):
|
|
185
|
+
idx = int(round(Decimal(str(mz)) // resolution))
|
|
186
|
+
|
|
187
|
+
intensity_val[idx] += intensity # Accumulate the intensity in the bin
|
|
188
|
+
if intensity_val[idx] < intensity: # Update the max delta m/z falling this bin
|
|
189
|
+
mz_val[idx] = mz
|
|
190
|
+
|
|
191
|
+
# Normalize to 0-1 range
|
|
192
|
+
intensity_val = (intensity_val - intensity_val.min()) / (intensity_val.max() - intensity_val.min())
|
|
193
|
+
|
|
194
|
+
# Stack the arrays horizontally
|
|
195
|
+
stacked_ms = np.column_stack((intensity_val, mz_val))
|
|
196
|
+
|
|
197
|
+
return True, x, y, stacked_ms
|
|
198
|
+
|
|
199
|
+
def remove_precursor_isotopic_peaks(x, y, precursor_mz, charge, tolerance=0.1):
|
|
200
|
+
# Calculate the spacing between isotopic peaks
|
|
201
|
+
spacing = 1 / abs(charge)
|
|
202
|
+
|
|
203
|
+
# Generate theoretical isotopic peaks
|
|
204
|
+
max_isotopes = 5 # Typically, we consider up to 5 isotopic peaks for large molecules
|
|
205
|
+
theoretical_isotopic_peaks = [precursor_mz - i * spacing for i in range(max_isotopes)]
|
|
206
|
+
|
|
207
|
+
# Identify isotopic peaks
|
|
208
|
+
isotopic_peaks = []
|
|
209
|
+
for value in x:
|
|
210
|
+
for theoretical_peak in theoretical_isotopic_peaks:
|
|
211
|
+
if abs(value - theoretical_peak) <= tolerance:
|
|
212
|
+
isotopic_peaks.append(value)
|
|
213
|
+
break
|
|
214
|
+
|
|
215
|
+
# Remove isotopic peaks from x and y
|
|
216
|
+
filtered_x = []
|
|
217
|
+
filtered_y = []
|
|
218
|
+
for xi, yi in zip(x, y):
|
|
219
|
+
if xi not in isotopic_peaks:
|
|
220
|
+
filtered_x.append(xi)
|
|
221
|
+
filtered_y.append(yi)
|
|
222
|
+
|
|
223
|
+
return filtered_x, filtered_y
|
|
224
|
+
|
|
225
|
+
def parse_collision_energy(ce_str, precursor_mz, charge=1):
|
|
226
|
+
# ratio constants for NCE
|
|
227
|
+
# charge = int(charge.lstrip('+').lstrip('-'))
|
|
228
|
+
charge_factor = {1: 1, 2: 0.9, 3: 0.85, 4: 0.8, 5: 0.75, 6: 0.75, 7: 0.75, 8: 0.75}
|
|
229
|
+
|
|
230
|
+
ce = None
|
|
231
|
+
nce = None
|
|
232
|
+
|
|
233
|
+
# match collision energy (eV)
|
|
234
|
+
matches_ev = {
|
|
235
|
+
# NIST20
|
|
236
|
+
r"^[\d]+[.]?[\d]*$": lambda x: float(x),
|
|
237
|
+
r"^[\d]+[.]?[\d]*[ ]?eV$": lambda x: float(x.rstrip(" eV")),
|
|
238
|
+
r"^[\d]+[.]?[\d]*[ ]?ev$": lambda x: float(x.rstrip(" ev")),
|
|
239
|
+
r"^[\d]+[.]?[\d]*[ ]?v$": lambda x: float(x.rstrip(" v")),
|
|
240
|
+
r"^[\d]+[.]?[\d]*[ ]?V$": lambda x: float(x.rstrip(" V")),
|
|
241
|
+
r"^NCE=[\d]+[.]?[\d]*% [\d]+[.]?[\d]*eV$": lambda x: float(x.split()[1].rstrip("eV")),
|
|
242
|
+
r"^nce=[\d]+[.]?[\d]*% [\d]+[.]?[\d]*ev$": lambda x: float(x.split()[1].rstrip("ev")),
|
|
243
|
+
# MassBank
|
|
244
|
+
r"^[\d]+[.]?[\d]*[ ]?V$": lambda x: float(x.rstrip(" V")),
|
|
245
|
+
r"^hcd[\d]+[.]?[\d]*$": lambda x: float(x.lstrip('hcd')),
|
|
246
|
+
r"^[\d]+HCD$": lambda x: float(x.rstrip("HCD")), # 35HCD
|
|
247
|
+
}
|
|
248
|
+
for k, v in matches_ev.items():
|
|
249
|
+
if re.match(k, ce_str):
|
|
250
|
+
ce = v(ce_str)
|
|
251
|
+
break
|
|
252
|
+
# match collision energy (NCE)
|
|
253
|
+
matches_nce = {
|
|
254
|
+
# MassBank
|
|
255
|
+
r"^[\d]+[.]?[\d]*[ ]?[%]? \(nominal\)$": lambda x: float(x.rstrip('% (nominal)')),
|
|
256
|
+
r"^[\d]+[.]?[\d]*[ ]?nce$": lambda x: float(x.rstrip(' nce')),
|
|
257
|
+
r"^[\d]+[.]?[\d]*[ ]?\(nce\)$": lambda x: float(x.rstrip(' (nce)')),
|
|
258
|
+
r"^NCE=[\d]+\%$": lambda x: float(x.lstrip('NCE=').rstrip('%')),
|
|
259
|
+
r"^[\d]+[.]?[\d]*\([Nn][Cc][Ee]\)$": lambda x: float(x.split('(')[0]), # 90(NCE)
|
|
260
|
+
r"^HCD \(NCE [\d]+[.]?[\d]*%\)$": lambda x: float(x.split(' ')[-1].rstrip('%)')), # HCD (NCE 40%)
|
|
261
|
+
# CASMI
|
|
262
|
+
r"^[\d]+[.]?[\d]*[ ]?\(nominal\)$": lambda x: float(x.rstrip("(nominal)").rstrip(' ')),
|
|
263
|
+
}
|
|
264
|
+
for k, v in matches_nce.items():
|
|
265
|
+
if re.match(k, ce_str):
|
|
266
|
+
nce = v(ce_str) * 0.01
|
|
267
|
+
break
|
|
268
|
+
|
|
269
|
+
# unknown collision energy
|
|
270
|
+
if ce_str == 'Unknown':
|
|
271
|
+
ce = 40
|
|
272
|
+
|
|
273
|
+
if nce == None and ce != None:
|
|
274
|
+
nce = ce * 500 * charge_factor[charge] / precursor_mz
|
|
275
|
+
elif ce == None and nce != None:
|
|
276
|
+
ce = nce * precursor_mz / (500 * charge_factor[charge])
|
|
277
|
+
else:
|
|
278
|
+
# raise Exception('Collision energy parse error: {}'.format(ce_str))
|
|
279
|
+
return None, None
|
|
280
|
+
return ce, nce
|
|
281
|
+
|
|
282
|
+
def melt_neutral_precursor(formula, precursor_type):
|
|
283
|
+
precursor_type = unify_precursor_type(precursor_type)
|
|
284
|
+
|
|
285
|
+
neutrue_list = ['H2O', 'NH3', 'CO2', 'CH4O2', 'CH2O2']
|
|
286
|
+
neutrue_counts = {}
|
|
287
|
+
|
|
288
|
+
# Step 1: Get the count of neutral losses/adducts
|
|
289
|
+
for neutrue in neutrue_list:
|
|
290
|
+
if neutrue in precursor_type:
|
|
291
|
+
pattern = r'([-+]?\d*)\b' + neutrue + r'\b'
|
|
292
|
+
matches = re.findall(pattern, precursor_type)
|
|
293
|
+
if matches:
|
|
294
|
+
count = matches[0]
|
|
295
|
+
if count == '+': count = 1
|
|
296
|
+
elif count == '-': count = -1
|
|
297
|
+
else:
|
|
298
|
+
try:
|
|
299
|
+
count = int(count)
|
|
300
|
+
except Exception as e:
|
|
301
|
+
print('Get the count of neutral losses/adducts', e)
|
|
302
|
+
print(precursor_type, neutrue, matches, count)
|
|
303
|
+
neutrue_counts[neutrue] = count
|
|
304
|
+
break # only one neutral adduct is allowed
|
|
305
|
+
|
|
306
|
+
# Step 2: Remove neutral losses/adducts from the precursor type string
|
|
307
|
+
pattern = r'([-+]?\d*)(?:' + '|'.join(neutrue_list) + ')' + r'\b'
|
|
308
|
+
adjust_precursor_type = re.sub(pattern, '', precursor_type)
|
|
309
|
+
|
|
310
|
+
# Step 3: Add neutral losses/adducts to formula
|
|
311
|
+
f_dict = formula_to_dict(formula)
|
|
312
|
+
for neutrue, count in neutrue_counts.items():
|
|
313
|
+
n_dict = formula_to_dict(neutrue)
|
|
314
|
+
for k, v in n_dict.items():
|
|
315
|
+
if k in f_dict.keys():
|
|
316
|
+
try:
|
|
317
|
+
f_dict[k] += int(v) * count
|
|
318
|
+
except Exception as e:
|
|
319
|
+
print('Add neutral losses/adducts to formula', e)
|
|
320
|
+
print(f_dict, int(v) * count)
|
|
321
|
+
else:
|
|
322
|
+
f_dict[k] = int(v) * count
|
|
323
|
+
adjust_formula = dict_to_formula(f_dict)
|
|
324
|
+
|
|
325
|
+
return adjust_formula, adjust_precursor_type
|
|
326
|
+
|
|
327
|
+
def unify_precursor_type(precursor_type):
|
|
328
|
+
# +1+-neutrue
|
|
329
|
+
# '[M+H-H2O]+', '[M-H2O+H]+', '[M+H-2H2O]+', '[M+H-NH3]+', '[M+H+NH3]+', '[M+NH4]+', '[M+H-CH2O2]+', '[M+H-CH4O2]+'
|
|
330
|
+
# -1+-neutrue
|
|
331
|
+
# '[M-H-CO2]-', '[M-CHO2]-', '[M-H-H2O]-'
|
|
332
|
+
if precursor_type == '[M+NH4]+': return '[M+H+NH3]+'
|
|
333
|
+
elif precursor_type == '[M-CHO2]-': return '[M-H-CO2]-'
|
|
334
|
+
elif precursor_type == '[M-H2O+H]+': return '[M+H-H2O]+'
|
|
335
|
+
else: return precursor_type
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
if __name__ == '__main__':
|
|
340
|
+
# melt_neutral_precursor
|
|
341
|
+
formula = 'C12H14O2'
|
|
342
|
+
precursor_type = '[M+H-CH2O2]+'
|
|
343
|
+
adjust_formula, adjust_precursor_type = melt_neutral_precursor(formula, precursor_type)
|
|
344
|
+
print(adjust_formula, adjust_precursor_type)
|
|
345
|
+
print(formula_to_vector(adjust_formula))
|
|
346
|
+
|
|
347
|
+
# generate_ms
|
|
348
|
+
x = [128.0511, 128.0553, 129.0544, 129.0585, 130.0665, 130.0725]
|
|
349
|
+
y = [102.5, 2.3, 10.99, 1., 999., 22.68]
|
|
350
|
+
precursor_mz = 130.06564948176495
|
|
351
|
+
print('origin mz', x)
|
|
352
|
+
print('origin intensity', y)
|
|
353
|
+
resolution = 0.2
|
|
354
|
+
max_mz = 1500
|
|
355
|
+
charge = 1
|
|
356
|
+
good_ms, mz, intensity, ms = generate_ms(x, y, precursor_mz, resolution, max_mz, charge)
|
|
357
|
+
print(good_ms, ms)
|
|
358
|
+
print('mz', mz)
|
|
359
|
+
print('intensity', intensity)
|
|
360
|
+
|
|
361
|
+
x = [1447.98, 279.24, 695.48, 1446.97, 415.23, 29.69, 1446.89]
|
|
362
|
+
y = [1., 0.02231055, 0.02118375, 0.01257913, 0.01197476, 0.00163897, 0.]
|
|
363
|
+
precursor_mz = 1448.97222710619
|
|
364
|
+
print('origin mz', x)
|
|
365
|
+
print('origin intensity', y)
|
|
366
|
+
resolution = 1.
|
|
367
|
+
max_mz = 1500
|
|
368
|
+
charge = -1
|
|
369
|
+
good_ms, mz, intensity, ms = generate_ms(x, y, precursor_mz, resolution, max_mz, charge)
|
|
370
|
+
print(good_ms, ms)
|
|
371
|
+
print('mz', mz)
|
|
372
|
+
print('intensity', intensity)
|