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 +5 -0
- protrider/cli.py +265 -0
- protrider/config.py +216 -0
- protrider/datasets/__init__.py +1 -0
- protrider/datasets/covariates.py +139 -0
- protrider/datasets/datasets.py +170 -0
- protrider/datasets/protein_intensities.py +98 -0
- protrider/model/__init__.py +2 -0
- protrider/model/model.py +299 -0
- protrider/model/model_helper.py +262 -0
- protrider/pipeline.py +634 -0
- protrider/plots.py +362 -0
- protrider/stats.py +318 -0
- protrider-0.9.1.dist-info/METADATA +272 -0
- protrider-0.9.1.dist-info/RECORD +19 -0
- protrider-0.9.1.dist-info/WHEEL +5 -0
- protrider-0.9.1.dist-info/entry_points.txt +2 -0
- protrider-0.9.1.dist-info/licenses/LICENSE +21 -0
- protrider-0.9.1.dist-info/top_level.txt +1 -0
protrider/__init__.py
ADDED
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
|