diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml
new file mode 100644
index 00000000..3796291d
--- /dev/null
+++ b/.github/workflows/black.yml
@@ -0,0 +1,35 @@
+name: Black linter
+
+on:
+ pull_request:
+ paths:
+ - '**.py'
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ with:
+ ref: ${{ github.head_ref }}
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Install Black
+ run: pip install 'black[jupyter]==23.11.0'
+
+ - name: Run Black
+ id: black_check
+ run: |
+ black --check --verbose . || echo "CHANGES_NEEDED=true" >> $GITHUB_ENV
+ continue-on-error: true
+
+ - name: Apply Black reformatting
+ if: env.CHANGES_NEEDED=='true'
+ run: |
+ black .
+ git config --global user.name 'github-actions'
+ git config --global user.email 'github-actions@github.com'
+ git add -A
+ git diff --quiet && git diff --staged --quiet || (git commit -m "Apply Black formatting" && git push)
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/alphadia/__init__.py b/alphadia/__init__.py
index bfef8ecd..83c347d4 100644
--- a/alphadia/__init__.py
+++ b/alphadia/__init__.py
@@ -2,7 +2,7 @@
__project__ = "alphadia"
-__version__ = "1.4.0"
+__version__ = "1.5.0"
__license__ = "Apache"
__description__ = "An open-source Python package of the AlphaPept ecosystem"
__author__ = "Mann Labs"
diff --git a/alphadia/cli.py b/alphadia/cli.py
index c75cea55..100bf9f2 100644
--- a/alphadia/cli.py
+++ b/alphadia/cli.py
@@ -11,6 +11,23 @@
from alphadia.workflow import reporting
from alphadia import utils
+from alphabase.constants import modification
+
+modification.add_new_modifications(
+ {
+ "Dimethyl:d12@Protein N-term": {"composition": "H(-2)2H(8)13C(2)"},
+ "Dimethyl:d12@Any N-term": {
+ "composition": "H(-2)2H(8)13C(2)",
+ },
+ "Dimethyl:d12@R": {
+ "composition": "H(-2)2H(8)13C(2)",
+ },
+ "Dimethyl:d12@K": {
+ "composition": "H(-2)2H(8)13C(2)",
+ },
+ }
+)
+
# alpha family imports
# third party imports
@@ -101,6 +118,7 @@ def gui():
"--config-update",
help="Dict which will be used to update the default config.",
type=str,
+ default={},
)
@click.option(
"--neptune-token",
@@ -124,7 +142,7 @@ def extract(**kwargs):
kwargs["neptune_tag"] = list(kwargs["neptune_tag"])
# load config file if specified
- config_update = None
+ config_update = {}
if kwargs["config"] is not None:
with open(kwargs["config"], "r") as f:
config_update = yaml.safe_load(f)
diff --git a/alphadia/fdr.py b/alphadia/fdr.py
index e63e5464..4171b242 100644
--- a/alphadia/fdr.py
+++ b/alphadia/fdr.py
@@ -27,6 +27,7 @@ def perform_fdr(
competetive: bool = False,
group_channels: bool = True,
figure_path: Optional[str] = None,
+ neptune_run=None,
):
"""Performs FDR calculation on a dataframe of PSMs
@@ -123,7 +124,7 @@ def perform_fdr(
classifier,
psm_df["qval"],
figure_path=figure_path,
- # neptune_run=neptune_run
+ neptune_run=neptune_run,
)
return psm_df
@@ -339,6 +340,7 @@ def plot_fdr(
ax[0].set_ylabel("true positive rate")
ax[0].legend()
+ ax[1].set_xlim(0, 1)
ax[1].hist(
np.concatenate([y_test_proba[y_test == 0], y_train_proba[y_train == 0]]),
bins=50,
@@ -376,7 +378,7 @@ def plot_fdr(
if figure_path is not None:
fig.savefig(os.path.join(figure_path, "fdr.pdf"))
- # if neptune_run is not None:
- # neptune_run['eval/fdr'].log(fig)
+ if neptune_run is not None:
+ neptune_run["eval/fdr"].log(fig)
plt.close()
diff --git a/alphadia/fdrexperimental.py b/alphadia/fdrexperimental.py
index 6f1cf366..c705ba56 100644
--- a/alphadia/fdrexperimental.py
+++ b/alphadia/fdrexperimental.py
@@ -3,18 +3,24 @@
import warnings
from copy import deepcopy
import typing
+import warnings
+from abc import ABC, abstractmethod
+from copy import deepcopy
# alphadia imports
# alpha family imports
# third party imports
+import numba as nb
import numpy as np
import pandas as pd
import torch
import torch.nn as nn
import torch.optim as optim
from sklearn import model_selection
+from tqdm import tqdm
+from torchmetrics.classification import BinaryAUROC
class Classifier(ABC):
@@ -122,13 +128,17 @@ def __init__(
input_dim: int = 10,
output_dim: int = 2,
test_size: float = 0.2,
- batch_size: int = 1000,
+ max_batch_size: int = 10000,
+ min_batch_number: int = 100,
epochs: int = 10,
learning_rate: float = 0.0002,
weight_decay: float = 0.00001,
layers: typing.List[int] = [100, 50, 20, 5],
dropout: float = 0.001,
- metric_interval: int = 1000,
+ calculate_metrics: bool = True,
+ metric_interval: int = 1,
+ patience: int = 3,
+ **kwargs,
):
"""Binary Classifier using a feed forward neural network.
@@ -144,14 +154,20 @@ def __init__(
test_size : float, default=0.2
Fraction of the data to be used for testing.
- batch_size : int, default=1000
- Batch size for training.
+ max_batch_size : int, default=5000
+ Maximum batch size for training.
+ The actual batch will be scaled to make sure at least min_batch_number batches are used.
+
+ min_batch_number : int, default=100
+ Minimum number of batches for training.
+ The actual batch number will be scaled if more than min_batchnumber * max_batch_size samples are available.
epochs : int, default=10
Number of epochs for training.
learning_rate : float, default=0.0002
- Learning rate for training.
+ Base learning rate for a batch size of max_batch_size.
+ If smaller batches are used, the learning rate will be scaled linearly.
weight_decay : float, default=0.00001
Weight decay for training.
@@ -162,13 +178,20 @@ def __init__(
dropout : float, default=0.001
Dropout probability for training.
- metric_interval : int, default=1000
- Interval for logging metrics during training.
+ calculate_metrics : bool, default=True
+ Whether to calculate metrics during training.
+
+ metric_interval : int, default=1
+ Interval for logging metrics during training, once per metric_interval epochs.
+
+ patience : int, default=3
+ Number of epochs to wait for improvement before early stopping.
"""
self.test_size = test_size
- self.batch_size = batch_size
+ self.max_batch_size = max_batch_size
+ self.min_batch_number = min_batch_number
self.epochs = epochs
self.learning_rate = learning_rate
self.weight_decay = weight_decay
@@ -177,6 +200,8 @@ def __init__(
self.input_dim = input_dim
self.output_dim = output_dim
self.metric_interval = metric_interval
+ self.calculate_metrics = calculate_metrics
+ self.patience = patience
self.network = None
self.optimizer = None
@@ -186,11 +211,18 @@ def __init__(
"epoch": [],
"batch_count": [],
"train_loss": [],
- "train_accuracy": [],
"test_loss": [],
- "test_accuracy": [],
+ "train_auc": [],
+ "train_fdr01": [],
+ "train_fdr1": [],
+ "test_auc": [],
+ "test_fdr01": [],
+ "test_fdr1": [],
}
+ if kwargs:
+ warnings.warn("Unknown arguments: {}".format(kwargs))
+
@property
def fitted(self):
return self._fitted
@@ -257,6 +289,32 @@ def from_state_dict(self, state_dict: dict):
self.__dict__.update(_state_dict)
+ def _prepare_data(self, x: np.ndarray, y: np.ndarray):
+ """Prepare the data for training: normalize, split into train and test set.
+
+ Parameters
+ ----------
+
+ x : np.array, dtype=float
+ Training data of shape (n_samples, n_features).
+
+ y : np.array, dtype=int
+ Target values of shape (n_samples,) or (n_samples, n_classes).
+ """
+ x -= x.mean(axis=0)
+ x /= x.std(axis=0) + 1e-6
+
+ if y.ndim == 1:
+ y = np.stack([1 - y, y], axis=1)
+ x_train, x_test, y_train, y_test = model_selection.train_test_split(
+ x, y, test_size=self.test_size
+ )
+ x_train = torch.from_numpy(x_train).float()
+ y_train = torch.from_numpy(y_train).float()
+ x_test = torch.from_numpy(x_test).float()
+ y_test = torch.from_numpy(y_test).float()
+ return x_train, x_test, y_train, y_test
+
def fit(self, x: np.ndarray, y: np.ndarray):
"""Fit the classifier to the data.
@@ -271,6 +329,10 @@ def fit(self, x: np.ndarray, y: np.ndarray):
"""
+ batch_number = max(self.min_batch_number, x.shape[0] // self.max_batch_size)
+ batch_size = x.shape[0] // batch_number
+ lr_scaled = self.learning_rate * batch_size / self.max_batch_size
+
force_reinit = False
if self.input_dim != x.shape[1] and self.network is not None:
@@ -289,64 +351,851 @@ def fit(self, x: np.ndarray, y: np.ndarray):
dropout=self.dropout,
)
- # normalize input
- x = (x - x.mean(axis=0)) / (x.std(axis=0) + 1e-6)
+ optimizer = optim.AdamW(
+ self.network.parameters(),
+ lr=lr_scaled,
+ weight_decay=self.weight_decay,
+ )
+
+ loss = nn.BCELoss()
+
+ binary_auroc = BinaryAUROC()
+
+ best_fdr1 = 0.0
+ patience = self.patience
+
+ x -= x.mean(axis=0)
+ x /= x.std(axis=0) + 1e-6
if y.ndim == 1:
y = np.stack([1 - y, y], axis=1)
-
x_train, x_test, y_train, y_test = model_selection.train_test_split(
x, y, test_size=self.test_size
)
+ x_train = torch.from_numpy(x_train).float()
+ y_train = torch.from_numpy(y_train).float()
+ x_test = torch.from_numpy(x_test).float()
+ y_test = torch.from_numpy(y_test).float()
- x_test = torch.Tensor(x_test)
- y_test = torch.Tensor(y_test)
+ num_batches = x_train.shape[0] // batch_size
+ batch_start_list = np.arange(num_batches) * batch_size
+ batch_stop_list = np.arange(num_batches) * batch_size + batch_size
- optimizer = optim.Adam(
- self.network.parameters(),
- lr=self.learning_rate,
- weight_decay=self.weight_decay,
- )
+ batch_count = 0
+ for epoch in tqdm(range(self.epochs)):
+ train_loss_sum = 0.0
+ test_loss_sum = 0.0
- loss = nn.BCELoss()
+ num_batches_train = 0
+ num_batches_test = 0
- batch_count = 0
+ # shuffle batches
+ order = np.random.permutation(num_batches)
+ batch_start_list = batch_start_list[order]
+ batch_stop_list = batch_stop_list[order]
- for j in range(self.epochs):
- order = np.random.permutation(len(x_train))
- x_train = torch.Tensor(x_train[order])
- y_train = torch.Tensor(y_train[order])
+ train_predictions_list = []
+ train_labels_list = []
- for i, (batch_x, batch_y) in enumerate(
- zip(x_train.split(self.batch_size), y_train.split(self.batch_size))
- ):
- y_pred = self.network(batch_x)
- loss_value = loss(y_pred, batch_y)
+ for batch_start, batch_stop in zip(batch_start_list, batch_stop_list):
+ y_pred = self.network(x_train[batch_start:batch_stop])
+ loss_value = loss(y_pred, y_train[batch_start:batch_stop])
- self.network.zero_grad()
+ optimizer.zero_grad()
loss_value.backward()
optimizer.step()
- if batch_count % self.metric_interval == 0:
- self.network.eval()
- with torch.no_grad():
- self.metrics["epoch"].append(j)
- self.metrics["batch_count"].append(batch_count)
- self.metrics["train_loss"].append(loss_value.item())
+ train_loss_sum += loss_value.detach()
+ train_predictions_list.append(y_pred.detach())
+ train_labels_list.append(y_train[batch_start:batch_stop].detach()[:, 1])
+ num_batches_train += 1
- y_pred_test = self.network(x_test)
- loss_value = loss(y_pred_test, y_test)
- self.metrics["test_loss"].append(loss_value.item())
+ train_predictions = torch.cat(train_predictions_list, dim=0)
+ train_labels = torch.cat(train_labels_list, dim=0)
- y_pred_train = self.network(x_train).detach().numpy()
- y_pred_test = self.network(x_test).detach().numpy()
+ auc, fdr01, fdr1 = self.get_auc_fdr(
+ train_predictions, train_labels, roc_object=binary_auroc
+ )
- self.metrics["train_accuracy"].append(
- np.sum(
- y_train[:, 1].detach().numpy()
- == np.argmax(y_pred_train, axis=1)
- )
- / len(y_train)
+ if not self.calculate_metrics:
+ # check for early stopping
+ if fdr1 > best_fdr1:
+ best_fdr1 = fdr1
+ patience = self.patience
+ else:
+ patience -= 1
+
+ if patience <= 0:
+ break
+ continue
+
+ if epoch % self.metric_interval != 0: # skip metrics if wrong epoch
+ continue
+
+ self.network.eval()
+ with torch.no_grad():
+ test_predictions_list = []
+ test_labels_list = []
+
+ test_batch_size = min(batch_size, x_test.shape[0])
+ test_num_batches = x_test.shape[0] // test_batch_size
+ test_batch_start_list = np.arange(test_num_batches) * test_batch_size
+ test_batch_stop_list = (
+ np.arange(test_num_batches) * test_batch_size + test_batch_size
+ )
+
+ for batch_start, batch_stop in zip(
+ test_batch_start_list, test_batch_stop_list
+ ):
+ batch_x_test = x_test[batch_start:batch_stop]
+ batch_y_test = y_test[batch_start:batch_stop]
+
+ y_pred_test = self.network(batch_x_test)
+ test_loss = loss(y_pred_test, batch_y_test)
+ test_predictions_list.append(y_pred_test.detach())
+ test_labels_list.append(batch_y_test.detach()[:, 1])
+ num_batches_test += 1
+ test_loss_sum += test_loss
+
+ # log metrics for train and test
+ average_train_loss = train_loss_sum / num_batches_train
+ average_test_loss = test_loss_sum / num_batches_test
+
+ self.metrics["train_loss"].append(average_train_loss.item())
+ self.metrics["test_loss"].append(average_test_loss.item())
+
+ self.metrics["train_auc"].append(auc.item())
+ self.metrics["train_fdr01"].append(fdr01.item())
+ self.metrics["train_fdr1"].append(fdr1.item())
+
+ test_predictions = torch.cat(test_predictions_list, dim=0)
+ test_labels = torch.cat(test_labels_list, dim=0)
+
+ auc, fdr01, fdr1 = self.get_auc_fdr(
+ test_predictions, test_labels, roc_object=binary_auroc
+ )
+
+ self.metrics["test_auc"].append(auc.item())
+ self.metrics["test_fdr01"].append(fdr01.item())
+ self.metrics["test_fdr1"].append(fdr1.item())
+
+ self.metrics["epoch"].append(epoch)
+
+ batch_count += num_batches_train
+ self.metrics["batch_count"].append(batch_count)
+
+ self.network.train()
+
+ # check for early stopping
+ if fdr1 > best_fdr1:
+ best_fdr1 = fdr1
+ patience = self.patience
+ else:
+ patience -= 1
+
+ if patience <= 0:
+ break
+
+ self._fitted = True
+
+ @torch.jit.export
+ def get_q_values(self, decoys_sorted: torch.Tensor):
+ """Calculates q-values for a dataframe containing PSMs.
+
+ Parameters
+ ----------
+
+ scores : torch.Tensor
+ Score to use for the selection. Ascending sorted values are expected.
+
+ decoys : torch.Tensor
+ Decoy information. Decoys are expected to be 1 and targets 0.
+
+ Returns
+ -------
+
+ torch.Tensor
+ The q-values.
+
+ """
+ decoy_cumsum = torch.cumsum(decoys_sorted, dim=0)
+ target_cumsum = torch.cumsum(1 - decoys_sorted, dim=0)
+ fdr_values = decoy_cumsum.float() / target_cumsum.float()
+ return self.fdr_to_q_values(fdr_values)
+
+ @torch.jit.export
+ def fdr_to_q_values(self, fdr_values: torch.Tensor):
+ """Converts FDR values to q-values.
+ Takes a ascending sorted array of FDR values and converts them to q-values.
+ for every element the lowest FDR where it would be accepted is used as q-value.
+
+ Parameters
+ ----------
+ fdr_values : torch.Tensor
+ The FDR values to convert.
+
+ Returns
+ -------
+ torch.Tensor
+ The q-values.
+ """
+ reversed_fdr = torch.flip(fdr_values, dims=[0])
+ cumulative_mins = torch.zeros_like(reversed_fdr)
+ min_value = float("inf")
+ for i in range(reversed_fdr.size(0)):
+ min_value = min(min_value, reversed_fdr[i].item())
+ cumulative_mins[i] = min_value
+ q_values = torch.flip(cumulative_mins, dims=[0])
+ return q_values
+
+ @torch.jit.export
+ def get_auc_fdr(self, predicted_probas: torch.Tensor, y: torch.Tensor, roc_object):
+ """Calculates the AUC and FDR for a given set of predicted probabilities and labels.
+
+ Parameters
+ ----------
+ predicted_probas : torch.Tensor
+ The predicted probabilities.
+
+ y : torch.Tensor
+ True labels. Decoys are expected to be 1 and targets 0.
+
+ roc_object : torchmetrics.classification.BinaryAUROC
+ The ROC object to use for calculating the AUC.
+
+ Returns
+ -------
+ torch.Tensor
+ """
+ scores = predicted_probas[:, 1]
+ sorted_indices = torch.argsort(scores, stable=True)
+ decoys_sorted = y[sorted_indices]
+ qval = self.get_q_values(decoys_sorted)
+
+ decoys_zero_mask = decoys_sorted == 0
+ qval = qval[decoys_zero_mask]
+
+ y_pred = torch.round(scores)
+ auc = roc_object(y_pred, y)
+ return auc, torch.sum(qval < 0.001), torch.sum(qval < 0.01)
+
+ def predict(self, x):
+ """Predict the class of the data.
+
+ Parameters
+ ----------
+
+ x : np.array, dtype=float
+ Data of shape (n_samples, n_features).
+
+ Returns
+ -------
+
+ y : np.array, dtype=int
+ Predicted class of shape (n_samples,).
+ """
+
+ if not self.fitted:
+ raise ValueError("Classifier has not been fitted yet.")
+
+ assert (
+ x.ndim == 2
+ ), "Input data must have batch and feature dimension. (n_samples, n_features)"
+ assert (
+ x.shape[1] == self.input_dim
+ ), "Input data must have the same number of features as the fitted classifier."
+
+ x = (x - x.mean(axis=0)) / (x.std(axis=0) + 1e-6)
+ self.network.eval()
+ return np.argmax(self.network(torch.Tensor(x)).detach().numpy(), axis=1)
+
+ def predict_proba(self, x: np.ndarray):
+ """Predict the class probabilities of the data.
+
+ Parameters
+ ----------
+
+ x : np.array, dtype=float
+ Data of shape (n_samples, n_features).
+
+ Returns
+ -------
+
+ y : np.array, dtype=float
+ Predicted class probabilities of shape (n_samples, n_classes).
+
+ """
+
+ if not self.fitted:
+ raise ValueError("Classifier has not been fitted yet.")
+
+ assert (
+ x.ndim == 2
+ ), "Input data must have batch and feature dimension. (n_samples, n_features)"
+ assert (
+ x.shape[1] == self.input_dim
+ ), "Input data must have the same number of features as the fitted classifier."
+
+ x = (x - x.mean(axis=0)) / (x.std(axis=0) + 1e-6)
+ self.network.eval()
+ return self.network(torch.Tensor(x)).detach().numpy()
+
+
+class BinaryClassifierLegacy(Classifier):
+ def __init__(
+ self,
+ input_dim: int = 10,
+ output_dim: int = 2,
+ test_size: float = 0.2,
+ batch_size: int = 1000,
+ epochs: int = 10,
+ learning_rate: float = 0.0002,
+ weight_decay: float = 0.00001,
+ layers: typing.List[int] = [100, 50, 20, 5],
+ dropout: float = 0.001,
+ metric_interval: int = 1000,
+ **kwargs,
+ ):
+ """Binary Classifier using a feed forward neural network.
+
+ Parameters
+ ----------
+
+ input_dim : int, default=10
+ Number of input features.
+
+ output_dim : int, default=2
+ Number of output classes.
+
+ test_size : float, default=0.2
+ Fraction of the data to be used for testing.
+
+ batch_size : int, default=1000
+ Batch size for training.
+
+ epochs : int, default=10
+ Number of epochs for training.
+
+ learning_rate : float, default=0.0002
+ Learning rate for training.
+
+ weight_decay : float, default=0.00001
+ Weight decay for training.
+
+ layers : typing.List[int], default=[100, 50, 20, 5]
+ typing.List of hidden layer sizes.
+
+ dropout : float, default=0.001
+ Dropout probability for training.
+
+ metric_interval : int, default=1000
+ Interval for logging metrics during training.
+
+ """
+
+ self.test_size = test_size
+ self.batch_size = batch_size
+ self.epochs = epochs
+ self.learning_rate = learning_rate
+ self.weight_decay = weight_decay
+ self.layers = layers
+ self.dropout = dropout
+ self.input_dim = input_dim
+ self.output_dim = output_dim
+ self.metric_interval = metric_interval
+
+ self.network = None
+ self.optimizer = None
+ self._fitted = False
+
+ self.metrics = {
+ "epoch": [],
+ "batch_count": [],
+ "train_loss": [],
+ "train_accuracy": [],
+ "test_loss": [],
+ "test_accuracy": [],
+ }
+
+ if kwargs:
+ warnings.warn("Unknown arguments: {}".format(kwargs))
+
+ @property
+ def fitted(self):
+ return self._fitted
+
+ @property
+ def metrics(self):
+ return self._metrics
+
+ @metrics.setter
+ def metrics(self, metrics):
+ self._metrics = metrics
+
+ def to_state_dict(self):
+ """Save the state of the classifier as a dictionary.
+
+ Returns
+ -------
+
+ dict : dict
+ Dictionary containing the state of the classifier.
+
+ """
+ dict = {
+ "_fitted": self._fitted,
+ "input_dim": self.input_dim,
+ "output_dim": self.output_dim,
+ "test_size": self.test_size,
+ "batch_size": self.batch_size,
+ "epochs": self.epochs,
+ "learning_rate": self.learning_rate,
+ "weight_decay": self.weight_decay,
+ "layers": self.layers,
+ "dropout": self.dropout,
+ "metric_interval": self.metric_interval,
+ "metrics": self.metrics,
+ }
+
+ if self._fitted:
+ dict["network_state_dict"] = self.network.state_dict()
+
+ return dict
+
+ def from_state_dict(self, state_dict: dict):
+ """Load the state of the classifier from a dictionary.
+
+ Parameters
+ ----------
+
+ dict : dict
+ Dictionary containing the state of the classifier.
+
+ """
+
+ _state_dict = deepcopy(state_dict)
+
+ if "network_state_dict" in _state_dict:
+ self.network = FeedForwardNN(
+ input_dim=_state_dict.pop("input_dim"),
+ output_dim=_state_dict.pop("output_dim"),
+ layers=_state_dict.pop("layers"),
+ dropout=_state_dict.pop("dropout"),
+ )
+ self.network.load_state_dict(state_dict.pop("network_state_dict"))
+
+ self.__dict__.update(_state_dict)
+
+ def fit(self, x: np.ndarray, y: np.ndarray):
+ """Fit the classifier to the data.
+
+ Parameters
+ ----------
+
+ x : np.array, dtype=float
+ Training data of shape (n_samples, n_features).
+
+ y : np.array, dtype=int
+ Target values of shape (n_samples,) or (n_samples, n_classes).
+
+ """
+
+ force_reinit = False
+
+ if self.input_dim != x.shape[1] and self.network is not None:
+ warnings.warn(
+ "Input dimension of network has changed. Network has been reinitialized."
+ )
+ force_reinit = True
+
+ # check if network has to be initialized
+ if self.network is None or force_reinit:
+ self.input_dim = x.shape[1]
+ self.network = FeedForwardNN(
+ input_dim=self.input_dim,
+ output_dim=self.output_dim,
+ layers=self.layers,
+ dropout=self.dropout,
+ )
+
+ # normalize input
+ x = (x - x.mean(axis=0)) / (x.std(axis=0) + 1e-6)
+
+ if y.ndim == 1:
+ y = np.stack([1 - y, y], axis=1)
+
+ x_train, x_test, y_train, y_test = model_selection.train_test_split(
+ x, y, test_size=self.test_size
+ )
+
+ x_test = torch.Tensor(x_test)
+ y_test = torch.Tensor(y_test)
+
+ optimizer = optim.Adam(
+ self.network.parameters(),
+ lr=self.learning_rate,
+ weight_decay=self.weight_decay,
+ )
+
+ loss = nn.BCELoss()
+
+ batch_count = 0
+
+ for j in range(self.epochs):
+ order = np.random.permutation(len(x_train))
+ x_train = torch.Tensor(x_train[order])
+ y_train = torch.Tensor(y_train[order])
+
+ for i, (batch_x, batch_y) in enumerate(
+ zip(x_train.split(self.batch_size), y_train.split(self.batch_size))
+ ):
+ y_pred = self.network(batch_x)
+ loss_value = loss(y_pred, batch_y)
+
+ self.network.zero_grad()
+ loss_value.backward()
+ optimizer.step()
+
+ if batch_count % self.metric_interval == 0:
+ self.network.eval()
+ with torch.no_grad():
+ self.metrics["epoch"].append(j)
+ self.metrics["batch_count"].append(batch_count)
+ self.metrics["train_loss"].append(loss_value.item())
+
+ y_pred_test = self.network(x_test)
+ loss_value = loss(y_pred_test, y_test)
+ self.metrics["test_loss"].append(loss_value.item())
+
+ y_pred_train = self.network(x_train).detach().numpy()
+ y_pred_test = self.network(x_test).detach().numpy()
+
+ self.metrics["train_accuracy"].append(
+ np.sum(
+ y_train[:, 1].detach().numpy()
+ == np.argmax(y_pred_train, axis=1)
+ )
+ / len(y_train)
+ )
+
+ self.metrics["test_accuracy"].append(
+ np.sum(
+ y_test[:, 1].detach().numpy()
+ == np.argmax(y_pred_test, axis=1)
+ )
+ / len(y_test)
+ )
+ self.network.train()
+
+ batch_count += 1
+
+ self._fitted = True
+
+ def predict(self, x):
+ """Predict the class of the data.
+
+ Parameters
+ ----------
+
+ x : np.array, dtype=float
+ Data of shape (n_samples, n_features).
+
+ Returns
+ -------
+
+ y : np.array, dtype=int
+ Predicted class of shape (n_samples,).
+ """
+
+ if not self.fitted:
+ raise ValueError("Classifier has not been fitted yet.")
+
+ assert (
+ x.ndim == 2
+ ), "Input data must have batch and feature dimension. (n_samples, n_features)"
+ assert (
+ x.shape[1] == self.input_dim
+ ), "Input data must have the same number of features as the fitted classifier."
+
+ x = (x - x.mean(axis=0)) / (x.std(axis=0) + 1e-6)
+ self.network.eval()
+ return np.argmax(self.network(torch.Tensor(x)).detach().numpy(), axis=1)
+
+ def predict_proba(self, x: np.ndarray):
+ """Predict the class probabilities of the data.
+
+ Parameters
+ ----------
+
+ x : np.array, dtype=float
+ Data of shape (n_samples, n_features).
+
+ Returns
+ -------
+
+ y : np.array, dtype=float
+ Predicted class probabilities of shape (n_samples, n_classes).
+
+ """
+
+ if not self.fitted:
+ raise ValueError("Classifier has not been fitted yet.")
+
+ assert (
+ x.ndim == 2
+ ), "Input data must have batch and feature dimension. (n_samples, n_features)"
+ assert (
+ x.shape[1] == self.input_dim
+ ), "Input data must have the same number of features as the fitted classifier."
+
+ x = (x - x.mean(axis=0)) / (x.std(axis=0) + 1e-6)
+ self.network.eval()
+ return self.network(torch.Tensor(x)).detach().numpy()
+
+
+class BinaryClassifierLegacyNewBatching(Classifier):
+ def __init__(
+ self,
+ input_dim: int = 10,
+ output_dim: int = 2,
+ test_size: float = 0.2,
+ batch_size: int = 1000,
+ epochs: int = 10,
+ learning_rate: float = 0.0002,
+ weight_decay: float = 0.00001,
+ layers: typing.List[int] = [100, 50, 20, 5],
+ dropout: float = 0.001,
+ metric_interval: int = 1000,
+ **kwargs,
+ ):
+ """Binary Classifier using a feed forward neural network.
+
+ Parameters
+ ----------
+
+ input_dim : int, default=10
+ Number of input features.
+
+ output_dim : int, default=2
+ Number of output classes.
+
+ test_size : float, default=0.2
+ Fraction of the data to be used for testing.
+
+ batch_size : int, default=1000
+ Batch size for training.
+
+ epochs : int, default=10
+ Number of epochs for training.
+
+ learning_rate : float, default=0.0002
+ Learning rate for training.
+
+ weight_decay : float, default=0.00001
+ Weight decay for training.
+
+ layers : typing.List[int], default=[100, 50, 20, 5]
+ typing.List of hidden layer sizes.
+
+ dropout : float, default=0.001
+ Dropout probability for training.
+
+ metric_interval : int, default=1000
+ Interval for logging metrics during training.
+
+ """
+
+ self.test_size = test_size
+ self.batch_size = batch_size
+ self.epochs = epochs
+ self.learning_rate = learning_rate
+ self.weight_decay = weight_decay
+ self.layers = layers
+ self.dropout = dropout
+ self.input_dim = input_dim
+ self.output_dim = output_dim
+ self.metric_interval = metric_interval
+
+ self.network = None
+ self.optimizer = None
+ self._fitted = False
+
+ self.metrics = {
+ "epoch": [],
+ "batch_count": [],
+ "train_loss": [],
+ "train_accuracy": [],
+ "test_loss": [],
+ "test_accuracy": [],
+ }
+
+ if kwargs:
+ warnings.warn("Unknown arguments: {}".format(kwargs))
+
+ @property
+ def fitted(self):
+ return self._fitted
+
+ @property
+ def metrics(self):
+ return self._metrics
+
+ @metrics.setter
+ def metrics(self, metrics):
+ self._metrics = metrics
+
+ def to_state_dict(self):
+ """Save the state of the classifier as a dictionary.
+
+ Returns
+ -------
+
+ dict : dict
+ Dictionary containing the state of the classifier.
+
+ """
+ dict = {
+ "_fitted": self._fitted,
+ "input_dim": self.input_dim,
+ "output_dim": self.output_dim,
+ "test_size": self.test_size,
+ "batch_size": self.batch_size,
+ "epochs": self.epochs,
+ "learning_rate": self.learning_rate,
+ "weight_decay": self.weight_decay,
+ "layers": self.layers,
+ "dropout": self.dropout,
+ "metric_interval": self.metric_interval,
+ "metrics": self.metrics,
+ }
+
+ if self._fitted:
+ dict["network_state_dict"] = self.network.state_dict()
+
+ return dict
+
+ def from_state_dict(self, state_dict: dict):
+ """Load the state of the classifier from a dictionary.
+
+ Parameters
+ ----------
+
+ dict : dict
+ Dictionary containing the state of the classifier.
+
+ """
+
+ _state_dict = deepcopy(state_dict)
+
+ if "network_state_dict" in _state_dict:
+ self.network = FeedForwardNN(
+ input_dim=_state_dict.pop("input_dim"),
+ output_dim=_state_dict.pop("output_dim"),
+ layers=_state_dict.pop("layers"),
+ dropout=_state_dict.pop("dropout"),
+ )
+ self.network.load_state_dict(state_dict.pop("network_state_dict"))
+
+ self.__dict__.update(_state_dict)
+
+ def fit(self, x: np.ndarray, y: np.ndarray):
+ """Fit the classifier to the data.
+
+ Parameters
+ ----------
+
+ x : np.array, dtype=float
+ Training data of shape (n_samples, n_features).
+
+ y : np.array, dtype=int
+ Target values of shape (n_samples,) or (n_samples, n_classes).
+
+ """
+
+ force_reinit = False
+
+ if self.input_dim != x.shape[1] and self.network is not None:
+ warnings.warn(
+ "Input dimension of network has changed. Network has been reinitialized."
+ )
+ force_reinit = True
+
+ # check if network has to be initialized
+ if self.network is None or force_reinit:
+ self.input_dim = x.shape[1]
+ self.network = FeedForwardNN(
+ input_dim=self.input_dim,
+ output_dim=self.output_dim,
+ layers=self.layers,
+ dropout=self.dropout,
+ )
+
+ # normalize input
+ x = (x - x.mean(axis=0)) / (x.std(axis=0) + 1e-6)
+
+ if y.ndim == 1:
+ y = np.stack([1 - y, y], axis=1)
+
+ x_train, x_test, y_train, y_test = model_selection.train_test_split(
+ x, y, test_size=self.test_size
+ )
+
+ x_test = torch.Tensor(x_test)
+ y_test = torch.Tensor(y_test)
+
+ optimizer = optim.Adam(
+ self.network.parameters(),
+ lr=self.learning_rate,
+ weight_decay=self.weight_decay,
+ )
+
+ loss = nn.BCELoss()
+
+ x_train = torch.Tensor(x_train)
+ y_train = torch.Tensor(y_train)
+
+ num_batches = (x_train.shape[0] // self.batch_size) - 1
+ batch_start_list = np.arange(num_batches) * self.batch_size
+ batch_stop_list = np.arange(num_batches) * self.batch_size + self.batch_size
+
+ batch_count = 0
+
+ for epoch in tqdm(range(self.epochs)):
+ # shuffle batches
+ order = np.random.permutation(num_batches)
+ batch_start_list = batch_start_list[order]
+ batch_stop_list = batch_stop_list[order]
+
+ for batch_start, batch_stop in zip(batch_start_list, batch_stop_list):
+ x_train_batch = x_train[batch_start:batch_stop]
+ y_train_batch = y_train[batch_start:batch_stop]
+ y_pred = self.network(x_train_batch)
+ loss_value = loss(y_pred, y_train_batch)
+
+ self.network.zero_grad()
+ loss_value.backward()
+ optimizer.step()
+
+ if batch_count % self.metric_interval == 0:
+ self.network.eval()
+ with torch.no_grad():
+ self.metrics["epoch"].append(epoch)
+ self.metrics["batch_count"].append(batch_count)
+ self.metrics["train_loss"].append(loss_value.item())
+
+ y_pred_test = self.network(x_test)
+ loss_value = loss(y_pred_test, y_test)
+ self.metrics["test_loss"].append(loss_value.item())
+
+ y_pred_train = self.network(x_train_batch).detach().numpy()
+ y_pred_test = self.network(x_test).detach().numpy()
+
+ self.metrics["train_accuracy"].append(
+ np.sum(
+ y_train_batch[:, 1].detach().numpy()
+ == np.argmax(y_pred_train, axis=1)
+ )
+ / len(y_train_batch)
)
self.metrics["test_accuracy"].append(
diff --git a/alphadia/features.py b/alphadia/features.py
index 1b3aabe1..ce8abf4c 100644
--- a/alphadia/features.py
+++ b/alphadia/features.py
@@ -546,11 +546,8 @@ def precursor_features(
dense_precursors: np.ndarray,
observation_importance,
template: np.ndarray,
+ feature_array: np.ndarray,
):
- feature_dict = nb.typed.Dict.empty(
- key_type=nb.types.unicode_type, value_type=nb.types.float32
- )
-
n_isotopes = isotope_intensity.shape[0]
n_observations = dense_precursors.shape[2]
@@ -569,14 +566,17 @@ def precursor_features(
sum_precursor_intensity * observation_importance_reshaped, axis=-1
).astype(np.float32)
- feature_dict["mono_ms1_intensity"] = weighted_sum_precursor_intensity[0]
- feature_dict["top_ms1_intensity"] = weighted_sum_precursor_intensity[
- np.argmax(isotope_intensity)
- ]
- feature_dict["sum_ms1_intensity"] = np.sum(weighted_sum_precursor_intensity)
- feature_dict["weighted_ms1_intensity"] = np.sum(
- weighted_sum_precursor_intensity * isotope_intensity
- )
+ # mono_ms1_intensity
+ feature_array[4] = weighted_sum_precursor_intensity[0]
+
+ # top_ms1_intensity
+ feature_array[5] = weighted_sum_precursor_intensity[np.argmax(isotope_intensity)]
+
+ # sum_ms1_intensity
+ feature_array[6] = np.sum(weighted_sum_precursor_intensity)
+
+ # weighted_ms1_intensity
+ feature_array[7] = np.sum(weighted_sum_precursor_intensity * isotope_intensity)
expected_scan_center = utils.tile(
dense_precursors.shape[3], n_isotopes * n_observations
@@ -601,31 +601,37 @@ def precursor_features(
mass_error_array = (observed_precursor_mz - isotope_mz) / isotope_mz * 1e6
weighted_mass_error = np.sum(mass_error_array[mz_mask] * isotope_intensity[mz_mask])
- feature_dict["weighted_mass_deviation"] = weighted_mass_error
- feature_dict["weighted_mass_error"] = np.abs(weighted_mass_error)
+ # weighted_mass_deviation
+ feature_array[8] = weighted_mass_error
- feature_dict["mz_observed"] = (
- isotope_mz[0] + weighted_mass_error * 1e-6 * isotope_mz[0]
- )
+ # weighted_mass_error
+ feature_array[9] = np.abs(weighted_mass_error)
- feature_dict["mono_ms1_height"] = observed_precursor_height[0]
- feature_dict["top_ms1_height"] = observed_precursor_height[
- np.argmax(isotope_intensity)
- ]
- feature_dict["sum_ms1_height"] = np.sum(observed_precursor_height)
- feature_dict["weighted_ms1_height"] = np.sum(
- observed_precursor_height * isotope_intensity
- )
+ # mz_observed
+ feature_array[10] = isotope_mz[0] + weighted_mass_error * 1e-6 * isotope_mz[0]
+
+ # mono_ms1_height
+ feature_array[11] = observed_precursor_height[0]
- feature_dict["isotope_intensity_correlation"] = numeric.save_corrcoeff(
+ # top_ms1_height
+ feature_array[12] = observed_precursor_height[np.argmax(isotope_intensity)]
+
+ # sum_ms1_height
+ feature_array[13] = np.sum(observed_precursor_height)
+
+ # weighted_ms1_height
+ feature_array[14] = np.sum(observed_precursor_height * isotope_intensity)
+
+ # isotope_intensity_correlation
+ feature_array[15] = numeric.save_corrcoeff(
isotope_intensity, np.sum(sum_precursor_intensity, axis=-1)
)
- feature_dict["isotope_height_correlation"] = numeric.save_corrcoeff(
+
+ # isotope_height_correlation
+ feature_array[16] = numeric.save_corrcoeff(
isotope_intensity, observed_precursor_height
)
- return feature_dict
-
@nb.njit()
def location_features(
@@ -636,21 +642,23 @@ def location_features(
frame_start,
frame_stop,
frame_center,
+ feature_array,
):
- feature_dict = nb.typed.Dict.empty(
- key_type=nb.types.unicode_type, value_type=nb.types.float32
- )
-
- feature_dict["base_width_mobility"] = (
+ # base_width_mobility
+ feature_array[0] = (
jit_data.mobility_values[scan_start] - jit_data.mobility_values[scan_stop - 1]
)
- feature_dict["base_width_rt"] = (
+
+ # base_width_rt
+ feature_array[1] = (
jit_data.rt_values[frame_stop - 1] - jit_data.rt_values[frame_start]
)
- feature_dict["rt_observed"] = jit_data.rt_values[frame_center]
- feature_dict["mobility_observed"] = jit_data.mobility_values[scan_center]
- return feature_dict
+ # rt_observed
+ feature_array[2] = jit_data.rt_values[frame_center]
+
+ # mobility_observed
+ feature_array[3] = jit_data.mobility_values[scan_center]
nb_float32_array = nb.types.Array(nb.types.float32, 1, "C")
@@ -662,18 +670,15 @@ def fragment_features(
observation_importance: np.ndarray,
template: np.ndarray,
fragments: np.ndarray,
+ feature_array: nb_float32_array,
):
- feature_dict = nb.typed.Dict.empty(
- key_type=nb.types.unicode_type, value_type=nb.types.float32
- )
-
fragment_feature_dict = nb.typed.Dict.empty(
key_type=nb.types.unicode_type, value_type=float_array
)
n_observations = observation_importance.shape[0]
n_fragments = dense_fragments.shape[1]
- feature_dict["n_observations"] = float(n_observations)
+ feature_array[17] = float(n_observations)
# (1, n_observations)
observation_importance_reshaped = observation_importance.reshape(1, -1)
@@ -763,44 +768,32 @@ def fragment_features(
o_fragment_height, fragment_height_weights_2d
)
- if np.sum(fragment_height_mask_1d) == 0.0:
- feature_dict["intensity_correlation"] = 0.0
- else:
- feature_dict["intensity_correlation"] = np.corrcoef(
+ if np.sum(fragment_height_mask_1d) > 0.0:
+ feature_array[18] = np.corrcoef(
observed_fragment_intensity, fragment_intensity_norm
)[0, 1]
- if np.sum(observed_fragment_height) == 0.0:
- feature_dict["height_correlation"] = 0.0
- else:
- feature_dict["height_correlation"] = np.corrcoef(
+ if np.sum(observed_fragment_height) > 0.0:
+ feature_array[19] = np.corrcoef(
observed_fragment_height, fragment_intensity_norm
)[0, 1]
- feature_dict["intensity_fraction"] = (
- np.sum(observed_fragment_intensity > 0.0) / n_fragments
- )
- feature_dict["height_fraction"] = (
- np.sum(observed_fragment_height > 0.0) / n_fragments
- )
+ feature_array[20] = np.sum(observed_fragment_intensity > 0.0) / n_fragments
+ feature_array[21] = np.sum(observed_fragment_height > 0.0) / n_fragments
- feature_dict["intensity_fraction_weighted"] = np.sum(
+ feature_array[22] = np.sum(
fragment_intensity_norm[observed_fragment_intensity > 0.0]
)
- feature_dict["height_fraction_weighted"] = np.sum(
- fragment_intensity_norm[observed_fragment_height > 0.0]
- )
+ feature_array[23] = np.sum(fragment_intensity_norm[observed_fragment_height > 0.0])
fragment_mask = observed_fragment_intensity > 0
- if np.sum(fragment_mask) == 0:
- feature_dict["mean_observation_score"] = 0.0
- else:
+ if np.sum(fragment_mask) > 0:
sum_template_intensity_expanded = sum_template_intensity.reshape(1, -1)
observation_score = cosine_similarity_a1(
sum_template_intensity_expanded, sum_fragment_intensity[fragment_mask]
).astype(np.float32)
- feature_dict["mean_observation_score"] = np.mean(observation_score)
+ feature_array[24] = np.mean(observation_score)
# ============= FRAGMENT TYPE FEATURES =============
@@ -810,44 +803,81 @@ def fragment_features(
weighted_b_ion_intensity = observed_fragment_intensity[b_ion_mask]
weighted_y_ion_intensity = observed_fragment_intensity[y_ion_mask]
- feature_dict["sum_b_ion_intensity"] = (
+ feature_array[25] = (
np.log(np.sum(weighted_b_ion_intensity) + 1)
if len(weighted_b_ion_intensity) > 0
else 0.0
)
- feature_dict["sum_y_ion_intensity"] = (
+ feature_array[26] = (
np.log(np.sum(weighted_y_ion_intensity) + 1)
if len(weighted_y_ion_intensity) > 0
else 0.0
)
- feature_dict["diff_b_y_ion_intensity"] = (
- feature_dict["sum_b_ion_intensity"] - feature_dict["sum_y_ion_intensity"]
- )
+ feature_array[27] = feature_array[25] - feature_array[26]
# ============= FRAGMENT FEATURES =============
mass_error = (observed_fragment_mz_mean - fragments.mz) / fragments.mz * 1e6
- fragment_feature_dict["mz_library"] = fragments.mz_library[
- fragment_height_mask_1d
- ].astype(np.float32)
- fragment_feature_dict["mz_observed"] = observed_fragment_mz_mean[
- fragment_height_mask_1d
- ].astype(np.float32)
- fragment_feature_dict["mass_error"] = mass_error[fragment_height_mask_1d].astype(
- np.float32
+ return (
+ observed_fragment_mz_mean,
+ mass_error,
+ observed_fragment_height,
+ observed_fragment_intensity,
)
- fragment_feature_dict["height"] = observed_fragment_height[
- fragment_height_mask_1d
- ].astype(np.float32)
- fragment_feature_dict["intensity"] = observed_fragment_intensity[
- fragment_height_mask_1d
- ].astype(np.float32)
- fragment_feature_dict["type"] = fragments.type[fragment_height_mask_1d].astype(
- np.float32
+
+
+@nb.njit()
+def fragment_mobility_correlation(
+ fragments_scan_profile,
+ template_scan_profile,
+ observation_importance,
+ fragment_intensity,
+):
+ n_observations = len(observation_importance)
+
+ fragment_mask_1d = np.sum(np.sum(fragments_scan_profile, axis=-1), axis=-1) > 0
+ if np.sum(fragment_mask_1d) < 3:
+ return 0, 0
+
+ non_zero_fragment_norm = fragment_intensity[fragment_mask_1d] / np.sum(
+ fragment_intensity[fragment_mask_1d]
+ )
+
+ # (n_observations, n_fragments, n_fragments)
+ fragment_scan_correlation_masked = numeric.fragment_correlation(
+ fragments_scan_profile[fragment_mask_1d],
+ )
+
+ # (n_fragments, n_fragments)
+ fragment_scan_correlation_maked_reduced = np.sum(
+ fragment_scan_correlation_masked * observation_importance.reshape(-1, 1, 1),
+ axis=0,
+ )
+ fragment_scan_correlation_list = np.dot(
+ fragment_scan_correlation_maked_reduced, non_zero_fragment_norm
+ )
+
+ # fragment_scan_correlation
+ fragment_scan_correlation = np.mean(fragment_scan_correlation_list)
+
+ # (n_observation, n_fragments)
+ fragment_template_scan_correlation = numeric.fragment_correlation_different(
+ fragments_scan_profile[fragment_mask_1d],
+ template_scan_profile.reshape(1, n_observations, -1),
+ ).reshape(n_observations, -1)
+
+ # (n_fragments)
+ fragment_template_scan_correlation_reduced = np.sum(
+ fragment_template_scan_correlation * observation_importance.reshape(-1, 1),
+ axis=0,
+ )
+ # template_scan_correlation
+ template_scan_correlation = np.dot(
+ fragment_template_scan_correlation_reduced, non_zero_fragment_norm
)
- return feature_dict, fragment_feature_dict
+ return fragment_scan_correlation, template_scan_correlation
@nb.njit
@@ -864,84 +894,16 @@ def profile_features(
scan_stop,
frame_start,
frame_stop,
+ feature_array,
):
n_observations = len(observation_importance)
-
- feature_dict = nb.typed.Dict.empty(
- key_type=nb.types.unicode_type, value_type=nb.types.float32
- )
- feature_dict["fragment_scan_correlation"] = 0.0
- feature_dict["top3_scan_correlation"] = 0.0
- feature_dict["fragment_frame_correlation"] = 0.0
- feature_dict["top3_frame_correlation"] = 0.0
- feature_dict["template_scan_correlation"] = 0.0
- feature_dict["template_frame_correlation"] = 0.0
- feature_dict["top3_b_ion_correlation"] = 0.0
- feature_dict["top3_y_ion_correlation"] = 0.0
- feature_dict["cycle_fwhm"] = 0.0
- feature_dict["mobility_fwhm"] = 0.0
- feature_dict["n_b_ions"] = 0.0
- feature_dict["n_y_ions"] = 0.0
-
- fragment_mask_2d = np.sum(fragments_scan_profile, axis=-1) > 0
- fragment_mask_1d = np.sum(np.sum(fragments_scan_profile, axis=-1), axis=-1) > 0
-
- fragment_weights_2d = fragment_mask_2d.astype(np.int8) * np.expand_dims(
- fragment_intensity, axis=-1
- )
-
- feature_dict["f_masked"] = np.mean(fragment_mask_1d)
-
- # stop if fewer than 3 fragments are observed
- if np.sum(fragment_mask_1d) < 3:
- return feature_dict
-
- non_zero_fragment_norm = fragment_intensity[fragment_mask_1d] / np.sum(
- fragment_intensity[fragment_mask_1d]
- )
- fragment_idx_sorted = np.argsort(non_zero_fragment_norm)[::-1]
-
- # ============= FRAGMENT MOBILITY CORRELATIONS =============
- # will be skipped if no mobility dimension is present
- if dia_data.has_mobility:
- # (n_observations, n_fragments, n_fragments)
- fragment_scan_correlation_masked = numeric.fragment_correlation(
- fragments_scan_profile[fragment_mask_1d],
- )
-
- # (n_fragments, n_fragments)
- fragment_scan_correlation_maked_reduced = np.sum(
- fragment_scan_correlation_masked * observation_importance.reshape(-1, 1, 1),
- axis=0,
- )
- fragment_scan_correlation_list = np.dot(
- fragment_scan_correlation_maked_reduced, non_zero_fragment_norm
- )
- feature_dict["fragment_scan_correlation"] = np.mean(
- fragment_scan_correlation_list
- )
-
- # (n_observation, n_fragments)
- fragment_template_scan_correlation = numeric.fragment_correlation_different(
- fragments_scan_profile[fragment_mask_1d],
- template_scan_profile.reshape(1, n_observations, -1),
- ).reshape(n_observations, -1)
-
- # (n_fragments)
- fragment_template_scan_correlation_reduced = np.sum(
- fragment_template_scan_correlation * observation_importance.reshape(-1, 1),
- axis=0,
- )
-
- feature_dict["template_scan_correlation"] = np.dot(
- fragment_template_scan_correlation_reduced, non_zero_fragment_norm
- )
+ fragment_idx_sorted = np.argsort(fragment_intensity)[::-1]
# ============= FRAGMENT RT CORRELATIONS =============
# (n_observations, n_fragments, n_fragments)
fragment_frame_correlation_masked = numeric.fragment_correlation(
- fragments_frame_profile[fragment_mask_1d],
+ fragments_frame_profile,
)
# print('fragment_frame_correlation_masked', fragment_frame_correlation_masked)
@@ -952,11 +914,9 @@ def profile_features(
axis=0,
)
fragment_frame_correlation_list = np.dot(
- fragment_frame_correlation_maked_reduced, non_zero_fragment_norm
- )
- feature_dict["fragment_frame_correlation"] = np.mean(
- fragment_frame_correlation_list
+ fragment_frame_correlation_maked_reduced, fragment_intensity
)
+ feature_array[31] = np.mean(fragment_frame_correlation_list)
# (3)
top_3_idxs = fragment_idx_sorted[:3]
@@ -964,11 +924,11 @@ def profile_features(
top_3_fragment_frame_correlation = fragment_frame_correlation_maked_reduced[
top_3_idxs, :
][:, top_3_idxs]
- feature_dict["top3_frame_correlation"] = np.mean(top_3_fragment_frame_correlation)
+ feature_array[32] = np.mean(top_3_fragment_frame_correlation)
# (n_observation, n_fragments)
fragment_template_frame_correlation = numeric.fragment_correlation_different(
- fragments_frame_profile[fragment_mask_1d],
+ fragments_frame_profile,
template_frame_profile.reshape(1, n_observations, -1),
).reshape(n_observations, -1)
@@ -978,32 +938,33 @@ def profile_features(
axis=0,
)
- feature_dict["template_frame_correlation"] = np.dot(
- fragment_template_frame_correlation_reduced, non_zero_fragment_norm
+ # template_frame_correlation
+ feature_array[33] = np.dot(
+ fragment_template_frame_correlation_reduced, fragment_intensity
)
# ============= FRAGMENT TYPE FEATURES =============
- fragment_type_filtered = fragment_type[fragment_mask_1d]
- b_ion_mask = fragment_type_filtered == 98
- y_ion_mask = fragment_type_filtered == 121
+ b_ion_mask = fragment_type == 98
+ y_ion_mask = fragment_type == 121
b_ion_index_sorted = fragment_idx_sorted[b_ion_mask]
y_ion_index_sorted = fragment_idx_sorted[y_ion_mask]
if len(b_ion_index_sorted) > 0:
b_ion_limit = min(len(b_ion_index_sorted), 3)
- feature_dict["top3_b_ion_correlation"] = fragment_frame_correlation_list[
+ # 'top3_b_ion_correlation'
+ feature_array[34] = fragment_frame_correlation_list[
b_ion_index_sorted[:b_ion_limit]
].mean()
- feature_dict["n_b_ions"] = float(len(b_ion_index_sorted))
+ feature_array[35] = float(len(b_ion_index_sorted))
if len(y_ion_index_sorted) > 0:
y_ion_limit = min(len(y_ion_index_sorted), 3)
- feature_dict["top3_y_ion_correlation"] = fragment_frame_correlation_list[
+ feature_array[36] = fragment_frame_correlation_list[
y_ion_index_sorted[:y_ion_limit]
].mean()
- feature_dict["n_y_ions"] = float(len(y_ion_index_sorted))
+ feature_array[37] = float(len(y_ion_index_sorted))
# ============= FWHM RT =============
@@ -1034,11 +995,9 @@ def profile_features(
cycle_fwhm_mean_list = np.sum(
cycle_fwhm * observation_importance.reshape(1, -1), axis=-1
)
- cycle_fwhm_mean_agg = np.sum(
- cycle_fwhm_mean_list[fragment_mask_1d] * non_zero_fragment_norm
- )
+ cycle_fwhm_mean_agg = np.sum(cycle_fwhm_mean_list * fragment_intensity)
- feature_dict["cycle_fwhm"] = cycle_fwhm_mean_agg
+ feature_array[38] = cycle_fwhm_mean_agg
# ============= FWHM MOBILITY =============
@@ -1078,11 +1037,9 @@ def profile_features(
mobility_fwhm_mean_list = np.sum(
mobility_fwhm * observation_importance.reshape(1, -1), axis=-1
)
- mobility_fwhm_mean_agg = np.sum(
- mobility_fwhm_mean_list[fragment_mask_1d] * non_zero_fragment_norm
- )
+ mobility_fwhm_mean_agg = np.sum(mobility_fwhm_mean_list * fragment_intensity)
- feature_dict["mobility_fwhm"] = mobility_fwhm_mean_agg
+ feature_array[39] = mobility_fwhm_mean_agg
# ============= RT SHIFT =============
@@ -1098,9 +1055,9 @@ def profile_features(
delta_frame_peak = median_frame_peak - np.floor(
fragments_frame_profile.shape[-1] / 2
)
- feature_dict["delta_frame_peak"] = np.sum(delta_frame_peak * observation_importance)
+ feature_array[40] = np.sum(delta_frame_peak * observation_importance)
- return feature_dict
+ return fragment_frame_correlation_list
@nb.njit
diff --git a/alphadia/grouping.py b/alphadia/grouping.py
index ffa7c7d0..a53ef1f0 100644
--- a/alphadia/grouping.py
+++ b/alphadia/grouping.py
@@ -87,7 +87,7 @@ def group_and_parsimony(
# check that all precursors are found again
if len(return_dict) != len(precursor_idx):
raise ValueError(
- "Not all precursors were found in the output of the grouping function."
+ f"Not all precursors were found in the output of the grouping function. {len(return_dict)} precursors were found, but {len(precursor_idx)} were expected."
)
# order by precursor index
@@ -100,6 +100,7 @@ def group_and_parsimony(
def perform_grouping(
psm: pd.DataFrame,
genes_or_proteins: str = "proteins",
+ decoy_column: str = "decoy",
):
"""Highest level function for grouping proteins in precursor table
@@ -112,22 +113,28 @@ def perform_grouping(
raise ValueError("Selected column must be 'genes' or 'proteins'")
# create non-duplicated view of precursor table
- duplicate_mask = ~psm.duplicated(
- subset=["precursor_idx", genes_or_proteins], keep="first"
- )
- upsm = psm.loc[duplicate_mask, ["precursor_idx", genes_or_proteins, "_decoy"]]
+ duplicate_mask = ~psm.duplicated(subset=["precursor_idx"], keep="first")
+ # make sure column is string
+ psm[genes_or_proteins] = psm[genes_or_proteins].astype(str)
+ upsm = psm.loc[duplicate_mask, ["precursor_idx", genes_or_proteins, decoy_column]]
+
+ # check if duplicate precursors exist
+ if upsm.duplicated(subset=["precursor_idx"]).any():
+ raise ValueError(
+ "The same precursor was found annotated to different proteins. Please make sure all precursors were searched with the same library."
+ )
# handle case with only one decoy class:
- unique_decoys = upsm["_decoy"].unique()
+ unique_decoys = upsm[decoy_column].unique()
if len(unique_decoys) == 1:
- upsm["_decoy"] = -1
+ upsm[decoy_column] = -1
upsm["pg_master"], upsm["pg"] = group_and_parsimony(
upsm.precursor_idx.values, upsm[genes_or_proteins].values
)
upsm = upsm[["precursor_idx", "pg_master", "pg"]]
else:
- target_mask = upsm["_decoy"] == 0
- decoy_mask = upsm["_decoy"] == 1
+ target_mask = upsm[decoy_column] == 0
+ decoy_mask = upsm[decoy_column] == 1
t_df = upsm[target_mask].copy()
new_columns = group_and_parsimony(
diff --git a/alphadia/libtransform.py b/alphadia/libtransform.py
index 6ed73991..bea8a73f 100644
--- a/alphadia/libtransform.py
+++ b/alphadia/libtransform.py
@@ -28,11 +28,11 @@ def __init__(self) -> None:
Processing steps can be chained together in a ProcessingPipeline."""
pass
- def __call__(self, input: typing.Any) -> typing.Any:
+ def __call__(self, *args: typing.Any) -> typing.Any:
"""Run the processing step on the input object."""
logger.info(f"Running {self.__class__.__name__}")
- if self.validate(input):
- return self.forward(input)
+ if self.validate(*args):
+ return self.forward(*args)
else:
logger.critical(
f"Input {input} failed validation for {self.__class__.__name__}"
@@ -41,11 +41,11 @@ def __call__(self, input: typing.Any) -> typing.Any:
f"Input {input} failed validation for {self.__class__.__name__}"
)
- def validate(self, input: typing.Any) -> bool:
+ def validate(self, *args: typing.Any) -> bool:
"""Validate the input object."""
raise NotImplementedError("Subclasses must implement this method")
- def forward(self, input: typing.Any) -> typing.Any:
+ def forward(self, *args: typing.Any) -> typing.Any:
"""Run the processing step on the input object."""
raise NotImplementedError("Subclasses must implement this method")
@@ -262,6 +262,12 @@ def forward(self, input: SpecLibBase) -> SpecLibBase:
decoy_lib.calc_fragment_mz_df()
decoy_lib._precursor_df["decoy"] = 1
+ # keep original precursor_idx and only create new ones for decoys
+ start_precursor_idx = input.precursor_df["precursor_idx"].max() + 1
+ decoy_lib._precursor_df["precursor_idx"] = np.arange(
+ start_precursor_idx, start_precursor_idx + len(decoy_lib.precursor_df)
+ )
+
input.append(decoy_lib)
input._precursor_df.sort_values("elution_group_idx", inplace=True)
input._precursor_df.reset_index(drop=True, inplace=True)
@@ -347,8 +353,24 @@ def forward(self, input: SpecLibBase) -> SpecLibBase:
class FlattenLibrary(ProcessingStep):
- def __init__(self) -> None:
- """Convert a `SpecLibBase` object into a `SpecLibFlat` object."""
+ def __init__(
+ self, top_k_fragments: int = 12, min_fragment_intensity: float = 0.01
+ ) -> None:
+ """Convert a `SpecLibBase` object into a `SpecLibFlat` object.
+
+ Parameters
+ ----------
+
+ top_k_fragments : int, optional
+ Number of top fragments to keep. Default is 12.
+
+ min_fragment_intensity : float, optional
+ Minimum intensity threshold for fragments. Default is 0.01.
+
+ """
+ self.top_k_fragments = top_k_fragments
+ self.min_fragment_intensity = min_fragment_intensity
+
super().__init__()
def validate(self, input: SpecLibBase) -> bool:
@@ -361,7 +383,10 @@ def forward(self, input: SpecLibBase) -> SpecLibFlat:
input._fragment_cardinality_df = fragment.calc_fragment_cardinality(
input.precursor_df, input._fragment_mz_df
)
- output = SpecLibFlat(min_fragment_intensity=0.0001, keep_top_k_fragments=100)
+ output = SpecLibFlat(
+ min_fragment_intensity=self.min_fragment_intensity,
+ keep_top_k_fragments=self.top_k_fragments,
+ )
output.parse_base_library(
input, custom_df={"cardinality": input._fragment_cardinality_df}
)
@@ -471,3 +496,32 @@ def forward(self, input: SpecLibFlat) -> SpecLibFlat:
logger.info(f"=======================================")
return input
+
+
+class MbrLibraryBuilder(ProcessingStep):
+ def __init__(self, fdr=0.01) -> None:
+ super().__init__()
+ self.fdr = fdr
+
+ def validate(self, psm_df, base_library) -> bool:
+ """Validate the input object. It is expected that the input is a `SpecLibFlat` object."""
+ return True
+
+ def forward(self, psm_df, base_library):
+ psm_df = psm_df[psm_df["qval"] <= self.fdr]
+ psm_df = psm_df[psm_df["decoy"] == 0]
+ rt_df = psm_df.groupby("elution_group_idx", as_index=False).agg(
+ rt=pd.NamedAgg(column="rt_observed", aggfunc="median")
+ )
+
+ mbr_spec_lib = base_library.copy()
+ mbr_spec_lib = base_library.copy()
+ if "rt" in mbr_spec_lib._precursor_df.columns:
+ mbr_spec_lib._precursor_df.drop(columns=["rt"], inplace=True)
+ mbr_spec_lib._precursor_df = mbr_spec_lib._precursor_df.merge(
+ rt_df, on="elution_group_idx", how="right"
+ )
+
+ mbr_spec_lib.remove_unused_fragments()
+
+ return mbr_spec_lib
diff --git a/alphadia/numba/fragments.py b/alphadia/numba/fragments.py
index 72835d0a..bd5bc5c4 100644
--- a/alphadia/numba/fragments.py
+++ b/alphadia/numba/fragments.py
@@ -119,6 +119,24 @@ def filter_by_min_mz(self, min_mz):
self.position = self.position[mask]
self.cardinality = self.cardinality[mask]
+ def apply_mask(self, mask):
+ """
+ Apply a boolean mask to the fragment container
+ """
+ self.precursor_idx = self.precursor_idx[mask]
+ self.mz_library = self.mz_library[mask]
+ self.mz = self.mz[mask]
+ self.intensity = self.intensity[mask]
+ self.type = self.type[mask]
+ self.loss_type = self.loss_type[mask]
+ self.charge = self.charge[mask]
+ self.number = self.number[mask]
+ self.position = self.position[mask]
+ self.cardinality = self.cardinality[mask]
+
+ if np.sum(mask) > 0:
+ self.intensity = self.intensity / np.sum(self.intensity)
+
@overload_method(
nb.types.misc.ClassInstanceType,
diff --git a/alphadia/outputtransform.py b/alphadia/outputtransform.py
new file mode 100644
index 00000000..8f0f5fe2
--- /dev/null
+++ b/alphadia/outputtransform.py
@@ -0,0 +1,754 @@
+# native imports
+import logging
+import os
+
+logger = logging.getLogger()
+
+from alphadia import grouping, libtransform
+from alphadia import fdr
+
+import pandas as pd
+import numpy as np
+from sklearn.preprocessing import StandardScaler
+from sklearn.model_selection import train_test_split
+from sklearn.neural_network import MLPClassifier
+
+import multiprocessing as mp
+
+from typing import List, Tuple, Iterator, Union
+import numba as nb
+from alphabase.spectral_library import base
+
+import directlfq.utils as lfqutils
+import directlfq.normalization as lfqnorm
+import directlfq.protein_intensity_estimation as lfqprot_estimation
+import directlfq.config as lfqconfig
+
+import logging
+
+logger = logging.getLogger()
+
+
+@nb.njit
+def hash(precursor_idx, number, type, charge):
+ # create a 64 bit hash from the precursor_idx, number and type
+ # the precursor_idx is the lower 32 bits
+ # the number is the next 8 bits
+ # the type is the next 8 bits
+ # the last 8 bits are used to distinguish between different charges of the same precursor
+ # this is necessary because I forgot to save the charge in the frag.tsv file :D
+ return precursor_idx + (number << 32) + (type << 40) + (charge << 48)
+
+
+def get_frag_df_generator(folder_list: List[str]):
+ """Return a generator that yields a tuple of (raw_name, frag_df)
+
+ Parameters
+ ----------
+
+ folder_list: List[str]
+ List of folders containing the frag.tsv file
+
+ Returns
+ -------
+
+ Iterator[Tuple[str, pd.DataFrame]]
+ Tuple of (raw_name, frag_df)
+
+ """
+
+ for folder in folder_list:
+ raw_name = os.path.basename(folder)
+ frag_path = os.path.join(folder, "frag.tsv")
+
+ if not os.path.exists(frag_path):
+ logger.warning(f"no frag file found for {raw_name}")
+ else:
+ try:
+ logger.info(f"reading frag file for {raw_name}")
+ run_df = pd.read_csv(
+ frag_path,
+ sep="\t",
+ dtype={
+ "precursor_idx": np.uint32,
+ "number": np.uint8,
+ "type": np.uint8,
+ },
+ )
+ except Exception as e:
+ logger.warning(f"Error reading frag file for {raw_name}")
+ logger.warning(e)
+ else:
+ yield raw_name, run_df
+
+
+class QuantBuilder:
+ def __init__(self, psm_df, column="intensity"):
+ self.psm_df = psm_df
+ self.column = column
+
+ def accumulate_frag_df_from_folders(
+ self, folder_list: List[str]
+ ) -> Tuple[pd.DataFrame, pd.DataFrame]:
+ """Accumulate the fragment data from a list of folders
+
+ Parameters
+ ----------
+
+ folder_list: List[str]
+ List of folders containing the frag.tsv file
+
+ Returns
+ -------
+ intensity_df: pd.DataFrame
+ Dataframe with the intensity data containing the columns precursor_idx, ion, raw_name1, raw_name2, ...
+
+ quality_df: pd.DataFrame
+ Dataframe with the quality data containing the columns precursor_idx, ion, raw_name1, raw_name2, ...
+ """
+
+ df_iterable = get_frag_df_generator(folder_list)
+ return self.accumulate_frag_df(df_iterable)
+
+ def accumulate_frag_df(
+ self, df_iterable: Iterator[Tuple[str, pd.DataFrame]]
+ ) -> Tuple[pd.DataFrame, pd.DataFrame]:
+ """Consume a generator of (raw_name, frag_df) tuples and accumulate the data in a single dataframe
+
+ Parameters
+ ----------
+
+ df_iterable: Iterator[Tuple[str, pd.DataFrame]]
+ Iterator of (raw_name, frag_df) tuples
+
+ Returns
+ -------
+ intensity_df: pd.DataFrame
+ Dataframe with the intensity data containing the columns precursor_idx, ion, raw_name1, raw_name2, ...
+
+ quality_df: pd.DataFrame
+ Dataframe with the quality data containing the columns precursor_idx, ion, raw_name1, raw_name2, ...
+ """
+
+ logger.info("Accumulating fragment data")
+
+ raw_name, df = next(df_iterable, (None, None))
+ if df is None:
+ logger.warning(f"no frag file found for {raw_name}")
+ return
+
+ df = prepare_df(df, self.psm_df, column=self.column)
+
+ intensity_df = df[["precursor_idx", "ion", self.column]].copy()
+ intensity_df.rename(columns={self.column: raw_name}, inplace=True)
+
+ quality_df = df[["precursor_idx", "ion", "correlation"]].copy()
+ quality_df.rename(columns={"correlation": raw_name}, inplace=True)
+
+ df_list = []
+ for raw_name, df in df_iterable:
+ df = prepare_df(df, self.psm_df, column=self.column)
+
+ intensity_df = intensity_df.merge(
+ df[["ion", self.column, "precursor_idx"]],
+ on=["ion", "precursor_idx"],
+ how="outer",
+ )
+ intensity_df.rename(columns={self.column: raw_name}, inplace=True)
+
+ quality_df = quality_df.merge(
+ df[["ion", "correlation", "precursor_idx"]],
+ on=["ion", "precursor_idx"],
+ how="outer",
+ )
+ quality_df.rename(columns={"correlation": raw_name}, inplace=True)
+
+ # replace nan with 0
+ intensity_df.fillna(0, inplace=True)
+ quality_df.fillna(0, inplace=True)
+
+ intensity_df["precursor_idx"] = intensity_df["precursor_idx"].astype(np.uint32)
+ quality_df["precursor_idx"] = quality_df["precursor_idx"].astype(np.uint32)
+
+ # annotate protein group
+ protein_df = self.psm_df.groupby("precursor_idx", as_index=False)["pg"].first()
+
+ intensity_df = intensity_df.merge(protein_df, on="precursor_idx", how="left")
+ intensity_df.rename(columns={"pg": "protein"}, inplace=True)
+
+ quality_df = quality_df.merge(protein_df, on="precursor_idx", how="left")
+ quality_df.rename(columns={"pg": "protein"}, inplace=True)
+
+ return intensity_df, quality_df
+
+ def filter_frag_df(
+ self,
+ intensity_df: pd.DataFrame,
+ quality_df: pd.DataFrame,
+ min_correlation: float = 0.5,
+ top_n: int = 3,
+ ) -> Tuple[pd.DataFrame, pd.DataFrame]:
+ """Filter the fragment data by quality
+
+ Parameters
+ ----------
+ intensity_df: pd.DataFrame
+ Dataframe with the intensity data containing the columns precursor_idx, ion, raw_name1, raw_name2, ...
+
+ quality_df: pd.DataFrame
+ Dataframe with the quality data containing the columns precursor_idx, ion, raw_name1, raw_name2, ...
+
+ min_correlation: float
+ Minimum correlation to keep a fragment, if not below top_n
+
+ top_n: int
+ Keep the top n fragments per precursor
+
+ Returns
+ -------
+
+ intensity_df: pd.DataFrame
+ Dataframe with the intensity data containing the columns precursor_idx, ion, raw_name1, raw_name2, ...
+
+ quality_df: pd.DataFrame
+ Dataframe with the quality data containing the columns precursor_idx, ion, raw_name1, raw_name2, ...
+
+ """
+
+ logger.info("Filtering fragments by quality")
+
+ run_columns = [
+ c
+ for c in intensity_df.columns
+ if c not in ["precursor_idx", "ion", "protein"]
+ ]
+
+ quality_df["total"] = np.mean(quality_df[run_columns].values, axis=1)
+ quality_df["rank"] = quality_df.groupby("precursor_idx")["total"].rank(
+ ascending=False, method="first"
+ )
+ mask = (quality_df["rank"].values <= top_n) | (
+ quality_df["total"].values > min_correlation
+ )
+ return intensity_df[mask], quality_df[mask]
+
+ def lfq(
+ self,
+ intensity_df: pd.DataFrame,
+ quality_df: pd.DataFrame,
+ num_samples_quadratic: int = 50,
+ min_nonan: int = 1,
+ num_cores: int = 8,
+ ) -> pd.DataFrame:
+ """Perform label-free quantification
+
+ Parameters
+ ----------
+
+ intensity_df: pd.DataFrame
+ Dataframe with the intensity data containing the columns precursor_idx, ion, raw_name1, raw_name2, ...
+
+ quality_df: pd.DataFrame
+ Dataframe with the quality data containing the columns precursor_idx, ion, raw_name1, raw_name2, ...
+
+ Returns
+ -------
+
+ lfq_df: pd.DataFrame
+ Dataframe with the label-free quantification data containing the columns precursor_idx, ion, intensity, protein
+
+ """
+
+ logger.info("Performing label-free quantification using directLFQ")
+
+ intensity_df.drop(columns=["precursor_idx"], inplace=True)
+
+ lfqconfig.set_global_protein_and_ion_id(protein_id="protein", quant_id="ion")
+
+ lfq_df = lfqutils.index_and_log_transform_input_df(intensity_df)
+ lfq_df = lfqutils.remove_allnan_rows_input_df(lfq_df)
+ lfq_df = lfqnorm.NormalizationManagerSamplesOnSelectedProteins(
+ lfq_df,
+ num_samples_quadratic=num_samples_quadratic,
+ selected_proteins_file=None,
+ ).complete_dataframe
+ protein_df, _ = lfqprot_estimation.estimate_protein_intensities(
+ lfq_df,
+ min_nonan=min_nonan,
+ num_samples_quadratic=num_samples_quadratic,
+ num_cores=num_cores,
+ )
+
+ return protein_df
+
+
+def prepare_df(df, psm_df, column="height"):
+ df = df[df["precursor_idx"].isin(psm_df["precursor_idx"])].copy()
+ df["ion"] = hash(
+ df["precursor_idx"].values,
+ df["number"].values,
+ df["type"].values,
+ df["charge"].values,
+ )
+ return df[["precursor_idx", "ion", column, "correlation"]]
+
+
+class SearchPlanOutput:
+ PSM_INPUT = "psm"
+ PRECURSOR_OUTPUT = "precursors"
+ STAT_OUTPUT = "stat"
+ PG_OUTPUT = "protein_groups"
+ LIBRARY_OUTPUT = "speclib.mbr"
+
+ def __init__(self, config: dict, output_folder: str):
+ """Combine individual searches into and build combined outputs
+
+ In alphaDIA the search plan orchestrates the library building preparation,
+ schedules the individual searches and combines the individual outputs into a single output.
+
+ The SearchPlanOutput class is responsible for combining the individual search outputs into a single output.
+
+ This includes:
+ - combining the individual precursor tables
+ - building the output stat table
+ - performing protein grouping
+ - performing protein FDR
+ - performin label-free quantification
+ - building the spectral library
+
+ Parameters
+ ----------
+
+ config: dict
+ Configuration dictionary
+
+ output_folder: str
+ Output folder
+ """
+ self._config = config
+ self._output_folder = output_folder
+
+ @property
+ def config(self):
+ return self._config
+
+ @property
+ def output_folder(self):
+ return self._output_folder
+
+ def build(
+ self,
+ folder_list: List[str],
+ base_spec_lib: base.SpecLibBase,
+ ):
+ """Build output from a list of seach outputs
+ The following files are written to the output folder:
+ - precursor.tsv
+ - protein_groups.tsv
+ - stat.tsv
+ - speclib.mbr.hdf
+
+ Parameters
+ ----------
+
+ folder_list: List[str]
+ List of folders containing the search outputs
+
+ base_spec_lib: base.SpecLibBase
+ Base spectral library
+
+ """
+ logger.progress("Processing search outputs")
+ psm_df = self.build_precursor_table(folder_list, save=False)
+ _ = self.build_stat_df(folder_list, psm_df=psm_df, save=True)
+ _ = self.build_protein_table(folder_list, psm_df=psm_df, save=True)
+ _ = self.build_library(base_spec_lib, psm_df=psm_df, save=True)
+
+ def load_precursor_table(self):
+ """Load precursor table from output folder.
+ Helper functions used by other builders.
+
+ Returns
+ -------
+
+ psm_df: pd.DataFrame
+ Precursor table
+ """
+
+ if not os.path.exists(
+ os.path.join(self.output_folder, f"{self.PRECURSOR_OUTPUT}.tsv")
+ ):
+ logger.error(
+ f"Can't continue as no {self.PRECURSOR_OUTPUT}.tsv file was found in the output folder: {self.output_folder}"
+ )
+ raise FileNotFoundError(
+ f"Can't continue as no {self.PRECURSOR_OUTPUT}.tsv file was found in the output folder: {self.output_folder}"
+ )
+ logger.info(f"Reading {self.PRECURSOR_OUTPUT}.tsv file")
+ psm_df = pd.read_csv(
+ os.path.join(self.output_folder, f"{self.PRECURSOR_OUTPUT}.tsv"), sep="\t"
+ )
+ return psm_df
+
+ def build_precursor_table(self, folder_list: List[str], save: bool = True):
+ """Build precursor table from a list of seach outputs
+
+ Parameters
+ ----------
+
+ folder_list: List[str]
+ List of folders containing the search outputs
+
+ save: bool
+ Save the precursor table to disk
+
+ Returns
+ -------
+
+ psm_df: pd.DataFrame
+ Precursor table
+ """
+ logger.progress("Performing protein grouping and FDR")
+
+ psm_df_list = []
+
+ for folder in folder_list:
+ raw_name = os.path.basename(folder)
+ psm_path = os.path.join(folder, f"{self.PSM_INPUT}.tsv")
+
+ logger.info(f"Building output for {raw_name}")
+
+ if not os.path.exists(psm_path):
+ logger.warning(f"no psm file found for {raw_name}, skipping")
+ run_df = pd.DataFrame()
+ else:
+ try:
+ run_df = pd.read_csv(psm_path, sep="\t")
+ except Exception as e:
+ logger.warning(f"Error reading psm file for {raw_name}")
+ logger.warning(e)
+ run_df = pd.DataFrame()
+
+ psm_df_list.append(run_df)
+
+ if len(psm_df_list) == 0:
+ logger.error("No psm files found, can't continue")
+ raise FileNotFoundError("No psm files found, can't continue")
+
+ logger.info("Building combined output")
+ psm_df = pd.concat(psm_df_list)
+
+ logger.info("Performing protein grouping")
+ if self.config["fdr"]["library_grouping"]:
+ psm_df["pg"] = psm_df[self.config["fdr"]["group_level"]]
+ psm_df["pg_master"] = psm_df[self.config["fdr"]["group_level"]]
+ else:
+ psm_df = grouping.perform_grouping(
+ psm_df, genes_or_proteins=self.config["fdr"]["group_level"]
+ )
+
+ logger.info("Performing protein FDR")
+ psm_df = perform_protein_fdr(psm_df)
+ psm_df = psm_df[psm_df["pg_qval"] <= self.config["fdr"]["fdr"]]
+
+ pg_count = psm_df[psm_df["decoy"] == 0]["pg"].nunique()
+ precursor_count = psm_df[psm_df["decoy"] == 0]["precursor_idx"].nunique()
+
+ logger.progress(
+ "================ Protein FDR =================",
+ )
+ logger.progress(f"Unique protein groups in output")
+ logger.progress(f" 1% protein FDR: {pg_count:,}")
+ logger.progress("")
+ logger.progress(f"Unique precursor in output")
+ logger.progress(f" 1% protein FDR: {precursor_count:,}")
+ logger.progress(
+ "================================================",
+ )
+
+ if not self.config["fdr"]["keep_decoys"]:
+ psm_df = psm_df[psm_df["decoy"] == 0]
+
+ if save:
+ logger.info("Writing precursor output to disk")
+ psm_df.to_csv(
+ os.path.join(self.output_folder, f"{self.PRECURSOR_OUTPUT}.tsv"),
+ sep="\t",
+ index=False,
+ float_format="%.6f",
+ )
+
+ return psm_df
+
+ def build_stat_df(
+ self,
+ folder_list: List[str],
+ psm_df: Union[pd.DataFrame, None] = None,
+ save: bool = True,
+ ):
+ """Build stat table from a list of seach outputs
+
+ Parameters
+ ----------
+
+ folder_list: List[str]
+ List of folders containing the search outputs
+
+ psm_df: Union[pd.DataFrame, None]
+ Combined precursor table. If None, the precursor table is loaded from disk.
+
+ save: bool
+ Save the precursor table to disk
+
+ Returns
+ -------
+
+ stat_df: pd.DataFrame
+ Precursor table
+ """
+ logger.progress("Building search statistics")
+
+ if psm_df is None:
+ psm_df = self.load_precursor_table()
+ psm_df = psm_df[psm_df["decoy"] == 0]
+
+ stat_df_list = []
+ for folder in folder_list:
+ raw_name = os.path.basename(folder)
+ stat_df_list.append(
+ build_stat_df(raw_name, psm_df[psm_df["run"] == raw_name])
+ )
+
+ stat_df = pd.concat(stat_df_list)
+
+ if save:
+ logger.info("Writing stat output to disk")
+ stat_df.to_csv(
+ os.path.join(self.output_folder, f"{self.STAT_OUTPUT}.tsv"),
+ sep="\t",
+ index=False,
+ float_format="%.6f",
+ )
+
+ return stat_df
+
+ def build_protein_table(
+ self,
+ folder_list: List[str],
+ psm_df: Union[pd.DataFrame, None] = None,
+ save: bool = True,
+ ):
+ """Accumulate fragment information and perform label-free protein quantification.
+
+ Parameters
+ ----------
+
+ folder_list: List[str]
+ List of folders containing the search outputs
+
+ psm_df: Union[pd.DataFrame, None]
+ Combined precursor table. If None, the precursor table is loaded from disk.
+
+ save: bool
+ Save the precursor table to disk
+
+ """
+ logger.progress("Performing label free quantification")
+
+ if psm_df is None:
+ psm_df = self.load_precursor_table()
+
+ # as we want to retain decoys in the output we are only removing them for lfq
+ qb = QuantBuilder(psm_df[psm_df["decoy"] == 0])
+ intensity_df, quality_df = qb.accumulate_frag_df_from_folders(folder_list)
+ intensity_df, quality_df = qb.filter_frag_df(
+ intensity_df,
+ quality_df,
+ top_n=self.config["search_output"]["min_k_fragments"],
+ min_correlation=self.config["search_output"]["min_correlation"],
+ )
+ protein_df = qb.lfq(
+ intensity_df,
+ quality_df,
+ num_cores=self.config["general"]["thread_count"],
+ min_nonan=self.config["search_output"]["min_nonnan"],
+ num_samples_quadratic=self.config["search_output"]["num_samples_quadratic"],
+ )
+
+ protein_df.rename(columns={"protein": "pg"}, inplace=True)
+
+ protein_df_melted = protein_df.melt(
+ id_vars="pg", var_name="run", value_name="intensity"
+ )
+
+ psm_df = psm_df.merge(protein_df_melted, on=["pg", "run"], how="left")
+
+ if save:
+ logger.info("Writing protein group output to disk")
+ protein_df.to_csv(
+ os.path.join(self.output_folder, f"{self.PG_OUTPUT}.tsv"),
+ sep="\t",
+ index=False,
+ float_format="%.6f",
+ )
+
+ logger.info("Writing psm output to disk")
+ psm_df.to_csv(
+ os.path.join(self.output_folder, f"{self.PRECURSOR_OUTPUT}.tsv"),
+ sep="\t",
+ index=False,
+ float_format="%.6f",
+ )
+
+ return protein_df
+
+ def build_library(
+ self,
+ base_spec_lib: base.SpecLibBase,
+ psm_df: Union[pd.DataFrame, None] = None,
+ save: bool = True,
+ ):
+ """Build spectral library
+
+ Parameters
+ ----------
+
+ base_spec_lib: base.SpecLibBase
+ Base spectral library
+
+ psm_df: Union[pd.DataFrame, None]
+ Combined precursor table. If None, the precursor table is loaded from disk.
+
+ save: bool
+ Save the generated spectral library to disk
+
+ """
+ logger.progress("Building spectral library")
+
+ if psm_df is None:
+ psm_df = self.load_precursor_table()
+ psm_df = psm_df[psm_df["decoy"] == 0]
+
+ libbuilder = libtransform.MbrLibraryBuilder(
+ fdr=0.01,
+ )
+
+ logger.info("Building MBR spectral library")
+ mbr_spec_lib = libbuilder(psm_df, base_spec_lib)
+
+ precursor_number = len(mbr_spec_lib.precursor_df)
+ protein_number = mbr_spec_lib.precursor_df.proteins.nunique()
+
+ # use comma to separate thousands
+ logger.info(
+ f"MBR spectral library contains {precursor_number:,} precursors, {protein_number:,} proteins"
+ )
+
+ logger.info("Writing MBR spectral library to disk")
+ mbr_spec_lib.save_hdf(os.path.join(self.output_folder, "speclib.mbr.hdf"))
+
+ if save:
+ logger.info("Writing MBR spectral library to disk")
+ mbr_spec_lib.save_hdf(os.path.join(self.output_folder, "speclib.mbr.hdf"))
+
+ return mbr_spec_lib
+
+
+def build_stat_df(raw_name, run_df):
+ """Build stat dataframe for run"""
+
+ base_dict = {
+ "run": raw_name,
+ "precursors": len(run_df),
+ "proteins": run_df["pg"].nunique(),
+ }
+
+ if "weighted_mass_error" in run_df.columns:
+ base_dict["ms1_accuracy"] = np.mean(run_df["weighted_mass_error"])
+
+ if "cycle_fwhm" in run_df.columns:
+ base_dict["fwhm_rt"] = np.mean(run_df["cycle_fwhm"])
+
+ if "mobility_fwhm" in run_df.columns:
+ base_dict["fwhm_mobility"] = np.mean(run_df["mobility_fwhm"])
+
+ return pd.DataFrame(
+ [
+ base_dict,
+ ]
+ )
+
+
+def perform_protein_fdr(psm_df):
+ """Perform protein FDR on PSM dataframe"""
+
+ protein_features = []
+ for _, group in psm_df.groupby(["pg", "decoy"]):
+ protein_features.append(
+ {
+ "genes": group["genes"].iloc[0],
+ "proteins": group["proteins"].iloc[0],
+ "decoy": group["decoy"].iloc[0],
+ "count": len(group),
+ "n_peptides": len(group["precursor_idx"].unique()),
+ "n_runs": len(group["run"].unique()),
+ "mean_score": group["proba"].mean(),
+ "best_score": group["proba"].min(),
+ "worst_score": group["proba"].max(),
+ }
+ )
+
+ feature_columns = [
+ "count",
+ "mean_score",
+ "n_peptides",
+ "n_runs",
+ "best_score",
+ "worst_score",
+ ]
+
+ protein_features = pd.DataFrame(protein_features)
+
+ X = protein_features[feature_columns].values
+ y = protein_features["decoy"].values
+
+ X_train, X_test, y_train, y_test = train_test_split(
+ X, y, test_size=0.2, random_state=42
+ )
+
+ scaler = StandardScaler()
+ X_train = scaler.fit_transform(X_train)
+ X_test = scaler.transform(X_test)
+
+ clf = MLPClassifier(random_state=0).fit(X_train, y_train)
+
+ protein_features["proba"] = clf.predict_proba(scaler.transform(X))[:, 1]
+ protein_features = pd.DataFrame(protein_features)
+
+ protein_features = fdr.get_q_values(
+ protein_features,
+ score_column="proba",
+ decoy_column="decoy",
+ qval_column="pg_qval",
+ )
+
+ fdr.plot_fdr(X_train, X_test, y_train, y_test, clf, protein_features["pg_qval"])
+
+ return pd.concat(
+ [
+ psm_df[psm_df["decoy"] == 0].merge(
+ protein_features[protein_features["decoy"] == 0][
+ ["proteins", "pg_qval"]
+ ],
+ on="proteins",
+ how="left",
+ ),
+ psm_df[psm_df["decoy"] == 1].merge(
+ protein_features[protein_features["decoy"] == 1][
+ ["proteins", "pg_qval"]
+ ],
+ on="proteins",
+ how="left",
+ ),
+ ]
+ )
diff --git a/alphadia/planning.py b/alphadia/planning.py
index f506b872..f03e6285 100644
--- a/alphadia/planning.py
+++ b/alphadia/planning.py
@@ -10,17 +10,33 @@
import typing
# alphadia imports
-from alphadia import utils, libtransform
+from alphadia import utils, libtransform, outputtransform
from alphadia.workflow import peptidecentric, base, reporting
import alphadia
+import alpharaw
+import alphabase
+import peptdeep
+import alphatims
+import directlfq
# alpha family imports
from alphabase.spectral_library.flat import SpecLibFlat
+from alphabase.spectral_library.base import SpecLibBase
# third party imports
import numpy as np
import pandas as pd
import os, psutil
+import torch
+import numba as nb
+
+
+@nb.njit
+def hash(precursor_idx, rank):
+ # create a 64 bit hash from the precursor_idx, number and type
+ # the precursor_idx is the lower 32 bits
+ # the rank is the next 8 bits
+ return precursor_idx + (rank << 32)
class Plan:
@@ -63,6 +79,7 @@ def __init__(
logger.progress("")
self.raw_file_list = raw_file_list
+ self.spec_lib_path = spec_lib_path
# default config path is not defined in the function definition to account for for different path separators on different OS
if config_path is None:
@@ -92,13 +109,19 @@ def __init__(
self.config["output"] = output_folder
logger.progress(f"version: {alphadia.__version__}")
+
# print hostname, date with day format and time
logger.progress(f"hostname: {socket.gethostname()}")
now = datetime.today().strftime("%Y-%m-%d %H:%M:%S")
logger.progress(f"date: {now}")
+ # print environment
+ self.log_environment()
+
self.load_library(spec_lib_path)
+ torch.set_num_threads(self.config["general"]["thread_count"])
+
@property
def raw_file_list(self) -> typing.List[str]:
"""List of input files locations."""
@@ -126,6 +149,15 @@ def spectral_library(self) -> SpecLibFlat:
def spectral_library(self, spectral_library: SpecLibFlat) -> None:
self._spectral_library = spectral_library
+ def log_environment(self):
+ logger.progress(f"=================== Environment ===================")
+ logger.progress(f"{'alphatims':<15} : {alphatims.__version__:}")
+ logger.progress(f"{'alpharaw':<15} : {alpharaw.__version__}")
+ logger.progress(f"{'alphabase':<15} : {alphabase.__version__}")
+ logger.progress(f"{'alphapeptdeep':<15} : {peptdeep.__version__}")
+ logger.progress(f"{'directlfq':<15} : {directlfq.__version__}")
+ logger.progress(f"===================================================")
+
def load_library(self, spec_lib_path):
if "fasta_list" in self.config:
fasta_files = self.config["fasta_list"]
@@ -148,15 +180,16 @@ def load_library(self, spec_lib_path):
prepare_pipeline = libtransform.ProcessingPipeline(
[
libtransform.DecoyGenerator(decoy_type="diann"),
- libtransform.FlattenLibrary(),
+ libtransform.FlattenLibrary(
+ self.config["search_advanced"]["top_k_fragments"]
+ ),
libtransform.InitFlatColumns(),
libtransform.LogFlatLibraryStats(),
]
)
speclib = import_pipeline(spec_lib_path)
- if self.config["library_loading"]["save_hdf"]:
- speclib.save_hdf(os.path.join(self.output_folder, "speclib.hdf"))
+ speclib.save_hdf(os.path.join(self.output_folder, "speclib.hdf"))
self.spectral_library = prepare_pipeline(speclib)
@@ -183,22 +216,57 @@ def run(
keep_decoys=False,
fdr=0.01,
):
+ logger.progress("Starting Search Workflows")
+
+ workflow_folder_list = []
+
for raw_name, dia_path, speclib in self.get_run_data():
workflow = None
try:
workflow = peptidecentric.PeptideCentricWorkflow(
- raw_name, self.config, dia_path, speclib
+ raw_name,
+ self.config,
)
+ workflow_folder_list.append(workflow.path)
+
+ # check if the raw file is already processed
+ psm_location = os.path.join(workflow.path, "psm.tsv")
+ frag_location = os.path.join(workflow.path, "frag.tsv")
+
+ if self.config["general"]["reuse_quant"]:
+ if os.path.exists(psm_location) and os.path.exists(frag_location):
+ logger.info(f"Found existing quantification for {raw_name}")
+ continue
+ logger.info(f"No existing quantification found for {raw_name}")
+
+ workflow.load(dia_path, speclib)
workflow.calibration()
- df = workflow.extraction()
- df = df[df["qval"] <= self.config["fdr"]["fdr"]]
+
+ psm_df, frag_df = workflow.extraction()
+ psm_df = psm_df[psm_df["qval"] <= self.config["fdr"]["fdr"]]
+
+ logger.info(f"Removing fragments below FDR threshold")
+
+ # to be optimized later
+ frag_df["candidate_key"] = hash(
+ frag_df["precursor_idx"].values, frag_df["rank"].values
+ )
+ psm_df["candidate_key"] = hash(
+ psm_df["precursor_idx"].values, psm_df["rank"].values
+ )
+
+ frag_df = frag_df[
+ frag_df["candidate_key"].isin(psm_df["candidate_key"])
+ ]
if self.config["multiplexing"]["multiplexed_quant"]:
- df = workflow.requantify(df)
- df = df[df["qval"] <= self.config["fdr"]["fdr"]]
- df["run"] = raw_name
- df.to_csv(os.path.join(workflow.path, "psm.tsv"), sep="\t", index=False)
+ psm_df = workflow.requantify(psm_df)
+ psm_df = psm_df[psm_df["qval"] <= self.config["fdr"]["fdr"]]
+
+ psm_df["run"] = raw_name
+ psm_df.to_csv(psm_location, sep="\t", index=False)
+ frag_df.to_csv(frag_location, sep="\t", index=False)
workflow.reporter.log_string(f"Finished workflow for {raw_name}")
workflow.reporter.context.__exit__(None, None, None)
@@ -214,58 +282,26 @@ def run(
logger.error(f"Workflow failed for {raw_name} with error {e}")
continue
- self.build_output()
-
- def build_output(self):
- output_path = self.config["output"]
- temp_path = os.path.join(output_path, base.TEMP_FOLDER)
-
- psm_df = []
- stat_df = []
-
- for raw_name, dia_path, speclib in self.get_run_data():
- run_path = os.path.join(temp_path, raw_name)
- psm_path = os.path.join(run_path, "psm.tsv")
- if not os.path.exists(psm_path):
- logger.warning(f"no psm file found for {raw_name}")
- continue
- run_df = pd.read_csv(os.path.join(run_path, "psm.tsv"), sep="\t")
+ try:
+ base_spec_lib = SpecLibBase()
+ base_spec_lib.load_hdf(
+ os.path.join(self.output_folder, "speclib.hdf"), load_mod_seq=True
+ )
- psm_df.append(run_df)
- stat_df.append(build_stat_df(run_df))
+ output = outputtransform.SearchPlanOutput(self.config, self.output_folder)
+ output.build(workflow_folder_list, base_spec_lib)
- psm_df = pd.concat(psm_df)
- stat_df = pd.concat(stat_df)
+ except Exception as e:
+ # get full traceback
+ import traceback
- psm_df.to_csv(
- os.path.join(output_path, "psm.tsv"),
- sep="\t",
- index=False,
- float_format="%.6f",
- )
- stat_df.to_csv(
- os.path.join(output_path, "stat.tsv"),
- sep="\t",
- index=False,
- float_format="%.6f",
- )
+ traceback.print_exc()
+ print(e)
+ logger.error(f"Output failed with error {e}")
+ return
- logger.info(f"Finished building output")
-
-
-def build_stat_df(run_df):
- run_stat_df = []
- for name, group in run_df.groupby("channel"):
- run_stat_df.append(
- {
- "run": run_df["run"].iloc[0],
- "channel": name,
- "precursors": np.sum(group["qval"] <= 0.01),
- "proteins": group[group["qval"] <= 0.01]["proteins"].nunique(),
- "ms1_accuracy": np.mean(group["weighted_mass_error"]),
- "fwhm_rt": np.mean(group["cycle_fwhm"]),
- "fwhm_mobility": np.mean(group["mobility_fwhm"]),
- }
- )
+ logger.progress("=================== Search Finished ===================")
- return pd.DataFrame(run_stat_df)
+ def clean(self):
+ if not self.config["library_loading"]["save_hdf"]:
+ os.remove(os.path.join(self.output_folder, "speclib.hdf"))
diff --git a/alphadia/plexscoring.py b/alphadia/plexscoring.py
index 9f1a2e33..486b1616 100644
--- a/alphadia/plexscoring.py
+++ b/alphadia/plexscoring.py
@@ -24,6 +24,8 @@
import numpy as np
import numba as nb
+NUM_FEATURES = 41
+
def candidate_features_to_candidates(
candidate_features_df: pd.DataFrame,
@@ -159,6 +161,7 @@ def multiplex_candidates(
@nb.experimental.jitclass()
class CandidateConfigJIT:
+ collect_fragments: nb.boolean
score_grouped: nb.boolean
exclude_shared_ions: nb.boolean
top_k_fragments: nb.uint32
@@ -170,6 +173,7 @@ class CandidateConfigJIT:
def __init__(
self,
+ collect_fragments: nb.boolean,
score_grouped: nb.boolean,
exclude_shared_ions: nb.types.bool_,
top_k_fragments: nb.uint32,
@@ -184,6 +188,7 @@ def __init__(
Please refer to :class:`.alphadia.plexscoring.CandidateConfig` for documentation.
"""
+ self.collect_fragments = collect_fragments
self.score_grouped = score_grouped
self.exclude_shared_ions = exclude_shared_ions
self.top_k_fragments = top_k_fragments
@@ -202,9 +207,10 @@ class CandidateConfig(config.JITConfig):
def __init__(self):
"""Create default config for CandidateScoring"""
+ self.collect_fragments = True
self.score_grouped = False
self.exclude_shared_ions = True
- self.top_k_fragments = 16
+ self.top_k_fragments = 12
self.top_k_isotopes = 4
self.reference_channel = -1
self.precursor_mz_tolerance = 15
@@ -215,6 +221,16 @@ def jit_container(self):
"""The numba jitclass for this config object."""
return CandidateConfigJIT
+ @property
+ def collect_fragments(self) -> bool:
+ """Collect fragment features.
+ Default: `collect_fragments = False`"""
+ return self._collect_fragments
+
+ @collect_fragments.setter
+ def collect_fragments(self, value):
+ self._collect_fragments = value
+
@property
def score_grouped(self) -> bool:
"""When multiplexing is used, some grouped features are calculated taking into account all channels.
@@ -239,7 +255,7 @@ def exclude_shared_ions(self, value):
@property
def top_k_fragments(self) -> int:
"""The number of fragments to consider for scoring. The top_k_fragments most intense fragments are used.
- Default: `top_k_fragments = 16`"""
+ Default: `top_k_fragments = 12`"""
return self._top_k_fragments
@top_k_fragments.setter
@@ -312,6 +328,10 @@ def validate(self):
self.fragment_mz_tolerance < 200
), "fragment_mz_tolerance must be less than 200"
+ def copy(self):
+ """Create a copy of the config object."""
+ return CandidateConfig.from_dict(self.to_dict())
+
float_array = nb.types.float32[:]
@@ -327,6 +347,8 @@ class Candidate:
failed: nb.boolean
+ output_idx: nb.uint32
+
# input columns
precursor_idx: nb.uint32
channel: nb.uint8
@@ -352,9 +374,10 @@ class Candidate:
# object properties
fragments: fragments.FragmentContainer.class_type.instance_type
- features: nb.types.DictType(nb.types.unicode_type, nb.float32)
fragment_feature_dict: nb.types.DictType(nb.types.unicode_type, nb.float32[:])
+ feature_array: nb.float32[::1]
+
dense_fragments: nb.float32[:, :, :, :, ::1]
dense_precursors: nb.float32[:, :, :, :, ::1]
@@ -369,6 +392,7 @@ class Candidate:
def __init__(
self,
+ output_idx: nb.uint32,
precursor_idx: nb.uint32,
channel: nb.uint8,
rank: nb.uint8,
@@ -384,6 +408,8 @@ def __init__(
precursor_mz: nb.float32,
isotope_intensity: nb.float32[::1],
) -> None:
+ self.output_idx = output_idx
+
self.precursor_idx = precursor_idx
self.channel = channel
self.rank = rank
@@ -413,22 +439,12 @@ def __str__(self):
def initialize(self, fragment_container, config):
# initialize all required dicts
# accessing uninitialized dicts in numba will result in a kernel crash :)
- self.features = nb.typed.Dict.empty(
- key_type=nb.types.unicode_type,
- value_type=nb.types.float32,
- )
self.fragment_feature_dict = nb.typed.Dict.empty(
key_type=nb.types.unicode_type, value_type=float_array
)
- self.fragments = fragment_container.slice(
- np.array([[self.frag_start_idx, self.frag_stop_idx, 1]])
- )
- if config.exclude_shared_ions:
- self.fragments.filter_by_cardinality(1)
- self.fragments.filter_top_k(config.top_k_fragments)
- self.fragments.sort_by_mz()
+ self.assemble_isotope_mz(config)
self.assemble_isotope_mz(config)
@@ -444,31 +460,32 @@ def assemble_isotope_mz(self, config):
* 1.0033548350700006
/ self.charge
)
- self.isotope_mz = offset.astype(nb.float32) + self.precursor_mz
+ return offset.astype(nb.float32) + self.precursor_mz
- def build_profiles(self, dense_fragments, template):
- # (n_fragments, n_observations, n_frames)
- self.fragments_frame_profile = features.or_envelope_2d(
- features.frame_profile_2d(dense_fragments[0])
- )
+ def process(
+ self,
+ jit_data,
+ psm_proto_df,
+ fragment_container,
+ config,
+ quadrupole_calibration,
+ debug,
+ ) -> None:
+ psm_proto_df.precursor_idx[self.output_idx] = self.precursor_idx
- # (n_observations, n_frames)
- self.template_frame_profile = features.or_envelope_1d(
- features.frame_profile_1d(template)
- )
+ isotope_mz = self.assemble_isotope_mz(config)
- # (n_fragments, n_observations, n_scans)
- self.fragments_scan_profile = features.or_envelope_2d(
- features.scan_profile_2d(dense_fragments[0])
+ # build fragment container
+ fragments = fragment_container.slice(
+ np.array([[self.frag_start_idx, self.frag_stop_idx, 1]])
)
+ if config.exclude_shared_ions:
+ fragments.filter_by_cardinality(1)
- # (n_observations, n_scans)
- self.template_scan_profile = features.or_envelope_1d(
- features.scan_profile_1d(template)
- )
+ fragments.filter_top_k(config.top_k_fragments)
+ fragments.sort_by_mz()
- def process(self, jit_data, config, quadrupole_calibration, debug) -> None:
- if len(self.fragments.mz) <= 3:
+ if len(fragments.mz) <= 3:
self.failed = True
return
@@ -482,8 +499,7 @@ def process(self, jit_data, config, quadrupole_calibration, debug) -> None:
scan_limit = np.array([[self.scan_start, self.scan_stop, 1]], dtype=np.uint64)
quadrupole_limit = np.array(
- [[np.min(self.isotope_mz) - 0.5, np.max(self.isotope_mz) + 0.5]],
- dtype=np.float32,
+ [[np.min(isotope_mz) - 0.5, np.max(isotope_mz) + 0.5]], dtype=np.float32
)
if debug:
@@ -498,7 +514,7 @@ def process(self, jit_data, config, quadrupole_calibration, debug) -> None:
dense_fragments, frag_precursor_index = jit_data.get_dense(
frame_limit,
scan_limit,
- self.fragments.mz,
+ fragments.mz,
config.fragment_mz_tolerance,
quadrupole_limit,
absolute_masses=True,
@@ -521,7 +537,7 @@ def process(self, jit_data, config, quadrupole_calibration, debug) -> None:
_dense_precursors, prec_precursor_index = jit_data.get_dense(
frame_limit,
scan_limit,
- self.isotope_mz,
+ isotope_mz,
config.precursor_mz_tolerance,
np.array([[-1.0, -1.0]]),
absolute_masses=True,
@@ -555,14 +571,14 @@ def process(self, jit_data, config, quadrupole_calibration, debug) -> None:
if debug:
# self.visualize_precursor(dense_precursors)
- self.visualize_fragments(dense_fragments, self.fragments)
+ self.visualize_fragments(dense_fragments, fragments)
# (n_isotopes, n_observations, n_scans)
qtf = quadrupole.quadrupole_transfer_function_single(
quadrupole_calibration,
frag_precursor_index,
np.arange(int(self.scan_start), int(self.scan_stop)),
- self.isotope_mz,
+ isotope_mz,
)
# (n_observation, n_scans, n_frames)
@@ -584,8 +600,6 @@ def process(self, jit_data, config, quadrupole_calibration, debug) -> None:
# DEBUG only used for debugging
# self.template = template
- self.build_profiles(dense_fragments, template)
-
if dense_fragments.shape[0] == 0:
self.failed = True
return
@@ -594,62 +608,168 @@ def process(self, jit_data, config, quadrupole_calibration, debug) -> None:
self.failed = True
return
+ fragment_mask_1d = (
+ np.sum(np.sum(np.sum(dense_fragments[0], axis=-1), axis=-1), axis=-1) > 0
+ )
+
+ if np.sum(fragment_mask_1d) < 2:
+ self.failed = True
+ return
+
+ # (2, n_valid_fragments, n_observations, n_scans, n_frames)
+ dense_fragments = dense_fragments[:, fragment_mask_1d]
+ fragments.apply_mask(fragment_mask_1d)
+
+ # (n_fragments, n_observations, n_frames)
+ fragments_frame_profile = features.or_envelope_2d(
+ features.frame_profile_2d(dense_fragments[0])
+ )
+
+ # (n_observations, n_frames)
+ template_frame_profile = features.or_envelope_1d(
+ features.frame_profile_1d(template)
+ )
+
+ # (n_fragments, n_observations, n_scans)
+ fragments_scan_profile = features.or_envelope_2d(
+ features.scan_profile_2d(dense_fragments[0])
+ )
+
+ # (n_observations, n_scans)
+ template_scan_profile = features.or_envelope_1d(
+ features.scan_profile_1d(template)
+ )
+
if debug:
self.visualize_profiles(
template,
- self.fragments_scan_profile,
- self.fragments_frame_profile,
- self.template_frame_profile,
- self.template_scan_profile,
+ fragments_scan_profile,
+ fragments_frame_profile,
+ template_frame_profile,
+ template_scan_profile,
jit_data.has_mobility,
)
- self.features.update(
- features.location_features(
- jit_data,
- self.scan_start,
- self.scan_stop,
- self.scan_center,
- self.frame_start,
- self.frame_stop,
- self.frame_center,
- )
+ # from here on features are being accumulated in the feature_array
+ # (n_features)
+ feature_array = np.zeros(NUM_FEATURES, dtype=np.float32)
+ feature_array[28] = np.mean(fragment_mask_1d)
+
+ features.location_features(
+ jit_data,
+ self.scan_start,
+ self.scan_stop,
+ self.scan_center,
+ self.frame_start,
+ self.frame_stop,
+ self.frame_center,
+ feature_array,
)
- self.features.update(
- features.precursor_features(
- self.isotope_mz,
- self.isotope_intensity,
- dense_precursors,
- observation_importance,
- template,
- )
+ features.precursor_features(
+ isotope_mz,
+ self.isotope_intensity,
+ dense_precursors,
+ observation_importance,
+ template,
+ feature_array,
)
- feature_dict, self.fragment_feature_dict = features.fragment_features(
- dense_fragments, observation_importance, template, self.fragments
+ # retrive first fragment features
+ # (n_valid_fragments)
+ mz_observed, mass_error, height, intensity = features.fragment_features(
+ dense_fragments,
+ observation_importance,
+ template,
+ fragments,
+ feature_array,
)
- self.features.update(feature_dict)
-
- self.features.update(
- features.profile_features(
- jit_data,
- self.fragments.intensity,
- self.fragments.type,
+ # store fragment features if requested
+ # only target precursors are stored
+ if config.collect_fragments:
+ psm_proto_df.fragment_precursor_idx[self.output_idx, : len(mz_observed)] = [
+ self.precursor_idx
+ ] * len(mz_observed)
+ psm_proto_df.fragment_rank[self.output_idx, : len(mz_observed)] = [
+ self.rank
+ ] * len(mz_observed)
+ psm_proto_df.fragment_mz_library[
+ self.output_idx, : len(fragments.mz_library)
+ ] = fragments.mz_library
+ psm_proto_df.fragment_mz[
+ self.output_idx, : len(fragments.mz)
+ ] = fragments.mz
+ psm_proto_df.fragment_mz_observed[
+ self.output_idx, : len(mz_observed)
+ ] = mz_observed
+
+ psm_proto_df.fragment_height[self.output_idx, : len(height)] = height
+ psm_proto_df.fragment_intensity[
+ self.output_idx, : len(intensity)
+ ] = intensity
+
+ psm_proto_df.fragment_mass_error[
+ self.output_idx, : len(mass_error)
+ ] = mass_error
+ psm_proto_df.fragment_number[
+ self.output_idx, : len(fragments.number)
+ ] = fragments.number
+ psm_proto_df.fragment_type[
+ self.output_idx, : len(fragments.type)
+ ] = fragments.type
+ psm_proto_df.fragment_charge[
+ self.output_idx, : len(fragments.charge)
+ ] = fragments.charge
+
+ # ============= FRAGMENT MOBILITY CORRELATIONS =============
+ # will be skipped if no mobility dimension is present
+ if jit_data.has_mobility:
+ (
+ feature_array[29],
+ feature_array[30],
+ ) = features.fragment_mobility_correlation(
+ fragments_scan_profile,
+ template_scan_profile,
observation_importance,
- self.fragments_scan_profile,
- self.fragments_frame_profile,
- self.template_scan_profile,
- self.template_frame_profile,
- self.scan_start,
- self.scan_stop,
- self.frame_start,
- self.frame_stop,
+ fragments.intensity,
)
+
+ # (n_valid_fragments)
+ correlation = features.profile_features(
+ jit_data,
+ fragments.intensity,
+ fragments.type,
+ observation_importance,
+ fragments_scan_profile,
+ fragments_frame_profile,
+ template_scan_profile,
+ template_frame_profile,
+ self.scan_start,
+ self.scan_stop,
+ self.frame_start,
+ self.frame_stop,
+ feature_array,
)
- def process_reference_channel(self, reference_candidate):
+ if config.collect_fragments:
+ psm_proto_df.fragment_correlation[
+ self.output_idx, : len(correlation)
+ ] = correlation
+
+ psm_proto_df.features[self.output_idx] = feature_array
+ psm_proto_df.valid[self.output_idx] = True
+
+ def process_reference_channel(self, reference_candidate, fragment_container):
+ fragments = fragment_container.slice(
+ np.array([[self.frag_start_idx, self.frag_stop_idx, 1]])
+ )
+ if config.exclude_shared_ions:
+ fragments.filter_by_cardinality(1)
+
+ fragments.filter_top_k(config.top_k_fragments)
+ fragments.sort_by_mz()
+
self.features.update(
features.reference_features(
reference_candidate.observation_importance,
@@ -662,7 +782,7 @@ def process_reference_channel(self, reference_candidate):
self.fragments_frame_profile,
self.template_scan_profile,
self.template_frame_profile,
- self.fragments.intensity,
+ fragments.intensity,
)
)
@@ -712,7 +832,13 @@ def __len__(self):
return len(self.candidates)
def process(
- self, fragment_container, jit_data, config, quadrupole_calibration, debug
+ self,
+ psm_proto_df,
+ fragment_container,
+ jit_data,
+ config,
+ quadrupole_calibration,
+ debug,
) -> None:
# get refrerence channel index
if config.reference_channel >= 0:
@@ -733,20 +859,28 @@ def process(
# process candidates
for candidate in self.candidates:
- candidate.initialize(fragment_container, config)
- candidate.process(jit_data, config, quadrupole_calibration, debug)
+ candidate.process(
+ jit_data,
+ psm_proto_df,
+ fragment_container,
+ config,
+ quadrupole_calibration,
+ debug,
+ )
# process reference channel features
if config.reference_channel >= 0:
for idx, candidate in enumerate(self.candidates):
if idx == reference_channel_idx:
continue
- candidate.process_reference_channel(
- self.candidates[reference_channel_idx]
- )
+ # candidate.process_reference_channel(
+ # self.candidates[reference_channel_idx]
+ # )
# update rank features
- candidate.features.update(features.rank_features(idx, self.candidates))
+ # candidate.features.update(
+ # features.rank_features(idx, self.candidates)
+ # )
score_group_type = ScoreGroup.class_type.instance_type
@@ -872,6 +1006,7 @@ def build_from_df(
self.score_groups[-1].candidates.append(
Candidate(
+ idx,
precursor_idx[idx],
channel[idx],
rank[idx],
@@ -896,7 +1031,7 @@ def build_from_df(
raise ValueError("precursor_idx must be unique within a score group")
current_precursor_idx = precursor_idx[idx]
- idx += 1
+ # idx += 1
def get_feature_columns(self):
"""Iterate all score groups and candidates and return a list of all feature names
@@ -981,9 +1116,7 @@ def collect_features(self):
feature_columns = self.get_feature_columns()
candidate_count = self.get_candidate_count()
- feature_array = np.empty(
- (candidate_count, len(feature_columns)), dtype=np.float32
- )
+ feature_array = np.empty((candidate_count, NUM_FEATURES), dtype=np.float32)
feature_array[:] = np.nan
precursor_idx_array = np.zeros(candidate_count, dtype=np.uint32)
@@ -995,13 +1128,8 @@ def collect_features(self):
for j in range(len(self[i].candidates)):
candidate = self[i].candidates[j]
- # iterate all features and add them to the feature array
- for key, value in candidate.features.items():
- # get the column index for the feature
- for k in range(len(feature_columns)):
- if feature_columns[k] == key:
- feature_array[candidate_idx, k] = value
- break
+ feature_array[candidate_idx] = candidate.feature_array
+ # candidate.feature_array = np.empty(0, dtype=np.float32)
precursor_idx_array[candidate_idx] = candidate.precursor_idx
rank_array[candidate_idx] = candidate.rank
@@ -1095,9 +1223,93 @@ def collect_fragments(self):
ScoreGroupContainer.__module__ = "alphatims.extraction.plexscoring"
+@nb.experimental.jitclass()
+class OuptutPsmDF:
+ valid: nb.boolean[::1]
+ precursor_idx: nb.uint32[::1]
+ rank: nb.uint8[::1]
+
+ features: nb.float32[:, ::1]
+
+ fragment_precursor_idx: nb.uint32[:, ::1]
+ fragment_rank: nb.uint8[:, ::1]
+
+ fragment_mz_library: nb.float32[:, ::1]
+ fragment_mz: nb.float32[:, ::1]
+ fragment_mz_observed: nb.float32[:, ::1]
+
+ fragment_height: nb.float32[:, ::1]
+ fragment_intensity: nb.float32[:, ::1]
+
+ fragment_mass_error: nb.float32[:, ::1]
+ fragment_correlation: nb.float32[:, ::1]
+
+ fragment_number: nb.uint8[:, ::1]
+ fragment_type: nb.uint8[:, ::1]
+ fragment_charge: nb.uint8[:, ::1]
+
+ def __init__(self, n_psm, top_k_fragments):
+ self.valid = np.zeros(n_psm, dtype=np.bool_)
+ self.precursor_idx = np.zeros(n_psm, dtype=np.uint32)
+ self.rank = np.zeros(n_psm, dtype=np.uint8)
+
+ self.features = np.zeros((n_psm, NUM_FEATURES), dtype=np.float32)
+
+ self.fragment_precursor_idx = np.zeros(
+ (n_psm, top_k_fragments), dtype=np.uint32
+ )
+ self.fragment_rank = np.zeros((n_psm, top_k_fragments), dtype=np.uint8)
+
+ self.fragment_mz_library = np.zeros((n_psm, top_k_fragments), dtype=np.float32)
+ self.fragment_mz = np.zeros((n_psm, top_k_fragments), dtype=np.float32)
+ self.fragment_mz_observed = np.zeros((n_psm, top_k_fragments), dtype=np.float32)
+
+ self.fragment_height = np.zeros((n_psm, top_k_fragments), dtype=np.float32)
+ self.fragment_intensity = np.zeros((n_psm, top_k_fragments), dtype=np.float32)
+
+ self.fragment_mass_error = np.zeros((n_psm, top_k_fragments), dtype=np.float32)
+ self.fragment_correlation = np.zeros((n_psm, top_k_fragments), dtype=np.float32)
+
+ self.fragment_number = np.zeros((n_psm, top_k_fragments), dtype=np.uint8)
+ self.fragment_type = np.zeros((n_psm, top_k_fragments), dtype=np.uint8)
+ self.fragment_charge = np.zeros((n_psm, top_k_fragments), dtype=np.uint8)
+
+ def to_fragment_df(self):
+ mask = self.fragment_mz_library.flatten() > 0
+
+ return (
+ self.fragment_precursor_idx.flatten()[mask],
+ self.fragment_rank.flatten()[mask],
+ self.fragment_mz_library.flatten()[mask],
+ self.fragment_mz.flatten()[mask],
+ self.fragment_mz_observed.flatten()[mask],
+ self.fragment_height.flatten()[mask],
+ self.fragment_intensity.flatten()[mask],
+ self.fragment_mass_error.flatten()[mask],
+ self.fragment_correlation.flatten()[mask],
+ self.fragment_number.flatten()[mask],
+ self.fragment_type.flatten()[mask],
+ self.fragment_charge.flatten()[mask],
+ )
+
+ def to_precursor_df(self):
+ return (
+ self.precursor_idx[self.valid],
+ self.rank[self.valid],
+ self.features[self.valid],
+ )
+
+
@alphatims.utils.pjit()
def _executor(
- i, sg_container, fragment_container, jit_data, config, quadrupole_calibration, debug
+ i,
+ sg_container,
+ psm_proto_df,
+ fragment_container,
+ jit_data,
+ config,
+ quadrupole_calibration,
+ debug,
):
"""
Helper function.
@@ -1105,10 +1317,24 @@ def _executor(
"""
sg_container[i].process(
- fragment_container, jit_data, config, quadrupole_calibration, debug
+ psm_proto_df,
+ fragment_container,
+ jit_data,
+ config,
+ quadrupole_calibration,
+ debug,
)
+@alphatims.utils.pjit()
+def transfer_feature(
+ idx, score_group_container, feature_array, precursor_idx_array, rank_array
+):
+ feature_array[idx] = score_group_container[idx].candidates[0].feature_array
+ precursor_idx_array[idx] = score_group_container[idx].candidates[0].precursor_idx
+ rank_array[idx] = score_group_container[idx].candidates[0].rank
+
+
class CandidateScoring:
"""Calculate features for each precursor candidate used in scoring."""
@@ -1266,8 +1492,6 @@ def assemble_score_group_container(
"""
- validate.candidates_df(candidates_df)
-
precursor_columns = [
"channel",
"flat_frag_start_idx",
@@ -1369,7 +1593,7 @@ def assemble_fragments(self) -> fragments.FragmentContainer:
)
def collect_candidates(
- self, candidates_df: pd.DataFrame, score_group_container: ScoreGroupContainer
+ self, candidates_df: pd.DataFrame, psm_proto_df
) -> pd.DataFrame:
"""Collect the features from the score group container and return a DataFrame.
@@ -1389,16 +1613,55 @@ def collect_candidates(
A DataFrame containing the features for each candidate.
"""
- (
- feature_array,
- precursor_idx_array,
- rank_array,
- feature_columns,
- ) = score_group_container.collect_features()
+ feature_columns = [
+ "base_width_mobility",
+ "base_width_rt",
+ "rt_observed",
+ "mobility_observed",
+ "mono_ms1_intensity",
+ "top_ms1_intensity",
+ "sum_ms1_intensity",
+ "weighted_ms1_intensity",
+ "weighted_mass_deviation",
+ "weighted_mass_error",
+ "mz_observed",
+ "mono_ms1_height",
+ "top_ms1_height",
+ "sum_ms1_height",
+ "weighted_ms1_height",
+ "isotope_intensity_correlation",
+ "isotope_height_correlation",
+ "n_observations",
+ "intensity_correlation",
+ "height_correlation",
+ "intensity_fraction",
+ "height_fraction",
+ "intensity_fraction_weighted",
+ "height_fraction_weighted",
+ "mean_observation_score",
+ "sum_b_ion_intensity",
+ "sum_y_ion_intensity",
+ "diff_b_y_ion_intensity",
+ "f_masked",
+ "fragment_scan_correlation",
+ "template_scan_correlation",
+ "fragment_frame_correlation",
+ "top3_frame_correlation",
+ "template_frame_correlation",
+ "top3_b_ion_correlation",
+ "n_b_ions",
+ "top3_y_ion_correlation",
+ "n_y_ions",
+ "cycle_fwhm",
+ "mobility_fwhm",
+ "delta_frame_peak",
+ ]
+
+ precursor_idx, rank, features = psm_proto_df.to_precursor_df()
- df = pd.DataFrame(feature_array, columns=feature_columns)
- df["precursor_idx"] = precursor_idx_array
- df["rank"] = rank_array
+ df = pd.DataFrame(features, columns=feature_columns)
+ df["precursor_idx"] = precursor_idx
+ df["rank"] = rank
# join candidate columns
candidate_df_columns = [
@@ -1431,7 +1694,20 @@ def collect_candidates(
"flat_frag_stop_idx",
"proteins",
"genes",
+ "sequence",
+ "mods",
+ "mod_sites",
] + utils.get_isotope_column_names(self.precursors_flat_df.columns)
+
+ precursor_df_columns += (
+ [self.rt_column] if self.rt_column not in precursor_df_columns else []
+ )
+ precursor_df_columns += (
+ [self.mobility_column]
+ if self.mobility_column not in precursor_df_columns
+ else []
+ )
+
df = utils.merge_missing_columns(
df,
self.precursors_flat_df,
@@ -1440,23 +1716,15 @@ def collect_candidates(
how="left",
)
- for col in ["delta_frame_peak"]:
- if col in df.columns:
- df.drop(col, axis=1, inplace=True)
-
- if self.dia_data.has_mobility:
- for col in [
- "fragment_scan_correlation",
- "top3_scan_correlation",
- "template_scan_correlation",
- ]:
- if col in df.columns:
- df.drop(col, axis=1, inplace=True)
+ if self.rt_column == "rt_library":
+ df["delta_rt"] = df["rt_observed"] - df["rt_library"]
+ else:
+ df["delta_rt"] = df["rt_observed"] - df[self.rt_column]
return df
def collect_fragments(
- self, candidates_df: pd.DataFrame, score_group_container: ScoreGroupContainer
+ self, candidates_df: pd.DataFrame, psm_proto_df
) -> pd.DataFrame:
"""Collect the fragment-level features from the score group container and return a DataFrame.
@@ -1477,16 +1745,23 @@ def collect_fragments(
"""
- (
- fragment_array,
- precursor_idx_array,
- rank_array,
- fragment_columns,
- ) = score_group_container.collect_fragments()
-
- df = pd.DataFrame(fragment_array, columns=fragment_columns)
- df["precursor_idx"] = precursor_idx_array
- df["rank"] = rank_array
+ colnames = [
+ "precursor_idx",
+ "rank",
+ "mz_library",
+ "mz",
+ "mz_observed",
+ "height",
+ "intensity",
+ "mass_error",
+ "correlation",
+ "number",
+ "type",
+ "charge",
+ ]
+ df = pd.DataFrame(
+ {key: value for value, key in zip(psm_proto_df.to_fragment_df(), colnames)}
+ )
# join precursor columns
precursor_df_columns = [
@@ -1530,38 +1805,70 @@ def __call__(self, candidates_df, thread_count=10, debug=False):
"""
logger.info("Starting candidate scoring")
- score_group_container = self.assemble_score_group_container(candidates_df)
fragment_container = self.assemble_fragments()
- # if debug mode, only iterate over 10 elution groups
- iterator_len = (
- min(10, len(score_group_container)) if debug else len(score_group_container)
- )
- thread_count = 1 if debug else thread_count
-
- alphatims.utils.set_threads(thread_count)
- _executor(
- range(iterator_len),
- score_group_container,
- fragment_container,
- self.dia_data,
- self.config.jitclass(),
- self.quadrupole_calibration.jit,
- debug,
- )
+ candidate_features_list = []
- candidate_features_df = self.collect_candidates(
- candidates_df, score_group_container
- )
- validate.candidate_features_df(candidate_features_df)
- fragment_features_df = self.collect_fragments(
- candidates_df, score_group_container
- )
- validate.fragment_features_df(fragment_features_df)
+ validate.candidates_df(candidates_df)
+
+ for decoy in [False, True]:
+ candidates_view_df = candidates_df[
+ candidates_df["decoy"] == (1 if decoy else 0)
+ ]
+ self.config.collect_fragments = not decoy
+
+ if decoy:
+ logger.info(f"Processing {len(candidates_view_df)} decoy candidates")
+ else:
+ logger.info(f"Processing {len(candidates_view_df)} target candidates")
+
+ score_group_container = self.assemble_score_group_container(
+ candidates_view_df
+ )
+
+ # build output containers
+ n_candidates = score_group_container.get_candidate_count()
+ if not decoy:
+ psm_proto_df = OuptutPsmDF(n_candidates, self.config.top_k_fragments)
+ else:
+ psm_proto_df = OuptutPsmDF(n_candidates, 0)
+
+ # if debug mode, only iterate over 10 elution groups
+ iterator_len = (
+ min(10, len(score_group_container))
+ if debug
+ else len(score_group_container)
+ )
+ thread_count = 1 if debug else thread_count
+
+ alphatims.utils.set_threads(thread_count)
+ _executor(
+ range(iterator_len),
+ score_group_container,
+ psm_proto_df,
+ fragment_container,
+ self.dia_data,
+ self.config.jitclass(),
+ self.quadrupole_calibration.jit,
+ debug,
+ )
+
+ logger.info("Finished candidate processing")
+ logger.info("Collecting candidate features")
+ candidate_features_df = self.collect_candidates(candidates_df, psm_proto_df)
+ validate.candidate_features_df(candidate_features_df)
+ candidate_features_list += [candidate_features_df]
+
+ if not decoy:
+ logger.info("Collecting fragment features")
+ fragment_features_df = self.collect_fragments(
+ candidates_df, psm_proto_df
+ )
+ validate.fragment_features_df(fragment_features_df)
logger.info("Finished candidate scoring")
del score_group_container
del fragment_container
- return candidate_features_df, fragment_features_df
+ return pd.concat(candidate_features_list), fragment_features_df
diff --git a/alphadia/testing.py b/alphadia/testing.py
index b34c0165..bccdf793 100644
--- a/alphadia/testing.py
+++ b/alphadia/testing.py
@@ -74,7 +74,7 @@ def filename_onedrive(sharing_url: str) -> str: # pragma: no cover
try:
remotefile = urlopen(encoded_url)
except:
- logging.info(f"Could not open {sharing_url} for reading filename")
+ print(f"Could not open {sharing_url} for reading filename")
raise ValueError(f"Could not open {sharing_url} for reading filename") from None
info = remotefile.info()["Content-Disposition"]
@@ -107,10 +107,10 @@ def download_onedrive(
output_path = os.path.join(output_dir, filename)
try:
path, message = urlretrieve(encoded_url, output_path, Progress())
- logging.info(f"{filename} successfully downloaded")
+ print(f"{filename} successfully downloaded")
return path
except:
- logging.info(f"Could not download {filename} from onedrive")
+ print(f"Could not download {filename} from onedrive")
return None
@@ -135,18 +135,18 @@ def update_onedrive(
filename = filename_onedrive(sharing_url)
output_path = os.path.join(output_dir, filename)
if not os.path.exists(output_path):
- logging.info(f"{filename} does not yet exist")
+ print(f"{filename} does not yet exist")
download_onedrive(sharing_url, output_dir)
# if file ends with .zip and zip=True
if unzip and filename.endswith(".zip"):
- logging.info(f"Unzipping {filename}")
+ print(f"Unzipping {filename}")
with zipfile.ZipFile(output_path, "r") as zip_ref:
zip_ref.extractall(output_dir)
- logging.info(f"{filename} successfully unzipped")
+ print(f"{filename} successfully unzipped")
else:
- logging.info(f"{filename} already exists")
+ print(f"{filename} already exists")
def encode_url_datashare(sharing_url: str) -> str: # pragma: no cover
@@ -188,7 +188,7 @@ def filename_datashare(sharing_url: str, tar=False) -> str: # pragma: no cover
try:
remotefile = urlopen(encoded_url)
except:
- # logging.info(f'Could not open {sharing_url} for reading filename')
+ # print(f'Could not open {sharing_url} for reading filename')
raise ValueError(f"Could not open {sharing_url} for reading filename") from None
info = remotefile.info()["Content-Disposition"]
@@ -223,11 +223,11 @@ def download_datashare(
try:
path, message = urlretrieve(encoded_url, output_path, Progress())
- logging.info(f"{filename} successfully downloaded")
+ print(f"{filename} successfully downloaded")
return path
except Exception as e:
- logging.info(f"Could not download {filename} from datashare")
+ print(f"Could not download {filename} from datashare")
return None
@@ -259,26 +259,26 @@ def update_datashare(
# file does not yet exist
if not os.path.exists(unzipped_path):
- logging.info(f"{filename} does not yet exist")
+ print(f"{filename} does not yet exist")
download_datashare(sharing_url, output_dir)
# if file ends with .zip and zip=True
if filename.endswith(".zip"):
with zipfile.ZipFile(output_path, "r") as zip_ref:
zip_ref.extractall(output_dir)
- logging.info(f"{filename} successfully unzipped")
+ print(f"{filename} successfully unzipped")
os.remove(output_path)
# file already exists
else:
# force download
if force:
- logging.info(f"{filename} already exists, but force=True")
+ print(f"{filename} already exists, but force=True")
# remove file
try:
os.remove(output_path)
- logging.info(f"{filename} successfully removed")
+ print(f"{filename} successfully removed")
except:
logging.error(f"Could not remove {filename}")
return
@@ -289,7 +289,9 @@ def update_datashare(
if filename.endswith(".zip"):
with zipfile.ZipFile(output_path, "r") as zip_ref:
zip_ref.extractall(output_dir)
- logging.info(f"{filename} successfully unzipped")
+ print(f"{filename} successfully unzipped")
os.remove(output_path)
- logging.info(f"{filename} already exists")
+ print(f"{filename} already exists")
+
+ return unzipped_path
diff --git a/alphadia/workflow/base.py b/alphadia/workflow/base.py
index 1245e50f..8e2ba451 100644
--- a/alphadia/workflow/base.py
+++ b/alphadia/workflow/base.py
@@ -31,8 +31,6 @@ def __init__(
self,
instance_name: str,
config: dict,
- dia_data_path: str,
- spectral_library: SpecLibBase,
) -> None:
"""
Parameters
@@ -62,6 +60,11 @@ def __init__(
)
os.mkdir(self.path)
+ def load(
+ self,
+ dia_data_path: str,
+ spectral_library: SpecLibBase,
+ ) -> None:
self.reporter = reporting.Pipeline(
backends=[
reporting.LogBackend(),
@@ -82,9 +85,9 @@ def __init__(
# initialize the calibration manager
self._calibration_manager = manager.CalibrationManager(
- config["calibration_manager"],
+ self.config["calibration_manager"],
path=os.path.join(self.path, self.CALIBRATION_MANAGER_PATH),
- load_from_file=config["general"]["reuse_calibration"],
+ load_from_file=self.config["general"]["reuse_calibration"],
reporter=self.reporter,
)
@@ -94,9 +97,9 @@ def __init__(
# initialize the optimization manager
self._optimization_manager = manager.OptimizationManager(
- config["optimization_manager"],
+ self.config["optimization_manager"],
path=os.path.join(self.path, self.OPTIMIZATION_MANAGER_PATH),
- load_from_file=config["general"]["reuse_calibration"],
+ load_from_file=self.config["general"]["reuse_calibration"],
figure_path=os.path.join(self.path, self.FIGURE_PATH),
reporter=self.reporter,
)
diff --git a/alphadia/workflow/peptidecentric.py b/alphadia/workflow/peptidecentric.py
index ce682bd0..db0ae75e 100644
--- a/alphadia/workflow/peptidecentric.py
+++ b/alphadia/workflow/peptidecentric.py
@@ -42,6 +42,7 @@
"base_width_mobility",
"base_width_rt",
"rt_observed",
+ "delta_rt",
"mobility_observed",
"mono_ms1_intensity",
"top_ms1_intensity",
@@ -83,8 +84,10 @@
"mobility_fwhm",
]
-classifier_base = fdrx.BinaryClassifier(
+classifier_base = fdrx.BinaryClassifierLegacyNewBatching(
test_size=0.001,
+ batch_size=5000,
+ learning_rate=0.001,
)
@@ -93,12 +96,18 @@ def __init__(
self,
instance_name: str,
config: dict,
- dia_data_path: str,
- spectral_library: SpecLibBase,
) -> None:
super().__init__(
instance_name,
config,
+ )
+
+ def load(
+ self,
+ dia_data_path: str,
+ spectral_library: SpecLibBase,
+ ) -> None:
+ super().load(
dia_data_path,
spectral_library,
)
@@ -128,14 +137,14 @@ def init_calibration_optimization_manager(self):
{
"current_epoch": 0,
"current_step": 0,
- "ms1_error": self.config["extraction_initial"]["initial_ms1_tolerance"],
- "ms2_error": self.config["extraction_initial"]["initial_ms2_tolerance"],
- "rt_error": self.config["extraction_initial"]["initial_rt_tolerance"],
- "mobility_error": self.config["extraction_initial"][
+ "ms1_error": self.config["search_initial"]["initial_ms1_tolerance"],
+ "ms2_error": self.config["search_initial"]["initial_ms2_tolerance"],
+ "rt_error": self.config["search_initial"]["initial_rt_tolerance"],
+ "mobility_error": self.config["search_initial"][
"initial_mobility_tolerance"
],
"column_type": "library",
- "num_candidates": self.config["extraction_initial"][
+ "num_candidates": self.config["search_initial"][
"initial_num_candidates"
],
"recalibration_target": self.config["calibration"][
@@ -241,7 +250,7 @@ def norm_to_rt(
else:
lower_rt = (
dia_data.rt_values[0]
- + self.config["extraction_initial"]["initial_rt_tolerance"] / 2
+ + self.config["search_initial"]["initial_rt_tolerance"] / 2
)
else:
lower_rt = active_gradient_start
@@ -251,7 +260,7 @@ def norm_to_rt(
upper_rt = self.config["calibration"]["active_gradient_stop"]
else:
upper_rt = dia_data.rt_values[-1] - (
- self.config["extraction_initial"]["initial_rt_tolerance"] / 2
+ self.config["search_initial"]["initial_rt_tolerance"] / 2
)
else:
upper_rt = active_gradient_stop
@@ -283,9 +292,9 @@ def norm_to_rt(
def get_exponential_batches(self, step):
"""Get the number of batches for a given step
This plan has the shape:
- 1, 1, 1, 2, 4, 8, 16, 32, 64, ...
+ 1, 2, 4, 8, 16, 32, 64, ...
"""
- return int(2 ** max(step - 3, 0))
+ return int(2**step)
def get_batch_plan(self):
n_eg = self.spectral_library._precursor_df["elution_group_idx"].nunique()
@@ -381,13 +390,13 @@ def calibration(self):
self.start_of_calibration()
for current_epoch in range(self.config["calibration"]["max_epochs"]):
- self.start_of_epoch(current_epoch)
-
if self.check_epoch_conditions():
pass
else:
break
+ self.start_of_epoch(current_epoch)
+
features = []
fragments = []
for current_step, (start_index, stop_index) in enumerate(self.batch_plan):
@@ -447,15 +456,13 @@ def end_of_epoch(self):
pass
def end_of_calibration(self):
- self.calibration_manager.predict(
- self.spectral_library._precursor_df, "precursor"
- )
- self.calibration_manager.predict(self.spectral_library._fragment_df, "fragment")
+ # self.calibration_manager.predict(self.spectral_library._precursor_df, 'precursor')
+ # self.calibration_manager.predict(self.spectral_library._fragment_df, 'fragment')
self.calibration_manager.save()
pass
def recalibration(self, precursor_df, fragments_df):
- precursor_df_filtered = precursor_df[precursor_df["qval"] < 0.001]
+ precursor_df_filtered = precursor_df[precursor_df["qval"] < 0.01]
precursor_df_filtered = precursor_df_filtered[
precursor_df_filtered["decoy"] == 0
]
@@ -492,10 +499,10 @@ def recalibration(self, precursor_df, fragments_df):
top_intensity_precursors["precursor_idx"]
)
]
- median_fragment_intensity = fragments_df_filtered["intensity"].median()
- fragments_df_filtered = fragments_df_filtered[
- fragments_df_filtered["intensity"] > median_fragment_intensity
- ].head(50000)
+
+ fragments_df_filtered = fragments_df.sort_values(
+ by=["correlation"], ascending=False
+ ).head(10000)
self.calibration_manager.fit(
fragments_df_filtered,
@@ -555,9 +562,6 @@ def check_recalibration(self, precursor_df):
self.com.accumulated_precursors_01FDR = len(
precursor_df[precursor_df["qval"] < 0.01]
)
- self.com.accumulated_precursors_001FDR = len(
- precursor_df[precursor_df["qval"] < 0.001]
- )
self.reporter.log_string(
f"=== checking if recalibration conditions were reached, target {self.com.recalibration_target} precursors ===",
@@ -568,7 +572,7 @@ def check_recalibration(self, precursor_df):
perform_recalibration = False
- if self.com.accumulated_precursors_001FDR > self.com.recalibration_target:
+ if self.com.accumulated_precursors_01FDR > self.com.recalibration_target:
perform_recalibration = True
return perform_recalibration
@@ -592,6 +596,7 @@ def extract_batch(self, batch_df):
config.update(self.config["selection_config"])
config.update(
{
+ "top_k_fragments": self.config["search_advanced"]["top_k_fragments"],
"rt_tolerance": self.com.rt_error,
"mobility_tolerance": self.com.mobility_error,
"candidate_count": self.com.num_candidates,
@@ -621,6 +626,7 @@ def extract_batch(self, batch_df):
config.update(self.config["scoring_config"])
config.update(
{
+ "top_k_fragments": self.config["search_advanced"]["top_k_fragments"],
"precursor_mz_tolerance": self.com.ms1_error,
"fragment_mz_tolerance": self.com.ms2_error,
"exclude_shared_ions": self.config["search"]["exclude_shared_ions"],
@@ -673,13 +679,10 @@ def extraction(self):
)
precursor_df = self.fdr_correction(features_df)
- if not self.config["fdr"]["keep_decoys"]:
- precursor_df = precursor_df[precursor_df["decoy"] == 0]
-
precursor_df = precursor_df[precursor_df["qval"] <= self.config["fdr"]["fdr"]]
self.log_precursor_df(precursor_df)
- return precursor_df
+ return precursor_df, fragments_df
def log_precursor_df(self, precursor_df):
total_precursors = len(precursor_df)
diff --git a/coverage.svg b/coverage.svg
index 2d1c7436..cb3cdc0e 100644
--- a/coverage.svg
+++ b/coverage.svg
@@ -9,13 +9,13 @@
-
+
coverage
coverage
- 37%
- 37%
+ 41%
+ 41%
diff --git a/gui/package.json b/gui/package.json
index 62076239..3b079ca2 100644
--- a/gui/package.json
+++ b/gui/package.json
@@ -1,7 +1,7 @@
{
"name": "alphadia",
"productName": "alphadia-gui",
- "version": "1.4.0",
+ "version": "1.5.0",
"description": "Graphical user interface for DIA data analysis",
"main": "dist/electron.js",
"homepage": "./",
diff --git a/misc/.bumpversion.cfg b/misc/.bumpversion.cfg
index cca14fea..59cfb78a 100644
--- a/misc/.bumpversion.cfg
+++ b/misc/.bumpversion.cfg
@@ -1,5 +1,5 @@
[bumpversion]
-current_version = 1.4.0
+current_version = 1.5.0
commit = True
tag = True
parse = (?P\d+)\.(?P\d+)\.(?P\d+)(\-(?P[a-z]+)(?P\d+))?
diff --git a/misc/config/default.yaml b/misc/config/default.yaml
index 8e9a36dd..095e56dc 100644
--- a/misc/config/default.yaml
+++ b/misc/config/default.yaml
@@ -9,6 +9,7 @@ general:
astral_ms1: false
log_level: 'INFO'
wsl: false
+ mmap_detector_events: false
library_loading:
rt_heuristic: 180
@@ -25,23 +26,19 @@ search:
target_mobility_tolerance: 0.04
target_rt_tolerance: 60
-fdr:
- fdr: 0.01
- group_level: 'proteins'
- competetive_scoring: true,
- keep_decoys: false,
- channel_wise_fdr: false,
+search_advanced:
+ top_k_fragments: 12
calibration:
min_epochs: 3
max_epochs: 20
- batch_size: 4000
+ batch_size: 8000
recalibration_target: 200
final_full_calibration: False
norm_rt_mode: 'linear'
search_initial:
- initial_num_candidates: 2
+ initial_num_candidates: 1
initial_ms1_tolerance: 30
initial_ms2_tolerance: 30
initial_mobility_tolerance: 0.08
@@ -54,7 +51,6 @@ selection_config:
sigma_scale_mobility: 1.
top_k_precursors: 3
- top_k_fragments: 12
kernel_size: 30
f_mobility: 1.0
@@ -74,7 +70,6 @@ selection_config:
scoring_config:
score_grouped: false
- top_k_fragments: 12
top_k_isotopes: 3
reference_channel: -1
precursor_mz_tolerance: 10
@@ -87,6 +82,20 @@ multiplexing:
reference_channel: 0
competetive_scoring: True
+fdr:
+ fdr: 0.01
+ group_level: 'proteins'
+ competetive_scoring: true
+ keep_decoys: false
+ channel_wise_fdr: false
+ library_grouping: false
+
+search_output:
+ min_k_fragments: 3
+ min_correlation: 0.5
+ num_samples_quadratic: 50
+ min_nonnan: 1
+
# configuration for the optimization manager
# initial parameters, will nbe optimized
optimization_manager:
diff --git a/nbs/debug/debug_lvl1.ipynb b/nbs/debug/debug_lvl1.ipynb
index 7e450f7e..a811242c 100644
--- a/nbs/debug/debug_lvl1.ipynb
+++ b/nbs/debug/debug_lvl1.ipynb
@@ -2,9 +2,18 @@
"cells": [
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 2,
"metadata": {},
- "outputs": [],
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "WARNING:root:WARNING: Temp mmap arrays are written to /var/folders/lc/9594t94d5b5_gn0y04w1jh980000gn/T/temp_mmap_8wbttgfv. Cleanup of this folder is OS dependant, and might need to be triggered manually! Current space: 135,225,167,872\n",
+ "WARNING:root:WARNING: No Bruker libraries are available for this operating system. Mobility and m/z values need to be estimated. While this estimation often returns acceptable results with errors < 0.02 Th, huge errors (e.g. offsets of 6 Th) have already been observed for some samples!\n"
+ ]
+ }
+ ],
"source": [
"%reload_ext autoreload\n",
"%autoreload 2\n",
@@ -16,13 +25,13 @@
"import os\n",
"\n",
"from alphabase.spectral_library.base import SpecLibBase\n",
- "from alphadia.extraction import data, planning\n",
- "from alphadia.extraction.workflow import manager, peptidecentric"
+ "from alphadia import data, planning\n",
+ "from alphadia.workflow import manager, peptidecentric"
]
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 5,
"metadata": {},
"outputs": [],
"source": [
@@ -62,30 +71,84 @@
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 6,
"metadata": {},
- "outputs": [],
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "0:00:00.000299 \u001b[32;20mPROGRESS: _ _ _ ___ ___ _ \u001b[0m\n",
+ "0:00:00.000810 \u001b[32;20mPROGRESS: /_\\ | |_ __| |_ __ _| \\_ _| /_\\ \u001b[0m\n",
+ "0:00:00.001200 \u001b[32;20mPROGRESS: / _ \\| | '_ \\ ' \\/ _` | |) | | / _ \\ \u001b[0m\n",
+ "0:00:00.001430 \u001b[32;20mPROGRESS: /_/ \\_\\_| .__/_||_\\__,_|___/___/_/ \\_\\\u001b[0m\n",
+ "0:00:00.001851 \u001b[32;20mPROGRESS: |_| \u001b[0m\n",
+ "0:00:00.002328 \u001b[32;20mPROGRESS: \u001b[0m\n",
+ "0:00:00.002817 INFO: loading default config from /Users/georgwallmann/Documents/git/alphadia/alphadia/../misc/config/default.yaml\n",
+ "0:00:00.010220 INFO: Applying config update from dict\n",
+ "0:00:00.010736 \u001b[32;20mPROGRESS: version: 1.3.2\u001b[0m\n",
+ "0:00:00.010906 \u001b[32;20mPROGRESS: hostname: Georgs-MacBook-Pro.local\u001b[0m\n",
+ "0:00:00.011244 \u001b[32;20mPROGRESS: date: 2023-11-14 16:30:39\u001b[0m\n",
+ "0:00:00.011553 INFO: Running DynamicLoader\n",
+ "0:00:01.958157 INFO: Running PrecursorInitializer\n",
+ "0:00:01.959985 INFO: Running AnnotateFasta\n",
+ "0:00:01.960559 INFO: Dropping decoys from input library before annotation\n",
+ "0:00:02.046945 INFO: Running IsotopeGenerator\n",
+ "0:00:02.047515 \u001b[33;20mWARNING: Input library already contains isotope information. Skipping isotope generation. \n",
+ " Please note that isotope generation outside of alphabase is not supported.\u001b[0m\n",
+ "0:00:02.047836 INFO: Running RTNormalization\n",
+ "0:00:02.055838 INFO: Running DecoyGenerator\n",
+ "0:00:05.156083 INFO: Running FlattenLibrary\n",
+ "0:00:11.293920 INFO: Running InitFlatColumns\n",
+ "0:00:11.294946 INFO: Running LogFlatLibraryStats\n",
+ "0:00:11.295227 INFO: ============ Library Stats ============\n",
+ "0:00:11.295416 INFO: Number of precursors: 470,599\n",
+ "0:00:11.358480 INFO: \tthereof targets:235,310\n",
+ "0:00:11.359254 INFO: \tthereof decoys: 235,289\n",
+ "0:00:11.363403 INFO: Number of elution groups: 235,310\n",
+ "0:00:11.363838 INFO: \taverage size: 2.00\n",
+ "0:00:11.377859 INFO: Number of proteins: 9,904\n",
+ "0:00:11.380199 INFO: Number of channels: 1 ([0])\n",
+ "0:00:11.380608 INFO: Isotopes Distribution for 6 isotopes\n",
+ "0:00:11.380883 INFO: =======================================\n"
+ ]
+ }
+ ],
"source": [
- "test_lib = SpecLibBase()\n",
- "test_lib.load_hdf(speclib, load_mod_seq=True)\n",
- "plan = planning.Plan(output_location, raw_files, test_lib)\n",
- "\n",
- "plan.config[\"general\"][\"reuse_calibration\"] = False\n",
- "plan.config[\"general\"][\"thread_count\"] = 10\n",
- "plan.config[\"general\"][\"astral_ms1\"] = False\n",
- "plan.config[\"calibration\"][\"norm_rt_mode\"] = \"linear\"\n",
- "\n",
- "plan.config[\"extraction_target\"][\"target_num_candidates\"] = 5\n",
- "plan.config[\"extraction_target\"][\"target_ms1_tolerance\"] = 3 if MODE == \"astral\" else 15\n",
- "plan.config[\"extraction_target\"][\"target_ms2_tolerance\"] = 5 if MODE == \"astral\" else 15\n",
- "plan.config[\"extraction_target\"][\"target_rt_tolerance\"] = 150"
+ "config_update = {\n",
+ " \"general\": {\n",
+ " \"reuse_calibration\": True,\n",
+ " \"reuse_quant\": True,\n",
+ " \"thread_count\": 10,\n",
+ " \"astral_ms1\": False,\n",
+ " },\n",
+ " \"search_initial\": {\n",
+ " \"initial_num_candidates\": 2,\n",
+ " },\n",
+ " \"search\": {\n",
+ " \"target_num_candidates\": 5,\n",
+ " \"target_ms1_tolerance\": 3 if MODE == \"astral\" else 15,\n",
+ " \"target_ms2_tolerance\": 5 if MODE == \"astral\" else 15,\n",
+ " \"target_rt_tolerance\": 120,\n",
+ " },\n",
+ " \"fdr\": {\"library_grouping\": True},\n",
+ "}\n",
+ "plan = planning.Plan(output_location, raw_files, speclib, config_update=config_update)"
]
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 7,
"metadata": {},
- "outputs": [],
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "0:00:13.456621 \u001b[32;20mPROGRESS: Loading raw file 1/1: 20230815_OA1_SoSt_SA_Whisper40_ADIAMA_HeLa_5ng_8Th14ms_FAIMS-40_1900V_noLoopCount_01\u001b[0m\n"
+ ]
+ }
+ ],
"source": [
"for raw_name, dia_path, speclib in plan.get_run_data():\n",
" pass"
@@ -93,19 +156,65 @@
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 9,
"metadata": {},
- "outputs": [],
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "11it [00:15, 1.43s/it]\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "None True\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "0:01:34.889078 INFO: Loaded CalibrationManager from /Users/georgwallmann/Documents/data/performance_tests/outputs/astral_lf_dia/.progress/20230815_OA1_SoSt_SA_Whisper40_ADIAMA_HeLa_5ng_8Th14ms_FAIMS-40_1900V_noLoopCount_01/calibration_manager.pkl\n",
+ "0:01:34.889792 INFO: Initializing CalibrationManager\n",
+ "0:01:34.890751 INFO: Disabling ion mobility calibration\n",
+ "0:01:34.891526 INFO: Loaded OptimizationManager from /Users/georgwallmann/Documents/data/performance_tests/outputs/astral_lf_dia/.progress/20230815_OA1_SoSt_SA_Whisper40_ADIAMA_HeLa_5ng_8Th14ms_FAIMS-40_1900V_noLoopCount_01/optimization_manager.pkl\n",
+ "0:01:34.892314 INFO: Initializing OptimizationManager\n",
+ "0:01:34.893053 \u001b[32;20mPROGRESS: Initializing workflow 20230815_OA1_SoSt_SA_Whisper40_ADIAMA_HeLa_5ng_8Th14ms_FAIMS-40_1900V_noLoopCount_01\u001b[0m\n",
+ "0:01:34.893844 INFO: Initializing OptimizationManager\n",
+ "0:01:34.894256 INFO: initial parameter: current_epoch = 0\n",
+ "0:01:34.894679 INFO: initial parameter: current_step = 0\n",
+ "0:01:34.895098 INFO: initial parameter: ms1_error = 30\n",
+ "0:01:34.895443 INFO: initial parameter: ms2_error = 30\n",
+ "0:01:34.895790 INFO: initial parameter: rt_error = 240\n",
+ "0:01:34.896160 INFO: initial parameter: mobility_error = 0.08\n",
+ "0:01:34.896496 INFO: initial parameter: column_type = library\n",
+ "0:01:34.896792 INFO: initial parameter: num_candidates = 2\n",
+ "0:01:34.897099 INFO: initial parameter: recalibration_target = 200\n",
+ "0:01:34.897395 INFO: initial parameter: accumulated_precursors = 0\n",
+ "0:01:34.897697 INFO: initial parameter: accumulated_precursors_01FDR = 0\n",
+ "0:01:34.897994 INFO: initial parameter: accumulated_precursors_001FDR = 0\n",
+ "0:01:34.898446 INFO: Initializing FDRManager\n",
+ "0:01:34.898759 \u001b[32;20mPROGRESS: Applying channel filter using only: [0]\u001b[0m\n",
+ "0:01:34.953161 \u001b[32;20mPROGRESS: 219,866 target precursors potentially observable (15,444 removed)\u001b[0m\n",
+ "0:01:35.043559 \u001b[32;20mPROGRESS: Skipping calibration as existing calibration was found\u001b[0m\n"
+ ]
+ }
+ ],
"source": [
"workflow = peptidecentric.PeptideCentricWorkflow(\n",
- " raw_name, plan.config, dia_path, speclib\n",
+ " raw_name,\n",
+ " plan.config,\n",
")\n",
+ "workflow.load(dia_path, speclib)\n",
"workflow.calibration()"
]
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 10,
"metadata": {},
"outputs": [],
"source": [
@@ -114,11 +223,25 @@
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 12,
"metadata": {},
- "outputs": [],
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "0:03:13.549251 INFO: Duty cycle consists of 76 frames, 1.34 seconds cycle time\n",
+ "0:03:13.549875 INFO: Duty cycle consists of 1 scans, 0.00000 1/K_0 resolution\n",
+ "0:03:13.550268 INFO: FWHM in RT is 3.59 seconds, sigma is 0.57\n",
+ "0:03:13.551165 INFO: FWHM in mobility is 0.000 1/K_0, sigma is 1.00\n",
+ "0:03:14.343229 INFO: Starting candidate selection\n",
+ "100%|██████████| 1000/1000 [00:21<00:00, 46.29it/s]\n",
+ "0:03:37.596133 INFO: Finished candidate selection\n"
+ ]
+ }
+ ],
"source": [
- "from alphadia.extraction import hybridselection\n",
+ "from alphadia import hybridselection\n",
"\n",
"config = hybridselection.HybridCandidateConfig()\n",
"config.update(workflow.config[\"selection_config\"])\n",
@@ -129,9 +252,7 @@
" \"candidate_count\": workflow.com.num_candidates,\n",
" \"precursor_mz_tolerance\": workflow.com.ms1_error,\n",
" \"fragment_mz_tolerance\": workflow.com.ms2_error,\n",
- " \"exclude_shared_ions\": workflow.config[\"library_loading\"][\n",
- " \"exclude_shared_ions\"\n",
- " ],\n",
+ " \"exclude_shared_ions\": workflow.config[\"search\"][\"exclude_shared_ions\"],\n",
" }\n",
")\n",
"\n",
@@ -152,11 +273,25 @@
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 13,
"metadata": {},
- "outputs": [],
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "0:05:34.568625 INFO: Starting candidate scoring\n",
+ " 0%| | 0/1991 [00:00, ?it/s]/Users/georgwallmann/Documents/git/alphatims/alphatims/utils.py:583: NumbaTypeSafetyWarning: \u001b[1m\u001b[1m\u001b[1m\u001b[1m\u001b[1m\u001b[1m\u001b[1munsafe cast from float64 to float32. Precision may be lost.\u001b[0m\u001b[0m\u001b[0m\u001b[0m\u001b[0m\u001b[0m\u001b[0m\n",
+ " numba_func(i, *args)\n",
+ "/Users/georgwallmann/Documents/git/alphatims/alphatims/utils.py:583: NumbaTypeSafetyWarning: \u001b[1m\u001b[1m\u001b[1m\u001b[1m\u001b[1m\u001b[1m\u001b[1munsafe cast from int64 to float32. Precision may be lost.\u001b[0m\u001b[0m\u001b[0m\u001b[0m\u001b[0m\u001b[0m\u001b[0m\n",
+ " numba_func(i, *args)\n",
+ "100%|██████████| 1991/1991 [00:57<00:00, 34.33it/s]\n",
+ "0:06:35.413602 INFO: Finished candidate scoring\n"
+ ]
+ }
+ ],
"source": [
- "from alphadia.extraction import plexscoring\n",
+ "from alphadia import plexscoring\n",
"\n",
"config = plexscoring.CandidateConfig()\n",
"config.update(workflow.config[\"scoring_config\"])\n",
@@ -164,9 +299,7 @@
" {\n",
" \"precursor_mz_tolerance\": workflow.com.ms1_error,\n",
" \"fragment_mz_tolerance\": workflow.com.ms2_error,\n",
- " \"exclude_shared_ions\": workflow.config[\"library_loading\"][\n",
- " \"exclude_shared_ions\"\n",
- " ],\n",
+ " \"exclude_shared_ions\": workflow.config[\"search\"][\"exclude_shared_ions\"],\n",
" }\n",
")\n",
"\n",
diff --git a/nbs/debug/debug_lvl1_bruker.ipynb b/nbs/debug/debug_lvl1_bruker.ipynb
deleted file mode 100644
index 8e9791f1..00000000
--- a/nbs/debug/debug_lvl1_bruker.ipynb
+++ /dev/null
@@ -1,1084 +0,0 @@
-{
- "cells": [
- {
- "cell_type": "code",
- "execution_count": 2,
- "metadata": {},
- "outputs": [
- {
- "name": "stderr",
- "output_type": "stream",
- "text": [
- "/var/folders/lc/9594t94d5b5_gn0y04w1jh980000gn/T/ipykernel_40742/913516776.py:8: NeptuneDeprecationWarning: You're importing the Neptune client library via the deprecated `neptune.new` module, which will be removed in a future release. Import directly from `neptune` instead.\n",
- " import neptune.new as neptune\n",
- "WARNING:root:WARNING: Temp mmap arrays are written to /var/folders/lc/9594t94d5b5_gn0y04w1jh980000gn/T/temp_mmap_gemnwtxx. Cleanup of this folder is OS dependant, and might need to be triggered manually! Current space: 461,516,173,312\n",
- "WARNING:root:WARNING: No Bruker libraries are available for this operating system. Mobility and m/z values need to be estimated. While this estimation often returns acceptable results with errors < 0.02 Th, huge errors (e.g. offsets of 6 Th) have already been observed for some samples!\n"
- ]
- }
- ],
- "source": [
- "%reload_ext autoreload\n",
- "%autoreload 2\n",
- "\n",
- "import numpy as np\n",
- "import pandas as pd\n",
- "import matplotlib.pyplot as plt\n",
- "import seaborn as sns\n",
- "import os\n",
- "\n",
- "from alphabase.spectral_library.base import SpecLibBase\n",
- "from alphadia.extraction import data, planning\n",
- "from alphadia.extraction.workflow import manager, peptidecentric"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 6,
- "metadata": {},
- "outputs": [],
- "source": [
- "performance_test_folder = \"/Users/georgwallmann/Documents/data/performance_tests\"\n",
- "\n",
- "raw_files = [\n",
- " os.path.join(\n",
- " performance_test_folder,\n",
- " \"raw_data/timstof_lf_diap/20230502_TIMS05_PaSk_SA_HeLa_21min_diaP_12scans_S2-A3_1_2089.d\",\n",
- " )\n",
- "]\n",
- "output_location = os.path.join(performance_test_folder, \"outputs/timstof_lf_diaPASEF\")\n",
- "speclib = os.path.join(\n",
- " performance_test_folder,\n",
- " \"libraries/timstof/21min_Evosep_HeLa_BR14_48fractions_diaPASEF_py_diAID_2_egs.hdf\",\n",
- ")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 7,
- "metadata": {},
- "outputs": [],
- "source": [
- "test_lib = SpecLibBase()\n",
- "test_lib.load_hdf(speclib, load_mod_seq=True)"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 8,
- "metadata": {},
- "outputs": [
- {
- "name": "stderr",
- "output_type": "stream",
- "text": [
- "0:00:00.000168 \u001b[32;20m PROGRESS: _ _ _ ___ ___ _ \u001b[0m\n",
- "0:00:00.001297 \u001b[32;20m PROGRESS: /_\\ | |_ __| |_ __ _| \\_ _| /_\\ \u001b[0m\n",
- "0:00:00.001964 \u001b[32;20m PROGRESS: / _ \\| | '_ \\ ' \\/ _` | |) | | / _ \\ \u001b[0m\n",
- "0:00:00.002346 \u001b[32;20m PROGRESS: /_/ \\_\\_| .__/_||_\\__,_|___/___/_/ \\_\\\u001b[0m\n",
- "0:00:00.002657 \u001b[32;20m PROGRESS: |_| \u001b[0m\n",
- "0:00:00.002985 \u001b[32;20m PROGRESS: \u001b[0m\n",
- "0:00:00.003550 \u001b[38;20m INFO: loading default config from /Users/georgwallmann/Documents/git/alphadia/alphadia/extraction/../../misc/config/default.yaml\u001b[0m\n",
- "0:00:00.012319 \u001b[32;20m PROGRESS: version: 1.3.2\u001b[0m\n",
- "0:00:00.012909 \u001b[32;20m PROGRESS: hostname: Georgs-MBP.fritz.box\u001b[0m\n",
- "0:00:00.013460 \u001b[32;20m PROGRESS: date: 2023-09-29 20:57:46\u001b[0m\n",
- "0:00:06.299999 \u001b[38;20m INFO: renaming precursor_columns columns\u001b[0m\n",
- "0:00:06.301150 \u001b[38;20m INFO: renaming fragment_columns columns\u001b[0m\n",
- "0:00:06.301634 \u001b[38;20m INFO: ========= Library Stats =========\u001b[0m\n",
- "0:00:06.302102 \u001b[38;20m INFO: Number of precursors: 460,153\u001b[0m\n",
- "0:00:06.359066 \u001b[38;20m INFO: \tthereof targets:230,483\u001b[0m\n",
- "0:00:06.359700 \u001b[38;20m INFO: \tthereof decoys: 229,670\u001b[0m\n",
- "0:00:06.363488 \u001b[38;20m INFO: Number of elution groups: 231,149\u001b[0m\n",
- "0:00:06.364158 \u001b[38;20m INFO: \taverage size: 1.99\u001b[0m\n",
- "0:00:06.382024 \u001b[38;20m INFO: Number of proteins: 9,899\u001b[0m\n",
- "0:00:06.382496 \u001b[33;20m WARNING: no channel column was found, will assume only one channel\u001b[0m\n",
- "0:00:06.383089 \u001b[38;20m INFO: Isotopes Distribution for 6 isotopes\u001b[0m\n",
- "0:00:06.383771 \u001b[38;20m INFO: =================================\u001b[0m\n",
- "0:00:06.385289 \u001b[33;20m WARNING: no precursor_idx column found, creating one\u001b[0m\n",
- "0:00:06.386231 \u001b[33;20m WARNING: no channel column found, creating one\u001b[0m\n",
- "/Users/georgwallmann/Documents/git/alphadia/alphadia/extraction/workflow/base.py:58: NeptuneWarning: To avoid unintended consumption of logging hours during interactive sessions, the following monitoring options are disabled unless set to 'True' when initializing the run: 'capture_stdout', 'capture_stderr', and 'capture_hardware_metrics'.\n",
- " self.neptune = neptune.init_run(\n"
- ]
- },
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "https://app.neptune.ai/MannLabs/alphaDIA/e/AL-419\n"
- ]
- },
- {
- "name": "stderr",
- "output_type": "stream",
- "text": [
- "0:00:08.302904 \u001b[38;20m INFO: Importing data from /Users/georgwallmann/Documents/data/performance_tests/raw_data/timstof_lf_diap/20230502_TIMS05_PaSk_SA_HeLa_21min_diaP_12scans_S2-A3_1_2089.d\u001b[0m\n",
- "0:00:08.321513 \u001b[38;20m INFO: Using .d import for /Users/georgwallmann/Documents/data/performance_tests/raw_data/timstof_lf_diap/20230502_TIMS05_PaSk_SA_HeLa_21min_diaP_12scans_S2-A3_1_2089.d\u001b[0m\n",
- "0:00:08.322025 \u001b[38;20m INFO: Reading frame metadata for /Users/georgwallmann/Documents/data/performance_tests/raw_data/timstof_lf_diap/20230502_TIMS05_PaSk_SA_HeLa_21min_diaP_12scans_S2-A3_1_2089.d\u001b[0m\n",
- "0:00:08.463579 \u001b[38;20m INFO: Reading 11,812 frames with 1,479,051,420 detector events for /Users/georgwallmann/Documents/data/performance_tests/raw_data/timstof_lf_diap/20230502_TIMS05_PaSk_SA_HeLa_21min_diaP_12scans_S2-A3_1_2089.d\u001b[0m\n",
- "100%|██████████| 11812/11812 [00:06<00:00, 1884.11it/s]\n",
- "0:00:14.807876 \u001b[38;20m INFO: Indexing /Users/georgwallmann/Documents/data/performance_tests/raw_data/timstof_lf_diap/20230502_TIMS05_PaSk_SA_HeLa_21min_diaP_12scans_S2-A3_1_2089.d...\u001b[0m\n",
- "0:00:14.808910 \u001b[38;20m INFO: Bruker DLL not available, estimating mobility values\u001b[0m\n",
- "0:00:14.809354 \u001b[38;20m INFO: Bruker DLL not available, estimating mz values\u001b[0m\n",
- "0:00:14.811082 \u001b[38;20m INFO: Indexing quadrupole dimension\u001b[0m\n",
- "0:00:14.998341 \u001b[38;20m INFO: Transposing detector events\u001b[0m\n",
- "100%|██████████| 20/20 [00:04<00:00, 4.03it/s]\n",
- "0:00:22.584380 \u001b[38;20m INFO: Finished transposing data\u001b[0m\n",
- "0:00:22.689207 \u001b[38;20m INFO: Successfully imported data from /Users/georgwallmann/Documents/data/performance_tests/raw_data/timstof_lf_diap/20230502_TIMS05_PaSk_SA_HeLa_21min_diaP_12scans_S2-A3_1_2089.d\u001b[0m\n",
- "0:00:22.786987 \u001b[38;20m INFO: ========= Initializing Calibration Manager =========\u001b[0m\n",
- "0:00:22.787540 \u001b[38;20m INFO: loading calibration config\u001b[0m\n",
- "0:00:22.787840 \u001b[38;20m INFO: found 2 calibration groups\u001b[0m\n",
- "0:00:22.788346 \u001b[38;20m INFO: Calibration group :fragment, found 1 estimator(s)\u001b[0m\n",
- "0:00:22.788779 \u001b[38;20m INFO: Calibration group :precursor, found 3 estimator(s)\u001b[0m\n",
- "0:00:22.789216 \u001b[38;20m INFO: ====================================================\u001b[0m\n",
- "0:00:22.789628 \u001b[38;20m INFO: ========= Initializing Optimization Manager =========\u001b[0m\n",
- "0:00:22.790002 \u001b[38;20m INFO: initial parameter: fwhm_rt = 5\u001b[0m\n",
- "0:00:22.790411 \u001b[38;20m INFO: initial parameter: fwhm_mobility = 0.01\u001b[0m\n",
- "0:00:22.790779 \u001b[38;20m INFO: ====================================================\u001b[0m\n",
- "0:00:22.791160 \u001b[32;20m PROGRESS: Initializing workflow 20230502_TIMS05_PaSk_SA_HeLa_21min_diaP_12scans_S2-A3_1_2089\u001b[0m\n",
- "0:00:22.791524 \u001b[38;20m INFO: ========= Initializing Optimization Manager =========\u001b[0m\n",
- "0:00:22.792006 \u001b[38;20m INFO: initial parameter: current_epoch = 0\u001b[0m\n",
- "0:00:22.792327 \u001b[38;20m INFO: initial parameter: current_step = 0\u001b[0m\n",
- "0:00:22.792656 \u001b[38;20m INFO: initial parameter: ms1_error = 30\u001b[0m\n",
- "0:00:22.792928 \u001b[38;20m INFO: initial parameter: ms2_error = 30\u001b[0m\n",
- "0:00:22.793322 \u001b[38;20m INFO: initial parameter: rt_error = 240\u001b[0m\n",
- "0:00:22.793661 \u001b[38;20m INFO: initial parameter: mobility_error = 0.08\u001b[0m\n",
- "0:00:22.794026 \u001b[38;20m INFO: initial parameter: column_type = library\u001b[0m\n",
- "0:00:22.794265 \u001b[38;20m INFO: initial parameter: num_candidates = 2\u001b[0m\n",
- "0:00:22.794469 \u001b[38;20m INFO: initial parameter: recalibration_target = 200\u001b[0m\n",
- "0:00:22.794965 \u001b[38;20m INFO: initial parameter: accumulated_precursors = 0\u001b[0m\n",
- "0:00:22.795399 \u001b[38;20m INFO: initial parameter: accumulated_precursors_01FDR = 0\u001b[0m\n",
- "0:00:22.795785 \u001b[38;20m INFO: initial parameter: accumulated_precursors_001FDR = 0\u001b[0m\n",
- "0:00:22.796098 \u001b[38;20m INFO: ====================================================\u001b[0m\n",
- "0:00:22.796360 \u001b[38;20m INFO: ========= Initializing FDR Manager =========\u001b[0m\n",
- "0:00:22.796659 \u001b[38;20m INFO: ====================================================\u001b[0m\n",
- "0:00:22.797137 \u001b[32;20m PROGRESS: Applying channel filter using only: [0]\u001b[0m\n",
- "0:00:22.919262 \u001b[38;20m INFO: calibration group: precursor, predicting mz\u001b[0m\n",
- "0:00:22.919800 \u001b[33;20m WARNING: mz prediction was skipped as it has not been fitted yet\u001b[0m\n",
- "0:00:22.920063 \u001b[38;20m INFO: calibration group: precursor, predicting rt\u001b[0m\n",
- "0:00:22.920297 \u001b[33;20m WARNING: rt prediction was skipped as it has not been fitted yet\u001b[0m\n",
- "0:00:22.920557 \u001b[38;20m INFO: calibration group: precursor, predicting mobility\u001b[0m\n",
- "0:00:22.920871 \u001b[33;20m WARNING: mobility prediction was skipped as it has not been fitted yet\u001b[0m\n",
- "0:00:22.921156 \u001b[38;20m INFO: calibration group: fragment, predicting mz\u001b[0m\n",
- "0:00:22.921439 \u001b[33;20m WARNING: mz prediction was skipped as it has not been fitted yet\u001b[0m\n",
- "/Users/georgwallmann/Documents/git/alphadia/alphadia/extraction/workflow/peptidecentric.py:305: NeptuneUnsupportedType: You're attempting to log a type that is not directly supported by Neptune ().\n",
- " Convert the value to a supported type, such as a string or float, or use stringify_unsupported(obj)\n",
- " for dictionaries or collections that contain unsupported values.\n",
- " For more, see https://docs.neptune.ai/help/value_of_unsupported_type\n",
- " self.neptune[f\"eval/{key}\"].log(value)\n",
- "0:00:22.924721 \u001b[32;20m PROGRESS: === Epoch 0, step 0, extracting elution groups 0 to 4000 ===\u001b[0m\n",
- "0:00:22.931979 \u001b[32;20m PROGRESS: MS1 error: 30, MS2 error: 30, RT error: 240, Mobility error: 0.08\u001b[0m\n",
- "0:00:24.458850 \u001b[38;20m INFO: Duty cycle consists of 13 frames, 1.39 seconds cycle time\u001b[0m\n",
- "0:00:24.459340 \u001b[38;20m INFO: Duty cycle consists of 928 scans, 0.00065 1/K_0 resolution\u001b[0m\n",
- "0:00:24.459644 \u001b[38;20m INFO: FWHM in RT is 5.00 seconds, sigma is 0.77\u001b[0m\n",
- "0:00:24.459992 \u001b[38;20m INFO: FWHM in mobility is 0.010 1/K_0, sigma is 6.57\u001b[0m\n",
- "0:00:25.253378 \u001b[38;20m INFO: Starting candidate selection\u001b[0m\n",
- "100%|██████████| 7936/7936 [01:25<00:00, 92.99it/s] \n",
- "0:01:52.319552 \u001b[38;20m INFO: Finished candidate selection\u001b[0m\n",
- "0:01:53.376656 \u001b[38;20m INFO: Starting candidate scoring\u001b[0m\n",
- " 0%| | 0/13888 [00:00, ?it/s]/Users/georgwallmann/Documents/git/alphatims/alphatims/utils.py:583: NumbaTypeSafetyWarning: \u001b[1m\u001b[1m\u001b[1m\u001b[1m\u001b[1m\u001b[1m\u001b[1munsafe cast from float64 to float32. Precision may be lost.\u001b[0m\u001b[0m\u001b[0m\u001b[0m\u001b[0m\u001b[0m\u001b[0m\n",
- " numba_func(i, *args)\n",
- "/Users/georgwallmann/Documents/git/alphatims/alphatims/utils.py:583: NumbaTypeSafetyWarning: \u001b[1m\u001b[1m\u001b[1m\u001b[1m\u001b[1m\u001b[1m\u001b[1munsafe cast from int64 to float32. Precision may be lost.\u001b[0m\u001b[0m\u001b[0m\u001b[0m\u001b[0m\u001b[0m\u001b[0m\n",
- " numba_func(i, *args)\n",
- "100%|██████████| 13888/13888 [01:01<00:00, 226.69it/s] \n",
- "0:02:56.666826 \u001b[33;20m WARNING: base_width_mobility has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.667547 \u001b[33;20m WARNING: base_width_rt has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.668124 \u001b[33;20m WARNING: rt_observed has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.668681 \u001b[33;20m WARNING: mobility_observed has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.669558 \u001b[33;20m WARNING: mono_ms1_intensity has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.670234 \u001b[33;20m WARNING: top_ms1_intensity has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.670834 \u001b[33;20m WARNING: sum_ms1_intensity has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.671283 \u001b[33;20m WARNING: weighted_ms1_intensity has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.671945 \u001b[33;20m WARNING: weighted_mass_deviation has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.672690 \u001b[33;20m WARNING: weighted_mass_error has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.673382 \u001b[33;20m WARNING: mz_observed has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.674053 \u001b[33;20m WARNING: mono_ms1_height has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.674613 \u001b[33;20m WARNING: top_ms1_height has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.675303 \u001b[33;20m WARNING: sum_ms1_height has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.675733 \u001b[33;20m WARNING: weighted_ms1_height has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.676220 \u001b[33;20m WARNING: isotope_intensity_correlation has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.676749 \u001b[33;20m WARNING: isotope_height_correlation has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.677399 \u001b[33;20m WARNING: n_observations has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.677899 \u001b[33;20m WARNING: intensity_correlation has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.678610 \u001b[33;20m WARNING: height_correlation has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.679114 \u001b[33;20m WARNING: intensity_fraction has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.680183 \u001b[33;20m WARNING: height_fraction has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.680799 \u001b[33;20m WARNING: intensity_fraction_weighted has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.681490 \u001b[33;20m WARNING: height_fraction_weighted has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.681933 \u001b[33;20m WARNING: mean_observation_score has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.682273 \u001b[33;20m WARNING: sum_b_ion_intensity has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.682694 \u001b[33;20m WARNING: sum_y_ion_intensity has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.683297 \u001b[33;20m WARNING: diff_b_y_ion_intensity has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.684539 \u001b[33;20m WARNING: fragment_frame_correlation has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.685218 \u001b[33;20m WARNING: top3_frame_correlation has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.686278 \u001b[33;20m WARNING: template_frame_correlation has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.686940 \u001b[33;20m WARNING: top3_b_ion_correlation has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.687428 \u001b[33;20m WARNING: top3_y_ion_correlation has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.688044 \u001b[33;20m WARNING: cycle_fwhm has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.688708 \u001b[33;20m WARNING: mobility_fwhm has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.689293 \u001b[33;20m WARNING: n_b_ions has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.690021 \u001b[33;20m WARNING: n_y_ions has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:56.690530 \u001b[33;20m WARNING: f_masked has 6 NaNs ( 0.04 % out of 13888)\u001b[0m\n",
- "0:02:57.572199 \u001b[38;20m INFO: Finished candidate scoring\u001b[0m\n",
- "0:02:57.637949 \u001b[38;20m INFO: number of dfs in features: 1, total number of features: 13888\u001b[0m\n",
- "0:02:57.638568 \u001b[38;20m INFO: performing precursor_channel_wise FDR with 39 features\u001b[0m\n",
- "0:02:57.639102 \u001b[38;20m INFO: Decoy channel: -1\u001b[0m\n",
- "0:02:57.639396 \u001b[38;20m INFO: Competetive: true,\u001b[0m\n",
- "0:02:57.663160 \u001b[33;20m WARNING: dropped 5 target PSMs due to missing features\u001b[0m\n",
- "0:02:57.663784 \u001b[33;20m WARNING: dropped 1 decoy PSMs due to missing features\u001b[0m\n",
- "0:02:59.046430 \u001b[38;20m INFO: Post FDR iterations: 13\u001b[0m\n",
- "0:02:59.267096 \u001b[38;20m INFO: Test AUC: 0.567\u001b[0m\n",
- "0:02:59.267637 \u001b[38;20m INFO: Train AUC: 0.587\u001b[0m\n",
- "0:02:59.268030 \u001b[38;20m INFO: AUC difference: 3.39%\u001b[0m\n"
- ]
- },
- {
- "data": {
- "image/png": "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+uuvM2nSJJo1a0blypUxmUy2DklERAopJaWKk8R/cNiR/W3rInMXfmlUxYYBiYiIiEhJ895777Fo0SKeeOIJW4ciIiKFnJZgK0bMH7Q3tpukvMfPE9rj6eZow4hEREREpKRJS0ujZcuWtg5DRESKACWlioHkNDPrlodjH38SgOUZ97F0WDcqeWiFExEREREpWE8//TTLly+3dRgiIlIEaPheMXBsclM62Z0wyt9UHkq/Kp62C0hERERESqyUlBTmz5/P+vXradiwIY6O1j33Z82aZaPIRESksFFSqohL+KAHAVclpLZ0/JqPWt1ru4BEREREpET79ddfady4MQD79++32qdJz0VE5GpKShVhcRveodKfW4yy5dXztLbTiEwREZHCIDzyiK1DKJa2/342R92PGUcY2bGODaKRa9m4caOtQxARkSJCGYwiKmXdG1T64SWjPPfenzEpISUiIiIiIiIiRYSyGEVQZnoqbHvLKE+q/w3Pt69rw4hERERERERERHJHSami5uLf2E2phIspHYA2aW/zau97bByUiIiIiO20ODUfNoZZP6RY2LJlCz169MDHxweTycTnn39utd9isTBx4kR8fHxwdXWlbdu2HDhwwKpNamoqw4YNo0KFCri7u9OzZ09Onz5dgFchIiLXo6RUUZKZCTOz50sYnvY83058zIYBiYiIiIjkn0uXLtGoUSPmzp17zf3Tp09n1qxZzJ07lx07duDt7U3Hjh25ePGi0SYkJITVq1ezYsUKtm7dSmJiIt27d8dsNhfUZYiIyHVoovOi5LOnjM2PMtozdPg43Jz0TygiIiIixVPXrl3p2rXrNfdZLBZmz57NhAkT6NWrFwCLFy/Gy8uL5cuXM3jwYOLj44mIiGDp0qV06NABgGXLluHr68v69evp3LlzgV2LiIjkpJ5SRcXGUDiwGoC9mTX4695Q6np72DgoERERERHbiI6OJjY2lk6dOhl1zs7OtGnThqioKAB27dpFenq6VRsfHx/8/f2NNiIiYjvqZlMU/PgebJ4GwDpzIM+mj2JzM18bByUiIiK51eLUfFuHIFJsxMbGAuDl5WVV7+XlxcmTJ402Tk5OlC1bNkebK8dfS2pqKqmpqUY5ISEhr8IWEZGrqKdUYZcQA2tfBODXTD+GpQ/DZDJRrby7jQMTEREREbE9k8lkVbZYLDnq/u1mbcLCwvD09DQevr76QlhEJD8oKVWYZWbCrHpG8fG0l0jFiTcfaWTDoEREREREbM/b2xsgR4+nuLg4o/eUt7c3aWlpnD9//rptrmX8+PHEx8cbjz/++COPoxcRESgESal3330XPz8/XFxcCAwM5IcffrB1SIVG+orHje3X0vuTgDtbX2zHw4F32DAqERERERHb8/Pzw9vbm8jISKMuLS2NzZs307JlSwACAwNxdHS0ahMTE8P+/fuNNtfi7OxM6dKlrR4iIpL3bDqn1MqVKwkJCeHdd9+lVatWvP/++3Tt2pWDBw9StWpVW4ZmexdjcTzyDQAnMr3YUrYXB4ffo9X2RERERKTESExM5NixY0Y5OjqavXv3Uq5cOapWrUpISAihoaHUrl2b2rVrExoaipubG/369QPA09OTQYMGMXr0aMqXL0+5cuUYM2YMAQEBxmp8IiJiOzbNcMyaNYtBgwbx9NNPAzB79my+++475s2bR1hYmC1Ds62/D8K8IKP4QNob/DKmre3iERERERGxgZ07d9KuXTujPGrUKAD69+/PokWLGDt2LMnJyQQHB3P+/HmaN2/OunXr8PDIXqU6PDwcBwcHevfuTXJyMu3bt2fRokXY29sX+PWIiIg1myWl0tLS2LVrF+PGjbOq79Sp03WXZy0xq2BclZB6I/1xVoZ0tWEwIiIiIiK20bZtWywWy3X3m0wmJk6cyMSJE6/bxsXFhTlz5jBnzpx8iFBERP4Lm80pdebMGcxm8zWXcL3e8qwlYhWM7yYYm7MzehFh7kY9b41hFxEREREREZHixeYTnedmCddivwrG3wdg+1wAvjM3Y3bGI1Qp42rjoERERERERERE8p7Nhu9VqFABe3v7Gy7h+m/Ozs44OzsXRHi2sWUGADGWcgxJD8HOBNvG3WfjoERERERERERE8p7Neko5OTkRGBhotTwrQGRk5A2XZy2uMlIuwYHVAKw134UFO356SSuCiIiIiIiIiEjxZNPV90aNGsUTTzxBs2bNCAoKYv78+Zw6dYohQ4bYMqwCd+LYQaovy57cfJW5DTsmdKCiRzHuFSYiIiIiIiIiJZpNk1J9+vTh7NmzTJo0iZiYGPz9/VmzZg3VqlWzZVgFKz2FqktbwuVptGZn/h9fTn4OB3ubT/clIiIiIiIiIpJvbJqUAggODiY4ONjWYdiGOZ30iC44mrKWuZ3hNppRY17B3u7aE72LiIiIiIiIiBQXNk9KlVgWC8y9C8fz0QC8mf4oQ0NeUkJKREREREREREoEjRGzlag5cDkh9aU5iAi7R3BzUo5QREREREREREoGZUFsIT0FIl8xisPTn+fH8W1tF4+IiIiIiIiISAFTT6mClpkJU7yNYsfU6YAJb08X28UkIiIiIiIiIlLAlJQqaCsfA7ImNt9urs9Ryx2UdXO0bUwiIiIiIiIiIgVMSamCtOcjOLwGgG3mBvRNfxmAXS93tGVUIiIiIiIiIiIFTkmpghJ/Gr4INoqPp4+nX/OqRId1w04r7olICbBlyxZ69OiBj48PJpOJzz//3Gq/xWJh4sSJ+Pj44OrqStu2bTlw4IBVm9TUVIYNG0aFChVwd3enZ8+enD59ugCvQkqy8Mgj13xIIbUxLOdDREREChUlpQrCpbMQ3sAo9kqdyOA2tQl9KACTSQkpESkZLl26RKNGjZg7d+4190+fPp1Zs2Yxd+5cduzYgbe3Nx07duTixYtGm5CQEFavXs2KFSvYunUriYmJdO/eHbPZXFCXISIiIiIieUSr7+WzzEwLqe+2xvVyeXDaSHZb6vC/rvVsGpeISEHr2rUrXbt2veY+i8XC7NmzmTBhAr169QJg8eLFeHl5sXz5cgYPHkz8/7d372FRlWv/wL8DwwACoggiHoIIUNEUhVQkQ/NAaYa2S000NTuo7TxtdcvPs75JaSnaTt80g7Z5KsVeUyPJLaaCJ4Q0IVFU1MLNFk1REwTu3x/GbEeGw8CsGWC+n+viumat9ay17nsNrId1z1rP3LyJdevWYf369ejTpw8A4Msvv0SrVq3www8/ICwszGS5ED2Md0sRERERVQ+LUgrKzb+Hpe/PwVKbB4+W7Czuhu9LnsILHTzMHBkRUe1y4cIFXL16Ff369dPOs7W1RWhoKJKSkvD2228jJSUF9+/f12nTvHlztG/fHklJSeUWpQoKClBQUKCdvnXrlnKJkMXqdmmNuUMgIiIiqnNYlFLQ36M+RIzmv/+kLrafhl3ju6Bdc2czRkVEVPtcvXoVAODu7q4z393dHdnZ2do2Go0GjRs3LtOmdH19oqKisGDBAiNHTERERERENcUxpRRy5WouYjRLtdPyzlEk/b++LEgREVXg0XH2RKTSsfcqaxMZGYmbN29qfy5fvmyUWImIiIiIqGZYlFLIrXWDtK8L30iEyq21+YIhIqrlmjVrBgBl7njKzc3V3j3VrFkzFBYW4saNG+W20cfW1hYNGzbU+SEiIiIiIvNjUUoJJ7+G//0HX2P+FfpC07KTmQMiIqrdHn/8cTRr1gwJCQnaeYWFhdi/fz+6d+8OAAgMDISNjY1Om5ycHPz888/aNkRkuZLP5+n9ISIiotqLY0oZWXFxMazj3gAA3BFbNH5lpZkjIiKqHW7fvo1z585ppy9cuIC0tDS4uLjgsccew+TJk7F48WL4+vrC19cXixcvRoMGDTB8+HAAgLOzM8aOHYu//e1vaNKkCVxcXDBt2jQ8+eST2m/jIyIiIiKiuoNFKWMSwc+LuqHjn5MvF87Hd+2bmzUkIqLa4vjx4+jVq5d2eurUqQCAUaNGITY2FjNmzMAff/yBCRMm4MaNG+jatSv27NkDJycn7TrLly+HWq3GkCFD8Mcff6B3796IjY2FtbW1yfMhIiIiIqKaYVHKiIpWBqIjsgAAh0vaYvvCt80cERFR7dGzZ0+ISLnLVSoV5s+fj/nz55fbxs7ODh9//DE+/vhjBSIkIiIiIiJT4phSRlJ84zLUNx4UpP4QDTSv74SdDT+5JyIiIiIiIiLSh3dKGYMI7q16Bg5/Tk73i0d0KxezhkRERETK6HZpjblDICIiIqoXWJSqqZISlKzrB4f71wEAs++PwT8iAs0cFBERERERERFR7cbH92oqcTGsfj0GALgl9ug/ZraZAyIiIiIiIiIiqv14p1RNFNwGflwKACgWFQIK1uK8j6uZgyIiIlNbnpCpd/6Uvn4mjoSIiIiIqO7gnVLVlXMSiGqhnRxcuBDvvxxgvniIiIiIiIiIiOoQFqWqo+A25LPe2sm590fBt1MohgS1MmNQRERERERERER1Bx/fqwbZOASq4kIAwPjCSfiupCsuDulo5qiIiIiIiIiIiOoOFqUMdeY7qLIPAQASijvju5KuWD+2i5mDIiIiImMpb4wwIiIiIjIuFqUM8fslYNMw7eTk++/g4vsDzBgQEREREREREVHdxDGlDHDji+Ha168UzMVP771kxmiIiIiIiIiIiOouFqWqSgSNb5wCAKwsGoS3Ro6A2pqHj4iIiIiIiIioOvj4XlVlxmtfXmr/V0z0dzdjMERERERksH1RZef1ijR9HERERASAd0pVzc1ftWNJ5UojPNHMxcwBERERERERERHVbbxTqjK/pQFrQrWTk+9PwIcBzc0XDxERERkFv2WPiIiIyLxYlKrMtrHalzFFYbjSqAuaN7I3Y0BERFRXlFf0mNLXz8SREBERERHVPixKVSQ7Gcg7BwBYdD8C64oH4Oj4YDMHRURERERERERU97EoVZGDy7Qv1xUPwF86t0RTJzszBkRERESm1O3SGnOHQERERFRvcaDz8mQnA2f3AADWFA1Ar9Zu+GhIRzMHRURERERERERUP/BOKX2KCoCY57STnxX1x8GRQWYMiIiIiIiIiIiofmFRSo+iFZ20B+YvBfOwa9Yr0Kh5UxkRERFRXZN8Pk/v/GDvJiaOhIiIiB7FSsuj/pMJdf6vAIAr4oq/vzUabk62Zg6KiIiIiIiIiKh+YVHqEXfW9te+XtDqc3R53MWM0RARERERERER1U8sSj1s/xI4FP4HALDw/kh8+vozZg6IiIiIiIiIiKh+YlHqYfve075sFjYFVlYqMwZDRERERERERFR/caDzPxWe+gaaP1+/WjgLm555wqzxEBERERERERHVZyxK/elK3Bx4//na+6nnzRoLERERGdfyhExzh0BEREREj2BRCkDu9RvwlksAgFn3X8d7g580c0REREREpKTk83kPXpyfpjM/2LsJ0CvSDBERERFZHhalAGg+7619Pelvs80YCRER1Wa826Z+63ZpjblDICIiIrIoFj/Q+d3cC2h0OwsAEIdeaOrSxMwRERERERERERHVfxZ9p9S9+8U48XEEnrYGfilphau9lpg7JCIiIqqBqt7NxruiiIiIiMzP7EWpVatWYenSpcjJyUG7du0QHR2NHj16mGTf7y2aiUXWpwEA3zUegSm9/EyyXyIiIn30FVSm9GXfVFMsQBERYN7rDiIi0s+sj+9t2bIFkydPxqxZs5CamooePXrg+eefx6VLlxTf98QVX2KR1YN/UtcX9cGUKTMV3ycREREREZmeOa87iIiofGYtSi1btgxjx47FG2+8gbZt2yI6OhqtWrXC6tWrFd/3u3mLta+fm6z8/oiIiIiIyDzMed1BRETlM9vje4WFhUhJScHMmbp3KPXr1w9JSUmK7vvw+Tx0s/oVAHArJBJurk0V3R8RERGZBh/VI6JHmfO6g4iIKma2otS1a9dQXFwMd3d3nfnu7u64evWq3nUKCgpQUFCgnb5161a19m1z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k5OD0NBQBAQE4Pjx44iPj8e///1vDBkypMI87969i6ioKHz22Wc4ffo0mjZtivz8fIwaNQoHDhzA4cOH4evri/79+yM/Px8AtO9VTEyMznv3/fffY8SIEZg4cSLS09Px6aefIjY2Fu+9916FMRARmdv06dOxb98+bN++HXv27EFiYiJSUlJ02owZMwaHDh3C5s2bcfLkSbzyyit47rnntIWctLQ09O7dG+3atUNycjIOHjyIgQMHori4GAAwY8YMbNu2DV988QVOnDgBHx8fhIWF4fr161CpVHj99dd1PlQBgM8//xw9evTAE088USbmoqIiDBo0CKGhoTh58iSSk5Px1ltvaT/c2LVrF1566SUMGDAAqamp2Lt3L4KCgrTrjx49GsePH8eOHTuQnJwMEUH//v11Ll709RGVHQciIqqaqvQ9pZydnTFgwAC91wPh4eFwdHQEADg5OSE2Nhbp6elYsWIF1q5di+XLl9coTp73qVYSIiNYvny5eHp6VtgmNzdXAMipU6dEROTChQsCQFJTU0VEZODAgTJmzBi9665bt05at24tJSUl2nkFBQVib28v33//vd519u3bJwBk8+bN2nl5eXlib28vW7ZsERGR4cOHS9++fXXWmz59uvj7+4uIyLZt26Rhw4Zy69YtvfsIDQ2VSZMmaac9PT1l+fLlOm1iYmLE2dlZO71s2TLx9vbWTp85c0YAyOnTp0VEZM6cOdKvXz+dbVy+fFkAyJkzZ/TGERMTIwAkLS1N7/JSRUVF4uTkJN9++612HgDZvn27TrsePXrI4sWLdeatX79ePDw8Ktw+EZE55efni0aj0XveLz1Xnzt3TlQqlfz666866/bu3VsiIyNFROTVV1+VkJAQvfu4ffu22NjYyIYNG7TzCgsLpXnz5rJkyRIREfntt9/E2tpajhw5ol3u5uYmsbGxereZl5cnACQxMVHv8uDgYImIiNC7LDMzUwDIoUOHtPOuXbsm9vb28tVXX4mI/j6iKseBiIgqV5W+51FxcXHi6Ogod+7cERGRmzdvip2dnezatavc/SxZskQCAwO10/PmzZOOHTtqpx+9LhERCQ8Pl1GjRokIz/tUe/FOKVJMVlYWhg8fDm9vbzRs2BCPP/44AODSpUt6248fPx6bN29GQEAAZsyYgaSkJO2ylJQUnDt3Dk5OTnB0dISjoyNcXFxw7969Ch8JBIDg4GDtaxcXF7Ru3RoZGRkAgIyMDISEhOi0DwkJwdmzZ1FcXIy+ffvC09MT3t7eGDlyJDZs2IC7d+9W63iUGjZsGLKzs3H48GEAwIYNGxAQEAB/f39trvv27dPm6ejoiDZt2gBAhblqNBp06NBBZ15ubi7GjRsHPz8/ODs7w9nZGbdv3y73PSiVkpKChQsX6sTw5ptvIicnp8b5ExEpJSsrC4WFhXrP+6VOnDgBEYGfn5/OOW7//v3ac2zpnVLl7eP+/fs6fYeNjQ26dOmi7Vs8PDwwYMAAfP755wCAnTt34t69e3jllVf0btPFxQWjR49GWFgYBg4ciBUrVujcbVtRPBkZGVCr1ejatat2XpMmTXT6OqBsH1GV40BERJWrrO9ZvHixznn20qVLGDBgANRqNXbs2AHgwXAiTk5O6Nevn3YbW7duxdNPP41mzZrB0dERc+bMqfR/+IrwvE+1FQc6J8UMHDgQrVq1wtq1a9G8eXOUlJSgffv25T5u9/zzzyM7Oxu7du3CDz/8gN69e+Odd97Bhx9+iJKSEr2PvQGAm5ubwbGVPhIhItrXpeTPcZ2AB7fNnjhxAomJidizZw/mzp2L+fPn49ixY9pxogzl4eGBXr16YePGjejWrRs2bdqEt99+W7u8pKQEAwcOxAcffKB33fLY29uXyWX06NH4z3/+g+joaHh6esLW1hbBwcGVPvJYUlKCBQsW6IxZUsrOzq6yFImIzOLh83d5SkpKYG1tjZSUFFhbW+ssK31koqIvpyjdh76+4+F5b7zxBkaOHInly5cjJiYGQ4cORYMGDcrdbkxMDCZOnIj4+Hhs2bIFs2fPRkJCArp161alePTNfzieR/uIqhwHIiKqXGV9z7hx43SG4WjevDnUajVefvllbNy4EcOGDcPGjRsxdOhQqNUPLs8PHz6MYcOGYcGCBQgLC4OzszM2b96sd5zEUlZWVmViefgxbp73qbbinVKkiLy8PGRkZGD27Nno3bs32rZtqx0gtiJubm4YPXo0vvzyS0RHR2PNmjUAgM6dO+Ps2bNo2rQpfHx8dH6cnZ0r3GbpHUnAg8FgMzMztXce+fv74+DBgzrtk5KS4Ofnpz1Zq9Vq9OnTB0uWLMHJkydx8eJF/Otf/9K7L41Gox1zpCIRERHYsmULkpOTkZWVhWHDhmmXde7cGadPn4aXl1eZXB0cHCrd9sMOHDiAiRMnon///trB3B8dMN3GxqZMzJ07d8aZM2fK7N/Hx4ff1kREtZaPjw9sbGz0nvdLderUCcXFxcjNzS1zfmvWrBkAoEOHDti7d2+5+9BoNDp9x/3793H8+HG0bdtWO69///5wcHDA6tWr8d133+H111+vNP5OnTohMjISSUlJaN++PTZu3FhpPP7+/igqKsKRI0e08/Ly8pCZmakTj759VXYciIiocpX1PS4uLjrn2NLCU0REBOLj43H69Gns27cPERER2vUPHToET09PzJo1C0FBQfD19dX58gt93NzcdO6yLS4uxs8//6yd5nmfaiteXZIiSr8hb82aNTh37hz+9a9/YerUqRWuM3fuXPzf//0fzp07h9OnT2Pnzp3af6gjIiLg6uqK8PBwHDhwABcuXMD+/fsxadIkXLlypcLtLly4EHv37sXPP/+M0aNHw9XVFYMGDQIA/O1vf8PevXuxaNEiZGZm4osvvsA//vEPTJs2DcCDRy5WrlyJtLQ0ZGdn45///CdKSkp0HgV5mJeXF3788Uf8+uuvFX5b3ksvvYRbt25h/Pjx6NWrF1q0aKFd9s477+D69et49dVXcfToUZw/fx579uzB66+/XqWC18N8fHywfv16ZGRk4MiRI4iIiCjzibuXlxf27t2Lq1evaguHc+fOxT//+U/Mnz8fp0+fRkZGhvaTeyKi2srR0RFjx47F9OnTdc77DxfT/fz8EBERgddeew1xcXG4cOECjh07hg8++AC7d+8GAERGRuLYsWOYMGECTp48iV9++QWrV6/GtWvX4ODggPHjx2P69OmIj49Heno63nzzTdy9exdjx47V7sfa2hqjR49GZGQkfHx8dB7reNSFCxcQGRmJ5ORkZGdnY8+ePTpFpXnz5mHTpk2YN28eMjIycOrUKSxZsgQA4Ovri/DwcLz55ps4ePAgfvrpJ4wYMQItWrRAeHh4ufusynEgIqLKVaXv0Sc0NBTu7u6IiIiAl5cXunXrpl3m4+ODS5cuYfPmzcjKysLKlSuxffv2Crf37LPPYteuXdi1axd++eUXTJgwAb///rt2Oc/7VGuZZygrqm/0DXSekJAgbdu2FVtbW+nQoYMkJibqDKr96EDnixYtkrZt24q9vb24uLhIeHi4nD9/Xru9nJwcee2118TV1VVsbW3F29tb3nzzTbl586bemEoHOv/222+lXbt2otFo5KmnniozGPjWrVvF399fbGxs5LHHHpOlS5dqlx04cEBCQ0OlcePGYm9vLx06dNAOki5SdkDB5ORk6dChg9ja2krpn9ejA52XeuWVVwSAfP7552WWZWZmyuDBg6VRo0Zib28vbdq0kcmTJ+sM9P6w8vZx4sQJCQoKEltbW/H19ZWvv/66zGDsO3bsEB8fH1Gr1TrvYXx8vHTv3l3s7e2lYcOG0qVLF1mzZo3e/RMR1Rb5+fkyYsQIadCggbi7u8uSJUvKnKsLCwtl7ty54uXlJTY2NtKsWTMZPHiwnDx5UtsmMTFRunfvLra2ttKoUSMJCwuTGzduiIjIH3/8Ie+++662PwoJCZGjR4+WiSUrK0sAaAdAL8/Vq1dl0KBB4uHhIRqNRjw9PWXu3LlSXFysbbNt2zYJCAgQjUYjrq6u8tJLL2mXXb9+XUaOHCnOzs5ib28vYWFhkpmZqV1eXh9RleNARESVq0rfo8/06dMFgMydO1fvsiZNmoijo6MMHTpUli9frnMuf3Sg88LCQhk/fry4uLhI06ZNJSoqSmeg89I2PO9TbaMSqcIADER1UGJiInr16oUbN25Ue/wnIiKi6jp06BB69uyJK1euwN3d3dzhEBGRCfXs2RMBAQGIjo42dyhEtRoHOiciIiIyooKCAly+fBlz5szBkCFDWJAiIiIiKgfHlCIiIiIyok2bNqF169a4efOmduwnIiIiIiqLj+8REREREREREZHJ8U4pIiIiIiIiIiIyORaliIiIiIiIiIjI5FiUIiIiIiIiIiIik2NRioiIiIiIiIiITI5FKSIiIiIiIiIiMjkWpYiIiIiIiIiIyORYlCIiIiIiIiIiIpNjUYqIiIiIiIiIiEyORSkiIiIiIiIiIjK5/w/3pnfwRub4NgAAAABJRU5ErkJggg==",
- "text/plain": [
- "