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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.