CATranS 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.
- catrans/__init__.py +14 -0
- catrans/_logging.py +38 -0
- catrans/core.py +575 -0
- catrans/data.py +61 -0
- catrans-0.1.0.dist-info/METADATA +268 -0
- catrans-0.1.0.dist-info/RECORD +8 -0
- catrans-0.1.0.dist-info/WHEEL +4 -0
- catrans-0.1.0.dist-info/licenses/LICENSE +21 -0
catrans/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from catrans.core import (
|
|
2
|
+
correlation_based_feature_selection,
|
|
3
|
+
run_catrans,
|
|
4
|
+
_diffExpAnalysis # recordar quitar esto antes de subir el paquete, que esto es solo para validaciones #TODO
|
|
5
|
+
)
|
|
6
|
+
from catrans.data import CatransClasses, CatransResult
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
'correlation_based_feature_selection',
|
|
10
|
+
'run_catrans',
|
|
11
|
+
'_diffExpAnalysis', # recordar quitar esto antes de subir el paquete, que esto es solo para validaciones #TODO
|
|
12
|
+
'CatransClasses',
|
|
13
|
+
'CatransResult',
|
|
14
|
+
]
|
catrans/_logging.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
logger = logging.getLogger(__name__)
|
|
5
|
+
logger.propagate = False
|
|
6
|
+
|
|
7
|
+
class CustomFormatter(logging.Formatter):
|
|
8
|
+
info_format = "[%(asctime)s] | %(levelname)-8s | %(message)s"
|
|
9
|
+
debug_format = "[%(asctime)s] | %(levelname)-8s | STEP: %(funcName)s | %(message)s"
|
|
10
|
+
|
|
11
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
12
|
+
orig_levelname = record.levelname
|
|
13
|
+
record.levelname = orig_levelname.center(8)
|
|
14
|
+
|
|
15
|
+
if record.levelno == logging.DEBUG:
|
|
16
|
+
self._style._fmt = self.debug_format
|
|
17
|
+
else:
|
|
18
|
+
self._style._fmt = self.info_format
|
|
19
|
+
return super().format(record)
|
|
20
|
+
|
|
21
|
+
def _get_logger() -> logging.Logger:
|
|
22
|
+
return logging.getLogger(name=__name__)
|
|
23
|
+
|
|
24
|
+
def _setup_internal_logger(verbose: bool) -> None:
|
|
25
|
+
# Si no hay handlers, creamos uno con el CustomFormatter
|
|
26
|
+
if not logger.handlers:
|
|
27
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
28
|
+
handler.setFormatter(CustomFormatter(datefmt='%y-%m-%d %H:%M:%S'))
|
|
29
|
+
logger.addHandler(handler)
|
|
30
|
+
|
|
31
|
+
# Si el usuario activa verbose, bajamos a INFO
|
|
32
|
+
# Si tú desde fuera pusiste DEBUG, esto no lo sobrescribe (porque DEBUG < INFO)
|
|
33
|
+
if verbose:
|
|
34
|
+
# if logger.level > logging.INFO or logger.level == logging.NOTSET:
|
|
35
|
+
logger.setLevel(logging.INFO)
|
|
36
|
+
else:
|
|
37
|
+
if logger.level == logging.NOTSET or logger.level == logging.INFO:
|
|
38
|
+
logger.setLevel(logging.WARNING)
|
catrans/core.py
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
# Load dependencies
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from scipy import stats
|
|
4
|
+
import numpy as np
|
|
5
|
+
from statsmodels.stats.multitest import multipletests
|
|
6
|
+
from pydeseq2.dds import DeseqDataSet
|
|
7
|
+
from pydeseq2.default_inference import DefaultInference
|
|
8
|
+
from pydeseq2.ds import DeseqStats
|
|
9
|
+
import igraph as ig
|
|
10
|
+
from inmoose.pycombat import pycombat_seq
|
|
11
|
+
from typing import Any, cast
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
from catrans.data import _CatransInput, CatransResult, CatransClasses
|
|
15
|
+
from catrans._logging import _get_logger, _setup_internal_logger
|
|
16
|
+
|
|
17
|
+
logger = _get_logger()
|
|
18
|
+
|
|
19
|
+
# Functions
|
|
20
|
+
# Using instructions in: https://pydeseq2.readthedocs.io/en/stable/auto_examples/plot_step_by_step.html#sphx-glr-auto-examples-plot-step-by-step-py
|
|
21
|
+
def _diffExpAnalysis(
|
|
22
|
+
data: pd.DataFrame,
|
|
23
|
+
classification: pd.DataFrame,
|
|
24
|
+
classes: CatransClasses,
|
|
25
|
+
n_cpus: int = 8,
|
|
26
|
+
verbose: bool = False,
|
|
27
|
+
design: str = '~class'
|
|
28
|
+
) -> tuple[int, pd.DataFrame]:
|
|
29
|
+
"""
|
|
30
|
+
Run differential expression analysis using DESeq2-like workflow.
|
|
31
|
+
|
|
32
|
+
This function wraps pydeseq2's `DeseqDataSet` and `DeseqStats` to compute
|
|
33
|
+
differential expression between groups in `classification`.
|
|
34
|
+
|
|
35
|
+
Parameters:
|
|
36
|
+
data: Gene-expression counts matrix (genes x samples).
|
|
37
|
+
classification: DataFrame with at least columns ['sample', 'class'] describing
|
|
38
|
+
group assignments for samples. Samples with NA class are ignored.
|
|
39
|
+
classes: CatransClasses object defining positive and negative labels.
|
|
40
|
+
n_cpus: Number of parallel workers to use for inference.
|
|
41
|
+
verbose: If True, prints progress messages from pydeseq2 internals.
|
|
42
|
+
design: Formula string for DESeq2 design (e.g. '~class').
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
tuple:
|
|
46
|
+
- degs (int): Number of differentially expressed genes (padj < 0.05).
|
|
47
|
+
- dea_res (DataFrame): Full results table from pydeseq2.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
# Filter classification to only include non-NA classes
|
|
51
|
+
non_na_classification = classification.loc[classification['class'].notna(), :].copy()
|
|
52
|
+
|
|
53
|
+
# Verify that we have 2 unique classes to run differential expression analysis
|
|
54
|
+
unique_classes = non_na_classification['class'].unique()
|
|
55
|
+
if len(unique_classes) != 2:
|
|
56
|
+
logger.warning(f"Fewer than 2 unique classes present ({list(unique_classes)}). Cannot perform differential expression.")
|
|
57
|
+
return 0, pd.DataFrame()
|
|
58
|
+
|
|
59
|
+
# Convert the class column to categorical with levels [negative, positive] to fix Wald contrast direction
|
|
60
|
+
non_na_classification['class'] = pd.Categorical(
|
|
61
|
+
non_na_classification['class'],
|
|
62
|
+
categories=[classes.negative, classes.positive]
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
logger.info("Loading data...")
|
|
66
|
+
logger.info(f"The dimensions of data are: {data.shape}.")
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
dat_c = data.loc[:, non_na_classification["sample"]]
|
|
70
|
+
except Exception:
|
|
71
|
+
raise ValueError('Classification must be a dataframe with a column sample and class')
|
|
72
|
+
|
|
73
|
+
logger.info(f"The dimensions of classified data are: {dat_c.shape}.")
|
|
74
|
+
|
|
75
|
+
logger.info("Running Differential Expression Analysis")
|
|
76
|
+
|
|
77
|
+
inference = DefaultInference(n_cpus=n_cpus)
|
|
78
|
+
dds = DeseqDataSet(
|
|
79
|
+
counts=dat_c.T,
|
|
80
|
+
metadata=non_na_classification,
|
|
81
|
+
design = design,
|
|
82
|
+
refit_cooks = True,
|
|
83
|
+
inference = inference,
|
|
84
|
+
quiet = not verbose
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
dds.fit_size_factors()
|
|
88
|
+
dds.fit_genewise_dispersions()
|
|
89
|
+
dds.fit_dispersion_trend()
|
|
90
|
+
dds.fit_dispersion_prior()
|
|
91
|
+
dds.fit_MAP_dispersions()
|
|
92
|
+
dds.fit_LFC()
|
|
93
|
+
|
|
94
|
+
# Potential outliers
|
|
95
|
+
dds.calculate_cooks()
|
|
96
|
+
if dds.refit_cooks:
|
|
97
|
+
dds.refit()
|
|
98
|
+
|
|
99
|
+
ds = DeseqStats(
|
|
100
|
+
dds,
|
|
101
|
+
contrast = np.array([0,1]),
|
|
102
|
+
cooks_filter=True,
|
|
103
|
+
independent_filter = True,
|
|
104
|
+
quiet = not verbose
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
ds.run_wald_test()
|
|
108
|
+
|
|
109
|
+
# Adjusted p-valor
|
|
110
|
+
if ds.independent_filter:
|
|
111
|
+
ds._independent_filtering()
|
|
112
|
+
else:
|
|
113
|
+
ds._p_value_adjustment()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
logger.info("Differential Expression Analysis was successfully performed.")
|
|
117
|
+
|
|
118
|
+
# Results
|
|
119
|
+
ds.summary()
|
|
120
|
+
dea_res = ds.results_df
|
|
121
|
+
logger.debug(f"Differential Expression Analysis results:\n{dea_res.head(20).to_string()}")
|
|
122
|
+
degs = dea_res.query('padj < 0.05').shape[0]
|
|
123
|
+
|
|
124
|
+
return degs, dea_res
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _correlation_of_gene_tuples(df: pd.DataFrame) -> pd.DataFrame:
|
|
128
|
+
"""
|
|
129
|
+
Calculate pairwise Pearson correlations and p-values for features (columns) in a DataFrame.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
# Calculate Pearson correlation (r)
|
|
133
|
+
r_df = df.corr(method='pearson')
|
|
134
|
+
# Calculate p-values with numpy
|
|
135
|
+
n = len(df)
|
|
136
|
+
df_degrees = n - 2
|
|
137
|
+
with np.errstate(divide='ignore', invalid='ignore'):
|
|
138
|
+
t_matrix = r_df * np.sqrt(df_degrees / (1 - r_df**2))
|
|
139
|
+
p_matrix = stats.t.sf(np.abs(t_matrix), df_degrees) * 2
|
|
140
|
+
|
|
141
|
+
np.fill_diagonal(p_matrix, np.nan)
|
|
142
|
+
p_df = pd.DataFrame(p_matrix, index=r_df.index, columns=r_df.columns)
|
|
143
|
+
|
|
144
|
+
# Avoid repeted combinations by only having from the top diagonal
|
|
145
|
+
mask = np.triu(np.ones(r_df.shape), k=1).astype(bool)
|
|
146
|
+
df_result = pd.DataFrame({
|
|
147
|
+
'Gene1' : np.repeat(r_df.index, r_df.shape[1])[mask.ravel()],
|
|
148
|
+
'Gene2' : np.tile(r_df.index, r_df.shape[0])[mask.ravel()],
|
|
149
|
+
'R' : r_df.values[mask],
|
|
150
|
+
'p_value' : p_df.values[mask]
|
|
151
|
+
})
|
|
152
|
+
return df_result
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _semisupervised_model(
|
|
156
|
+
data_raw: pd.DataFrame,
|
|
157
|
+
data: pd.DataFrame,
|
|
158
|
+
signature: list[str],
|
|
159
|
+
acute_classification: pd.DataFrame,
|
|
160
|
+
model: Any,
|
|
161
|
+
threshold: float,
|
|
162
|
+
classes: CatransClasses,
|
|
163
|
+
design: str = 'class',
|
|
164
|
+
n_cpus: int = 8
|
|
165
|
+
) -> CatransResult:
|
|
166
|
+
"""
|
|
167
|
+
Run an iterative semi-supervised model to relabel samples and re-run DE analysis.
|
|
168
|
+
Returns a `CatransResult` object containing iterations and classification history.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
results = CatransResult()
|
|
172
|
+
signal = True
|
|
173
|
+
old_class = acute_classification[design]
|
|
174
|
+
i = 0
|
|
175
|
+
# baseline for degs; this way we can infer if classifications are indeed "better" than the initial classification.
|
|
176
|
+
degs, _ = _diffExpAnalysis(
|
|
177
|
+
data = data_raw,
|
|
178
|
+
classification = acute_classification,
|
|
179
|
+
classes = classes,
|
|
180
|
+
n_cpus = n_cpus,
|
|
181
|
+
design= "~"+design
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
while signal:
|
|
186
|
+
|
|
187
|
+
i += 1
|
|
188
|
+
logger.info(f"Iteration {i}")
|
|
189
|
+
|
|
190
|
+
# Create the data frame with normalised and batch corrected data and the class for each sample
|
|
191
|
+
# Filter to only labeled samples to avoid training on unlabeled (NaN) samples
|
|
192
|
+
labeled_samples = old_class.dropna().index
|
|
193
|
+
X_tr = data.loc[signature, labeled_samples].T.to_numpy()
|
|
194
|
+
y_tr = old_class.loc[labeled_samples]
|
|
195
|
+
y_tr = y_tr.map(lambda x: 1 if x == classes.positive else 0).to_numpy()
|
|
196
|
+
|
|
197
|
+
# Train the model and predict
|
|
198
|
+
logger.info("Generating new predictions")
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
model.fit(X_tr, y_tr)
|
|
202
|
+
y_pred = model.predict_proba(data.loc[signature, :].T.to_numpy())
|
|
203
|
+
except Exception as e:
|
|
204
|
+
logger.error(f"Program has suffered a critical exception: {e}")
|
|
205
|
+
y_pred = None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
logger.info("Predictions were successfully performed")
|
|
209
|
+
# Check the prediction
|
|
210
|
+
new_classes = pd.DataFrame(y_pred)
|
|
211
|
+
|
|
212
|
+
new_classes.set_index(data.columns, inplace=True)
|
|
213
|
+
|
|
214
|
+
new_classes['prior_classification'] = old_class
|
|
215
|
+
new_classes.columns = ['negative_class_probability','positive_class_probability', 'prior_classification']
|
|
216
|
+
new_classes['current_classification'] = new_classes.apply(
|
|
217
|
+
lambda row: classes.negative if row['negative_class_probability'] >= threshold else (classes.positive if row['positive_class_probability'] > threshold else np.nan),
|
|
218
|
+
axis = 1
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
logger.info("Updating classification")
|
|
222
|
+
|
|
223
|
+
logger.info(f"The number of unclassified samples in previous iteration is {int(new_classes['prior_classification'].isnull().sum())}")
|
|
224
|
+
logger.info(f"The number of unclassified samples in current iteration is {int(new_classes['current_classification'].isnull().sum())}")
|
|
225
|
+
# Stopping criteria
|
|
226
|
+
if not all(new_classes['prior_classification'] == new_classes['current_classification']) and int(new_classes['prior_classification'].isnull().sum()) > int(new_classes['current_classification'].isnull().sum()):
|
|
227
|
+
|
|
228
|
+
# Update results object incrementally
|
|
229
|
+
logger.info("Classification changed; running Differential Expression Analysis...")
|
|
230
|
+
classification = pd.DataFrame({
|
|
231
|
+
"sample" : new_classes.loc[new_classes['current_classification'].notna(),:].index,
|
|
232
|
+
design : new_classes.loc[new_classes['current_classification'].notna(),"current_classification"]
|
|
233
|
+
})
|
|
234
|
+
logger.info("Performing Differential Expression Analysis with new classification.")
|
|
235
|
+
|
|
236
|
+
new_degs, dea_results = _diffExpAnalysis(
|
|
237
|
+
data = data_raw,
|
|
238
|
+
classification = classification,
|
|
239
|
+
classes = classes,
|
|
240
|
+
n_cpus = n_cpus,
|
|
241
|
+
design= "~"+design
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
logger.info(f"DEGs on prior iteration: {degs}")
|
|
245
|
+
logger.info(f"DEGs on current iteration: {new_degs}")
|
|
246
|
+
|
|
247
|
+
if new_degs > degs:
|
|
248
|
+
|
|
249
|
+
logging.info("All conditions passed, saving iteration result.")
|
|
250
|
+
results._add_iteration(i, new_degs, dea_results, new_classes)
|
|
251
|
+
old_class = new_classes['current_classification']
|
|
252
|
+
degs = new_degs
|
|
253
|
+
|
|
254
|
+
else:
|
|
255
|
+
logger.info("Stopping condition met: no more DEGs discovered than in previous step.")
|
|
256
|
+
logging.info("Results of this iteration will not be saved.")
|
|
257
|
+
|
|
258
|
+
signal = False
|
|
259
|
+
|
|
260
|
+
else:
|
|
261
|
+
logger.info("Stopping condition met: No changes in classification or no new samples classified.")
|
|
262
|
+
logging.info("Results of this iteration will not be saved.")
|
|
263
|
+
|
|
264
|
+
signal = False
|
|
265
|
+
|
|
266
|
+
logger.info("Program ended.")
|
|
267
|
+
final_df = pd.DataFrame(old_class)
|
|
268
|
+
final_df.columns = [design]
|
|
269
|
+
results._set_final_classification(final_df)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
return results
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _get_normalized_dataset(
|
|
276
|
+
counts_df: pd.DataFrame,
|
|
277
|
+
metadata_df: pd.DataFrame,
|
|
278
|
+
batch_col: str | None = None,
|
|
279
|
+
log_transform: bool = True,
|
|
280
|
+
) -> pd.DataFrame:
|
|
281
|
+
"""
|
|
282
|
+
Normalize and batch-correct count data for downstream modeling.
|
|
283
|
+
|
|
284
|
+
This function applies ComBat-seq batch correction on raw counts and then
|
|
285
|
+
uses pydeseq2 to compute normalized counts, optionally applying log1p.
|
|
286
|
+
|
|
287
|
+
Parameters:
|
|
288
|
+
counts_df: Raw count matrix (genes x samples).
|
|
289
|
+
metadata_df: Sample metadata containing at least 'sample' and batch column.
|
|
290
|
+
batch_col: Column name in metadata specifying batch assignments.
|
|
291
|
+
log_transform: If True, apply log1p to normalized counts.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
DataFrame of normalized (and optionally log-transformed) counts.
|
|
295
|
+
"""
|
|
296
|
+
logger.info("Performing Data Normalization")
|
|
297
|
+
|
|
298
|
+
if batch_col:
|
|
299
|
+
logger.info(f"Batch column input: {batch_col}")
|
|
300
|
+
logger.info("Performing batch correction")
|
|
301
|
+
# pycombat expects (genes x samples)
|
|
302
|
+
batch = metadata_df[batch_col].tolist()
|
|
303
|
+
data_for_dds = pycombat_seq(counts_df, batch).T
|
|
304
|
+
else:
|
|
305
|
+
logger.info("No batch column input")
|
|
306
|
+
logger.info("Normalization will be performed without batch correction")
|
|
307
|
+
|
|
308
|
+
data_for_dds = counts_df.T
|
|
309
|
+
|
|
310
|
+
inference = DefaultInference(n_cpus=1)
|
|
311
|
+
dds = DeseqDataSet(
|
|
312
|
+
counts=pd.DataFrame(data_for_dds), # pd.df just to shut up pylance error
|
|
313
|
+
metadata=metadata_df,
|
|
314
|
+
design_factors='1',
|
|
315
|
+
inference=inference,
|
|
316
|
+
quiet=True
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
dds.fit_size_factors()
|
|
320
|
+
|
|
321
|
+
normed_counts = pd.DataFrame(
|
|
322
|
+
dds.layers["normed_counts"],
|
|
323
|
+
index=pd.DataFrame(data_for_dds).index,
|
|
324
|
+
columns=pd.DataFrame(data_for_dds).columns,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
if log_transform:
|
|
328
|
+
normed_counts = pd.DataFrame(np.log1p(normed_counts))
|
|
329
|
+
|
|
330
|
+
logger.info("Normalized data successfully")
|
|
331
|
+
|
|
332
|
+
return normed_counts.T
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def correlation_based_feature_selection(
|
|
336
|
+
data: pd.DataFrame,
|
|
337
|
+
acute_classification: pd.DataFrame,
|
|
338
|
+
classes: CatransClasses = CatransClasses(),
|
|
339
|
+
pval: float = 0.05,
|
|
340
|
+
cutoff_corr: float | None = None,
|
|
341
|
+
cutoff_corr_genes: float | None = None,
|
|
342
|
+
n_cpus: int = 8,
|
|
343
|
+
verbose: bool = False
|
|
344
|
+
) -> list[str] | None:
|
|
345
|
+
"""
|
|
346
|
+
Find gene-gene correlation and select representative features.
|
|
347
|
+
|
|
348
|
+
This function computes gene-wise Spearman correlation against the class label,
|
|
349
|
+
then builds an undirected graph of highly correlated genes and selects a
|
|
350
|
+
representative gene per connected component.
|
|
351
|
+
|
|
352
|
+
Parameters:
|
|
353
|
+
data: Gene-expression matrix (genes x samples).
|
|
354
|
+
acute_classification: DataFrame with columns ['sample', 'class'] representing
|
|
355
|
+
the class label for each sample.
|
|
356
|
+
classes: CatransClasses object defining positive and negative labels.
|
|
357
|
+
pval: P-value threshold for significance when filtering gene-gene correlations.
|
|
358
|
+
cutoff_corr: Minimum absolute Spearman R for including genes in the gene-gene graph.
|
|
359
|
+
cutoff_corr_genes: Minimum absolute Pearson R for edge inclusion in the graph.
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
DataFrame with selected genes and their correlation statistics.
|
|
363
|
+
"""
|
|
364
|
+
|
|
365
|
+
_setup_internal_logger(verbose)
|
|
366
|
+
|
|
367
|
+
logger.info("Initializing Correlation-Based Feature Learning")
|
|
368
|
+
|
|
369
|
+
classified_samples_acute = acute_classification.loc[acute_classification['class'].notna(), 'sample']
|
|
370
|
+
|
|
371
|
+
# Samples are columns
|
|
372
|
+
dat_c = data.loc[:, classified_samples_acute]
|
|
373
|
+
|
|
374
|
+
corr_list = []
|
|
375
|
+
# Map labels to numeric to ensure scipy spearmanr calculates correctly on consistent numeric values
|
|
376
|
+
y_class = acute_classification.loc[:, 'class'].map({classes.negative: 0, classes.positive: 1})
|
|
377
|
+
logger.info("Performing correlation between Acute classification and genes expression")
|
|
378
|
+
|
|
379
|
+
for i in range(dat_c.shape[0]):
|
|
380
|
+
res = stats.spearmanr(dat_c.iloc[i, :], y_class)
|
|
381
|
+
corr_list.append({
|
|
382
|
+
'rho': res[0],
|
|
383
|
+
'p_value': res[1]
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
corr1 = pd.DataFrame(corr_list, index=dat_c.index)
|
|
387
|
+
corr1['Genes'] = corr1.index
|
|
388
|
+
|
|
389
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
390
|
+
logger.debug(f"corr1:\n{corr1.head(n=200).to_string()}")
|
|
391
|
+
|
|
392
|
+
if not cutoff_corr:
|
|
393
|
+
logger.info("Correlation between genes expression and the initial classification is not defined")
|
|
394
|
+
logger.info("Using Q3 of absolute rho values as cutoff")
|
|
395
|
+
cutoff_corr = abs(corr1['rho']).quantile(0.75)
|
|
396
|
+
|
|
397
|
+
logger.info(f"R cutoff value between genes expression and classification: {cutoff_corr:.3f}")
|
|
398
|
+
corr_genes = corr1[(abs(corr1['rho'])>=cutoff_corr) & (corr1['p_value']<=pval)].index.to_list()
|
|
399
|
+
logger.info(f"Number of significant genes with R higher than {cutoff_corr:.3f}: {len(corr_genes)}")
|
|
400
|
+
|
|
401
|
+
if len(corr_genes) == 0:
|
|
402
|
+
|
|
403
|
+
logger.warning(f"There are no genes higher than {cutoff_corr}.")
|
|
404
|
+
logger.warning(f"The maximum R value is {np.round(np.max(abs(corr1['rho'])),2)}.") # Fixed typo: 'R' to 'rho'
|
|
405
|
+
logger.warning("By default the analyses will be performed only with significant correlations (p-value <= 0.05)")
|
|
406
|
+
|
|
407
|
+
corr_genes = corr1[corr1['p_value']<=pval].index.to_list()
|
|
408
|
+
|
|
409
|
+
subdat = dat_c.loc[corr_genes,:]
|
|
410
|
+
|
|
411
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
412
|
+
logger.debug(f"Selecction of genes data:\n{subdat.head(n=200).to_string()}")
|
|
413
|
+
logger.debug(f"Shape: {subdat.shape}")
|
|
414
|
+
logger.info("Performing Gene to Gene correlation for Feature Selection")
|
|
415
|
+
|
|
416
|
+
corr2 = _correlation_of_gene_tuples(subdat.T)
|
|
417
|
+
# pval_correction_method = 'fdr_bh'
|
|
418
|
+
pval_correction_method = 'bonferroni'
|
|
419
|
+
logger.debug(f"Using {pval_correction_method} to correct p-values")
|
|
420
|
+
_, corr2['p_adj'], _, _ = multipletests(corr2['p_value'], alpha=0.05, method=pval_correction_method)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
# if logger.level == logging.DEBUG:
|
|
424
|
+
# corr1.to_csv("corr1.csv", index=False, header = True) # DEBUG
|
|
425
|
+
# corr2.to_csv("corr2.csv", index=False, header = True) # DEBUG
|
|
426
|
+
# corr_2_2.to_csv("corr2_2.csv", index=False, header = True) # DEBUG
|
|
427
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
428
|
+
logger.debug(f"correlations Gene to Gene Results:\n{corr2.head(n=200).to_string()}")
|
|
429
|
+
|
|
430
|
+
# Using iGraph for gene selection.
|
|
431
|
+
# Correlation threshold is used to build a graph.
|
|
432
|
+
# We only select the most representative (higher R with CLASSIFICATION) gene from each set
|
|
433
|
+
|
|
434
|
+
if not cutoff_corr_genes:
|
|
435
|
+
percentile = 0.75
|
|
436
|
+
logger.info("Correlation between genes expression is not defined")
|
|
437
|
+
logger.info("Using Q3 of absolute R values as cutoff")
|
|
438
|
+
# logger.info(f"Using percentile {percentile} of absolute R values as cutoff")
|
|
439
|
+
cutoff_corr_genes = abs(corr2['R']).quantile(percentile)
|
|
440
|
+
|
|
441
|
+
logger.info(f"R Gene x Gene cutoff value: {cutoff_corr_genes:.3f}") # NOTE: change wording
|
|
442
|
+
|
|
443
|
+
gene_tuples = (
|
|
444
|
+
corr2[
|
|
445
|
+
(corr2['p_adj'] < pval)
|
|
446
|
+
& (abs(corr2['R']) > cutoff_corr_genes)
|
|
447
|
+
]
|
|
448
|
+
.loc[:, ['Gene1', "Gene2"]]
|
|
449
|
+
.itertuples(index=False) # Generator
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
# Graph building
|
|
453
|
+
g = ig.Graph.TupleList(gene_tuples)
|
|
454
|
+
components = g.connected_components(mode='weak')
|
|
455
|
+
all_genes = set(corr2['Gene1']).union(set(corr2['Gene2']))
|
|
456
|
+
current_nodes = set(g.vs['name'])
|
|
457
|
+
isolated_nodes = all_genes - current_nodes
|
|
458
|
+
g.add_vertices(list(isolated_nodes))
|
|
459
|
+
components = g.connected_components(mode='weak')
|
|
460
|
+
|
|
461
|
+
toselection = corr1.loc[g.get_vertex_dataframe()['name'],:]
|
|
462
|
+
toselection['membership'] = components.membership
|
|
463
|
+
selected_genes = []
|
|
464
|
+
|
|
465
|
+
for i in set(toselection['membership']):
|
|
466
|
+
logger.debug(f"Cluster: {i}")
|
|
467
|
+
values = toselection.loc[toselection['membership'] == i]
|
|
468
|
+
logger.debug(f"Number of genes in Cluster {i}: {len(values)}")
|
|
469
|
+
logger.debug(f"Cluster values: \n{values.to_string()}")
|
|
470
|
+
selected_gene = abs(values['rho']).idxmax()
|
|
471
|
+
logger.debug(f"Gene with max absolute R value from Cluster {i}: {selected_gene}")
|
|
472
|
+
selected_genes.append(selected_gene)
|
|
473
|
+
|
|
474
|
+
corr1_final = corr1.loc[(corr1['Genes'].isin(selected_genes) & (corr1['p_value'] <= pval))] # is pval needed?
|
|
475
|
+
logger.debug(corr1_final.to_string())
|
|
476
|
+
logger.info(f"Number of final genes selected: {len(corr1_final)}")
|
|
477
|
+
|
|
478
|
+
return cast(list[str], corr1_final['Genes'].to_list())
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def run_catrans(
|
|
482
|
+
raw_counts: pd.DataFrame,
|
|
483
|
+
metadata: pd.DataFrame,
|
|
484
|
+
acute_classification: pd.DataFrame,
|
|
485
|
+
model: Any,
|
|
486
|
+
classes: CatransClasses | None = None,
|
|
487
|
+
batch_col: str | None = None,
|
|
488
|
+
model_threshold: float = 0.8,
|
|
489
|
+
n_cpus: int = 8,
|
|
490
|
+
signature_genes: list[str] | None = None,
|
|
491
|
+
verbose: bool = False
|
|
492
|
+
) -> CatransResult:
|
|
493
|
+
|
|
494
|
+
_setup_internal_logger(verbose=verbose)
|
|
495
|
+
|
|
496
|
+
if classes is None:
|
|
497
|
+
classes = CatransClasses()
|
|
498
|
+
|
|
499
|
+
# Argument checking
|
|
500
|
+
if not isinstance(acute_classification, pd.DataFrame):
|
|
501
|
+
raise TypeError("acute_classification must be a pandas.DataFrame")
|
|
502
|
+
|
|
503
|
+
if 'sample' not in acute_classification.columns or 'class' not in acute_classification.columns:
|
|
504
|
+
raise ValueError("acute_classification must be a pandas.DataFrame with 'sample' and 'class' columns.")
|
|
505
|
+
|
|
506
|
+
# Validate classes in acute_classification
|
|
507
|
+
allowed_values = {classes.positive, classes.negative}
|
|
508
|
+
actual_values = set(acute_classification['class'].dropna().unique())
|
|
509
|
+
invalid_values = actual_values - allowed_values
|
|
510
|
+
if invalid_values:
|
|
511
|
+
raise ValueError(
|
|
512
|
+
f"Found invalid labels in acute_classification['class']: {invalid_values}. "
|
|
513
|
+
f"Only '{classes.positive}' (positive) and '{classes.negative}' (negative) are allowed, plus NaN."
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
# Create a copy to avoid mutating the caller's DataFrame in-place
|
|
517
|
+
acute_classification = acute_classification.copy()
|
|
518
|
+
|
|
519
|
+
acute_classification.set_index(
|
|
520
|
+
pd.Index(acute_classification['sample'].to_list()),
|
|
521
|
+
inplace=True
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
if not hasattr(model, 'predict_proba'):
|
|
525
|
+
raise AttributeError("Passed model must have a predict_proba method in order to run semi-supervised learning.")
|
|
526
|
+
|
|
527
|
+
if signature_genes is None:
|
|
528
|
+
logger.info("No signature genes were provided.")
|
|
529
|
+
logger.info("Feature selection will be performed with default parameters.")
|
|
530
|
+
|
|
531
|
+
signature_genes = correlation_based_feature_selection(
|
|
532
|
+
data=raw_counts,
|
|
533
|
+
acute_classification=acute_classification,
|
|
534
|
+
classes=classes,
|
|
535
|
+
n_cpus=n_cpus,
|
|
536
|
+
verbose=verbose
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
if signature_genes is None:
|
|
540
|
+
raise ValueError("Feature selection failed to produce a gene signature.")
|
|
541
|
+
|
|
542
|
+
input_data = _CatransInput(
|
|
543
|
+
raw_counts=raw_counts,
|
|
544
|
+
metadata=metadata,
|
|
545
|
+
acute_classification=acute_classification,
|
|
546
|
+
model=model,
|
|
547
|
+
classes=classes,
|
|
548
|
+
batch_col=batch_col,
|
|
549
|
+
model_threshold=model_threshold,
|
|
550
|
+
n_cpus=n_cpus,
|
|
551
|
+
signature_genes=signature_genes
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
logger.info("Initializing CATranS algorithm")
|
|
555
|
+
logger.info("Normalizing DataSet...")
|
|
556
|
+
|
|
557
|
+
normalized_data = _get_normalized_dataset(
|
|
558
|
+
counts_df=input_data.raw_counts,
|
|
559
|
+
metadata_df=input_data.metadata,
|
|
560
|
+
batch_col=input_data.batch_col
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
results = _semisupervised_model(
|
|
564
|
+
data_raw=input_data.raw_counts,
|
|
565
|
+
data=normalized_data,
|
|
566
|
+
signature=input_data.signature_genes or [],
|
|
567
|
+
acute_classification=input_data.acute_classification,
|
|
568
|
+
model=input_data.model,
|
|
569
|
+
threshold=input_data.model_threshold,
|
|
570
|
+
classes=input_data.classes,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
return results
|
|
574
|
+
|
|
575
|
+
|
catrans/data.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any, List, Optional
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class CatransClasses:
|
|
7
|
+
positive: str = "NonSurvivor"
|
|
8
|
+
negative: str = "Survivor"
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class _CatransInput:
|
|
12
|
+
raw_counts: pd.DataFrame
|
|
13
|
+
metadata: pd.DataFrame
|
|
14
|
+
acute_classification: pd.DataFrame
|
|
15
|
+
model: Any
|
|
16
|
+
classes: CatransClasses
|
|
17
|
+
batch_col: Optional[str] = None
|
|
18
|
+
model_threshold: float = 0.8
|
|
19
|
+
n_cpus: int = 8
|
|
20
|
+
signature_genes: Optional[List[str]] = None
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class _IterationResult:
|
|
24
|
+
iteration: int
|
|
25
|
+
differentially_expressed_genes: int
|
|
26
|
+
differential_expression_analysis_results: pd.DataFrame
|
|
27
|
+
model_classification_results: pd.DataFrame
|
|
28
|
+
|
|
29
|
+
class CatransResult:
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self.iterations: List[_IterationResult] = list()
|
|
32
|
+
self.final_classification: Optional[pd.DataFrame] = None
|
|
33
|
+
|
|
34
|
+
def _add_iteration(
|
|
35
|
+
self,
|
|
36
|
+
iteration_index: int,
|
|
37
|
+
de_genes_count: int,
|
|
38
|
+
dea_results: pd.DataFrame,
|
|
39
|
+
model_classification_results: pd.DataFrame
|
|
40
|
+
) -> None:
|
|
41
|
+
|
|
42
|
+
self.iterations.append(
|
|
43
|
+
_IterationResult(
|
|
44
|
+
iteration=iteration_index,
|
|
45
|
+
differentially_expressed_genes=de_genes_count,
|
|
46
|
+
differential_expression_analysis_results=dea_results,
|
|
47
|
+
model_classification_results=model_classification_results
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def number_of_iterations(self) -> int:
|
|
53
|
+
return len(self.iterations)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def last_iteration(self) -> _IterationResult:
|
|
57
|
+
return self.iterations[-1]
|
|
58
|
+
|
|
59
|
+
def _set_final_classification(self, classification: pd.DataFrame) -> None:
|
|
60
|
+
self.final_classification = classification
|
|
61
|
+
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: CATranS
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Clinical Acute Transcriptomic Stratification
|
|
5
|
+
Project-URL: Homepage, https://github.com/ruben-canas-ivi/CATranS
|
|
6
|
+
Project-URL: Issues, https://github.com/ruben-canas-ivi/CATranS/issues
|
|
7
|
+
Author-email: Patricia Sebastián-León <patricia.sebastian@ivirma.com>, Antonio Parraga-Leo <antonio.parraga@ivirma.com>, Rubén Cañas-Cañas <ruben.canas@ivirma.com>
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: bioinformatics,deseq2,genomics,machine-learning,stratification
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: igraph>=1.0.0
|
|
20
|
+
Requires-Dist: inmoose>=0.9.1
|
|
21
|
+
Requires-Dist: numpy>=2.3.0
|
|
22
|
+
Requires-Dist: pandas>=2.3.3
|
|
23
|
+
Requires-Dist: pydeseq2>=0.5.4
|
|
24
|
+
Requires-Dist: scikit-learn>=1.8.0
|
|
25
|
+
Requires-Dist: scipy>=1.17.1
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# CATranS: Clinical Acute Transcriptomic Stratification
|
|
29
|
+
|
|
30
|
+
**CATranS** is a robust bioinformatics and machine learning pipeline written in Python designed to stratify acute clinical cohorts using transcriptomic count data.
|
|
31
|
+
|
|
32
|
+
Through an iterative, semi-supervised learning approach, CATranS combines data normalization, batch correction, correlation-based feature selection, and class prediction to refine classifications of patient cohorts (e.g., separating acute from non-acute states, or predicting survival outcomes).
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Key Features
|
|
37
|
+
|
|
38
|
+
- **Iterative Semi-Supervised Relabeling**: Safely expands training cohorts by predicting clinical labels on unclassified/unlabeled samples using high-confidence thresholds.
|
|
39
|
+
- **Generalized Custom Classes**: Supports user-defined positive and negative class labeling (e.g., `Survivor`/`NonSurvivor`, `Mild`/`Severe`, `Responders`/`NonResponders`) with robust Categorical ordering to fix Wald contrast directions in DESeq2.
|
|
40
|
+
- **Batch Correction**: Integrated with `inmoose` for ComBat-seq count batch effect correction.
|
|
41
|
+
- **DESeq2-style Normalization**: Leverages the high-performance `pydeseq2` implementation for median-of-ratios normalization.
|
|
42
|
+
- **Correlation-Based Feature Learning**: Custom graph-theory based gene selection utilizing `igraph`. Filters out highly redundant co-expressed genes and selects optimal representative biomarkers correlating with clinical outcomes.
|
|
43
|
+
- **Flexible Estimator Integration**: Compatible with any Scikit-Learn classifier exposing a `predict_proba` method.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Workflow Overview
|
|
48
|
+
|
|
49
|
+
```mermaid
|
|
50
|
+
graph TD
|
|
51
|
+
A[Raw Count Matrix & Metadata] --> B[Data Normalization & Optional ComBat-seq Batch Correction]
|
|
52
|
+
B --> C[Feature Selection: Spearman Correlation with Labels]
|
|
53
|
+
C --> D[Graph Construction: Pearson Gene-Gene Network]
|
|
54
|
+
D --> E[Redundant Feature Compression: Connected Components Vertex Selection]
|
|
55
|
+
E --> F[Iterative Semi-Supervised Model Training]
|
|
56
|
+
F --> G[Fit Classifier & Predict Unlabeled Samples]
|
|
57
|
+
G --> H{Meets Threshold?}
|
|
58
|
+
H -- Yes --> I[Relabel Samples & Run DESeq2 Differential Expression Analysis]
|
|
59
|
+
I --> J{Are there new DEGs?}
|
|
60
|
+
J -- Yes --> F
|
|
61
|
+
J -- No --> K[Final Output Classifications & Iteration History]
|
|
62
|
+
H -- No --> K
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Installation
|
|
68
|
+
|
|
69
|
+
We recommend using [uv](https://github.com/astral-sh/uv) or `pip` for rapid virtual environment setup and dependency management.
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# Clone the repository
|
|
73
|
+
git clone https://github.com/ruben-canas-ivi/CATranS.git
|
|
74
|
+
cd CATranS
|
|
75
|
+
|
|
76
|
+
# Install dependencies and the package using uv
|
|
77
|
+
uv sync
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Dependencies
|
|
81
|
+
- Python `>=3.11`
|
|
82
|
+
- `numpy>=2.3.0`
|
|
83
|
+
- `pandas>=2.3.3`
|
|
84
|
+
- `scipy>=1.14.0`
|
|
85
|
+
- `scikit-learn>=1.8.0`
|
|
86
|
+
- `pydeseq2>=0.5.4`
|
|
87
|
+
- `igraph>=1.0.0`
|
|
88
|
+
- `inmoose>=0.9.1`
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## API Reference & Usage Scenarios
|
|
93
|
+
|
|
94
|
+
### Core Classes & Functions
|
|
95
|
+
|
|
96
|
+
#### 1. `CatransClasses`
|
|
97
|
+
Defines the names of the binary classes.
|
|
98
|
+
- `positive` (default `"Dead"`): The target/case class.
|
|
99
|
+
- `negative` (default `"Alive"`): The reference/control class.
|
|
100
|
+
|
|
101
|
+
#### 2. `correlation_based_feature_selection`
|
|
102
|
+
Identifies representative biomarker genes by correlating expression levels with labels, building a gene-gene correlation network, and selecting one representative gene per connected component.
|
|
103
|
+
|
|
104
|
+
#### 3. `run_catrans`
|
|
105
|
+
Runs the iterative semi-supervised relabeling pipeline.
|
|
106
|
+
- `raw_counts` (pd.DataFrame): Genes (rows) by Samples (columns).
|
|
107
|
+
- `metadata` (pd.DataFrame): Samples (rows) with metadata.
|
|
108
|
+
- `acute_classification` (pd.DataFrame): Samples (rows) with initially known labels under the column `"class"` (unlabeled samples should be `NaN`).
|
|
109
|
+
- `model`: Any Scikit-Learn compatible classifier (e.g. `RandomForestClassifier`, `SVC(probability=True)`).
|
|
110
|
+
- `classes` (CatransClasses, Optional): Custom positive/negative class names.
|
|
111
|
+
- `batch_col` (str, Optional): Metadata column to use for ComBat-seq batch correction.
|
|
112
|
+
- `model_threshold` (float, default `0.8`): Confidence threshold to assign a label.
|
|
113
|
+
- `signature_genes` (list[str], Optional): Genes to use for training the model. If `None`, `correlation_based_feature_selection` runs automatically.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
### Usage Scenarios
|
|
118
|
+
|
|
119
|
+
#### Scenario 1: Normal Workflow (Two-step Feature Selection + Stratification)
|
|
120
|
+
This is the recommended workflow where you first find a non-redundant gene signature, then pass it to the CATranS pipeline.
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
import pandas as pd
|
|
124
|
+
from sklearn.ensemble import RandomForestClassifier
|
|
125
|
+
import catrans
|
|
126
|
+
|
|
127
|
+
# 1. Load your transcriptomics and clinical datasets
|
|
128
|
+
counts = pd.read_csv("data/raw_counts.tsv", sep="\t", index_col=0)
|
|
129
|
+
metadata = pd.read_csv("data/metadata.tsv", sep="\t", index_col=0)
|
|
130
|
+
initial_classes = pd.read_csv("data/initial_classes.tsv", sep="\t", index_col=0)
|
|
131
|
+
|
|
132
|
+
# 2. Define custom labels: "Survivor" (negative) and "NonSurvivor" (positive)
|
|
133
|
+
my_classes = catrans.CatransClasses(positive="NonSurvivor", negative="Survivor")
|
|
134
|
+
|
|
135
|
+
# 3. Perform correlation-based feature selection to identify candidate biomarkers
|
|
136
|
+
signature = catrans.correlation_based_feature_selection(
|
|
137
|
+
data=counts,
|
|
138
|
+
acute_classification=initial_classes,
|
|
139
|
+
classes=my_classes,
|
|
140
|
+
pval=0.05,
|
|
141
|
+
cutoff_corr=0.5,
|
|
142
|
+
cutoff_corr_genes=0.8,
|
|
143
|
+
n_cpus=4
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# 4. Run the semi-supervised relabeling pipeline using the selected signature
|
|
147
|
+
results = catrans.run_catrans(
|
|
148
|
+
raw_counts=counts,
|
|
149
|
+
metadata=metadata,
|
|
150
|
+
acute_classification=initial_classes,
|
|
151
|
+
classes=my_classes,
|
|
152
|
+
model=RandomForestClassifier(random_state=42),
|
|
153
|
+
model_threshold=0.75,
|
|
154
|
+
signature_genes=signature,
|
|
155
|
+
n_cpus=4
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
print(results.final_classification)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
#### Scenario 2: Predefined/Custom Gene Signature (Direct Stratification)
|
|
162
|
+
If you already have a target gene signature (e.g., from literature or a previous experiment), you can bypass the feature selection step entirely and run the stratification pipeline directly.
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
import pandas as pd
|
|
166
|
+
from sklearn.svm import SVC
|
|
167
|
+
import catrans
|
|
168
|
+
|
|
169
|
+
counts = pd.read_csv("data/raw_counts.tsv", sep="\t", index_col=0)
|
|
170
|
+
metadata = pd.read_csv("data/metadata.tsv", sep="\t", index_col=0)
|
|
171
|
+
initial_classes = pd.read_csv("data/initial_classes.tsv", sep="\t", index_col=0)
|
|
172
|
+
|
|
173
|
+
# Custom predefined signature from literature
|
|
174
|
+
custom_signature = ["ENSG00000034693.15", "ENSG00000101188.5", "ENSG00000104870.13"]
|
|
175
|
+
|
|
176
|
+
# Run the CATranS pipeline directly with the custom signature
|
|
177
|
+
results = catrans.run_catrans(
|
|
178
|
+
raw_counts=counts,
|
|
179
|
+
metadata=metadata,
|
|
180
|
+
acute_classification=initial_classes,
|
|
181
|
+
model=SVC(probability=True, random_state=42),
|
|
182
|
+
model_threshold=0.8,
|
|
183
|
+
signature_genes=custom_signature,
|
|
184
|
+
n_cpus=4
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
print(f"Completed in {results.number_of_iterations} iterations.")
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
#### Scenario 3: Implicit Feature Selection (Defaulting Signature Genes)
|
|
191
|
+
If you do not specify a list of `signature_genes`, `run_catrans` will automatically execute `correlation_based_feature_selection` under the hood using default parameters.
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
import pandas as pd
|
|
195
|
+
from sklearn.ensemble import RandomForestClassifier
|
|
196
|
+
import catrans
|
|
197
|
+
|
|
198
|
+
counts = pd.read_csv("data/raw_counts.tsv", sep="\t", index_col=0)
|
|
199
|
+
metadata = pd.read_csv("data/metadata.tsv", sep="\t", index_col=0)
|
|
200
|
+
initial_classes = pd.read_csv("data/initial_classes.tsv", sep="\t", index_col=0)
|
|
201
|
+
|
|
202
|
+
# Run CATranS (it will automatically perform feature selection internally)
|
|
203
|
+
results = catrans.run_catrans(
|
|
204
|
+
raw_counts=counts,
|
|
205
|
+
metadata=metadata,
|
|
206
|
+
acute_classification=initial_classes,
|
|
207
|
+
model=RandomForestClassifier(random_state=42),
|
|
208
|
+
model_threshold=0.75,
|
|
209
|
+
n_cpus=4
|
|
210
|
+
)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
#### Scenario 4: Default Thresholds & Core Settings
|
|
214
|
+
You can omit optional arguments like `model_threshold` or `classes` if you wish to use the defaults (`model_threshold=0.8`, `classes` defaulting to `"Dead"`/`"Alive"`).
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
import pandas as pd
|
|
218
|
+
from sklearn.ensemble import RandomForestClassifier
|
|
219
|
+
import catrans
|
|
220
|
+
|
|
221
|
+
counts = pd.read_csv("data/raw_counts.tsv", sep="\t", index_col=0)
|
|
222
|
+
metadata = pd.read_csv("data/metadata.tsv", sep="\t", index_col=0)
|
|
223
|
+
initial_classes = pd.read_csv("data/initial_classes.tsv", sep="\t", index_col=0)
|
|
224
|
+
|
|
225
|
+
# Minimal parameters
|
|
226
|
+
results = catrans.run_catrans(
|
|
227
|
+
raw_counts=counts,
|
|
228
|
+
metadata=metadata,
|
|
229
|
+
acute_classification=initial_classes,
|
|
230
|
+
model=RandomForestClassifier(random_state=42)
|
|
231
|
+
)
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
#### Scenario 5: Running with Batch Correction
|
|
235
|
+
To normalize and batch-correct raw counts concurrently, specify the name of the batch column in your metadata dataframe:
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
results = catrans.run_catrans(
|
|
239
|
+
raw_counts=counts,
|
|
240
|
+
metadata=metadata,
|
|
241
|
+
acute_classification=initial_classes,
|
|
242
|
+
model=RandomForestClassifier(random_state=42),
|
|
243
|
+
batch_col="batch" # ComBat-seq batch effect correction column
|
|
244
|
+
)
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Testing & Quality Assurance
|
|
250
|
+
|
|
251
|
+
CATranS utilizes `pytest` for automated test suites, `ruff` for code style consistency, and `mypy` for strict type checking.
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
# Run tests
|
|
255
|
+
uv run pytest
|
|
256
|
+
|
|
257
|
+
# Check code format and style
|
|
258
|
+
uv run ruff check .
|
|
259
|
+
|
|
260
|
+
# Perform static type checks
|
|
261
|
+
uv run mypy src/catrans
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## License
|
|
267
|
+
|
|
268
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
catrans/__init__.py,sha256=2YLkbeddIMqNLiOedoQZMCVSRgb4LlAl2lW5Jj8rT5A,496
|
|
2
|
+
catrans/_logging.py,sha256=YoFzPtDDs4JogpKh3C8ZfzajpOeDMSz3XaQc04NKItE,1423
|
|
3
|
+
catrans/core.py,sha256=bdyKDwJE5B9dT1vqdq80ex8COH_9Xxdu61T88Ikhj1s,21888
|
|
4
|
+
catrans/data.py,sha256=WfEXgkPlNFb1t6_BwDKFQ-WJrmEManUA5jj4-TEenRA,1773
|
|
5
|
+
catrans-0.1.0.dist-info/METADATA,sha256=xcDqt865sGdXLjmTThH2V6Gdnto0Cf1aXFLVvDAt3s4,10218
|
|
6
|
+
catrans-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
7
|
+
catrans-0.1.0.dist-info/licenses/LICENSE,sha256=TMXaKrmR7IOOkX6DeBhc_d9UmTaix8VmIhWO0_0QxZw,1072
|
|
8
|
+
catrans-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ruben-canas-ivi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|