cellmaps-utils 0.10.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.
@@ -0,0 +1,945 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ import uuid
5
+ import shutil
6
+ from datetime import date
7
+ import logging
8
+ import pandas as pd
9
+ import cellmaps_utils
10
+ from cellmaps_utils.basecmdtool import BaseCommandLineTool
11
+ from cellmaps_utils.exceptions import CellMapsError
12
+ from cellmaps_utils import constants
13
+ from cellmaps_utils.provenance import ProvenanceUtil
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class APMSDataLoader(BaseCommandLineTool):
19
+ """
20
+ Creates RO-Crate of AP-MS data from
21
+ raw AP-MS tables
22
+ """
23
+ COMMAND = 'apmsconverter'
24
+
25
+ BAIT_COL_NAME = 'Bait'
26
+
27
+ LEGACY_FORMAT = 'legacy'
28
+ """
29
+ Value for ``--input_format`` denoting the original SAINT tables
30
+ """
31
+
32
+ ZMADEX_SAINT_FORMAT = 'zmadex_saint'
33
+ """
34
+ Value for ``--input_format`` denoting the Zmadex/SAINT tables
35
+ introduced with the 2025_04_04 Krogan release
36
+ """
37
+
38
+ INPUT_FORMATS = [LEGACY_FORMAT, ZMADEX_SAINT_FORMAT]
39
+
40
+ DRUG_COL = 'drug'
41
+ """
42
+ Name of column in Zmadex/SAINT input used to split treatment arms.
43
+ The column is consumed by the split and never written out
44
+ """
45
+
46
+ ZMADEX_SAINT_COL_MAP = {'baitGene': 'Bait',
47
+ 'preyProtein': 'Prey',
48
+ 'preyGene': 'PreyGene',
49
+ 'batch': 'Batch',
50
+ 'meanZmadex': 'Zmadex',
51
+ 'meanLog2FC': 'BaitControlLog2FC',
52
+ 'obsCount': 'ObservedCount',
53
+ 'missingCount': 'MissingCount',
54
+ 'SAINT.BFDR.intensity': 'SAINTBFDRIntensity',
55
+ 'SAINT.BFDR.spc': 'SAINTBFDRSpectralCount',
56
+ 'passZ': 'PassZ',
57
+ 'passSAINTintensity': 'PassSAINTIntensity',
58
+ 'passSAINTspc': 'PassSAINTSpectralCount',
59
+ 'label': 'ReferenceLabel'}
60
+ """
61
+ Zmadex/SAINT source column to normalized AP-MS column
62
+ """
63
+
64
+ APMS_COLS = ['Bait', 'Prey', 'PreyGene', 'Batch',
65
+ 'Zmadex', 'BaitControlLog2FC',
66
+ 'ObservedCount', 'MissingCount',
67
+ 'SAINTBFDRIntensity', 'SAINTBFDRSpectralCount',
68
+ 'PassZ', 'PassSAINTIntensity', 'PassSAINTSpectralCount',
69
+ 'ReferenceLabel']
70
+ """
71
+ Column order of apms.tsv and apms_unfiltered.tsv. Identical for every
72
+ treatment so downstream consumers need no per-crate branching
73
+ """
74
+
75
+ MSSTATS_COL_MAP = {'Batch': 'Batch',
76
+ 'Bait': 'Bait',
77
+ 'Protein': 'Prey',
78
+ 'log2FC': 'DrugEffectLog2FC',
79
+ 'SE': 'DrugEffectSE',
80
+ 'Tvalue': 'DrugEffectTValue',
81
+ 'DF': 'DrugEffectDF',
82
+ 'pvalue': 'DrugEffectPValue',
83
+ 'adj.pvalue': 'DrugEffectAdjustedPValue',
84
+ 'Significant': 'DrugEffectSignificant',
85
+ 'Effect': 'DrugEffect',
86
+ 'issue': 'DrugEffectIssue',
87
+ 'Label': 'DrugEffectComparison'}
88
+ """
89
+ MSstats source column to normalized drug effect column
90
+ """
91
+
92
+ DRUG_EFFECT_COLS = ['Batch', 'Bait', 'Prey',
93
+ 'DrugEffectLog2FC', 'DrugEffectSE',
94
+ 'DrugEffectTValue', 'DrugEffectDF',
95
+ 'DrugEffectPValue', 'DrugEffectAdjustedPValue',
96
+ 'DrugEffectSignificant', 'DrugEffect',
97
+ 'DrugEffectIssue', 'DrugEffectComparison']
98
+ """
99
+ Column order of apms_drug_effect.tsv
100
+ """
101
+
102
+ KEY_COLS = ['Batch', 'Bait', 'Prey']
103
+ """
104
+ Join key. All three are needed, HDAC2 and USP7 appear in two batches
105
+ so Bait + Prey alone is not unique
106
+ """
107
+
108
+ PASS_COLS = ['PassZ', 'PassSAINTIntensity', 'PassSAINTSpectralCount']
109
+
110
+ FILTER_ANY_PASS = 'any_pass'
111
+ FILTER_NONE = 'none'
112
+
113
+ FILTER_MODES = {FILTER_ANY_PASS: PASS_COLS,
114
+ 'passz': ['PassZ'],
115
+ 'saint_intensity': ['PassSAINTIntensity'],
116
+ 'saint_spc': ['PassSAINTSpectralCount'],
117
+ FILTER_NONE: []}
118
+ """
119
+ ``--filter`` value to the columns that must hold ``TRUE``
120
+ """
121
+
122
+ DRUG_EFFECT_ROWS_ALL = 'all'
123
+ DRUG_EFFECT_ROWS_SAINT_UNIVERSE = 'saint_universe'
124
+ DRUG_EFFECT_ROWS_APMS_KEYS = 'apms_keys'
125
+
126
+ DRUG_EFFECT_ROWS_CHOICES = [DRUG_EFFECT_ROWS_ALL,
127
+ DRUG_EFFECT_ROWS_SAINT_UNIVERSE,
128
+ DRUG_EFFECT_ROWS_APMS_KEYS]
129
+
130
+ TRUE_VAL = 'TRUE'
131
+
132
+ KNOWN_DRUGS = ['DMSO', 'VRST']
133
+
134
+ DRUG_TO_TREATMENT = {'DMSO': 'untreated',
135
+ 'VRST': 'vorinostat'}
136
+
137
+ KNOWN_DRUG_EFFECTS = ['up', 'down', 'insig',
138
+ 'gained', 'lost', 'unidentified']
139
+
140
+ GENE_SET_ALIASES = {'SMARCA4': 'BRG1',
141
+ 'MORF4L1': 'MRG15',
142
+ 'NSD1': 'KMT3B'}
143
+ """
144
+ Baits published under a legacy alias in the CM4AI gene set tables.
145
+ Resolved before the gene set check so they are not reported missing
146
+ """
147
+
148
+ CHROMATIN_GENE_SET = set("""
149
+ PARP1 BAP1 USP7 NEDD4 BRCA1 BARD1 BRE1A CBL MSL1 PHF6 TAF1 UHRF1 UHRF2 KAT2A KAT3A KAT3B
150
+ KAT6A KAT6B HDAC1 HDAC2 HDAC3 HDAC4 HDAC5 HDAC6 HDAC7 HDAC8 HDAC9 HDAC10 HDAC11 SIRT2
151
+ KDM3B KDM4C KDM5A KDM5B KDM5C KDM6A KDM6B TET1 TET2 TET3 ATM ATR AURKB AURKA CSNK2A1
152
+ HASPIN JAK2 MSK1 MSK2 PRKCA PRKCB PRKCD BAZ1B PRKAA1 PRKAA2 PRKAB1 PRKAB2 PRKAG1 PRKAG2
153
+ PRKAG3 DNMT1 DNMT3A DNMT3B KMT1D KMT1F KMT2A KMT2C KMT2D KMT2E KMT2H KMT3B PRMT1 EYA1
154
+ EYA3 PPP1CA PPP1CB PPP1CC PPP4C BPTF BRD4 BRD7 BRPF1 CHD4 ING1 TRIM24 YWHAZ YWHAE YWHAQ
155
+ YWHAB YWHAH YWHAG BRG1 CBX2 CBX3 MRG15 53BP1 BRD3 CHD1 MBTD1 PDP1 RAG2
156
+ """.split())
157
+ """
158
+ CM4AI Year 1 chromatin modifier genes, see
159
+ https://cm4ai.org/product-documentation/#year-1-chromatin-modifier-genes-proteins
160
+ """
161
+
162
+ METABOLIC_GENE_SET = set("""
163
+ FOLR1 MTHFR MTR MTHFD1 MAT1A MAT2A MAT2B AHCY SHMT1 RFK FLAD1 TKFC ACAD9 ACADM ACADS
164
+ ACADSB ACADVL ACAT1 ACOT7 ACOX3 ACSS2 ACSS3 GMPS LDHA LDHB LDHD ACLY ALDH5A1 DLAT DLD
165
+ GLUD1 GLUD2 IDH1 IDH3A IDH3B IDH3G MDH2 OGDH PDHA1 PDHA2 PDHB SDHA G6PD GAPDH NADK
166
+ NDUFS4 NDUFAB1 NMNAT2 NNT PGD PGM1 PHGDH PYGL SRC KIT ITPKA AKT1 BRAF CDK4 DAPK3 FYN
167
+ PIK3CA PIK3C2A NUAK1 STK4 MAPK1 MTOR PIP5K1C RIPK1 RPS6KA3 KRAS TGFBR2 WEE1 PPA2 AHCYL2
168
+ CYP27A1 DIO3 GSTM1 HEXA NAGS NCOA1 PANK2 PARP4 PARP6 PLCD4 PLCG1 SLC2A1 UGDH UGT8 UBE3C
169
+ CYP24A1 GCLM GYG2 PDE4A PLPP7 AMDHD2 ABHD6
170
+ """.split())
171
+ """
172
+ CM4AI Year 2 metabolic enzyme genes, see
173
+ https://cm4ai.org/product-documentation/#year-2-metabolic-enzyme-genes-proteins
174
+ """
175
+
176
+ GENE_SETS = {'chromatin': CHROMATIN_GENE_SET,
177
+ 'metabolic': METABOLIC_GENE_SET}
178
+
179
+ SUMMARY_MARKER = '@@DATASET_SUMMARY@@'
180
+ """
181
+ Placeholder in apms_zmadex_saint_readme.txt replaced with a
182
+ description of what this particular RO-Crate holds
183
+ """
184
+
185
+ def __init__(self, theargs,
186
+ provenance_utils=ProvenanceUtil()):
187
+ """
188
+ Constructor
189
+
190
+ :param theargs: Command line arguments that at minimum need
191
+ to have the following attributes:
192
+ :type theargs: :py:class:`~python.argparse.Namespace`
193
+ """
194
+ super().__init__()
195
+ self._outdir = os.path.abspath(theargs.outdir)
196
+ self._inputs = theargs.inputs
197
+ self._name = theargs.name
198
+ self._organization_name = theargs.organization_name
199
+ self._project_name = theargs.project_name
200
+ self._release = theargs.release
201
+ self._cell_line = theargs.cell_line
202
+ self._treatment = theargs.treatment
203
+ self._tissue = theargs.tissue
204
+ self._author = theargs.author
205
+ self._gene_set = theargs.gene_set
206
+ self._baitcolname = theargs.baitcolname
207
+ self._set_name = theargs.set_name
208
+ self._input_format = getattr(theargs, 'input_format',
209
+ APMSDataLoader.LEGACY_FORMAT)
210
+ self._drug = getattr(theargs, 'drug', None)
211
+ self._drug_effect_input = getattr(theargs, 'drug_effect_input', None)
212
+ self._drug_effect_rows = getattr(theargs, 'drug_effect_rows',
213
+ APMSDataLoader.DRUG_EFFECT_ROWS_ALL)
214
+ self._exclude_baits = getattr(theargs, 'exclude_baits', ['MDA'])
215
+ self._filter = getattr(theargs, 'filter',
216
+ APMSDataLoader.FILTER_ANY_PASS)
217
+ self._no_unfiltered = getattr(theargs, 'no_unfiltered', False)
218
+ self._provenance_utils = provenance_utils
219
+ self._softwareid = None
220
+ self._input_data_dict = theargs.__dict__
221
+ self._qc = {}
222
+
223
+ def run(self):
224
+ """
225
+ Run method to create RO-Crate from AP-MS data tables. This process involves merging input tables,
226
+ registering the dataset and related software in the RO-Crate.
227
+
228
+ :return:
229
+ """
230
+ self._validate_args()
231
+ self._generate_rocrate_dir_path()
232
+ if os.path.exists(self._outdir):
233
+ raise CellMapsError(self._outdir + ' already exists')
234
+
235
+ logger.debug('Creating directory ' + str(self._outdir))
236
+ os.makedirs(self._outdir, mode=0o755)
237
+
238
+ keywords = [self._project_name, self._release,
239
+ self._cell_line, self._treatment, self._tissue,
240
+ 'AP-MS edgelist']
241
+
242
+ if self._gene_set is not None:
243
+ keywords.append(self._gene_set)
244
+
245
+ description = ' '.join(keywords)
246
+
247
+ info_dict = {
248
+ constants.DATASET_NAME: self._name,
249
+ constants.DATASET_ORGANIZATION_NAME: self._organization_name,
250
+ constants.DATASET_PROJECT_NAME: self._project_name,
251
+ constants.DATASET_RELEASE: self._release,
252
+ constants.DATASET_CELL_LINE: self._cell_line,
253
+ constants.DATASET_TREATMENT: self._treatment,
254
+ constants.DATASET_TISSUE: self._tissue,
255
+ constants.DATASET_AUTHOR: self._author,
256
+ constants.DATASET_GENE_SET: self._gene_set
257
+ }
258
+
259
+ self.save_dataset_info_to_json(self._outdir, info_dict, constants.DATASET_INFO_FILE)
260
+
261
+ self._provenance_utils.register_rocrate(self._outdir,
262
+ name=self._name,
263
+ organization_name=self._organization_name,
264
+ project_name=self._project_name,
265
+ description=description,
266
+ keywords=keywords,
267
+ guid=self._get_fairscape_id())
268
+ gen_dsets = []
269
+
270
+ if self._input_format == APMSDataLoader.ZMADEX_SAINT_FORMAT:
271
+ gen_dsets.extend(self._run_zmadex_saint(description=description,
272
+ keywords=keywords))
273
+ else:
274
+ file_path = self._merge_and_save_apms_data()
275
+ file_desc = description + ' AP-MS file'
276
+ file_keywords = keywords.copy()
277
+ file_keywords.extend(['file'])
278
+ dset_id = self._provenance_utils.register_dataset(rocrate_path=self._outdir, source_file=file_path,
279
+ skip_copy=True,
280
+ data_dict={'name': ' AP-MS file',
281
+ 'description': file_desc,
282
+ 'keywords': file_keywords,
283
+ 'data-format': 'tsv',
284
+ 'author': self._author,
285
+ 'version': self._release,
286
+ 'date-published': date.today().strftime('%Y-%m-%d')},
287
+ guid=self._get_fairscape_id())
288
+ gen_dsets.append(dset_id)
289
+ self._register_software(keywords=keywords, description=description)
290
+ self._register_computation(generated_dataset_ids=gen_dsets,
291
+ description=description,
292
+ keywords=keywords)
293
+ self._copy_over_apms_readme()
294
+ return 0
295
+
296
+ def _validate_args(self):
297
+ """
298
+ Checks the combination of command line flags makes sense before
299
+ any directory is created
300
+
301
+ :raises CellMapsError: if the flags are contradictory
302
+ """
303
+ if self._input_format not in APMSDataLoader.INPUT_FORMATS:
304
+ raise CellMapsError('--input_format must be one of ' +
305
+ str(APMSDataLoader.INPUT_FORMATS) +
306
+ ' received: ' + str(self._input_format))
307
+
308
+ if self._input_format == APMSDataLoader.LEGACY_FORMAT:
309
+ if self._drug_effect_input is not None:
310
+ raise CellMapsError('--drug_effect_input is only supported '
311
+ 'with --input_format ' +
312
+ APMSDataLoader.ZMADEX_SAINT_FORMAT)
313
+ return
314
+
315
+ if self._inputs is None or len(self._inputs) != 1:
316
+ raise CellMapsError('--input_format ' +
317
+ APMSDataLoader.ZMADEX_SAINT_FORMAT +
318
+ ' expects exactly one --inputs file, received: ' +
319
+ str(self._inputs))
320
+
321
+ if self._drug is None:
322
+ raise CellMapsError('--drug is required with --input_format ' +
323
+ APMSDataLoader.ZMADEX_SAINT_FORMAT)
324
+
325
+ if self._filter not in APMSDataLoader.FILTER_MODES:
326
+ raise CellMapsError('--filter must be one of ' +
327
+ str(list(APMSDataLoader.FILTER_MODES.keys())) +
328
+ ' received: ' + str(self._filter))
329
+
330
+ if self._drug_effect_rows not in APMSDataLoader.DRUG_EFFECT_ROWS_CHOICES:
331
+ raise CellMapsError('--drug_effect_rows must be one of ' +
332
+ str(APMSDataLoader.DRUG_EFFECT_ROWS_CHOICES) +
333
+ ' received: ' + str(self._drug_effect_rows))
334
+
335
+ if self._drug_effect_input is None:
336
+ if self._drug_effect_rows != APMSDataLoader.DRUG_EFFECT_ROWS_ALL:
337
+ raise CellMapsError('--drug_effect_rows has nothing to act on '
338
+ 'without --drug_effect_input')
339
+ elif self._drug == 'DMSO':
340
+ logger.warning('--drug_effect_input was passed with --drug DMSO. '
341
+ 'The MSstats contrast is vorinostat versus DMSO, so '
342
+ 'it normally belongs with the treated arm only')
343
+
344
+ expected_treatment = APMSDataLoader.DRUG_TO_TREATMENT.get(self._drug)
345
+ if expected_treatment is not None and expected_treatment != self._treatment:
346
+ logger.warning('--drug ' + str(self._drug) + ' is usually paired '
347
+ 'with --treatment ' + expected_treatment +
348
+ ' but --treatment is ' + str(self._treatment))
349
+
350
+ def _run_zmadex_saint(self, description='', keywords=None):
351
+ """
352
+ Writes and registers every file of a Zmadex/SAINT RO-Crate.
353
+
354
+ The two inputs are never merged. The AP-MS tables are built from the
355
+ Zmadex/SAINT file and the drug effect table from the MSstats file,
356
+ the only thing passing between them is a key set used by the
357
+ ``--drug_effect_rows`` restriction.
358
+
359
+ :return: ids of registered datasets
360
+ :rtype: list
361
+ """
362
+ keywords = [] if keywords is None else keywords
363
+ gen_dsets = []
364
+
365
+ saint_df = self._load_zmadex_saint()
366
+ arm_df = self._select_drug_arm(saint_df)
367
+ filtered_df = self._apply_filter(arm_df)
368
+
369
+ if len(filtered_df) == 0:
370
+ raise CellMapsError('Filter ' + str(self._filter) + ' left no rows '
371
+ 'for --drug ' + str(self._drug) +
372
+ '. Refusing to write an empty ' +
373
+ constants.APMS_TSV_FILE)
374
+
375
+ gen_dsets.append(self._write_and_register(filtered_df,
376
+ constants.APMS_TSV_FILE,
377
+ name='AP-MS file',
378
+ desc=description + ' AP-MS file',
379
+ keywords=keywords))
380
+
381
+ if not self._no_unfiltered:
382
+ gen_dsets.append(self._write_and_register(arm_df,
383
+ constants.APMS_UNFILTERED_TSV_FILE,
384
+ name='AP-MS unfiltered file',
385
+ desc=description +
386
+ ' complete pre-filter AP-MS matrix, '
387
+ 'same schema as ' +
388
+ constants.APMS_TSV_FILE,
389
+ keywords=keywords))
390
+
391
+ if self._drug_effect_input is not None:
392
+ drug_df = self._load_msstats()
393
+ drug_df = self._restrict_drug_effect(drug_df, arm_df, filtered_df)
394
+ self._check_drug_effect_coverage(filtered_df, drug_df)
395
+ gen_dsets.append(self._write_and_register(drug_df,
396
+ constants.APMS_DRUG_EFFECT_TSV_FILE,
397
+ name='AP-MS drug effect file',
398
+ desc=description +
399
+ ' MSstats vorinostat versus DMSO '
400
+ 'contrast keyed on Batch, Bait and '
401
+ 'Prey. Not an interaction table',
402
+ keywords=keywords))
403
+
404
+ self._check_gene_set(arm_df)
405
+ gen_dsets.append(self._write_qc(description=description,
406
+ keywords=keywords))
407
+ return gen_dsets
408
+
409
+ def _read_table(self, input_file):
410
+ """
411
+ Reads **input_file** keeping every value as text so the floats in
412
+ the source round trip unchanged and blanks stay blank
413
+
414
+ :return: contents of file
415
+ :rtype: :py:class:`pandas.DataFrame`
416
+ """
417
+ thesep = ',' if input_file.endswith('.csv') else '\t'
418
+ return pd.read_csv(input_file, sep=thesep, dtype=str, na_filter=False)
419
+
420
+ def _load_zmadex_saint(self):
421
+ """
422
+ Loads the Zmadex/SAINT file and renames its columns to the
423
+ normalized AP-MS schema
424
+
425
+ :raises CellMapsError: if a required column is missing
426
+ :rtype: :py:class:`pandas.DataFrame`
427
+ """
428
+ df = self._read_table(self._inputs[0])
429
+ required = list(APMSDataLoader.ZMADEX_SAINT_COL_MAP.keys())
430
+ required.append(APMSDataLoader.DRUG_COL)
431
+ missing = [x for x in required if x not in df.columns]
432
+ if len(missing) > 0:
433
+ raise CellMapsError('Zmadex/SAINT input ' + str(self._inputs[0]) +
434
+ ' is missing required column(s): ' + str(missing))
435
+ self._qc['zmadex_saint_input_file'] = os.path.basename(self._inputs[0])
436
+ self._qc['zmadex_saint_input_rows'] = len(df)
437
+ return df.rename(columns=APMSDataLoader.ZMADEX_SAINT_COL_MAP)
438
+
439
+ def _select_drug_arm(self, df):
440
+ """
441
+ Selects the rows matching ``--drug`` and drops the drug column,
442
+ leaving the schema identical for every treatment
443
+
444
+ :raises CellMapsError: if the drug is absent or the key is not unique
445
+ :rtype: :py:class:`pandas.DataFrame`
446
+ """
447
+ found_drugs = sorted(df[APMSDataLoader.DRUG_COL].unique())
448
+ self._qc['drugs_in_input'] = found_drugs
449
+ unexpected = [x for x in found_drugs
450
+ if x not in APMSDataLoader.KNOWN_DRUGS]
451
+ if len(unexpected) > 0:
452
+ logger.warning('Unexpected value(s) in ' +
453
+ APMSDataLoader.DRUG_COL + ' column: ' +
454
+ str(unexpected))
455
+ if self._drug not in found_drugs:
456
+ raise CellMapsError('--drug ' + str(self._drug) + ' not found in ' +
457
+ APMSDataLoader.DRUG_COL + ' column. Found: ' +
458
+ str(found_drugs))
459
+
460
+ arm_df = df[df[APMSDataLoader.DRUG_COL] == self._drug]
461
+ arm_df = arm_df[APMSDataLoader.APMS_COLS].reset_index(drop=True)
462
+
463
+ dupes = int(arm_df.duplicated(APMSDataLoader.KEY_COLS).sum())
464
+ if dupes > 0:
465
+ raise CellMapsError(str(dupes) + ' duplicate ' +
466
+ str(APMSDataLoader.KEY_COLS) + ' row(s) found '
467
+ 'for --drug ' + str(self._drug))
468
+ self._qc['drug_arm'] = self._drug
469
+ self._qc['apms_rows_before_filter'] = len(arm_df)
470
+ self._qc['apms_duplicate_keys'] = dupes
471
+ for colname in APMSDataLoader.PASS_COLS:
472
+ self._qc[colname + '_true'] = int((arm_df[colname] ==
473
+ APMSDataLoader.TRUE_VAL).sum())
474
+ self._qc['apms_rows_with_blank_scores'] = int((arm_df['Zmadex'] == '').sum())
475
+ return arm_df
476
+
477
+ def _apply_filter(self, df):
478
+ """
479
+ Keeps rows holding ``TRUE`` in any column the ``--filter`` mode names.
480
+
481
+ Blanks need no special handling, an empty string is simply not
482
+ ``TRUE``, which is why the frame is read with ``dtype=str`` and
483
+ ``na_filter=False``
484
+
485
+ :rtype: :py:class:`pandas.DataFrame`
486
+ """
487
+ cols = APMSDataLoader.FILTER_MODES[self._filter]
488
+ self._qc['filter'] = self._filter
489
+ if len(cols) == 0:
490
+ self._qc['apms_rows_after_filter'] = len(df)
491
+ return df
492
+ filtered_df = df[(df[cols] == APMSDataLoader.TRUE_VAL).any(axis=1)]
493
+ filtered_df = filtered_df.reset_index(drop=True)
494
+ self._qc['apms_rows_after_filter'] = len(filtered_df)
495
+ logger.info('Filter ' + str(self._filter) + ' kept ' +
496
+ str(len(filtered_df)) + ' of ' + str(len(df)) + ' rows')
497
+ return filtered_df
498
+
499
+ def _load_msstats(self):
500
+ """
501
+ Loads the MSstats file, drops the excluded baits and renames the
502
+ columns to the normalized drug effect schema.
503
+
504
+ Protein groups are counted and written through unchanged. Exploding
505
+ them gains 0.55% more keys while creating tens of thousands of
506
+ ambiguous ones, and every key that survives the AP-MS filter is
507
+ already covered without it
508
+
509
+ :raises CellMapsError: if a required column is missing
510
+ :rtype: :py:class:`pandas.DataFrame`
511
+ """
512
+ df = self._read_table(self._drug_effect_input)
513
+ missing = [x for x in APMSDataLoader.MSSTATS_COL_MAP
514
+ if x not in df.columns]
515
+ if len(missing) > 0:
516
+ raise CellMapsError('MSstats input ' + str(self._drug_effect_input) +
517
+ ' is missing required column(s): ' + str(missing))
518
+ self._qc['msstats_input_file'] = os.path.basename(self._drug_effect_input)
519
+ self._qc['msstats_input_rows'] = len(df)
520
+
521
+ excluded = {}
522
+ for bait in self._exclude_baits:
523
+ excluded[bait] = int((df['Bait'] == bait).sum())
524
+ df = df[df['Bait'] != bait]
525
+ self._qc['msstats_rows_excluded_by_bait'] = excluded
526
+ self._qc['msstats_rows_after_bait_exclusion'] = len(df)
527
+
528
+ self._qc['msstats_protein_group_rows'] = int(df['Protein'].str.contains(';').sum())
529
+
530
+ unexpected = [x for x in sorted(df['Effect'].unique())
531
+ if x not in APMSDataLoader.KNOWN_DRUG_EFFECTS]
532
+ if len(unexpected) > 0:
533
+ logger.warning('Unexpected value(s) in Effect column: ' +
534
+ str(unexpected))
535
+
536
+ df = df.rename(columns=APMSDataLoader.MSSTATS_COL_MAP)
537
+ df = df[APMSDataLoader.DRUG_EFFECT_COLS].reset_index(drop=True)
538
+ self._qc['drug_effect_categories'] = {str(k): int(v) for k, v in
539
+ df['DrugEffect'].value_counts().items()}
540
+ return df
541
+
542
+ def _restrict_drug_effect(self, drug_df, arm_df, filtered_df):
543
+ """
544
+ Applies ``--drug_effect_rows``. This is a key membership test, it
545
+ selects rows and never adds a column or changes row multiplicity.
546
+
547
+ ``all`` is a no-op and is the only mode computable from the MSstats
548
+ file alone. ``apms_keys`` gives the same result as
549
+ ``saint_universe`` when ``--filter none`` is in effect, since the
550
+ unfiltered arm is the SAINT universe for that arm
551
+
552
+ :rtype: :py:class:`pandas.DataFrame`
553
+ """
554
+ self._qc['drug_effect_rows'] = self._drug_effect_rows
555
+ before = len(drug_df)
556
+ self._qc['drug_effect_rows_before_restriction'] = before
557
+
558
+ if self._drug_effect_rows == APMSDataLoader.DRUG_EFFECT_ROWS_SAINT_UNIVERSE:
559
+ target_df = arm_df
560
+ elif self._drug_effect_rows == APMSDataLoader.DRUG_EFFECT_ROWS_APMS_KEYS:
561
+ target_df = filtered_df
562
+ else:
563
+ self._qc['drug_effect_rows_dropped_by_restriction'] = 0
564
+ self._qc['drug_effect_rows_written'] = before
565
+ return drug_df
566
+
567
+ drug_keys = pd.MultiIndex.from_frame(drug_df[APMSDataLoader.KEY_COLS])
568
+ target_keys = pd.MultiIndex.from_frame(target_df[APMSDataLoader.KEY_COLS])
569
+ drug_df = drug_df[drug_keys.isin(target_keys)].reset_index(drop=True)
570
+
571
+ self._qc['drug_effect_rows_dropped_by_restriction'] = before - len(drug_df)
572
+ self._qc['drug_effect_rows_written'] = len(drug_df)
573
+ logger.info('--drug_effect_rows ' + str(self._drug_effect_rows) +
574
+ ' kept ' + str(len(drug_df)) + ' of ' + str(before) + ' rows')
575
+ return drug_df
576
+
577
+ def _check_drug_effect_coverage(self, filtered_df, drug_df):
578
+ """
579
+ The only place the join between the two inputs survives. It reports
580
+ how much of apms.tsv the drug effect table covers and writes nothing
581
+
582
+ """
583
+ apms_keys = pd.MultiIndex.from_frame(filtered_df[APMSDataLoader.KEY_COLS])
584
+ drug_keys = pd.MultiIndex.from_frame(drug_df[APMSDataLoader.KEY_COLS])
585
+ covered = int(apms_keys.isin(drug_keys).sum())
586
+ total = len(apms_keys)
587
+ self._qc['apms_keys'] = total
588
+ self._qc['apms_keys_with_drug_effect'] = covered
589
+ self._qc['apms_keys_without_drug_effect'] = total - covered
590
+ if covered < total:
591
+ logger.warning(str(total - covered) + ' of ' + str(total) +
592
+ ' rows in ' + constants.APMS_TSV_FILE +
593
+ ' have no match in ' +
594
+ constants.APMS_DRUG_EFFECT_TSV_FILE +
595
+ '. The two inputs may have drifted out of sync')
596
+
597
+ def _check_gene_set(self, arm_df):
598
+ """
599
+ Compares the baits against the published CM4AI gene set, resolving
600
+ legacy aliases first. Reference material, never a release gate, so
601
+ this only warns
602
+
603
+ """
604
+ baits = sorted(arm_df['Bait'].unique())
605
+ self._qc['bait_count'] = len(baits)
606
+ gene_set = APMSDataLoader.GENE_SETS.get(self._gene_set)
607
+ if gene_set is None:
608
+ return
609
+ unlisted = [x for x in baits
610
+ if APMSDataLoader.GENE_SET_ALIASES.get(x, x) not in gene_set]
611
+ self._qc['baits_not_in_gene_set'] = unlisted
612
+ if len(unlisted) > 0:
613
+ logger.warning(str(len(unlisted)) + ' bait(s) are not on the '
614
+ 'published ' + str(self._gene_set) + ' gene set: ' +
615
+ str(unlisted))
616
+
617
+ def _write_and_register(self, df, filename, name='', desc='', keywords=None):
618
+ """
619
+ Writes **df** into the RO-Crate and registers it as a dataset
620
+
621
+ :return: id of registered dataset
622
+ :rtype: str
623
+ """
624
+ keywords = [] if keywords is None else keywords
625
+ file_path = os.path.join(self._outdir, filename)
626
+ df.to_csv(file_path, sep='\t', index=False)
627
+ logger.info('Wrote ' + str(len(df)) + ' rows to ' + file_path)
628
+ file_keywords = keywords.copy()
629
+ file_keywords.extend(['file'])
630
+ return self._provenance_utils.register_dataset(rocrate_path=self._outdir,
631
+ source_file=file_path,
632
+ skip_copy=True,
633
+ data_dict={'name': name,
634
+ 'description': desc,
635
+ 'keywords': file_keywords,
636
+ 'data-format': 'tsv',
637
+ 'author': self._author,
638
+ 'version': self._release,
639
+ 'date-published': date.today().strftime('%Y-%m-%d')},
640
+ guid=self._get_fairscape_id())
641
+
642
+ def _write_qc(self, description='', keywords=None):
643
+ """
644
+ Writes the QC/summary statistics file and registers it
645
+
646
+ :return: id of registered dataset
647
+ :rtype: str
648
+ """
649
+ keywords = [] if keywords is None else keywords
650
+ qc_path = os.path.join(self._outdir, constants.APMS_QC_FILE)
651
+ with open(qc_path, 'w') as f:
652
+ json.dump(self._qc, f, indent=2, sort_keys=True)
653
+ logger.info('AP-MS summary: ' + json.dumps(self._qc, sort_keys=True))
654
+ file_keywords = keywords.copy()
655
+ file_keywords.extend(['file'])
656
+ qc_desc = description + ' AP-MS summary statistics'
657
+ return self._provenance_utils.register_dataset(rocrate_path=self._outdir,
658
+ source_file=qc_path,
659
+ skip_copy=True,
660
+ data_dict={'name': 'AP-MS QC file',
661
+ 'description': qc_desc,
662
+ 'keywords': file_keywords,
663
+ 'data-format': 'json',
664
+ 'author': self._author,
665
+ 'version': self._release,
666
+ 'date-published': date.today().strftime('%Y-%m-%d')},
667
+ guid=self._get_fairscape_id())
668
+
669
+ def _get_fairscape_id(self):
670
+ """
671
+ Creates a unique id
672
+ :return:
673
+ """
674
+ return str(uuid.uuid4()) + ':' + os.path.basename(self._outdir)
675
+
676
+ def _get_dataset_summary(self):
677
+ """
678
+ Builds the description of what this RO-Crate actually holds, so the
679
+ readme reports the filter and row set that were used rather than a
680
+ hard coded guess
681
+
682
+ :rtype: str
683
+ """
684
+ lines = ['Contents of this RO-Crate',
685
+ '-------------------------',
686
+ '',
687
+ 'Treatment: ' + str(self._treatment) +
688
+ ' (drug arm ' + str(self._drug) + ')',
689
+ '',
690
+ constants.APMS_TSV_FILE + ': ' +
691
+ str(self._qc.get('apms_rows_after_filter', 'unknown')) +
692
+ ' rows, filtered with --filter ' + str(self._filter) +
693
+ ' from ' + str(self._qc.get('apms_rows_before_filter', 'unknown')) +
694
+ ' rows.']
695
+ if self._no_unfiltered:
696
+ lines.append('')
697
+ lines.append('This RO-Crate does not include ' +
698
+ constants.APMS_UNFILTERED_TSV_FILE + '.')
699
+ else:
700
+ lines.append('')
701
+ lines.append(constants.APMS_UNFILTERED_TSV_FILE + ': ' +
702
+ str(self._qc.get('apms_rows_before_filter', 'unknown')) +
703
+ ' rows, the complete pre-filter matrix with the '
704
+ 'identical schema. Nothing is dropped from it.')
705
+
706
+ lines.append('')
707
+ if self._drug_effect_input is None:
708
+ lines.append('This RO-Crate has no ' +
709
+ constants.APMS_DRUG_EFFECT_TSV_FILE + ' by design. The '
710
+ 'MSstats contrast is vorinostat versus DMSO, so it is '
711
+ 'published with the vorinostat RO-Crate only.')
712
+ else:
713
+ lines.append(constants.APMS_DRUG_EFFECT_TSV_FILE + ': ' +
714
+ str(self._qc.get('drug_effect_rows_written', 'unknown')) +
715
+ ' rows, selected with --drug_effect_rows ' +
716
+ str(self._drug_effect_rows) + '.')
717
+ if self._drug_effect_rows == APMSDataLoader.DRUG_EFFECT_ROWS_ALL:
718
+ lines.append('')
719
+ lines.append('This is the complete MSstats output minus the '
720
+ 'excluded bait(s) ' + str(self._exclude_baits) +
721
+ '. It therefore holds many more rows than ' +
722
+ constants.APMS_TSV_FILE + ', including preys that '
723
+ 'are not in the AP-MS tables at all.')
724
+ lines.append('')
725
+ lines.append('Of the ' + str(self._qc.get('apms_keys', 'unknown')) +
726
+ ' rows in ' + constants.APMS_TSV_FILE + ', ' +
727
+ str(self._qc.get('apms_keys_with_drug_effect', 'unknown')) +
728
+ ' have a drug effect record.')
729
+ return '\n'.join(lines)
730
+
731
+ def _copy_over_apms_readme(self):
732
+ """
733
+ Copies over the readme matching the input format. A reader of a given
734
+ RO-Crate should see only the columns that RO-Crate holds
735
+
736
+ """
737
+ if self._input_format == APMSDataLoader.ZMADEX_SAINT_FORMAT:
738
+ src = os.path.join(os.path.dirname(__file__),
739
+ 'apms_zmadex_saint_readme.txt')
740
+ with open(src, 'r') as f:
741
+ readme = f.read()
742
+ readme = readme.replace(APMSDataLoader.SUMMARY_MARKER,
743
+ self._get_dataset_summary())
744
+ with open(os.path.join(self._outdir, 'readme.txt'), 'w') as f:
745
+ f.write(readme)
746
+ return
747
+ apms_readme = os.path.join(os.path.dirname(__file__), 'apms_readme.txt')
748
+ shutil.copy(apms_readme, os.path.join(self._outdir, 'readme.txt'))
749
+
750
+ def _generate_rocrate_dir_path(self):
751
+ """
752
+ Generates a directory path for the RO-Crate based on provided metadata like project name, gene set,
753
+ cell line, treatment, and release version.
754
+ """
755
+ dir_name = self._project_name.lower() + '_'
756
+ if self._gene_set is not None:
757
+ dir_name += self._gene_set.lower() + '_'
758
+ dir_name += self._cell_line.lower() + '_'
759
+ dir_name += self._treatment.lower()
760
+ if self._set_name is not None:
761
+ dir_name += '_' + self._set_name
762
+ dir_name += '_apms_'
763
+ dir_name += self._release.lower()
764
+ dir_name = dir_name.replace(' ', '_')
765
+ self._outdir = os.path.join(self._outdir, dir_name)
766
+
767
+ def _merge_and_save_apms_data(self):
768
+ """
769
+ Merges AP-MS data from input files into a single DataFrame and saves the combined data to a TSV file within
770
+ the RO-Crate directory.
771
+
772
+ :return: The file path to the saved AP-MS data TSV file.
773
+ """
774
+ df_list = []
775
+ for input in self._inputs:
776
+ thesep = '\t'
777
+ if input.endswith('.csv'):
778
+ thesep = ','
779
+ cur_df = pd.read_csv(input, sep=thesep, na_filter=False)
780
+ if self._baitcolname != 'Bait':
781
+ if self._baitcolname in cur_df.columns:
782
+ cur_df.rename({self._baitcolname: 'Bait'}, axis=1, inplace=True)
783
+ # Handles case where HDAC2 in initial cm4ai dataset
784
+ # had several columns lacking .x suffix
785
+ # we are fixing this by checking for those columns and if
786
+ # found just renaming them in place
787
+ for colname in ['PreyGene', 'NumReplicates', 'AvgP',
788
+ 'MaxP', 'TopoAvgP', 'TopoMaxP',
789
+ 'SaintScore', 'FoldChange', 'BFDR',
790
+ 'boosted_by']:
791
+ if colname in cur_df.columns:
792
+ if colname + '.x' in cur_df.columns:
793
+ cur_df.drop(columns=colname + '.x', inplace=True)
794
+ cur_df.rename({colname: colname + '.x'}, axis=1, inplace=True)
795
+
796
+ df_list.append(cur_df)
797
+
798
+ df = pd.concat(df_list)
799
+
800
+ apms_path = os.path.join(self._outdir, constants.APMS_TSV_FILE)
801
+
802
+ df.to_csv(apms_path, sep='\t', index=False)
803
+ return apms_path
804
+
805
+ def _register_computation(self, generated_dataset_ids=[],
806
+ description='',
807
+ keywords=[]):
808
+ """
809
+ Registers the computation process in the RO-Crate
810
+ # Todo: added in used dataset, software and what is being generated
811
+ :return:
812
+ """
813
+ logger.debug('Getting id of input rocrate')
814
+ comp_keywords = keywords.copy()
815
+ comp_keywords.extend(['computation'])
816
+ description = description + ' run of ' + cellmaps_utils.__name__
817
+ self._provenance_utils.register_computation(self._outdir,
818
+ name='AP-MS',
819
+ run_by=str(self._provenance_utils.get_login()),
820
+ command=str(self._input_data_dict),
821
+ description=description,
822
+ keywords=comp_keywords,
823
+ used_software=[self._softwareid],
824
+ generated=generated_dataset_ids,
825
+ guid=self._get_fairscape_id())
826
+
827
+ def _register_software(self, description='',
828
+ keywords=[]):
829
+ """
830
+ Registers this tool
831
+
832
+ :raises CellMapsImageEmbeddingError: If fairscape call fails
833
+ """
834
+ software_keywords = keywords.copy()
835
+ software_keywords.extend(['tools', cellmaps_utils.__name__])
836
+ software_description = description + ' ' + \
837
+ cellmaps_utils.__description__
838
+ self._softwareid = self._provenance_utils.register_software(self._outdir,
839
+ name=cellmaps_utils.__name__,
840
+ description=software_description,
841
+ author=cellmaps_utils.__author__,
842
+ version=cellmaps_utils.__version__,
843
+ file_format='py',
844
+ keywords=software_keywords,
845
+ url=cellmaps_utils.__repo_url__,
846
+ guid=self._get_fairscape_id())
847
+
848
+ def add_subparser(subparsers):
849
+ """
850
+ Adds a command-line subparser for the APMSDataLoader tool.
851
+
852
+ :return:
853
+ """
854
+ desc = """
855
+
856
+ Version {version}
857
+
858
+ {cmd} Loads AP-MS data into a RO-Crate
859
+ """.format(version=cellmaps_utils.__version__,
860
+ cmd=APMSDataLoader.COMMAND)
861
+
862
+ parser = subparsers.add_parser(APMSDataLoader.COMMAND,
863
+ help='Loads AP-MS data into a RO-Crate',
864
+ description=desc,
865
+ formatter_class=constants.ArgParseFormatter)
866
+ parser.add_argument('outdir',
867
+ help='Directory where RO-Crate will be created')
868
+ parser.add_argument('--inputs', required=True, nargs="+",
869
+ help='One or more table files with the following '
870
+ 'fields: [Bait, Prey] and for filtering also '
871
+ 'containing [BFDR.x, logOddsScore')
872
+ parser.add_argument('--input_format',
873
+ choices=APMSDataLoader.INPUT_FORMATS,
874
+ default=APMSDataLoader.LEGACY_FORMAT,
875
+ help='Format of --inputs. "' +
876
+ APMSDataLoader.LEGACY_FORMAT + '" merges the '
877
+ 'original SAINT tables unchanged. "' +
878
+ APMSDataLoader.ZMADEX_SAINT_FORMAT + '" expects '
879
+ 'exactly one Zmadex/SAINT table and requires --drug')
880
+ parser.add_argument('--drug', choices=APMSDataLoader.KNOWN_DRUGS,
881
+ help='Treatment arm to select from the drug column '
882
+ 'of a Zmadex/SAINT input. Required with '
883
+ '--input_format ' +
884
+ APMSDataLoader.ZMADEX_SAINT_FORMAT)
885
+ parser.add_argument('--drug_effect_input',
886
+ help='MSstats drug effect table. Written to ' +
887
+ constants.APMS_DRUG_EFFECT_TSV_FILE + ' as its '
888
+ 'own file so ' + constants.APMS_TSV_FILE +
889
+ ' keeps the same schema in every RO-Crate. The '
890
+ 'contrast is vorinostat versus DMSO, so pass '
891
+ 'this on the treated run only')
892
+ parser.add_argument('--drug_effect_rows',
893
+ choices=APMSDataLoader.DRUG_EFFECT_ROWS_CHOICES,
894
+ default=APMSDataLoader.DRUG_EFFECT_ROWS_ALL,
895
+ help='Which MSstats rows to publish. "all" is the '
896
+ 'complete table minus --exclude_baits, '
897
+ '"saint_universe" keeps rows whose key is in '
898
+ 'the drug arm, "apms_keys" keeps rows whose key '
899
+ 'is in the written ' + constants.APMS_TSV_FILE)
900
+ parser.add_argument('--exclude_baits', nargs='+', default=['MDA'],
901
+ help='Bait(s) to drop from --drug_effect_input. The '
902
+ 'default is the parental cell line control')
903
+ parser.add_argument('--filter',
904
+ choices=list(APMSDataLoader.FILTER_MODES.keys()),
905
+ default=APMSDataLoader.FILTER_ANY_PASS,
906
+ help='Rows to keep in ' + constants.APMS_TSV_FILE +
907
+ '. "none" writes the complete matrix. Applies '
908
+ 'to the AP-MS tables only, never to ' +
909
+ constants.APMS_DRUG_EFFECT_TSV_FILE)
910
+ parser.add_argument('--no_unfiltered', action='store_true',
911
+ help='Do not write ' +
912
+ constants.APMS_UNFILTERED_TSV_FILE)
913
+ parser.add_argument('--author', default='Krogan Lab',
914
+ help='Author that created this data')
915
+ parser.add_argument('--name', default='AP-MS',
916
+ help='Name of this run, needed for FAIRSCAPE')
917
+ parser.add_argument('--organization_name', default='Krogan Lab',
918
+ help='Name of organization running this tool, needed '
919
+ 'for FAIRSCAPE. Usually set to lab')
920
+ parser.add_argument('--project_name', default='CM4AI',
921
+ help='Name of project running this tool, needed for '
922
+ 'FAIRSCAPE. Usually set to funding source')
923
+ parser.add_argument('--release', required=True,
924
+ help='Version of release. For example: 0.1 alpha')
925
+ parser.add_argument('--treatment', default='untreated',
926
+ choices=['paclitaxel', 'vorinostat', 'untreated'],
927
+ help='Treatment of sample.')
928
+ parser.add_argument('--cell_line', default='MDA-MB-468',
929
+ help='Name of cell line. For example MDA-MB-468')
930
+ parser.add_argument('--gene_set', choices=['chromatin', 'metabolic'],
931
+ default='chromatin',
932
+ help='Gene set for dataset')
933
+ parser.add_argument('--set_name',
934
+ help='If set, adds value to RO-Crate folder name before _apms_<version>. '
935
+ 'Example values set1')
936
+ parser.add_argument('--tissue', choices=['undifferentiated', 'neuron',
937
+ 'cardiomyocytes', ''],
938
+ default='breast; mammary gland',
939
+ help='Tissue for dataset. Since the default --cell_line '
940
+ 'is MDA-MB-468, this value is set to the tissue '
941
+ 'for that cell line')
942
+ parser.add_argument('--baitcolname', default='Bait',
943
+ help='Name of bait column in input file(s)')
944
+
945
+ return parser