clacell 2.1.2__tar.gz → 2.2.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clacell
3
- Version: 2.1.2
3
+ Version: 2.2.0
4
4
  Summary: robust cell type classifier for immune cells
5
5
  Project-URL: Homepage, https://github.com/Boolean-true/CLACELL
6
6
  License-Expression: MIT
@@ -15,6 +15,7 @@ Requires-Dist: scikit-image>=0.26.0
15
15
  Requires-Dist: scikit-learn>=1.8.0
16
16
  Requires-Dist: scikit-optimize>=0.10.2
17
17
  Requires-Dist: scipy>=1.17.1
18
+ Requires-Dist: torch>=2.13.0
18
19
  Description-Content-Type: text/markdown
19
20
 
20
21
  # CLACELL
@@ -0,0 +1,6 @@
1
+ from .classifier import CellClassifier
2
+ from .conditional_classifier import ConditionalCellClassifier
3
+ from .marker_annotator import MarkerAnnotator
4
+ from .preprocessing import preprocess_data
5
+
6
+ __all__ = ["CellClassifier", "ConditionalCellClassifier", "MarkerAnnotator", "preprocess_data"]
@@ -0,0 +1,421 @@
1
+ import pandas as pd
2
+ import scipy.stats as stats
3
+ from sklearn.base import BaseEstimator, ClassifierMixin
4
+ from sklearn.preprocessing import StandardScaler
5
+ from sklearn.model_selection import RandomizedSearchCV
6
+ from sklearn.preprocessing import OneHotEncoder
7
+ from sklearn.svm import LinearSVC
8
+ import torch
9
+ import torch.nn as nn
10
+ import torch.optim as optim
11
+ from torch.utils.data import DataLoader, TensorDataset
12
+
13
+ from .test_robustness import test_robustness
14
+
15
+
16
+ class ConditionalDAE(nn.Module):
17
+ def __init__(self, input_dim, num_donors, latent_dim=128, noise_factor=0.3):
18
+ super(ConditionalDAE, self).__init__()
19
+
20
+ self.noise_factor = noise_factor
21
+
22
+ self.encoder = nn.Sequential(
23
+ nn.Linear(input_dim + num_donors, 256),
24
+ nn.BatchNorm1d(256),
25
+ nn.ReLU(),
26
+ nn.Dropout(0.1),
27
+ nn.Linear(256, latent_dim)
28
+ )
29
+
30
+ self.decoder = nn.Sequential(
31
+ nn.Linear(latent_dim + num_donors, 256),
32
+ nn.BatchNorm1d(256),
33
+ nn.ReLU(),
34
+ nn.Dropout(0.1),
35
+ nn.Linear(256, input_dim)
36
+ )
37
+
38
+ def forward(self, x, cond):
39
+ if self.training:
40
+ noise = torch.randn_like(x) * self.noise_factor
41
+ x_noisy = x + noise
42
+ else:
43
+ x_noisy = x
44
+
45
+ # Combine Gene data and Donor ID for the Encoder
46
+ x_cond = torch.cat([x_noisy, cond], dim=1)
47
+ latent = self.encoder(x_cond)
48
+
49
+ # Combine Latent space and Donor ID for the Decoder
50
+ latent_cond = torch.cat([latent, cond], dim=1)
51
+ reconstructed = self.decoder(latent_cond)
52
+
53
+ return reconstructed, latent
54
+
55
+
56
+ class ScRNACVAEClassifier:
57
+ def __init__(self, cdae, classifier, scaler, num_donors):
58
+ self.cdae = cdae
59
+ self.classifier = classifier
60
+ self.scaler = scaler
61
+ self.num_donors = num_donors
62
+ self.cdae.eval()
63
+ self.device = next(cdae.parameters()).device
64
+
65
+ def _transform_to_latent(self, X):
66
+ if hasattr(X, "toarray"):
67
+ X = X.toarray()
68
+ if self.scaler is not None:
69
+ X = self.scaler.transform(X)
70
+
71
+ X_tensor = torch.tensor(X, dtype=torch.float32).to(self.device)
72
+
73
+ cond_dummy = torch.zeros((X_tensor.shape[0], self.num_donors), dtype=torch.float32).to(self.device)
74
+
75
+ with torch.no_grad():
76
+ _, latent_tensor = self.cdae(X_tensor, cond_dummy)
77
+ X_latent = latent_tensor.cpu().numpy()
78
+
79
+ return X_latent
80
+
81
+ def predict(self, X):
82
+ X_latent = self._transform_to_latent(X)
83
+ return self.classifier.predict(X_latent)
84
+
85
+ def predict_proba(self, X):
86
+ X_latent = self._transform_to_latent(X)
87
+ return self.classifier.predict_proba(X_latent)
88
+
89
+
90
+ class ConditionalCellClassifier(BaseEstimator, ClassifierMixin):
91
+ def __init__(self, n_iter_search=50, random_state=None):
92
+ """
93
+ Initializes the cell classififer.
94
+ """
95
+ self.n_iter_search = n_iter_search
96
+ self.random_state = random_state
97
+
98
+ self.model = None
99
+ self.best_params_ = None
100
+ self.is_trained = False
101
+ self.genes_in_training_set = None
102
+
103
+ def random_search(
104
+ self,
105
+ X_train,
106
+ y_train,
107
+ donor_train,
108
+ X_test=None,
109
+ y_test=None,
110
+ donor_test=None,
111
+ labels="scumi-annotation",
112
+ n_jobs=1,
113
+ ):
114
+ """
115
+ Executes a hyperparameter tuning on the training set and returns the score on the test set.
116
+ Automatically followed by a final training with the best parameters.
117
+ """
118
+ if not isinstance(X_train, pd.DataFrame):
119
+ raise ValueError("X_train must be a pandas DataFrame.")
120
+
121
+ if not isinstance(donor_train, pd.Series):
122
+ raise ValueError("donor_train must be a pandas Series.")
123
+
124
+ if X_test is not None:
125
+ if not isinstance(X_test, pd.DataFrame):
126
+ raise ValueError("X_test must be a pandas DataFrame.")
127
+ if y_test is None:
128
+ raise ValueError("y_test must be provided if X_test is provided.")
129
+ if donor_test is None:
130
+ raise ValueError("donor_test must be provided if X_test is provided.")
131
+ if not isinstance(donor_test, pd.Series):
132
+ raise ValueError("donor_test must be a pandas Series.")
133
+
134
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
135
+
136
+ if hasattr(X_train, "toarray"):
137
+ X_train = X_train.toarray()
138
+ if X_test is not None and hasattr(X_test, "toarray"):
139
+ X_test = X_test.toarray()
140
+
141
+ scaler = StandardScaler()
142
+ X_train_scaled = scaler.fit_transform(X_train)
143
+
144
+ oh_encoder = OneHotEncoder(sparse_output=False)
145
+ donor_train_oh = oh_encoder.fit_transform(donor_train.to_numpy().reshape(-1, 1))
146
+
147
+ num_donors = donor_train_oh.shape[1]
148
+ input_dim = X_train_scaled.shape[1]
149
+ latent_dim = 128
150
+
151
+ # Train DAE
152
+ input_dim = X_train_scaled.shape[1]
153
+ cdae = ConditionalDAE(input_dim, num_donors, latent_dim).to(device)
154
+
155
+ criterion = nn.MSELoss()
156
+ optimizer = optim.AdamW(cdae.parameters(), lr=1e-3, weight_decay=1e-4)
157
+
158
+ train_dataset = TensorDataset(
159
+ torch.tensor(X_train_scaled, dtype=torch.float32),
160
+ torch.tensor(donor_train_oh, dtype=torch.float32)
161
+ )
162
+ train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True)
163
+
164
+
165
+ num_epochs = 150
166
+ best_loss = float('inf')
167
+ patience_counter = 0
168
+ patience = 5
169
+ delta_loss = 0.0002
170
+ cdae.train()
171
+
172
+ print("Start Conditional DAE Training...")
173
+ for epoch in range(num_epochs):
174
+ epoch_loss = 0.0
175
+ for x_batch, cond_batch in train_loader:
176
+ x_batch = x_batch.to(device)
177
+ cond_batch = cond_batch.to(device)
178
+
179
+ optimizer.zero_grad()
180
+ reconstructed, latent = cdae(x_batch, cond_batch)
181
+
182
+ loss = criterion(reconstructed, x_batch)
183
+ loss.backward()
184
+ optimizer.step()
185
+ epoch_loss += loss.item() * x_batch.size(0)
186
+
187
+ total_epoch_loss = epoch_loss / len(train_loader.dataset)
188
+ if (epoch + 1) % 10 == 0 or epoch == 0:
189
+ print(f"Epoch [{epoch+1}/{num_epochs}], Loss: {total_epoch_loss:.4f}")
190
+
191
+ # Early Stopping
192
+ if total_epoch_loss < best_loss - delta_loss:
193
+ best_loss = total_epoch_loss
194
+ patience_counter = 0
195
+ else:
196
+ patience_counter += 1
197
+
198
+ if patience_counter >= patience:
199
+ print(f"Early Stopping after [{epoch+1}/{num_epochs}] Epochs!")
200
+ break
201
+
202
+ cdae.eval()
203
+ print("\nExtract robust features...")
204
+ with torch.no_grad():
205
+ # Prepare training data for latent space extraction
206
+ X_train_tensor = torch.tensor(X_train_scaled, dtype=torch.float32).to(device)
207
+ cond_train_tensor = torch.tensor(donor_train_oh, dtype=torch.float32).to(device)
208
+ _, X_train_latent_tensor = cdae(X_train_tensor, cond_train_tensor)
209
+ X_train_latent = X_train_latent_tensor.cpu().numpy()
210
+
211
+ print("Start Hyperparametertuning...")
212
+ base_model = LinearSVC()
213
+
214
+ param_distributions = [
215
+ {
216
+ 'C': stats.loguniform(1e-3, 2.0),
217
+ 'penalty': ['l2'],
218
+ 'dual': [True, False],
219
+ 'class_weight': ['balanced', None],
220
+ 'tol': stats.loguniform(1e-3, 1e-1)
221
+ },
222
+ {
223
+ 'C': stats.loguniform(1e-3, 2.0),
224
+ 'penalty': ['l1'],
225
+ 'dual': [False],
226
+ 'class_weight': ['balanced', None],
227
+ 'tol': stats.loguniform(1e-3, 1e-1)
228
+ }
229
+ ]
230
+ random_search = RandomizedSearchCV(
231
+ estimator=base_model,
232
+ param_distributions=param_distributions,
233
+ n_iter=self.n_iter_search,
234
+ cv=5,
235
+ scoring='accuracy',
236
+ n_jobs=n_jobs,
237
+ verbose=10
238
+ )
239
+ random_search.fit(X_train_latent, y_train)
240
+
241
+ self.best_params_ = random_search.best_params_
242
+ print(f"Best parameters found: {self.best_params_}")
243
+ best_model = random_search.best_estimator_
244
+
245
+ robust_model = ScRNACVAEClassifier(
246
+ cdae=cdae,
247
+ classifier=best_model,
248
+ scaler=scaler,
249
+ num_donors=num_donors
250
+ )
251
+
252
+ self.model = robust_model
253
+ self.is_trained = True
254
+ self.genes_in_training_set = X_train.columns.tolist()
255
+
256
+ if X_test is not None and y_test is not None:
257
+ # Compute Robustness score on test set with best parameters
258
+ self.evaluate(X_test, y_test, labels=labels)
259
+
260
+ # Automatically call train with best parameters on complete dataset after random search
261
+ print(
262
+ "\nStart final training with best parameters on complete training data..."
263
+ )
264
+ X = pd.concat([X_train, X_test], axis=0, ignore_index=True)
265
+ y = pd.concat([y_train, y_test], axis=0, ignore_index=True)
266
+ donors = pd.concat([donor_train, donor_test], axis=0, ignore_index=True)
267
+ self.train(X, y, donors, **self.best_params_)
268
+
269
+ def train(
270
+ self, X_train, y_train, donor_train, **hyperparameters
271
+ ):
272
+ """
273
+ Trains the model one the complete dataset with the given hyperparameters.
274
+ Can be either called automatically after random search or manually with custom hyperparameters.
275
+ """
276
+ if not isinstance(X_train, pd.DataFrame):
277
+ raise ValueError("X_train must be a pandas DataFrame.")
278
+
279
+ if not isinstance(donor_train, pd.Series):
280
+ raise ValueError("donor_train must be a pandas Series.")
281
+
282
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
283
+
284
+ if hasattr(X_train, "toarray"):
285
+ X_train = X_train.toarray()
286
+
287
+ scaler = StandardScaler()
288
+ X_train_scaled = scaler.fit_transform(X_train)
289
+
290
+ oh_encoder = OneHotEncoder(sparse_output=False)
291
+ donor_train_oh = oh_encoder.fit_transform(donor_train.to_numpy().reshape(-1, 1))
292
+
293
+ num_donors = donor_train_oh.shape[1]
294
+ input_dim = X_train_scaled.shape[1]
295
+ latent_dim = 128
296
+
297
+ # Train DAE
298
+ input_dim = X_train_scaled.shape[1]
299
+ cdae = ConditionalDAE(input_dim, num_donors, latent_dim).to(device)
300
+
301
+ criterion = nn.MSELoss()
302
+ optimizer = optim.AdamW(cdae.parameters(), lr=1e-3, weight_decay=1e-4)
303
+
304
+ train_dataset = TensorDataset(
305
+ torch.tensor(X_train_scaled, dtype=torch.float32),
306
+ torch.tensor(donor_train_oh, dtype=torch.float32)
307
+ )
308
+ train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True)
309
+
310
+
311
+ num_epochs = 150
312
+ best_loss = float('inf')
313
+ patience_counter = 0
314
+ patience = 5
315
+ delta_loss = 0.0002
316
+ cdae.train()
317
+
318
+ print("Start Conditional DAE Training...")
319
+ for epoch in range(num_epochs):
320
+ epoch_loss = 0.0
321
+ for x_batch, cond_batch in train_loader:
322
+ x_batch = x_batch.to(device)
323
+ cond_batch = cond_batch.to(device)
324
+
325
+ optimizer.zero_grad()
326
+ reconstructed, latent = cdae(x_batch, cond_batch)
327
+
328
+ loss = criterion(reconstructed, x_batch)
329
+ loss.backward()
330
+ optimizer.step()
331
+ epoch_loss += loss.item() * x_batch.size(0)
332
+
333
+ total_epoch_loss = epoch_loss / len(train_loader.dataset)
334
+ if (epoch + 1) % 10 == 0 or epoch == 0:
335
+ print(f"Epoch [{epoch+1}/{num_epochs}], Loss: {total_epoch_loss:.4f}")
336
+
337
+ # Early Stopping
338
+ if total_epoch_loss < best_loss - delta_loss:
339
+ best_loss = total_epoch_loss
340
+ patience_counter = 0
341
+ else:
342
+ patience_counter += 1
343
+
344
+ if patience_counter >= patience:
345
+ print(f"Early Stopping after [{epoch+1}/{num_epochs}] Epochs!")
346
+ break
347
+
348
+ cdae.eval()
349
+ print("\nExtract robust features...")
350
+ with torch.no_grad():
351
+ # Prepare training data for latent space extraction
352
+ X_train_tensor = torch.tensor(X_train_scaled, dtype=torch.float32).to(device)
353
+ cond_train_tensor = torch.tensor(donor_train_oh, dtype=torch.float32).to(device)
354
+ _, X_train_latent_tensor = cdae(X_train_tensor, cond_train_tensor)
355
+ X_train_latent = X_train_latent_tensor.cpu().numpy()
356
+
357
+
358
+ base_model = LinearSVC(random_state=self.random_state, **hyperparameters)
359
+ base_model.fit(X_train_latent, y_train)
360
+ robust_model = ScRNACVAEClassifier(
361
+ cdae=cdae,
362
+ classifier=base_model,
363
+ scaler=scaler,
364
+ num_donors=num_donors
365
+ )
366
+
367
+ self.model = robust_model
368
+ self.is_trained = True
369
+ self.genes_in_training_set = X_train.columns.tolist()
370
+
371
+ def evaluate(
372
+ self,
373
+ X_test,
374
+ y_test,
375
+ labels="scumi-annotation",
376
+ X_ood=None,
377
+ y_ood=None,
378
+ feature_importances=None,
379
+ log_to_console=True,
380
+ log_to_file=True,
381
+ ):
382
+ """
383
+ Evaluates the model on the test set and returns the score.
384
+ If the model is not trained yet, it raises an error.
385
+ """
386
+ if not self.is_trained:
387
+ raise RuntimeError(
388
+ "The model wasn't trained yet. Call 'train' or 'random_search' first."
389
+ )
390
+
391
+ print("Evaluate model on test data...")
392
+ return test_robustness(
393
+ self.model,
394
+ X_test,
395
+ y_test,
396
+ labels,
397
+ X_ood,
398
+ y_ood,
399
+ feature_importances,
400
+ log_to_console=log_to_console,
401
+ log_to_file=log_to_file,
402
+ )
403
+
404
+ def predict(self, X):
405
+ """
406
+ Infers new data with the trained model and returns the labels.
407
+ If the model is not trained yet, it raises an error.
408
+ """
409
+ if not isinstance(X, pd.DataFrame):
410
+ raise ValueError("X must be a pandas DataFrame.")
411
+
412
+ if not self.is_trained:
413
+ raise RuntimeError(
414
+ "The model wasn't trained yet. Call 'train' or 'random_search' first."
415
+ )
416
+
417
+ # Filter genes that are not in the training set and reorder the remaining genes to match the training set
418
+ X = X.reindex(columns=self.genes_in_training_set, fill_value=0)
419
+
420
+ print("Infer new data...")
421
+ return self.model.predict(X)
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "clacell"
3
3
  license = "MIT"
4
- version = "2.1.2"
4
+ version = "2.2.0"
5
5
  description = "robust cell type classifier for immune cells"
6
6
  readme = "README.md"
7
7
  requires-python = ">=3.13"
@@ -15,6 +15,7 @@ dependencies = [
15
15
  "scikit-image>=0.26.0",
16
16
  "igraph>=1.0.0",
17
17
  "scikit-optimize>=0.10.2",
18
+ "torch>=2.13.0",
18
19
  ]
19
20
 
20
21
  [build-system]
@@ -27,4 +28,4 @@ include = [
27
28
  ]
28
29
 
29
30
  [project.urls]
30
- "Homepage" = "https://github.com/Boolean-true/CLACELL"
31
+ "Homepage" = "https://github.com/Boolean-true/CLACELL"
@@ -1,5 +0,0 @@
1
- from .classifier import CellClassifier
2
- from .marker_annotator import MarkerAnnotator
3
- from .preprocessing import preprocess_data
4
-
5
- __all__ = ["CellClassifier", "MarkerAnnotator", "preprocess_data"]
File without changes
File without changes
File without changes
File without changes