protrider 0.9.1__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.
protrider/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .config import ProtriderConfig, load_config
2
+ from .model import ModelInfo
3
+ from .pipeline import Result, run
4
+
5
+ __all__ = ["ProtriderConfig", "ModelInfo", "Result", "run", "load_config"]
protrider/cli.py ADDED
@@ -0,0 +1,265 @@
1
+ from collections import defaultdict
2
+
3
+ import numpy as np
4
+ import yaml
5
+ from pathlib import Path
6
+ import click
7
+ import torch
8
+ import logging
9
+
10
+ from .pipeline import run as run_pipeline
11
+ from .config import load_config
12
+ from . import plots
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @click.group()
18
+ def cli() -> None:
19
+ """# PROTRIDER
20
+
21
+ PROTRIDER is a package for calling protein outliers on mass spectrometry data
22
+
23
+ Links:
24
+ - Publication: FIXME
25
+ - Official code repository: https://github.com/gagneurlab/PROTRIDER
26
+
27
+ """
28
+ pass
29
+
30
+
31
+ @cli.group(chain=True)
32
+ @click.option(
33
+ "--config",
34
+ help="Configuration file containing PROTRIDER custom options",
35
+ type=click.Path(exists=True, dir_okay=False),
36
+ )
37
+ @click.option(
38
+ '--plot_title',
39
+ type=str,
40
+ default="",
41
+ help="Title of the plots"
42
+ )
43
+ @click.pass_context
44
+ def plot(ctx, config: str, plot_title: str = ""):
45
+ """
46
+ Plot the results of a PROTRIDER run.
47
+ """
48
+ if config is None:
49
+ click.echo('No config file provided. Exiting.')
50
+ return
51
+
52
+ config = yaml.load(open(config), Loader=yaml.FullLoader)
53
+ config = defaultdict(lambda: None, config)
54
+ if config['verbose']:
55
+ logging.basicConfig(
56
+ level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', )
57
+ else:
58
+ logging.basicConfig(
59
+ level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', )
60
+
61
+ out_dir = config['out_dir']
62
+ if Path(out_dir).is_dir() is False:
63
+ click.echo(f'Output directory {out_dir} does not exist. Exiting.')
64
+ return
65
+
66
+ ctx.obj = config
67
+ ctx.obj['plot_title'] = plot_title
68
+
69
+
70
+ @plot.command('all')
71
+ @click.pass_context
72
+ def plot_all(ctx):
73
+ """
74
+ Plot all results for a PROTRIDER run.
75
+ """
76
+ if ctx.obj is None:
77
+ return
78
+ out_dir = ctx.obj['out_dir']
79
+ plot_title = ctx.obj['plot_title']
80
+ logger.info("plotting all plots")
81
+ plots.plot_pvals(out_dir, ctx.obj['pval_dist'], plot_title)
82
+ plots.plot_aberrant_per_sample(out_dir, plot_title)
83
+ plots.plot_encoding_dim(out_dir, ctx.obj['find_q_method'], plot_title)
84
+ plots.plot_training_loss(out_dir, plot_title)
85
+ plots.plot_correlation_heatmap(
86
+ out_dir, ctx.obj['sample_annotation'], plot_title, None)
87
+
88
+
89
+ @plot.command('pvals')
90
+ @click.pass_context
91
+ def plot_pvals(ctx):
92
+ """
93
+ Plot pvalue plots for a PROTRIDER run.
94
+ """
95
+ if ctx.obj is None:
96
+ return
97
+ out_dir = ctx.obj['out_dir']
98
+ plot_title = ctx.obj['plot_title']
99
+ logger.info("plotting pvalue plots")
100
+ plots.plot_pvals(out_dir, ctx.obj['pval_dist'], plot_title)
101
+
102
+
103
+ @plot.command('aberrant_per_sample')
104
+ @click.pass_context
105
+ def plot_aberrant_per_sample(ctx):
106
+ """
107
+ Plot number of aberrant proteins per sample for a PROTRIDER run.
108
+ """
109
+ if ctx.obj is None:
110
+ return
111
+ out_dir = ctx.obj['out_dir']
112
+ plot_title = ctx.obj['plot_title']
113
+ logger.info("plotting number of aberrant proteins per sample")
114
+ plots.plot_aberrant_per_sample(out_dir, plot_title)
115
+
116
+
117
+ @plot.command('encoding_dim')
118
+ @click.pass_context
119
+ def plot_encoding_dim(ctx):
120
+ """
121
+ Plot encoding dimension search plot for a PROTRIDER run.
122
+ """
123
+ if ctx.obj is None:
124
+ return
125
+ out_dir = ctx.obj['out_dir']
126
+ plot_title = ctx.obj['plot_title']
127
+ logger.info("plotting encoding dimension search plot")
128
+ plots.plot_encoding_dim(out_dir, ctx.obj['find_q_method'], plot_title)
129
+
130
+
131
+ @plot.command('training_loss')
132
+ @click.pass_context
133
+ def plot_training_loss(ctx):
134
+ """
135
+ Plot training loss history for a PROTRIDER run.
136
+ """
137
+ if ctx.obj is None:
138
+ return
139
+ out_dir = ctx.obj['out_dir']
140
+ plot_title = ctx.obj['plot_title']
141
+ logger.info("plotting training loss")
142
+ plots.plot_training_loss(out_dir, plot_title)
143
+
144
+
145
+ @plot.command('expected_vs_observed')
146
+ @click.option(
147
+ '--protein_id',
148
+ type=str,
149
+ help="Id of the protein to plot"
150
+ )
151
+ @click.pass_context
152
+ def plot_expected_vs_observed(ctx, protein_id: str):
153
+ """
154
+ Plot expected vs observed protein intensity for a PROTRIDER run.
155
+ """
156
+ if ctx.obj is None:
157
+ return
158
+ if protein_id is None:
159
+ click.echo(
160
+ 'No protein_id provided for expected vs observed plot. Exiting.')
161
+ return
162
+ out_dir = ctx.obj['out_dir']
163
+ plot_title = ctx.obj['plot_title']
164
+ logger.info(
165
+ f"plotting expected vs observed protein intensitiy for protein {protein_id}")
166
+ plots.plot_expected_vs_observed(out_dir, protein_id, plot_title)
167
+
168
+
169
+ @plot.command('correlation_heatmap')
170
+ @click.option(
171
+ '--covariate',
172
+ type=str,
173
+ help="Name of the covariate to color the samples by"
174
+ )
175
+ @click.pass_context
176
+ def plot_correlation_heatmap(ctx, covariate: str):
177
+ """
178
+ Plot correlation heatmap for a PROTRIDER run.
179
+ """
180
+ if ctx.obj is None:
181
+ return
182
+ out_dir = ctx.obj['out_dir']
183
+ plot_title = ctx.obj['plot_title']
184
+ logger.info("plotting correlation heatmap")
185
+ plots.plot_correlation_heatmap(
186
+ out_dir, ctx.obj['sample_annotation'], plot_title, covariate_name=covariate)
187
+
188
+
189
+ @cli.command('run')
190
+ @click.option(
191
+ "--config",
192
+ help="Configuration file containing PROTRIDER custom options",
193
+ type=click.Path(exists=True, dir_okay=False),
194
+ )
195
+ def run_cli(config: str):
196
+ """Run the PROTRIDER pipeline.
197
+ """
198
+ run(config)
199
+
200
+
201
+ def run(config_path: str):
202
+ """Run the PROTRIDER pipeline.
203
+ """
204
+ if config_path is None:
205
+ click.echo('No config file provided. Exiting.')
206
+ return
207
+
208
+ # Load and validate config using ProtriderConfig dataclass
209
+ config = load_config(config_path)
210
+
211
+ out_dir = config.out_dir
212
+ if out_dir is None:
213
+ click.echo('Output directory has not been specified in the config. Exiting.')
214
+ return
215
+
216
+ if config.verbose:
217
+ logging.basicConfig(
218
+ level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', )
219
+ else:
220
+ logging.basicConfig(
221
+ level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', )
222
+
223
+ logger.info('Starting protrider')
224
+ logger.info("Config:\n%s", yaml.dump(config.as_dict(), default_flow_style=False))
225
+
226
+ path = Path(config.out_dir)
227
+ path.mkdir(parents=True, exist_ok=True)
228
+
229
+ # Log function and base function are now computed in config.__post_init__
230
+ if config.log_func is None:
231
+ logger.warning(
232
+ 'No log function passed for preprocessing. \nAssuming data is already log transformed.')
233
+
234
+ if (config.find_q_method == 'OHT') and config.cov_used:
235
+ logger.warning('OHT has not been evaluated with covariates yet')
236
+
237
+ if (config.find_q_method == 'OHT') and config.presence_absence:
238
+ logger.warning(
239
+ 'OHT has not been evaluated on presence/absence analysis yet')
240
+
241
+ if config.seed is not None:
242
+ logger.info('Setting random seed: %s', config.seed)
243
+ torch.manual_seed(config.seed)
244
+ np.random.seed(config.seed)
245
+
246
+ # Run PROTRIDER - all inputs are in config
247
+ result, model_info, fit_parameters, gs_results = run_pipeline(config)
248
+
249
+ # Save results
250
+ # Save wide format (individual CSV files)
251
+ result.save(config.out_dir, format="wide")
252
+ # Save long format summary
253
+ result.save(config.out_dir, format="long", include_all=config.report_all)
254
+ # Save model information
255
+ model_info.save(config.out_dir)
256
+ # Save config
257
+ config.save(config.out_dir)
258
+ # Save fit parameters
259
+ fit_parameters.to_csv(config.out_dir)
260
+ # Save grid search results if available
261
+ if gs_results is not None:
262
+ gs_results.to_csv(config.out_dir)
263
+
264
+ if __name__ == '__main__':
265
+ cli(obj={})
protrider/config.py ADDED
@@ -0,0 +1,216 @@
1
+ """Configuration loading utilities."""
2
+ import yaml
3
+ import torch
4
+ from pathlib import Path
5
+ from typing import Union, Callable
6
+ from dataclasses import dataclass, field
7
+ from typing import Optional, List, Literal
8
+ import numpy as np
9
+
10
+ @dataclass
11
+ class ProtriderConfig:
12
+ """Configuration for PROTRIDER pipeline.
13
+
14
+ Matrix Format Requirements:
15
+ ---------------------------
16
+ input_intensities: File path (str)
17
+ - File format: columns = samples, rows = proteins
18
+
19
+ sample_annotation: File path (str) or None
20
+ - Format: rows = samples
21
+ """
22
+
23
+ # I/O paths
24
+ input_intensities: str # File path only
25
+ input_format: Literal["proteins_as_rows", "proteins_as_columns"] = "proteins_as_rows"
26
+ index_col: str = "protein_ID"
27
+ out_dir: Optional[str] = None # File path or None
28
+ sample_annotation: Optional[str] = None # File path or None
29
+
30
+ # Preprocessing params
31
+ max_allowed_NAs_per_protein: float = 0.3
32
+ log_func_name: Optional[Literal["log", "log2", "log10"]] = "log"
33
+
34
+ # Computed fields (set in __post_init__)
35
+ log_func: Optional[Callable] = field(init=False, repr=False, default=None)
36
+ base_fn: Callable = field(init=False, repr=False, default=None)
37
+ device_torch: torch.device = field(init=False, repr=False, default=None)
38
+
39
+ # Covariates
40
+ cov_used: Optional[List[str]] = None
41
+
42
+ # Reproducibility
43
+ seed: Optional[int] = 42
44
+
45
+ # Grid search outlier injection params
46
+ inj_freq: float = 1e-3
47
+ inj_mean: float = 3
48
+ inj_sd: float = 1.6
49
+ gs_epochs: int = None
50
+
51
+ # Model params
52
+ autoencoder_training: bool = True
53
+ n_layers: int = 1
54
+ n_epochs: int = 100
55
+ lr: float = 1e-4
56
+ batch_size: Optional[int] = None
57
+ find_q_method: str = "OHT" # "OHT", "gs", or an integer
58
+ init_pca: bool = True
59
+ h_dim: Optional[int] = None
60
+ patience: int = 50
61
+ min_delta: float = 1e-4
62
+
63
+ # Presence absence modelling
64
+ presence_absence: bool = False
65
+ lambda_presence_absence: float = 0.5
66
+
67
+ # Wandb logging
68
+ use_wandb: bool = False
69
+ wandb_project: Optional[str] = "protrider"
70
+ wandb_name: Optional[str] = None
71
+
72
+ # Statistical params
73
+ pval_dist: Literal["gaussian", "t"] = "t"
74
+ pval_adj: Literal["by", "bh"] = "by"
75
+ pval_sided: Literal["two-sided", "left", "right"] = "two-sided"
76
+ pseudocount: float = 0.01
77
+ common_degrees_freedom: bool = True # whether to use common degrees of freedom; more stable for small datasets
78
+
79
+ # Reporting params
80
+ outlier_threshold: float = 0.1
81
+ report_all: bool = True
82
+
83
+ # Runtime params
84
+ verbose: bool = False
85
+ device: Literal["gpu", "cpu"] = "gpu"
86
+ n_jobs: int = -1 # Number of parallel jobs, -1 means using all processors
87
+
88
+ # Model checkpoint path for saving/loading trained models
89
+ # If None: train from scratch and save to out_dir/model.pt
90
+ # If path exists: load model from this path and skip training
91
+ # If path doesn't exist: train and save to this path
92
+ checkpoint_path: Optional[str] = None
93
+
94
+ def __post_init__(self):
95
+ """Validate configuration after initialization and set computed fields."""
96
+ # Validation
97
+ if self.max_allowed_NAs_per_protein < 0 or self.max_allowed_NAs_per_protein > 1:
98
+ raise ValueError("max_allowed_NAs_per_protein must be between 0 and 1")
99
+
100
+ if self.n_layers < 1:
101
+ raise ValueError("n_layers must be at least 1")
102
+
103
+ if self.n_epochs < 1:
104
+ raise ValueError("n_epochs must be at least 1")
105
+
106
+ if self.lr <= 0:
107
+ raise ValueError("lr must be positive")
108
+
109
+ if self.outlier_threshold < 0 or self.outlier_threshold > 1:
110
+ raise ValueError("outlier_threshold must be between 0 and 1")
111
+
112
+ if self.find_q_method not in ["OHT", "gs", "bs"] and not self.find_q_method.isdigit():
113
+ raise ValueError("find_q_method must be 'OHT', 'gs', 'bs' or an integer string")
114
+
115
+ if self.presence_absence and self.n_layers != 1:
116
+ import warnings
117
+ warnings.warn("Presence absence modeling is only validated with n_layers=1")
118
+
119
+ # Set log_func and base_fn based on log_func_name
120
+ if self.log_func_name == "log2":
121
+ self.log_func = np.log2
122
+ self.base_fn = lambda x: 2 ** x
123
+ elif self.log_func_name == "log10":
124
+ self.log_func = np.log10
125
+ self.base_fn = lambda x: 10 ** x
126
+ elif self.log_func_name == "log":
127
+ self.log_func = np.log
128
+ self.base_fn = np.exp
129
+ elif self.log_func_name is None:
130
+ self.log_func = None
131
+ self.base_fn = np.exp
132
+ else:
133
+ raise ValueError(f"Log func {self.log_func_name} not supported.")
134
+
135
+ # Set PyTorch device
136
+ self.device_torch = torch.device("cuda" if (torch.cuda.is_available() and self.device == 'gpu') else "cpu")
137
+
138
+ def save(self, out_dir: Union[str, Path]) -> None:
139
+ """
140
+ Save configuration to a YAML file.
141
+
142
+ Only serializable fields (those with init=True) are saved.
143
+ Computed fields like log_func, base_fn, and device_torch are excluded.
144
+
145
+ Args:
146
+ out_dir: Output directory path where config.yaml will be saved
147
+ """
148
+ import logging
149
+
150
+ logger = logging.getLogger(__name__)
151
+ out_dir = Path(out_dir)
152
+ out_p = out_dir / 'config.yaml'
153
+
154
+ # Only save fields that are part of __init__ (exclude computed fields)
155
+ config_dict = self.as_dict()
156
+
157
+ with open(out_p, 'w') as f:
158
+ yaml.safe_dump(config_dict, f)
159
+
160
+ logger.info(f"Saved run config to {out_p}")
161
+
162
+ def as_dict(self) -> dict:
163
+ """
164
+ Convert the ProtriderConfig dataclass to a dictionary,
165
+ excluding non-serializable fields.
166
+
167
+ Returns:
168
+ dict: Dictionary representation of the configuration
169
+ """
170
+ import dataclasses
171
+
172
+ return {
173
+ f.name: getattr(self, f.name)
174
+ for f in dataclasses.fields(self)
175
+ if f.init # Only include fields that are initialized (excludes computed fields)
176
+ }
177
+
178
+
179
+ def load_config(config_path: Union[str, Path]) -> ProtriderConfig:
180
+ """
181
+ Load PROTRIDER configuration from a YAML file.
182
+
183
+ Args:
184
+ config_path: Path to the YAML configuration file
185
+
186
+ Returns:
187
+ ProtriderConfig object with validated configuration
188
+
189
+ Raises:
190
+ FileNotFoundError: If config file doesn't exist
191
+ ValueError: If configuration is invalid
192
+ """
193
+ config_path = Path(config_path)
194
+
195
+ if not config_path.exists():
196
+ raise FileNotFoundError(f"Config file not found: {config_path}")
197
+
198
+ with open(config_path, 'r') as f:
199
+ config_dict = yaml.safe_load(f)
200
+
201
+ if config_dict is None:
202
+ raise ValueError(f"Empty configuration file: {config_path}")
203
+
204
+ # Handle scientific notation that gets loaded as strings
205
+ if 'lr' in config_dict and isinstance(config_dict['lr'], str):
206
+ config_dict['lr'] = float(config_dict['lr'])
207
+ if 'inj_freq' in config_dict and isinstance(config_dict['inj_freq'], str):
208
+ config_dict['inj_freq'] = float(config_dict['inj_freq'])
209
+
210
+ # Convert to ProtriderConfig, which will validate the fields
211
+ try:
212
+ config = ProtriderConfig(**config_dict)
213
+ except TypeError as e:
214
+ raise ValueError(f"Invalid configuration: {e}")
215
+
216
+ return config
@@ -0,0 +1 @@
1
+ from .datasets import PCADataset, ProtriderDataset, ProtriderSubset
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+ from pathlib import Path
6
+ from typing import Optional
7
+ import logging
8
+
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ __all__ = ['parse_covariates']
13
+
14
+ def parse_covariates(sa_file: Optional[str], cov_used: Optional[list]) -> tuple[np.ndarray, np.ndarray]:
15
+ """Parse covariates from sample annotation file.
16
+
17
+ Args:
18
+ sa_file: Path to sample annotation file (CSV/TSV)
19
+ cov_used: List of covariate column names to use
20
+
21
+ Returns:
22
+ tuple: (covariates, centered_covariates_noNA)
23
+ - covariates: Raw covariates with NAs replaced by 0
24
+ - centered_covariates_noNA: Centered numerical covariates with NAs replaced by 0
25
+ """
26
+ if sa_file is None:
27
+ raise ValueError("Sample annotation file is required.")
28
+ if cov_used is None:
29
+ raise ValueError("Covariates to use must be specified.")
30
+
31
+ # Read sample annotation file
32
+ sample_anno = read_annotation_file(sa_file)
33
+ logger.info(f'Finished reading sample annotation with shape: {sample_anno.shape}')
34
+
35
+ # Process covariates
36
+ processed_covariates = _process_covariates(sample_anno[cov_used])
37
+
38
+ # Combine all covariate types
39
+ covariates, centered_covariates = _combine_covariates(processed_covariates)
40
+
41
+ # Validate output
42
+ assert np.isnan(covariates).sum() == 0, "Covariates contain NaN values"
43
+ assert np.isnan(centered_covariates).sum() == 0, "Centered covariates contain NaN values"
44
+
45
+ return covariates, centered_covariates
46
+
47
+
48
+ def read_annotation_file(sa_file):
49
+ """Read sample annotation file based on file extension."""
50
+ file_extension = Path(sa_file).suffix
51
+ if file_extension == '.csv':
52
+ return pd.read_csv(sa_file)
53
+ elif file_extension == '.tsv':
54
+ return pd.read_csv(sa_file, sep="\t")
55
+ else:
56
+ raise ValueError(f"Unsupported file type: {file_extension}")
57
+
58
+
59
+ def _is_numeric_dtype(dtype):
60
+ """Check if pandas dtype is numeric."""
61
+ return pd.api.types.is_numeric_dtype(dtype)
62
+
63
+
64
+ def _process_covariates(cov_data):
65
+ """Process covariates into numerical, categorical, and NA indicator variables."""
66
+ numerical_covs = []
67
+ categorical_covs = []
68
+ na_indicator_covs = []
69
+
70
+ for col_name, series in cov_data.items():
71
+ # Skip covariates with insufficient variation
72
+ if series.nunique() < 2:
73
+ logger.warning(f"Covariate '{col_name}' has less than 2 unique values. Skipping...")
74
+ continue
75
+
76
+ # Create NA indicator if needed
77
+ if series.isna().any():
78
+ na_indicator = series.isna().astype(int).to_frame(name=f'{col_name}_NA')
79
+ na_indicator_covs.append(na_indicator)
80
+
81
+ # Process based on data type
82
+ if _is_numeric_dtype(series.dtype) and series.dtype != bool:
83
+ numerical_covs.append(series.to_frame())
84
+ else:
85
+ # One-hot encode categorical variables
86
+ categorical_encoded = pd.get_dummies(series, drop_first=True, dummy_na=False, prefix=col_name)
87
+ categorical_covs.append(categorical_encoded)
88
+
89
+ # Log covariate counts
90
+ logger.info(f'No. numerical covariates used: {len(numerical_covs)}')
91
+ logger.info(f'No. categorical covariates used: {len(categorical_covs)}')
92
+ logger.info(f'No. NA indicator covariates used: {len(na_indicator_covs)}')
93
+
94
+ return {
95
+ 'numerical': numerical_covs,
96
+ 'categorical': categorical_covs,
97
+ 'na_indicators': na_indicator_covs
98
+ }
99
+
100
+
101
+ def _combine_covariates(processed_covariates):
102
+ """Combine processed covariates into final arrays."""
103
+ covariate_arrays = []
104
+ centered_covariate_arrays = []
105
+
106
+ # Process numerical covariates
107
+ if processed_covariates['numerical']:
108
+ numerical_data = pd.concat(processed_covariates['numerical'], axis=1).values
109
+ # Center numerical data and handle NAs
110
+ means = np.nanmean(numerical_data, axis=0, keepdims=True)
111
+ centered_numerical = numerical_data - means
112
+
113
+ # Replace NAs with 0
114
+ numerical_no_na = np.where(np.isnan(numerical_data), 0, numerical_data)
115
+ centered_numerical_no_na = np.where(np.isnan(centered_numerical), 0, centered_numerical)
116
+
117
+ covariate_arrays.append(numerical_no_na)
118
+ centered_covariate_arrays.append(centered_numerical_no_na)
119
+
120
+ # Process categorical covariates (no centering needed)
121
+ if processed_covariates['categorical']:
122
+ categorical_data = pd.concat(processed_covariates['categorical'], axis=1).values
123
+ covariate_arrays.append(categorical_data)
124
+ centered_covariate_arrays.append(categorical_data)
125
+
126
+ # Process NA indicators (no centering needed)
127
+ if processed_covariates['na_indicators']:
128
+ na_data = pd.concat(processed_covariates['na_indicators'], axis=1).values
129
+ covariate_arrays.append(na_data)
130
+ centered_covariate_arrays.append(na_data)
131
+
132
+ # Concatenate all covariate types
133
+ if covariate_arrays:
134
+ covariates = np.concatenate(covariate_arrays, axis=1)
135
+ centered_covariates = np.concatenate(centered_covariate_arrays, axis=1)
136
+ else:
137
+ raise ValueError("No valid covariates found.")
138
+
139
+ return covariates, centered_covariates