Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/purple-snakes-stop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@platforma-open/milaboratories.generation-probability.software": major
"@platforma-open/milaboratories.generation-probability.workflow": major
"@platforma-open/milaboratories.generation-probability.block": major
"@platforma-open/milaboratories.generation-probability.model": major
"@platforma-open/milaboratories.generation-probability.ui": major
---

Initial release.

- Per-clonotype generation probability (Pgen) via OLGA on BCR and TCR
repertoires from MiXCR clonotyping.
- Human and mouse models for IGH, IGK, IGL, TRA, and TRB; recombination
model resolved from dataset species and per-chain locus metadata.
- Emits raw Pgen and -log10(Pgen) per chain (heavy/light for BCR,
beta/alpha for TCR).
13 changes: 13 additions & 0 deletions block/docs/description.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Overview

For every clonotype in a repertoire, this block computes the chance that V(D)J recombination would build its exact CDR3 by chance, before any immune selection — a value called its generation probability (Pgen). Some CDR3s form readily through many recombination paths (high Pgen); others sit far from the sequences recombination favors and are rare (low Pgen). Pgen spans about twenty orders of magnitude across a repertoire, so it ranks clonotypes on a scale that V/J gene usage and clone abundance cannot.

What a rare CDR3 means depends on your data. In post-immunisation antibody (BCR) repertoires, a rare heavy-chain CDR3 has drifted far from germline through affinity maturation, so Pgen ranks lead candidates by that distance. T-cell (TCR) repertoires undergo no somatic hypermutation, so there Pgen instead separates common public CDR3s — shared across people by chance — from rare, clonally expanded ones.

Each clonotype carries the raw Pgen and its −log10 form, where a higher −log10 marks a rarer CDR3, computed on its primary chain. These values feed downstream ranking blocks such as Lead Selection.

# Method

Pgen is computed with [OLGA](https://github.com/statbiophys/OLGA) v1.3.0, which sums over the V(D)J recombination paths that yield each CDR3 under a learned model. Calibrated models cover human and mouse across IGH / IGK / IGL / TRA / TRB. On anything else — another species such as camelid, or sequences with no true V(D)J origin such as display libraries and synthetic sets — OLGA still returns a number, but one with no biological meaning. OLGA is distributed under GPL-3.0.

> Sethna Z, Elhanati Y, Callan CG Jr, Walczak AM, Mora T. OLGA: fast computation of generation probabilities of B- and T-cell receptor amino acid sequences and motifs. _Bioinformatics_ 35(17):2974–2981, 2019. [doi:10.1093/bioinformatics/btz035](https://doi.org/10.1093/bioinformatics/btz035)
18 changes: 14 additions & 4 deletions block/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,22 @@
"ui": "@platforma-open/milaboratories.generation-probability.ui/dist"
},
"meta": {
"title": "generation-probability",
"description": "generation-probability block",
"title": "Generation Probability",
"logo": "file:logos/block-logo.png",
"url": "https://github.com/platforma-open/generation-probability",
"support": "mailto:support@milaboratories.com",
"description": "Scores each BCR or TCR clonotype by how likely V(D)J recombination is to produce its CDR3 by chance — its generation probability (Pgen) — a clonal-rarity and germline-distance signal for lead ranking.",
"longDescription": "file:docs/description.md",
"changelog": "file:CHANGELOG.md",
"tags": [
"vdj",
"bcr",
"tcr",
"downstream"
],
"organization": {
"name": "milaboratories",
"url": "https://example.com",
"name": "MiLaboratories Inc",
"url": "https://milaboratories.com/",
"logo": "file:logos/organization-logo.png"
}
}
Expand Down
2 changes: 2 additions & 0 deletions model/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"check": "ts-builder check --target block-model"
},
"dependencies": {
"@milaboratories/graph-maker": "catalog:",
"@milaboratories/helpers": "catalog:",
"@platforma-sdk/model": "catalog:"
},
"devDependencies": {
Expand Down
182 changes: 176 additions & 6 deletions model/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,183 @@
import type { InferOutputsType } from "@platforma-sdk/model";
import { BlockModelV3, DataModelBuilder } from "@platforma-sdk/model";
import type { GraphMakerState } from "@milaboratories/graph-maker";
import {
AccessorColumnsProvider,
BlockModelV3,
ColumnsCollection,
createPFrameForGraphs,
createPlDataTableStateV2,
createPlDataTableV3,
DataModelBuilder,
deriveColumnOptions,
InferOutputsType,
ListOptionBase,
PColumn,
PColumnDataUniversal,
PColumnIdAndSpec,
PlDataTableStateV2,
} from "@platforma-sdk/model";

export type BlockArgs = Record<string, never>;
export const SPECIES_OPTIONS = [
{ label: "Human", value: "human" },
{ label: "Mouse", value: "mouse" },
] as const;

const dataModel = new DataModelBuilder().from<BlockArgs>("v1").init(() => ({}));
export const PGEN_NAME = "pl7.app/vdj/generationProbability";
const CHAIN_NAME = "pl7.app/vdj/chain";

export type BlockData = {
inputAnchor?: string;
datasetLabel: string;
species?: (typeof SPECIES_OPTIONS)[number]["value"];
tableState: PlDataTableStateV2;
distributionGraphState: GraphMakerState;
};

const inputSelectors = [
{
axes: [{ name: "pl7.app/vdj/clonotypeKey" }],
annotations: { "pl7.app/isAnchor": "true" },
},
{
axes: [{ name: "pl7.app/vdj/scClonotypeKey" }],
annotations: { "pl7.app/isAnchor": "true" },
},
{
axes: [{ name: "pl7.app/variantKey" }],
annotations: { "pl7.app/isAnchor": "true" },
},
];

const dataModel = new DataModelBuilder().from<BlockData>("v1").init(() => ({
datasetLabel: "",
tableState: createPlDataTableStateV2(),
distributionGraphState: {
title: "Generation Probability",
template: "bins",
currentTab: null,
layersSettings: { bins: { fillColor: "#99E099" } },
axesSettings: { axisY: { scale: "log" } },
},
}));

export const platforma = BlockModelV3.create(dataModel)
.args(() => ({}))
.sections(() => [{ type: "link", href: "/", label: "Main" }])

.args((data) => {
if (data.inputAnchor == null) throw new Error("Input dataset is required");
if (data.species == null) throw new Error("Species is required");
return {
inputAnchor: data.inputAnchor,
species: data.species,
};
})

.output("inputOptions", () => {
const collection = ColumnsCollection(["result_pool"]).filter({
include: inputSelectors,
});
const scorableIds = new Set(
collection
.getColumns()
.filter(
(anchor) =>
!ColumnsCollection(["result_pool"])
.discover({
anchors: { main: anchor.getSpec() },
include: [{ name: [{ type: "exact", value: CHAIN_NAME }] }],
mode: "enrichment",
})
.isEmpty(),
)
.map((anchor) => anchor.id),
);
return deriveColumnOptions(collection)
.filter(({ id }) => scorableIds.has(id))
.map<ListOptionBase<string>>(({ id, label }) => ({ value: id, label }));
})

.outputWithStatus("pgenTable", (ctx) => {
const pgenOutput = ctx.outputs?.resolve("pgenPf");
if (pgenOutput === undefined) return undefined;
const collection = ColumnsCollection([pgenOutput]);
if (!collection.isFinal()) return undefined;
return createPlDataTableV3(ctx, {
primaryColumns: collection.filter({ include: [{ name: PGEN_NAME }] }).getColumns(),
columns: collection.filter({ exclude: [{ name: PGEN_NAME }] }).getColumns(),
tableState: ctx.data.tableState,
});
})

// Only the raw pgen columns (one per chain), so the graph's value picker doubles as the
// Heavy/Light chooser.
.outputWithStatus("pgenGraphPf", (ctx) => {
const pgenOutput = ctx.outputs?.resolve("pgenPf");
if (pgenOutput === undefined) return undefined;
const provider = AccessorColumnsProvider(pgenOutput);
if (!provider.isFinal()) return undefined;
const pgenCols = provider
.getColumns()
.map<PColumn<undefined | PColumnDataUniversal>>((column) => ({
id: column.id,
spec: column.getSpec(),
data: column.getData(),
}))
.filter((column) => column.spec.name === PGEN_NAME);
if (pgenCols.length === 0) return undefined;
return createPFrameForGraphs(ctx, pgenCols);
})

.output("pgenGraphPfCols", (ctx) => {
const pgenOutput = ctx.outputs?.resolve("pgenPf");
if (pgenOutput === undefined) return undefined;
const provider = AccessorColumnsProvider(pgenOutput);
if (!provider.isFinal()) return undefined;
return provider
.getColumns()
.map<PColumnIdAndSpec>((column) => ({ columnId: column.id, spec: column.getSpec() }))
.filter((column) => column.spec.name === PGEN_NAME);
})

.output("progress", (ctx) =>
ctx.outputs
?.resolve("progress")
?.getProgressLog("progress: ")
.mapDefined((progressLine) => {
const progress = progressLine?.match(/progress: (?<progress>\S+)/)?.groups?.progress;
if (!progress) return true;
return Number(progress) || true;
}),
)

.output("skippedChains", (ctx) =>
ctx.outputs
?.resolve("progress")
?.getProgressLog("skipped: ")
.mapDefined((line) => {
const match = line?.match(/skipped: (?<chains>.*)/)?.groups;
if (!match) return undefined;
const chains = match.chains.split(",").filter(Boolean);
if (chains.length === 0) return undefined;
return chains;
}),
)

.output("isRunning", (ctx) => ctx.outputs?.getIsReadyOrError() === false)

.title(() => "Generation Probability")

.subtitle((ctx) =>
[
ctx.data.datasetLabel,
SPECIES_OPTIONS.find((option) => option.value === ctx.data.species)?.label,
]
.filter(Boolean)
.join(" - "),
)

.sections(() => [
{ type: "link", href: "/", label: "Main" },
{ type: "link", href: "/distribution", label: "Distribution" },
])

.done();

export type BlockOutputs = InferOutputsType<typeof platforma>;
Loading
Loading