Skip to content

BCR Embedding with scibex — Stephenson 2021 COVID-19 Dataset

This tutorial walks through a complete scibex workflow on the Stephenson 2021 COVID-19 PBMC dataset (5 000 BCR-containing cells):

  1. Load a pre-processed scirpy MuData
  2. Embed heavy- and light-chain CDR3 sequences with scibex
  3. Visualise the embedding space
  4. Train a logistic-regression classifier to predict patient outcome (Home vs Death)

scibex wraps the R Ibex package and stores results directly in AnnData.obsm, following scverse conventions. It operates on the airr modality of a scirpy MuData — no manual sequence extraction needed.


Prerequisites

pip install scibex scirpy scanpy muon

scibex also requires R with the Ibex package installed. Run the setup cell below. It installs everything and is a no-op if already installed.

In [1]:
import os

os.chdir("../..")
import scibex as ib
The following package(s) have broken symlinks into the cache:
- codetools
- lattice
- MASS
- Matrix
- survival
Use `renv::repair()` to try and reinstall these packages.

- Project '~/personal/projects/nb/Ibex-project/scibex' loaded. [renv 1.2.3]
The following package(s) have broken symlinks into the cache:
- codetools
- lattice
- MASS
- Matrix
- survival
Use `renv::repair()` to try and reinstall these packages.

In [1]:
# Install R dependencies (fast no-op if already installed)
ib.install_r_deps()
All R dependencies already installed.
In [2]:
import warnings

import pandas as pd
import scanpy as sc
import scirpy as ir

warnings.filterwarnings("ignore")
sc.settings.verbosity = 1
sc.settings.set_figure_params(dpi=80, frameon=False)

1. Load the dataset

The dataset is a MuData with two modalities:

  • gex — gene expression (pre-processed; PCA/UMAP already computed)
  • airr — BCR contigs in AIRR format, with obsm["chain_indices"] already populated by scirpy
In [3]:
mdata = ir.datasets.stephenson2021_5k()
mdata
Out[3]:
MuData object with n_obs × n_vars = 5000 × 24929
  2 modalities
    gex:	5000 × 24929
      obs:	'sample_id', 'n_genes', 'n_genes_by_counts', 'total_counts', 'total_counts_mt', 'pct_counts_mt', 'full_clustering', 'initial_clustering', 'Resample', 'Collection_Day', 'Sex', 'Age_interval', 'Swab_result', 'Status', 'Smoker', 'Status_on_day_collection', 'Status_on_day_collection_summary', 'Days_from_onset', 'Site', 'time_after_LPS', 'Worst_Clinical_Status', 'Outcome', 'patient_id'
      var:	'feature_types'
      obsm:	'X_pca', 'X_pca_harmony', 'X_umap'
      layers:	'raw'
    airr:	5000 × 0
      obs:	'receptor_type', 'receptor_subtype', 'chain_pairing'
      uns:	'chain_indices', 'scirpy_version'
      obsm:	'airr', 'chain_indices'
In [4]:
mdata["gex"].obs[["Status_on_day_collection_summary", "Outcome"]].value_counts().head(10)
Out[4]:
Status_on_day_collection_summary  Outcome
Moderate                          Home       1155
Mild                              Home        949
Critical                          Home        684
Severe                            Home        573
                                  unknown     507
Asymptomatic                      Home        456
Critical                          unknown     265
Severe                            Death       208
Mild                              unknown     203
Moderate                          Death         0
Name: count, dtype: int64

2. Embed BCR sequences with scibex

ib.tl.ibex reads CDR3 sequences directly from the scirpy chain_indices in the airr modality and writes an [N, D] embedding array to mdata["airr"].obsm[key_added]. Cells that lack a usable chain receive rows filled with zeros (the default fill_value=0.0; pass fill_value=float("nan") to get NaN instead). Pass verbose=True to see how many cells were affected.

Available methods:

method encoder_model Description
"encoder" "CNN" CDR3 convolutional autoencoder
"encoder" "VAE" CDR3 variational autoencoder
"encoder" "CNN.EXP" CDR1+2+3 CNN (needs cdr1_aa/cdr2_aa AIRR fields)
"encoder" "VAE.EXP" CDR1+2+3 VAE
"geometric" Fast rule-based geometric transform (BLOSUM62-based)

Available encoder_input options: "atchleyFactors", "kideraFactors", "crucianiProperties", "MSWHIM", "tScales", "OHE"

For EXP models, strategy controls how cells with partial CDR missingness (CDR1 or CDR2 absent but CDR3 present) are handled:

  • "lenient" (default) — substitute missing CDRs with "NA" (Ibex's R convention) and embed anyway
  • "strict" — any missing CDR → zero row
In [5]:
# Heavy-chain CDR1+2+3 embedding (CNN.EXP with Atchley factors)
# verbose=True reports how many cells lacked complete CDR1/CDR2/CDR3 (filled with zeros)
ib.tl.ibex(
    mdata,
    chain="Heavy",
    method="encoder",
    encoder_model="CNN.EXP",
    encoder_input="atchleyFactors",
    key_added="X_ibex_heavy",
)

# Light-chain CDR1+2+3 embedding
ib.tl.ibex(
    mdata,
    chain="Light",
    method="encoder",
    encoder_model="CNN.EXP",
    encoder_input="atchleyFactors",
    key_added="X_ibex_light",
)

print("Heavy embedding:", mdata["airr"].obsm["X_ibex_heavy"].shape)
print("Light embedding:", mdata["airr"].obsm["X_ibex_light"].shape)
Heavy embedding: (5000, 128)
Light embedding: (5000, 128)

3. Visualise the heavy-chain embedding

We compute a UMAP from the heavy-chain Ibex embedding and colour cells by COVID-19 outcome and clinical status.

Note: gex and airr are stored with different cell orderings in this MuData. We reindex the embeddings to the gex cell ordering via barcode before handing off to scanpy.

In [6]:
gex = mdata["gex"]

# Align heavy embeddings to gex cell ordering via barcode index
heavy_df = pd.DataFrame(
    mdata["airr"].obsm["X_ibex_heavy"],
    index=mdata["airr"].obs_names,
)
gex.obsm["X_ibex_heavy"] = heavy_df.reindex(gex.obs_names).values

sc.pp.neighbors(gex, use_rep="X_ibex_heavy", n_neighbors=30, metric="cosine")
sc.tl.umap(gex)

sc.pl.umap(gex, color=["Outcome", "Status_on_day_collection_summary"], ncols=2, wspace=0.4)
No description has been provided for this image

4. Predict COVID-19 outcome from paired BCR embeddings

We concatenate the heavy- and light-chain embeddings into a single feature matrix, then train a regularised logistic regression. LogisticRegressionCV selects the regularisation strength via leave-one-out CV on the training data. We evaluate with stratified 5-fold cross-validation and report balanced accuracy (appropriate given the ~18:1 Home/Death imbalance).

In [7]:
from sklearn.linear_model import LogisticRegressionCV
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
In [8]:
# Align light embeddings to gex cell ordering
light_df = pd.DataFrame(
    mdata["airr"].obsm["X_ibex_light"],
    index=mdata["airr"].obs_names,
)

outcome = mdata["gex"].obs["Outcome"]

X = pd.concat(
    [heavy_df.reindex(outcome.index), light_df.reindex(outcome.index)],
    axis=1,
).values

y = outcome.values

# Keep only cells with a known outcome label
labeled = y != "unknown"
X_clf, y_clf = X[labeled], y[labeled]

print(f"Labeled cells: {labeled.sum()} (Home={(y_clf == 'Home').sum()}, Death={(y_clf == 'Death').sum()})")
Labeled cells: 4025 (Home=3817, Death=208)
In [9]:
pipe = make_pipeline(
    StandardScaler(),
    LogisticRegressionCV(
        max_iter=500,
        cv=3,
        class_weight="balanced",
        random_state=42,
    ),
)

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)
scores = cross_val_score(pipe, X_clf, y_clf, cv=cv, scoring="balanced_accuracy")

print(f"5-fold CV balanced accuracy: {scores.mean():.3f} ± {scores.std():.3f}")
print(f"Per-fold: {scores.round(3)}")
5-fold CV balanced accuracy: 0.538 ± 0.024
Per-fold: [0.517 0.567 0.508 0.532 0.565]

Session info

In [10]:
sc.logging.print_header()
Out[10]:
PackageVersion
pandas3.0.3
mudata0.3.8
scibex0.1.0b2
scanpy1.11.5
scirpy0.24.0
anndata0.11.3
scikit-learn1.9.0
numpy2.4.4
ComponentInfo
Python3.12.13 | packaged by conda-forge | (main, Mar 5 2026, 16:50:00) [GCC 14.3.0]
OSLinux-6.6.87.2-microsoft-standard-WSL2-x86_64-with-glibc2.39
CPU16/16 logical CPU cores, x86_64
GPUID: 0, NVIDIA GeForce RTX 5060 Laptop GPU, Driver: 596.08, Memory: 8151 MiB
Updated2026-06-24 04:49
Dependencies
DependencyVersion
llvmlite0.47.0
torch2.12.1+cu132
ipykernel7.3.0
MarkupSafe3.0.3
argcomplete3.6.2
stack-data0.6.3
fonttools4.63.0
cuda-pathfinder1.2.2
seaborn0.13.2
PyYAML6.0.3
platformdirs4.10.0
pyarrow24.0.0
statsmodels0.14.6
numba0.65.1
fsspec2026.4.0
Jinja23.1.6
regex2026.5.9
patsy1.0.2
opt_einsum3.4.0
psutil7.2.2
requests2.28.1
tqdm4.66.5
networkx3.6.1
ipywidgets8.1.8
pyzmq27.1.0
adjustText1.4.0
RapidFuzz3.14.5
umap-learn0.5.12
urllib31.26.13
jupyter_core5.9.1
session-info20.4.1
pynndescent0.6.0
decorator5.3.1
asttokens3.0.1
kiwisolver1.5.0
scverse-misc0.0.9
plotly6.8.0
six1.17.0
pycparser3.0 (3.00)
brotli1.2.0
rich15.0.0
legacy-api-wrap1.5
awkward_cpp53
joblib1.5.3
pooch1.9.0 (v1.9.0)
parso0.8.7
msgpack1.2.1
muon0.1.7
logomaker0.8.7
rpy2-robjects3.6.5
matplotlib3.11.0
cycler0.12.1
executing2.2.1
rpy2-rinterface3.6.6
comm0.2.3
charset-normalizer2.1.1
matplotlib-inline0.2.2
packaging24.1
idna3.4
wcwidth0.8.1
natsort8.4.0
tzlocal5.4.3
jedi0.20.0
Pygments2.20.0
threadpoolctl3.6.0
certifi2022.12.7 (2022.12.07)
pyparsing3.3.2
tornado6.5.7
awkward2.9.1
narwhals2.22.1
scipy1.18.0
python-dateutil2.9.0.post0
prompt_toolkit3.0.52
cuda-bindings13.0.3
pure_eval0.2.3
texttable1.7.0
igraph1.0.0
setuptools70.2.0
jupyter_client8.9.1
typing_extensions4.15.0
Levenshtein0.27.3
traitlets5.15.1
debugpy1.8.21
h5py3.14.0
pillow12.2.0
ipython9.14.1
cffi2.0.0
Copyable Markdown
| Package      | Version |
| ------------ | ------- |
| pandas       | 3.0.3   |
| mudata       | 0.3.8   |
| scibex       | 0.1.0b2 |
| scanpy       | 1.11.5  |
| scirpy       | 0.24.0  |
| anndata      | 0.11.3  |
| scikit-learn | 1.9.0   |
| numpy        | 2.4.4   |

| Dependency         | Version                |
| ------------------ | ---------------------- |
| llvmlite           | 0.47.0                 |
| torch              | 2.12.1+cu132           |
| ipykernel          | 7.3.0                  |
| MarkupSafe         | 3.0.3                  |
| argcomplete        | 3.6.2                  |
| stack-data         | 0.6.3                  |
| fonttools          | 4.63.0                 |
| cuda-pathfinder    | 1.2.2                  |
| seaborn            | 0.13.2                 |
| PyYAML             | 6.0.3                  |
| platformdirs       | 4.10.0                 |
| pyarrow            | 24.0.0                 |
| statsmodels        | 0.14.6                 |
| numba              | 0.65.1                 |
| fsspec             | 2026.4.0               |
| Jinja2             | 3.1.6                  |
| regex              | 2026.5.9               |
| patsy              | 1.0.2                  |
| opt_einsum         | 3.4.0                  |
| psutil             | 7.2.2                  |
| requests           | 2.28.1                 |
| tqdm               | 4.66.5                 |
| networkx           | 3.6.1                  |
| ipywidgets         | 8.1.8                  |
| pyzmq              | 27.1.0                 |
| adjustText         | 1.4.0                  |
| RapidFuzz          | 3.14.5                 |
| umap-learn         | 0.5.12                 |
| urllib3            | 1.26.13                |
| jupyter_core       | 5.9.1                  |
| session-info2      | 0.4.1                  |
| pynndescent        | 0.6.0                  |
| decorator          | 5.3.1                  |
| asttokens          | 3.0.1                  |
| kiwisolver         | 1.5.0                  |
| scverse-misc       | 0.0.9                  |
| plotly             | 6.8.0                  |
| six                | 1.17.0                 |
| pycparser          | 3.0 (3.00)             |
| brotli             | 1.2.0                  |
| rich               | 15.0.0                 |
| legacy-api-wrap    | 1.5                    |
| awkward_cpp        | 53                     |
| joblib             | 1.5.3                  |
| pooch              | 1.9.0 (v1.9.0)         |
| parso              | 0.8.7                  |
| msgpack            | 1.2.1                  |
| muon               | 0.1.7                  |
| logomaker          | 0.8.7                  |
| rpy2-robjects      | 3.6.5                  |
| matplotlib         | 3.11.0                 |
| cycler             | 0.12.1                 |
| executing          | 2.2.1                  |
| rpy2-rinterface    | 3.6.6                  |
| comm               | 0.2.3                  |
| charset-normalizer | 2.1.1                  |
| matplotlib-inline  | 0.2.2                  |
| packaging          | 24.1                   |
| idna               | 3.4                    |
| wcwidth            | 0.8.1                  |
| natsort            | 8.4.0                  |
| tzlocal            | 5.4.3                  |
| jedi               | 0.20.0                 |
| Pygments           | 2.20.0                 |
| threadpoolctl      | 3.6.0                  |
| certifi            | 2022.12.7 (2022.12.07) |
| pyparsing          | 3.3.2                  |
| tornado            | 6.5.7                  |
| awkward            | 2.9.1                  |
| narwhals           | 2.22.1                 |
| scipy              | 1.18.0                 |
| python-dateutil    | 2.9.0.post0            |
| prompt_toolkit     | 3.0.52                 |
| cuda-bindings      | 13.0.3                 |
| pure_eval          | 0.2.3                  |
| texttable          | 1.7.0                  |
| igraph             | 1.0.0                  |
| setuptools         | 70.2.0                 |
| jupyter_client     | 8.9.1                  |
| typing_extensions  | 4.15.0                 |
| Levenshtein        | 0.27.3                 |
| traitlets          | 5.15.1                 |
| debugpy            | 1.8.21                 |
| h5py               | 3.14.0                 |
| pillow             | 12.2.0                 |
| ipython            | 9.14.1                 |
| cffi               | 2.0.0                  |

| Component | Info                                                                           |
| --------- | ------------------------------------------------------------------------------ |
| Python    | 3.12.13 | packaged by conda-forge | (main, Mar  5 2026, 16:50:00) [GCC 14.3.0] |
| OS        | Linux-6.6.87.2-microsoft-standard-WSL2-x86_64-with-glibc2.39                   |
| CPU       | 16/16 logical CPU cores, x86_64                                                |
| GPU       | ID: 0, NVIDIA GeForce RTX 5060 Laptop GPU, Driver: 596.08, Memory: 8151 MiB    |
| Updated   | 2026-06-24 04:49                                                               |