@datagrok/peptides 1.27.8 → 1.27.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/CLAUDE.md +1 -0
- package/CREDITS.md +74 -0
- package/dist/package-test.js +1 -1
- package/dist/package-test.js.map +1 -1
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/package.json +8 -8
- package/src/model.ts +1 -2
- package/src/package-api.ts +7 -0
- package/src/package.g.ts +10 -0
- package/src/package.ts +13 -0
- package/src/tests/viewers.ts +56 -0
- package/src/utils/misc.ts +2 -2
- package/src/viewers/peptide-generation-viewer.ts +745 -0
- package/test-console-output-1.log +97 -137
- package/test-record-1.mp4 +0 -0
- package/tsconfig.json +2 -1
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
/* eslint-disable max-len */
|
|
2
|
+
import * as grok from 'datagrok-api/grok';
|
|
3
|
+
import * as ui from 'datagrok-api/ui';
|
|
4
|
+
import * as DG from 'datagrok-api/dg';
|
|
5
|
+
import wu from 'wu';
|
|
6
|
+
import $ from 'cash-dom';
|
|
7
|
+
import * as rxjs from 'rxjs';
|
|
8
|
+
|
|
9
|
+
import * as C from '../utils/constants';
|
|
10
|
+
import {PeptideUtils} from '../peptideUtils';
|
|
11
|
+
import {scaleActivity, highlightMonomerPosition} from '../utils/misc';
|
|
12
|
+
import {calculateMonomerPositionStatistics} from '../utils/algorithms';
|
|
13
|
+
import {MonomerPositionStats, PositionStats, StatsItem} from '../utils/statistics';
|
|
14
|
+
import {showTooltip} from '../utils/tooltips';
|
|
15
|
+
import {splitAlignedSequences} from '@datagrok-libraries/bio/src/utils/splitter';
|
|
16
|
+
import {StringListSeqSplitted} from '@datagrok-libraries/bio/src/utils/macromolecule/utils';
|
|
17
|
+
import {ALPHABET, TAGS as bioTAGS} from '@datagrok-libraries/bio/src/utils/macromolecule';
|
|
18
|
+
import {GapOriginals, GAP_SYMBOL, NOTATION} from '@datagrok-libraries/bio/src/utils/macromolecule/consts';
|
|
19
|
+
import {ISeqHandler} from '@datagrok-libraries/bio/src/utils/macromolecule/seq-handler';
|
|
20
|
+
import {HelmTypes} from '@datagrok-libraries/bio/src/helm/consts';
|
|
21
|
+
import {HelmType} from '@datagrok-libraries/bio/src/helm/types';
|
|
22
|
+
import {PeptidesModel, VIEWER_TYPE} from '../model';
|
|
23
|
+
import {SARViewer} from './sar-viewer';
|
|
24
|
+
|
|
25
|
+
export enum PEPTIDE_GENERATION_PROPS {
|
|
26
|
+
SEQUENCE = 'sequence',
|
|
27
|
+
ACTIVITY = 'activity',
|
|
28
|
+
ACTIVITY_SCALING = 'activityScaling',
|
|
29
|
+
ACTIVITY_TARGET = 'activityTarget',
|
|
30
|
+
PEPTIDE_COUNT = 'peptideCount',
|
|
31
|
+
CANDIDATES_PER_POSITION = 'candidatesPerPosition',
|
|
32
|
+
BEAM_WIDTH = 'beamWidth',
|
|
33
|
+
MIN_SUPPORT = 'minSupport',
|
|
34
|
+
MAX_P_VALUE = 'maxPValue',
|
|
35
|
+
SHRINKAGE = 'shrinkageStrength',
|
|
36
|
+
EXCLUDE_EXISTING = 'excludeExisting',
|
|
37
|
+
ONLY_SIGNIFICANT = 'onlySignificantPositions',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Generated-peptides result grid column names. */
|
|
41
|
+
export enum GEN_COLUMN_NAMES {
|
|
42
|
+
PEPTIDE = 'Peptide',
|
|
43
|
+
PREDICTED = 'Predicted activity',
|
|
44
|
+
CONFIDENCE = 'Confidence',
|
|
45
|
+
MIN_SUPPORT = 'Min support',
|
|
46
|
+
MEAN_P_VALUE = 'Mean p-value',
|
|
47
|
+
SIGNIFICANT_POSITIONS = 'Significant positions',
|
|
48
|
+
NOVEL = 'Novel',
|
|
49
|
+
BASIS = 'Basis',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A single candidate monomer choice at a given position. */
|
|
53
|
+
type Candidate = {
|
|
54
|
+
position: string,
|
|
55
|
+
monomer: string,
|
|
56
|
+
/** Deviation of this monomer's group mean activity from the global mean (additive contribution). */
|
|
57
|
+
contribution: number,
|
|
58
|
+
count: number,
|
|
59
|
+
pValue: number | null,
|
|
60
|
+
significant: boolean,
|
|
61
|
+
supported: boolean,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** A partial peptide during beam search. */
|
|
65
|
+
type BeamItem = {
|
|
66
|
+
monomers: string[],
|
|
67
|
+
candidates: Candidate[],
|
|
68
|
+
cumContribution: number,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const PEPTIDE_GENERATION_VIEWER_NAME = 'Peptide Generation';
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* De novo peptide generator.
|
|
75
|
+
*
|
|
76
|
+
* Reuses the per-monomer-per-position statistics (the same engine that powers the Most Potent Residues and
|
|
77
|
+
* Sequence Variability Map viewers) to synthesize a ranked set of new candidate peptides under an additive
|
|
78
|
+
* positional (Free-Wilson-style) activity model:
|
|
79
|
+
*
|
|
80
|
+
* predicted(peptide) = globalMean + Σ_positions (meanActivity(monomer@position) − globalMean)
|
|
81
|
+
*
|
|
82
|
+
* Candidates are enumerated with a bounded beam search over the best-performing monomers at each position and
|
|
83
|
+
* ranked by predicted activity for the chosen target (High/Low). Each generated peptide is reported together
|
|
84
|
+
* with the statistical support behind the prediction, so the (position-independence) assumption of the model
|
|
85
|
+
* stays visible to the user.
|
|
86
|
+
*/
|
|
87
|
+
export class PeptideGenerationViewer extends DG.JsViewer {
|
|
88
|
+
sequenceColumnName: string;
|
|
89
|
+
activityColumnName: string;
|
|
90
|
+
activityScaling: C.SCALING_METHODS;
|
|
91
|
+
activityTarget: C.ACTIVITY_TARGET;
|
|
92
|
+
peptideCount: number;
|
|
93
|
+
candidatesPerPosition: number;
|
|
94
|
+
beamWidth: number;
|
|
95
|
+
minSupport: number;
|
|
96
|
+
maxPValue: number;
|
|
97
|
+
shrinkageStrength: number;
|
|
98
|
+
excludeExisting: boolean;
|
|
99
|
+
onlySignificantPositions: boolean;
|
|
100
|
+
|
|
101
|
+
private _grid: DG.Grid | null = null;
|
|
102
|
+
private _positionColumns: DG.Column<string>[] | null = null;
|
|
103
|
+
private _monomerPositionStats: MonomerPositionStats | null = null;
|
|
104
|
+
/** Scaled activity column aligned to the source dataframe, used for the invariant-map style tooltips. */
|
|
105
|
+
private _scaledActivityCol: DG.Column<number> | null = null;
|
|
106
|
+
/** Per generated position (grid column name) → per result-row chosen monomer stats, for cell rendering/tooltips. */
|
|
107
|
+
private _basisByPosition: {[position: string]: (Candidate | null)[]} = {};
|
|
108
|
+
private _basisPositionNames: string[] = [];
|
|
109
|
+
private _basisPositionSet: Set<string> = new Set();
|
|
110
|
+
/** Per result-row max absolute contribution, for fast per-row circle-size normalization. */
|
|
111
|
+
private _rowMaxAbsContribution: number[] = [];
|
|
112
|
+
/** Monomer symbol → text color derived from the monomer library background color. */
|
|
113
|
+
private _monomerColorCache: Map<string, string> = new Map();
|
|
114
|
+
private _biotype: HelmType = HelmTypes.AA;
|
|
115
|
+
|
|
116
|
+
constructor() {
|
|
117
|
+
super();
|
|
118
|
+
this.sequenceColumnName = this.column(PEPTIDE_GENERATION_PROPS.SEQUENCE,
|
|
119
|
+
{semType: DG.SEMTYPE.MACROMOLECULE, nullable: false});
|
|
120
|
+
this.activityColumnName = this.column(PEPTIDE_GENERATION_PROPS.ACTIVITY,
|
|
121
|
+
{columnTypeFilter: 'numerical', nullable: false});
|
|
122
|
+
this.activityScaling = this.string(PEPTIDE_GENERATION_PROPS.ACTIVITY_SCALING, C.SCALING_METHODS.NONE,
|
|
123
|
+
{choices: Object.values(C.SCALING_METHODS), nullable: false,
|
|
124
|
+
description: 'Activity transformation applied before computing statistics'}) as C.SCALING_METHODS;
|
|
125
|
+
this.activityTarget = this.string(PEPTIDE_GENERATION_PROPS.ACTIVITY_TARGET, C.ACTIVITY_TARGET.HIGH,
|
|
126
|
+
{choices: Object.values(C.ACTIVITY_TARGET), nullable: false,
|
|
127
|
+
description: 'Whether to design peptides that maximize (High) or minimize (Low) the scaled activity'}) as C.ACTIVITY_TARGET;
|
|
128
|
+
this.peptideCount = this.int(PEPTIDE_GENERATION_PROPS.PEPTIDE_COUNT, 50,
|
|
129
|
+
{min: 1, max: 1000, description: 'How many peptides to generate'});
|
|
130
|
+
this.candidatesPerPosition = this.int(PEPTIDE_GENERATION_PROPS.CANDIDATES_PER_POSITION, 3,
|
|
131
|
+
{min: 1, max: 10, description: 'Number of best-performing monomers considered at each position'});
|
|
132
|
+
this.beamWidth = this.int(PEPTIDE_GENERATION_PROPS.BEAM_WIDTH, 100,
|
|
133
|
+
{min: 1, max: 2000, description: 'Beam search width. Larger values explore more combinations at higher cost'});
|
|
134
|
+
this.minSupport = this.int(PEPTIDE_GENERATION_PROPS.MIN_SUPPORT, 3,
|
|
135
|
+
{min: 1, max: 1000, description: 'Minimum number of observed sequences backing a monomer for it to be trusted'});
|
|
136
|
+
this.maxPValue = this.float(PEPTIDE_GENERATION_PROPS.MAX_P_VALUE, 1,
|
|
137
|
+
{min: 0, max: 1, description: 'Only consider monomers with a t-test p-value at or below this value (1 = no filtering)'});
|
|
138
|
+
this.shrinkageStrength = this.float(PEPTIDE_GENERATION_PROPS.SHRINKAGE, 5,
|
|
139
|
+
{min: 0, max: 100, description: 'Shrinks each position\'s contribution towards zero by a factor of ' +
|
|
140
|
+
'count/(count+k), damping poorly-supported monomers so they cannot inflate the predicted activity. ' +
|
|
141
|
+
'0 disables shrinkage (raw additive model, prone to over-prediction)'});
|
|
142
|
+
this.excludeExisting = this.bool(PEPTIDE_GENERATION_PROPS.EXCLUDE_EXISTING, true,
|
|
143
|
+
{description: 'Exclude generated peptides that already exist in the source data'});
|
|
144
|
+
this.onlySignificantPositions = this.bool(PEPTIDE_GENERATION_PROPS.ONLY_SIGNIFICANT, false,
|
|
145
|
+
{description: 'Vary only positions that have at least one statistically significant monomer; keep the ' +
|
|
146
|
+
'most common monomer elsewhere'});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
get name(): string {
|
|
150
|
+
return PEPTIDE_GENERATION_VIEWER_NAME;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
onTableAttached(): void {
|
|
154
|
+
super.onTableAttached();
|
|
155
|
+
const seqCol = this.dataFrame.columns.bySemType(DG.SEMTYPE.MACROMOLECULE);
|
|
156
|
+
if (seqCol == null) {
|
|
157
|
+
grok.shell.warning('Peptide Generation: no Macromolecule column found');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
this.getProperty(PEPTIDE_GENERATION_PROPS.SEQUENCE + 'ColumnName')?.set(this, seqCol.name);
|
|
161
|
+
const potentialActivity = wu(this.dataFrame.columns.numerical)
|
|
162
|
+
.find((col) => col.name.toLowerCase().includes('activity')) ?? wu(this.dataFrame.columns.numerical).next()?.value;
|
|
163
|
+
if (potentialActivity)
|
|
164
|
+
this.getProperty(PEPTIDE_GENERATION_PROPS.ACTIVITY + 'ColumnName')?.set(this, potentialActivity.name);
|
|
165
|
+
this.subs.push(DG.debounce(this.dataFrame.onFilterChanged, 500).subscribe(() => {
|
|
166
|
+
this._propChangeTimer && clearTimeout(this._propChangeTimer);
|
|
167
|
+
this._monomerPositionStats = null;
|
|
168
|
+
this._grid = null;
|
|
169
|
+
this.render();
|
|
170
|
+
}));
|
|
171
|
+
this.render();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private _propChangeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
175
|
+
onPropertyChanged(_property: DG.Property | null): void {
|
|
176
|
+
if (!this.dataFrame || !this.sequenceColumnName || !this.activityColumnName)
|
|
177
|
+
return;
|
|
178
|
+
this._propChangeTimer && clearTimeout(this._propChangeTimer);
|
|
179
|
+
this._propChangeTimer = setTimeout(() => {
|
|
180
|
+
this._positionColumns = null;
|
|
181
|
+
this._monomerPositionStats = null;
|
|
182
|
+
this._grid = null;
|
|
183
|
+
this.render();
|
|
184
|
+
}, 500);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** PeptidesModel already attached to the dataframe, or null. Never force-creates one. */
|
|
188
|
+
private get existingModel(): PeptidesModel | null {
|
|
189
|
+
return (this.dataFrame?.temp?.[PeptidesModel.modelName] as PeptidesModel) ?? null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Per-monomer-per-position statistics. Reuses the cached statistics of the SAR viewers (or the analysis model)
|
|
194
|
+
* when they cover the same sequence/activity/scaling and filtering state, mirroring how those viewers share stats
|
|
195
|
+
* among themselves. Falls back to computing its own (filter-aware) statistics when nothing reusable is present.
|
|
196
|
+
*/
|
|
197
|
+
get monomerPositionStats(): MonomerPositionStats {
|
|
198
|
+
if (this._monomerPositionStats != null)
|
|
199
|
+
return this._monomerPositionStats;
|
|
200
|
+
|
|
201
|
+
const wantFiltered = this.dataFrame.filter.anyFalse;
|
|
202
|
+
const paramsMatch = (o: {sequenceColumnName?: string, activityColumnName?: string,
|
|
203
|
+
activityScaling?: C.SCALING_METHODS} | null | undefined): boolean =>
|
|
204
|
+
o != null && this.sequenceColumnName === o.sequenceColumnName &&
|
|
205
|
+
this.activityColumnName === o.activityColumnName && this.activityScaling === o.activityScaling;
|
|
206
|
+
|
|
207
|
+
const model = this.existingModel;
|
|
208
|
+
if (model != null) {
|
|
209
|
+
// A sibling SAR viewer may already hold matching stats. Its mean/count/p-value are pure activity statistics
|
|
210
|
+
// (unaffected by any invariant-map value aggregation), so only the data and filtering intent must match.
|
|
211
|
+
for (const viewerType of [VIEWER_TYPE.SEQUENCE_VARIABILITY_MAP, VIEWER_TYPE.MOST_POTENT_RESIDUES]) {
|
|
212
|
+
const viewer = model.findViewer(viewerType) as SARViewer | null;
|
|
213
|
+
if (viewer?._monomerPositionStats != null && paramsMatch(viewer) &&
|
|
214
|
+
(!wantFiltered || viewer.dataSource === 'Filtered'))
|
|
215
|
+
return this._monomerPositionStats = viewer._monomerPositionStats;
|
|
216
|
+
}
|
|
217
|
+
// The model caches unfiltered statistics; safe to reuse only when we are not filtering.
|
|
218
|
+
if (!wantFiltered && model.monomerPositionStats != null && paramsMatch(model.settings))
|
|
219
|
+
return this._monomerPositionStats = model.monomerPositionStats;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const scaledActivityCol = scaleActivity(this.dataFrame.getCol(this.activityColumnName), this.activityScaling);
|
|
223
|
+
this._monomerPositionStats = calculateMonomerPositionStatistics(scaledActivityCol, this.dataFrame.filter,
|
|
224
|
+
this.getPositionColumns(), {isFiltered: wantFiltered});
|
|
225
|
+
return this._monomerPositionStats;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
detach(): void {
|
|
229
|
+
this.subs.forEach((sub) => sub.unsubscribe());
|
|
230
|
+
this._currentCellSub?.unsubscribe();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Resolves per-position monomer columns, reusing the ones produced by an existing analysis when possible. */
|
|
234
|
+
private getPositionColumns(): DG.Column<string>[] {
|
|
235
|
+
if (this._positionColumns != null)
|
|
236
|
+
return this._positionColumns;
|
|
237
|
+
const tagged = wu(this.dataFrame.columns.toList())
|
|
238
|
+
.filter((col) => col.getTag(C.TAGS.POSITION_COL) === `${true}`).toArray();
|
|
239
|
+
if (tagged.length !== 0)
|
|
240
|
+
this._positionColumns = tagged as DG.Column<string>[];
|
|
241
|
+
else {
|
|
242
|
+
this._positionColumns = splitAlignedSequences(this.dataFrame.getCol(this.sequenceColumnName),
|
|
243
|
+
PeptideUtils.getSeqHelper()).columns.toList();
|
|
244
|
+
}
|
|
245
|
+
return this._positionColumns;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
render(): void {
|
|
249
|
+
$(this.root).empty();
|
|
250
|
+
if (!this.dataFrame || !this.sequenceColumnName || !this.activityColumnName) {
|
|
251
|
+
this.root.appendChild(ui.divText('Please select a sequence and an activity column in the viewer properties'));
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
this._grid ??= this.createGrid();
|
|
257
|
+
} catch (e) {
|
|
258
|
+
grok.log.error(e as string);
|
|
259
|
+
this.root.appendChild(ui.divText(`Peptide Generation failed: ${(e as Error)?.message ?? e}`));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const targetLabel = this.activityTarget === C.ACTIVITY_TARGET.HIGH ? 'highest' : 'lowest';
|
|
264
|
+
const subtitle = ui.divText(
|
|
265
|
+
`${this._grid.table.rowCount} peptides generated for ${targetLabel} activity ` +
|
|
266
|
+
`(additive positional model — treats positions as independent)`,
|
|
267
|
+
{style: {color: 'var(--grey-5)', fontSize: '12px'}});
|
|
268
|
+
|
|
269
|
+
const addIcon = ui.iconFA('plus', () => this.openAsTableView(),
|
|
270
|
+
'Open the generated peptides as a new table');
|
|
271
|
+
addIcon.style.cursor = 'pointer';
|
|
272
|
+
addIcon.style.color = 'var(--blue-1)';
|
|
273
|
+
|
|
274
|
+
const infoIcon = ui.iconFA('info-circle');
|
|
275
|
+
infoIcon.style.color = 'var(--blue-1)';
|
|
276
|
+
infoIcon.style.cursor = 'help';
|
|
277
|
+
ui.tooltip.bind(infoIcon, () => this.buildInfoTooltip());
|
|
278
|
+
|
|
279
|
+
const header = ui.divH([subtitle, addIcon, infoIcon],
|
|
280
|
+
{style: {alignItems: 'center', gap: '6px', padding: '4px 8px'}});
|
|
281
|
+
|
|
282
|
+
const gridRoot = this._grid.root;
|
|
283
|
+
gridRoot.style.width = '100%';
|
|
284
|
+
this.root.appendChild(ui.divV([header, gridRoot], {style: {height: '100%'}}));
|
|
285
|
+
this._grid.invalidate();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Opens the generated peptides as a standalone table view. The custom-rendered per-position basis columns are
|
|
290
|
+
* flattened to plain "monomer, contribution, p-value" text so the exported table is self-describing.
|
|
291
|
+
*/
|
|
292
|
+
private openAsTableView(): void {
|
|
293
|
+
if (this._grid == null)
|
|
294
|
+
return;
|
|
295
|
+
const clone = this._grid.table.clone();
|
|
296
|
+
clone.name = 'Generated Peptides';
|
|
297
|
+
for (const posName of this._basisPositionNames) {
|
|
298
|
+
const col = clone.col(posName);
|
|
299
|
+
const cellData = this._basisByPosition[posName];
|
|
300
|
+
if (col == null || cellData == null)
|
|
301
|
+
continue;
|
|
302
|
+
col.init((i) => {
|
|
303
|
+
const c = cellData[i];
|
|
304
|
+
if (c == null || !c.monomer)
|
|
305
|
+
return '';
|
|
306
|
+
const pValue = c.pValue == null ? 'n/a' :
|
|
307
|
+
(c.pValue < 0.001 ? c.pValue.toExponential(1) : c.pValue.toFixed(3));
|
|
308
|
+
return `${c.monomer}, ${this.formatContribution(c.contribution)}, ${pValue}`;
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
const v = grok.shell.addTableView(clone);
|
|
312
|
+
setTimeout(() => grok.shell.v = v, 50);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Rich explanation shown on hovering the info icon: how the algorithm works and how to read the results. */
|
|
316
|
+
private buildInfoTooltip(): HTMLElement {
|
|
317
|
+
return ui.divV([
|
|
318
|
+
ui.h3('How Peptide Generation works'),
|
|
319
|
+
ui.divText('Additive positional (Free-Wilson) model', {style: {fontWeight: 'bold'}}),
|
|
320
|
+
ui.divText('Predicted activity = global mean + Σ over positions of (chosen monomer\'s mean activity − ' +
|
|
321
|
+
'global mean). Each per-position contribution is shrunk by count / (count + k) so that poorly-supported ' +
|
|
322
|
+
'monomers cannot dominate the prediction.'),
|
|
323
|
+
ui.divText('Generation', {style: {fontWeight: 'bold', marginTop: '6px'}}),
|
|
324
|
+
ui.divText('At each position the best-scoring monomers toward the chosen High/Low target are kept, then a ' +
|
|
325
|
+
'beam search combines them into whole peptides, ranked by predicted activity.'),
|
|
326
|
+
ui.divText('Per-position basis columns', {style: {fontWeight: 'bold', marginTop: '6px'}}),
|
|
327
|
+
ui.divText('The trailing columns (one per position) show the chosen monomer, an effect circle, and the ' +
|
|
328
|
+
'signed contribution. In each circle:'),
|
|
329
|
+
ui.list([
|
|
330
|
+
'Size = effect magnitude (|contribution|), normalized within the row: bigger = stronger effect on activity.',
|
|
331
|
+
'Color = direction and significance: red raises activity, blue lowers it, pale ≈ weak significance, ' +
|
|
332
|
+
'grey = not enough data to test.',
|
|
333
|
+
'Hover a cell for the full activity distribution of that monomer-at-position versus the rest.',
|
|
334
|
+
], {processNode: (a) => {a.style.maxWidth = '470px'; a.style.display = 'block';},
|
|
335
|
+
}),
|
|
336
|
+
ui.divText('Keep in mind', {style: {fontWeight: 'bold', marginTop: '6px'}}),
|
|
337
|
+
ui.list([
|
|
338
|
+
'Predicted activity is a ranking score / optimistic estimate, NOT a calibrated measurement.',
|
|
339
|
+
'Because contributions add across positions, it can extrapolate well beyond the observed activity ' +
|
|
340
|
+
'range — a peptide combining the best residue at every position may not exist or behave additively ' +
|
|
341
|
+
'(epistasis is invisible to this model).',
|
|
342
|
+
'Judge reliability with the Confidence, Min support, Significant positions and Basis columns.',
|
|
343
|
+
'Raise "Shrinkage strength" or enable "Only significant positions" for more conservative magnitudes. ' +
|
|
344
|
+
'On data with no real signal, few or no significant peptides should appear.',
|
|
345
|
+
], {processNode: (a) => {a.style.maxWidth = '470px'; a.style.display = 'block';}}),
|
|
346
|
+
], {style: {maxWidth: '480px', fontSize: '12px'}});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private _currentCellSub: rxjs.Subscription | null = null;
|
|
350
|
+
/** Builds the generated-peptides grid. */
|
|
351
|
+
private createGrid(): DG.Grid {
|
|
352
|
+
const resultDf = this.generatePeptides();
|
|
353
|
+
const grid = resultDf.plot.grid();
|
|
354
|
+
grid.props.allowEdit = false;
|
|
355
|
+
grid.props.showReadOnlyNotifications = false;
|
|
356
|
+
this._currentCellSub?.unsubscribe();
|
|
357
|
+
this._currentCellSub = grid.onCurrentCellChanged.subscribe((g) => {
|
|
358
|
+
if (g && g.dart && g.cell.dart && (g.tableRowIndex ?? -1) > -1 && g.cell.column?.name === GEN_COLUMN_NAMES.PEPTIDE)
|
|
359
|
+
grok.shell.o = DG.SemanticValue.fromGridCell(g);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const predictedCol = grid.col(GEN_COLUMN_NAMES.PREDICTED);
|
|
363
|
+
if (predictedCol)
|
|
364
|
+
predictedCol.format = '#.000';
|
|
365
|
+
const pValCol = grid.col(GEN_COLUMN_NAMES.MEAN_P_VALUE);
|
|
366
|
+
if (pValCol)
|
|
367
|
+
pValCol.format = '#.000';
|
|
368
|
+
const confCol = grid.col(GEN_COLUMN_NAMES.CONFIDENCE);
|
|
369
|
+
if (confCol)
|
|
370
|
+
confCol.format = '0.##%';
|
|
371
|
+
|
|
372
|
+
// The per-position basis columns supersede the terse text summary; keep the data but hide the column.
|
|
373
|
+
const basisTextCol = grid.col(GEN_COLUMN_NAMES.BASIS);
|
|
374
|
+
if (basisTextCol)
|
|
375
|
+
basisTextCol.visible = false;
|
|
376
|
+
|
|
377
|
+
// Custom-render the per-position basis cells: monomer + effect circle + contribution number.
|
|
378
|
+
const alphabet = this.dataFrame.getCol(this.sequenceColumnName).getTag(bioTAGS.alphabet) ?? ALPHABET.UN;
|
|
379
|
+
this._biotype = (alphabet === ALPHABET.RNA || alphabet === ALPHABET.DNA) ? HelmTypes.NUCLEOTIDE : HelmTypes.AA;
|
|
380
|
+
this._monomerColorCache.clear();
|
|
381
|
+
grid.props.rowHeight = 22;
|
|
382
|
+
for (const posName of this._basisPositionNames) {
|
|
383
|
+
const gc = grid.col(posName);
|
|
384
|
+
if (gc)
|
|
385
|
+
gc.width = 85;
|
|
386
|
+
}
|
|
387
|
+
grid.onCellRender.subscribe((args) => this.renderBasisCell(args));
|
|
388
|
+
grid.onCellTooltip((gridCell, x, y) => this.showBasisTooltip(gridCell, x, y));
|
|
389
|
+
grid.root.addEventListener('mouseleave', () => this.dataFrame?.rows?.highlight(() => false));
|
|
390
|
+
|
|
391
|
+
// Highest (or lowest) predicted activity on top.
|
|
392
|
+
grid.sort([GEN_COLUMN_NAMES.PREDICTED], [this.activityTarget !== C.ACTIVITY_TARGET.HIGH]);
|
|
393
|
+
return grid;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Renders a per-position basis cell: the chosen monomer, a circle whose radius encodes the (per-row normalized)
|
|
398
|
+
* absolute contribution and whose color encodes direction and significance, and the signed contribution value.
|
|
399
|
+
* @param args - Grid cell render arguments.
|
|
400
|
+
*/
|
|
401
|
+
private renderBasisCell(args: DG.GridCellRenderArgs): void {
|
|
402
|
+
const cell = args.cell;
|
|
403
|
+
const posName = cell.tableColumn?.name;
|
|
404
|
+
if (!cell.isTableCell || posName == null || !this._basisPositionSet.has(posName))
|
|
405
|
+
return;
|
|
406
|
+
|
|
407
|
+
const g = args.g;
|
|
408
|
+
const b = args.bounds;
|
|
409
|
+
g.save();
|
|
410
|
+
g.beginPath();
|
|
411
|
+
g.rect(b.x, b.y, b.width, b.height);
|
|
412
|
+
g.clip();
|
|
413
|
+
|
|
414
|
+
const rowIdx = cell.tableRowIndex;
|
|
415
|
+
const cand = rowIdx == null ? null : this._basisByPosition[posName]?.[rowIdx] ?? null;
|
|
416
|
+
if (cand == null || !cand.monomer) {
|
|
417
|
+
args.preventDefault();
|
|
418
|
+
g.restore();
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const pad = 3;
|
|
423
|
+
const midY = b.y + b.height / 2;
|
|
424
|
+
const monomerW = b.width * 0.36;
|
|
425
|
+
const numberW = b.width * 0.40;
|
|
426
|
+
const circleZoneX = b.x + pad + monomerW;
|
|
427
|
+
const circleZoneW = Math.max(6, b.width - monomerW - numberW - 2 * pad);
|
|
428
|
+
|
|
429
|
+
g.textBaseline = 'middle';
|
|
430
|
+
g.font = '11px Roboto, Roboto Local, sans-serif';
|
|
431
|
+
|
|
432
|
+
// Monomer symbol, colored with its monomer-library background color, ellipsized to the allocated width.
|
|
433
|
+
g.textAlign = 'left';
|
|
434
|
+
g.fillStyle = this.monomerTextColor(cand.monomer);
|
|
435
|
+
g.fillText(this.fitText(g, cand.monomer, monomerW - pad), b.x + pad, midY);
|
|
436
|
+
|
|
437
|
+
// Effect circle: radius ∝ |contribution| (per-row normalized); color = direction × significance (red/blue).
|
|
438
|
+
const rowMaxAbs = this._rowMaxAbsContribution[rowIdx!] || 0;
|
|
439
|
+
const rCoef = rowMaxAbs > 0 ? Math.min(1, Math.abs(cand.contribution) / rowMaxAbs) : 0;
|
|
440
|
+
const maxRadius = Math.min(circleZoneW, b.height) / 2 * 0.9;
|
|
441
|
+
const radius = Math.max(2, maxRadius * rCoef);
|
|
442
|
+
g.beginPath();
|
|
443
|
+
g.fillStyle = this.contributionColor(cand.contribution, cand.pValue);
|
|
444
|
+
g.arc(circleZoneX + circleZoneW / 2, midY, radius, 0, Math.PI * 2);
|
|
445
|
+
g.fill();
|
|
446
|
+
g.lineWidth = 0.5;
|
|
447
|
+
g.strokeStyle = 'rgba(0,0,0,0.18)';
|
|
448
|
+
g.stroke();
|
|
449
|
+
|
|
450
|
+
// Signed contribution value.
|
|
451
|
+
g.textAlign = 'right';
|
|
452
|
+
g.fillStyle = '#606060';
|
|
453
|
+
g.fillText(this.fitText(g, this.formatContribution(cand.contribution), numberW - pad), b.x + b.width - pad, midY);
|
|
454
|
+
|
|
455
|
+
args.preventDefault();
|
|
456
|
+
g.restore();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Circle color: hue encodes direction (red = raises activity, blue = lowers), saturation encodes significance
|
|
461
|
+
* (vivid when highly significant, pale near p≈1, grey when the t-test could not be run). Bounded, so an exact
|
|
462
|
+
* p=0 simply saturates instead of skewing any normalization.
|
|
463
|
+
*/
|
|
464
|
+
private contributionColor(contribution: number, pValue: number | null): string {
|
|
465
|
+
if (pValue == null)
|
|
466
|
+
return 'rgb(200, 200, 200)';
|
|
467
|
+
const [r, g, b] = contribution >= 0 ? [198, 40, 40] : [21, 101, 192];
|
|
468
|
+
const sig = Math.min(1, Math.max(0, -Math.log10(Math.max(pValue, 1e-12)) / 3)); // p≤0.001 → full color
|
|
469
|
+
const mix = (c: number): number => Math.round(255 + (c - 255) * sig);
|
|
470
|
+
return `rgb(${mix(r)}, ${mix(g)}, ${mix(b)})`;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Text color for a monomer, taken from its monomer-library background color (falls back to dark grey). */
|
|
474
|
+
private monomerTextColor(monomer: string): string {
|
|
475
|
+
const cached = this._monomerColorCache.get(monomer);
|
|
476
|
+
if (cached != null)
|
|
477
|
+
return cached;
|
|
478
|
+
let color = '#333333';
|
|
479
|
+
try {
|
|
480
|
+
const colors = PeptideUtils.getMonomerLib().getMonomerColors(this._biotype, monomer);
|
|
481
|
+
if (colors?.backgroundcolor)
|
|
482
|
+
color = colors.backgroundcolor;
|
|
483
|
+
} catch {
|
|
484
|
+
// monomer library not ready — keep the neutral fallback
|
|
485
|
+
}
|
|
486
|
+
this._monomerColorCache.set(monomer, color);
|
|
487
|
+
return color;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Compact signed contribution label, e.g. "+1.24" or "-0.4". */
|
|
491
|
+
private formatContribution(v: number): string {
|
|
492
|
+
const s = Math.abs(v) >= 10 ? v.toFixed(1) : v.toFixed(2);
|
|
493
|
+
return `${v >= 0 ? '+' : ''}${s}`;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Truncates text with an ellipsis so it fits within maxWidth on the given canvas context. */
|
|
497
|
+
private fitText(g: CanvasRenderingContext2D, text: string, maxWidth: number): string {
|
|
498
|
+
if (maxWidth <= 0 || g.measureText(text).width <= maxWidth)
|
|
499
|
+
return text;
|
|
500
|
+
let t = text;
|
|
501
|
+
while (t.length > 1 && g.measureText(`${t}…`).width > maxWidth)
|
|
502
|
+
t = t.slice(0, -1);
|
|
503
|
+
return t.length > 1 ? `${t}…` : t;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Shows the invariant-map style activity-distribution tooltip for a basis cell (monomer-at-position vs the rest).
|
|
508
|
+
* @param gridCell - Hovered grid cell.
|
|
509
|
+
* @param x - Tooltip x coordinate.
|
|
510
|
+
* @param y - Tooltip y coordinate.
|
|
511
|
+
* @return - Whether the tooltip was handled.
|
|
512
|
+
*/
|
|
513
|
+
private showBasisTooltip(gridCell: DG.GridCell, x: number, y: number): boolean {
|
|
514
|
+
const posName = gridCell.tableColumn?.name;
|
|
515
|
+
if (!gridCell.isTableCell || posName == null || !this._basisPositionSet.has(posName) ||
|
|
516
|
+
gridCell.tableRowIndex == null)
|
|
517
|
+
return false;
|
|
518
|
+
const cand = this._basisByPosition[posName]?.[gridCell.tableRowIndex] ?? null;
|
|
519
|
+
if (cand == null || !cand.monomer || this._scaledActivityCol == null)
|
|
520
|
+
return false;
|
|
521
|
+
|
|
522
|
+
const monomerPosition = {monomerOrCluster: cand.monomer, positionOrClusterType: posName};
|
|
523
|
+
highlightMonomerPosition(monomerPosition, this.dataFrame, this.monomerPositionStats);
|
|
524
|
+
// Explain the glyph encoding at the top of the distribution panel.
|
|
525
|
+
const direction = cand.contribution >= 0 ? 'raises' : 'lowers';
|
|
526
|
+
const additionalStats = {
|
|
527
|
+
'Monomer': gridCell.cell.valueString,
|
|
528
|
+
'Circle size': `effect magnitude |Δ| = ${Math.abs(cand.contribution).toFixed(2)}`,
|
|
529
|
+
'Circle color': cand.pValue == null ? `${direction} activity (significance: n/a — too few samples)` :
|
|
530
|
+
`${direction} activity, significance p = ` +
|
|
531
|
+
`${cand.pValue < 0.001 ? cand.pValue.toExponential(1) : cand.pValue.toFixed(3)}`,
|
|
532
|
+
};
|
|
533
|
+
return showTooltip(this.dataFrame, this._scaledActivityCol, [], {
|
|
534
|
+
monomerPosition, x, y, mpStats: this.monomerPositionStats, fromViewer: true, additionalStats,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Runs the additive-model beam search and returns the generated-peptides dataframe.
|
|
540
|
+
* @return - Dataframe with generated peptides and per-peptide statistics.
|
|
541
|
+
*/
|
|
542
|
+
generatePeptides(): DG.DataFrame {
|
|
543
|
+
const seqCol = this.dataFrame.getCol(this.sequenceColumnName);
|
|
544
|
+
const scaledActivityCol = scaleActivity(this.dataFrame.getCol(this.activityColumnName), this.activityScaling);
|
|
545
|
+
this._scaledActivityCol = scaledActivityCol; // reused by the basis-cell distribution tooltips
|
|
546
|
+
// Baseline must match the population the statistics were computed over (filtered rows when a filter is active).
|
|
547
|
+
const globalMean = this.dataFrame.filter.anyFalse ?
|
|
548
|
+
DG.Stats.fromColumn(scaledActivityCol, this.dataFrame.filter).avg : scaledActivityCol.stats.avg;
|
|
549
|
+
|
|
550
|
+
const positionColumns = this.getPositionColumns();
|
|
551
|
+
const stats: MonomerPositionStats = this.monomerPositionStats;
|
|
552
|
+
|
|
553
|
+
const isHigh = this.activityTarget === C.ACTIVITY_TARGET.HIGH;
|
|
554
|
+
// Ordered per-position candidate lists.
|
|
555
|
+
const perPositionCandidates: Candidate[][] = [];
|
|
556
|
+
for (const posCol of positionColumns) {
|
|
557
|
+
const posName = posCol.name;
|
|
558
|
+
const posStats = stats[posName] as PositionStats | undefined;
|
|
559
|
+
if (!posStats)
|
|
560
|
+
continue;
|
|
561
|
+
|
|
562
|
+
const monomerEntries: Candidate[] = [];
|
|
563
|
+
let bestFallback: Candidate | null = null; // most-supported monomer, used when nothing passes the filters
|
|
564
|
+
let hasSignificant = false;
|
|
565
|
+
for (const monomer of Object.keys(posStats)) {
|
|
566
|
+
if (monomer === 'general')
|
|
567
|
+
continue;
|
|
568
|
+
const item = posStats[monomer] as StatsItem;
|
|
569
|
+
// Empirical-Bayes shrinkage: damp the marginal effect of monomers with little supporting data so that
|
|
570
|
+
// noisy per-position maxima do not sum into wildly out-of-range predictions (winner's curse).
|
|
571
|
+
const shrinkFactor = item.count / (item.count + this.shrinkageStrength);
|
|
572
|
+
const contribution = (item.mean - globalMean) * shrinkFactor;
|
|
573
|
+
const significant = item.pValue != null && item.pValue <= 0.05;
|
|
574
|
+
hasSignificant ||= significant;
|
|
575
|
+
const candidate: Candidate = {
|
|
576
|
+
position: posName, monomer, contribution, count: item.count, pValue: item.pValue,
|
|
577
|
+
significant, supported: item.count >= this.minSupport,
|
|
578
|
+
};
|
|
579
|
+
if (bestFallback === null || candidate.count > bestFallback.count)
|
|
580
|
+
bestFallback = candidate;
|
|
581
|
+
const passesSupport = item.count >= this.minSupport;
|
|
582
|
+
const passesPValue = this.maxPValue >= 1 || (item.pValue != null && item.pValue <= this.maxPValue);
|
|
583
|
+
if (passesSupport && passesPValue)
|
|
584
|
+
monomerEntries.push(candidate);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (monomerEntries.length === 0) {
|
|
588
|
+
// Keep the position fixed to its most common monomer so generated sequences stay aligned and valid.
|
|
589
|
+
if (bestFallback !== null)
|
|
590
|
+
perPositionCandidates.push([bestFallback]);
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// When requested, don't vary positions without a significant signal — pin them to the best supported monomer.
|
|
595
|
+
if (this.onlySignificantPositions && !hasSignificant) {
|
|
596
|
+
perPositionCandidates.push([bestFallback ?? monomerEntries[0]]);
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Rank towards the target and keep the top-K.
|
|
601
|
+
monomerEntries.sort((a, b) => isHigh ? b.contribution - a.contribution : a.contribution - b.contribution);
|
|
602
|
+
perPositionCandidates.push(monomerEntries.slice(0, this.candidatesPerPosition));
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const generated = this.beamSearch(perPositionCandidates, isHigh);
|
|
606
|
+
// Position order of the beam-search candidate slots, used to lay out the per-position basis columns.
|
|
607
|
+
const positionOrder = perPositionCandidates.map((candidates) => candidates[0].position);
|
|
608
|
+
return this.buildResultDataFrame(generated, seqCol, globalMean, positionOrder);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Bounded beam search over the per-position candidate monomers, keeping the highest (or lowest) scoring partials.
|
|
613
|
+
* @param perPositionCandidates - Ordered candidate monomers for each position.
|
|
614
|
+
* @param isHigh - Whether higher scaled activity is the objective.
|
|
615
|
+
* @return - Ranked list of complete peptides.
|
|
616
|
+
*/
|
|
617
|
+
private beamSearch(perPositionCandidates: Candidate[][], isHigh: boolean): BeamItem[] {
|
|
618
|
+
// Enough breadth to still satisfy peptideCount after novelty filtering.
|
|
619
|
+
const width = Math.max(this.beamWidth, this.peptideCount * (this.excludeExisting ? 2 : 1));
|
|
620
|
+
const better = (a: number, b: number): number => isHigh ? b - a : a - b;
|
|
621
|
+
|
|
622
|
+
let beam: BeamItem[] = [{monomers: [], candidates: [], cumContribution: 0}];
|
|
623
|
+
for (const candidates of perPositionCandidates) {
|
|
624
|
+
const expanded: BeamItem[] = [];
|
|
625
|
+
for (const item of beam) {
|
|
626
|
+
for (const candidate of candidates) {
|
|
627
|
+
expanded.push({
|
|
628
|
+
monomers: [...item.monomers, candidate.monomer],
|
|
629
|
+
candidates: [...item.candidates, candidate],
|
|
630
|
+
cumContribution: item.cumContribution + candidate.contribution,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
expanded.sort((a, b) => better(a.cumContribution, b.cumContribution));
|
|
635
|
+
beam = expanded.slice(0, width);
|
|
636
|
+
}
|
|
637
|
+
return beam;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Assembles the generated peptides into a grid-ready dataframe with sequence, prediction and supporting statistics.
|
|
642
|
+
* @param generated - Ranked generated peptides.
|
|
643
|
+
* @param seqCol - Source sequence column (used for notation and novelty checks).
|
|
644
|
+
* @param globalMean - Global mean of the scaled activity.
|
|
645
|
+
* @return - Result dataframe.
|
|
646
|
+
*/
|
|
647
|
+
private buildResultDataFrame(generated: BeamItem[], seqCol: DG.Column<string>, globalMean: number,
|
|
648
|
+
positionOrder: string[]): DG.DataFrame {
|
|
649
|
+
const seqHelper = PeptideUtils.getSeqHelper();
|
|
650
|
+
const sh: ISeqHandler = seqHelper.getSeqHandler(seqCol);
|
|
651
|
+
const gapOriginal = GapOriginals[sh.notation as NOTATION] ?? GAP_SYMBOL;
|
|
652
|
+
const joiner = sh.joiner;
|
|
653
|
+
|
|
654
|
+
// helm joiner does not add v.20 postfix, so we make sure we remove them
|
|
655
|
+
const existing = new Set<string>(sh.isHelm() ? seqCol.categories.map((s) => s?.endsWith('V2.0') || s?.endsWith('V1.0') ? s.substring(0, s.length - 4) : s) :seqCol.categories);
|
|
656
|
+
|
|
657
|
+
const seqStrings: string[] = [];
|
|
658
|
+
const predicted: number[] = [];
|
|
659
|
+
const confidence: number[] = [];
|
|
660
|
+
const minSupportArr: number[] = [];
|
|
661
|
+
const meanPValue: number[] = [];
|
|
662
|
+
const significantPositions: number[] = [];
|
|
663
|
+
const novel: boolean[] = [];
|
|
664
|
+
const basis: string[] = [];
|
|
665
|
+
// Per surviving row: the chosen candidate at each position (aligned to positionOrder).
|
|
666
|
+
const basisRows: Candidate[][] = [];
|
|
667
|
+
for (const item of generated) {
|
|
668
|
+
const seq = joiner(new StringListSeqSplitted(item.monomers, gapOriginal));
|
|
669
|
+
const isNovel = !existing.has(seq);
|
|
670
|
+
if (this.excludeExisting && !isNovel)
|
|
671
|
+
continue;
|
|
672
|
+
if (seqStrings.length >= this.peptideCount)
|
|
673
|
+
break;
|
|
674
|
+
|
|
675
|
+
const varied = item.candidates.filter((c) => c.supported || c.significant);
|
|
676
|
+
const consideredCount = Math.max(varied.length, 1);
|
|
677
|
+
let nSignificant = 0;
|
|
678
|
+
let nSupported = 0;
|
|
679
|
+
let minCount = Number.POSITIVE_INFINITY;
|
|
680
|
+
let pValueSum = 0;
|
|
681
|
+
let pValueCount = 0;
|
|
682
|
+
for (const candidate of item.candidates) {
|
|
683
|
+
if (candidate.significant)
|
|
684
|
+
++nSignificant;
|
|
685
|
+
if (candidate.supported)
|
|
686
|
+
++nSupported;
|
|
687
|
+
minCount = Math.min(minCount, candidate.count);
|
|
688
|
+
if (candidate.pValue != null) {
|
|
689
|
+
pValueSum += candidate.pValue;
|
|
690
|
+
++pValueCount;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const topContribs = [...item.candidates]
|
|
695
|
+
.sort((a, b) => Math.abs(b.contribution) - Math.abs(a.contribution)).slice(0, 5);
|
|
696
|
+
const basisStr = topContribs
|
|
697
|
+
.map((c) => `${c.position}:${c.monomer}(${c.contribution >= 0 ? '+' : ''}${c.contribution.toFixed(2)})`)
|
|
698
|
+
.join(', ');
|
|
699
|
+
|
|
700
|
+
seqStrings.push(seq);
|
|
701
|
+
predicted.push(globalMean + item.cumContribution);
|
|
702
|
+
confidence.push(nSupported === 0 ? 0 : (nSignificant / consideredCount));
|
|
703
|
+
minSupportArr.push(minCount === Number.POSITIVE_INFINITY ? 0 : minCount);
|
|
704
|
+
meanPValue.push(pValueCount === 0 ? DG.FLOAT_NULL : pValueSum / pValueCount);
|
|
705
|
+
significantPositions.push(nSignificant);
|
|
706
|
+
novel.push(isNovel);
|
|
707
|
+
basis.push(basisStr);
|
|
708
|
+
basisRows.push(item.candidates);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const peptideCol = sh.getNewColumnFromList(GEN_COLUMN_NAMES.PEPTIDE, seqStrings);
|
|
712
|
+
const columns: DG.Column[] = [
|
|
713
|
+
peptideCol,
|
|
714
|
+
DG.Column.fromList(DG.COLUMN_TYPE.FLOAT, GEN_COLUMN_NAMES.PREDICTED, predicted),
|
|
715
|
+
DG.Column.fromList(DG.COLUMN_TYPE.FLOAT, GEN_COLUMN_NAMES.CONFIDENCE, confidence),
|
|
716
|
+
DG.Column.fromList(DG.COLUMN_TYPE.INT, GEN_COLUMN_NAMES.MIN_SUPPORT, minSupportArr),
|
|
717
|
+
DG.Column.fromList(DG.COLUMN_TYPE.FLOAT, GEN_COLUMN_NAMES.MEAN_P_VALUE, meanPValue),
|
|
718
|
+
DG.Column.fromList(DG.COLUMN_TYPE.INT, GEN_COLUMN_NAMES.SIGNIFICANT_POSITIONS, significantPositions),
|
|
719
|
+
DG.Column.fromList(DG.COLUMN_TYPE.BOOL, GEN_COLUMN_NAMES.NOVEL, novel),
|
|
720
|
+
DG.Column.fromList(DG.COLUMN_TYPE.STRING, GEN_COLUMN_NAMES.BASIS, basis),
|
|
721
|
+
];
|
|
722
|
+
|
|
723
|
+
// One string column per position holding each row's chosen monomer, custom-rendered as monomer + circle + number.
|
|
724
|
+
this._basisByPosition = {};
|
|
725
|
+
this._basisPositionNames = [];
|
|
726
|
+
this._rowMaxAbsContribution = basisRows.map((cands) =>
|
|
727
|
+
cands.reduce((mx, c) => Math.max(mx, Math.abs(c.contribution)), 0));
|
|
728
|
+
const usedNames = new Set(columns.map((col) => col.name));
|
|
729
|
+
for (let j = 0; j < positionOrder.length; ++j) {
|
|
730
|
+
let colName = positionOrder[j];
|
|
731
|
+
while (usedNames.has(colName)) // avoid clashing with the statistics columns
|
|
732
|
+
colName = `${colName} `;
|
|
733
|
+
usedNames.add(colName);
|
|
734
|
+
const cellCandidates = basisRows.map((cands) => cands[j] ?? null);
|
|
735
|
+
columns.push(DG.Column.fromStrings(colName, cellCandidates.map((c) => c?.monomer ?? '')));
|
|
736
|
+
this._basisByPosition[colName] = cellCandidates;
|
|
737
|
+
this._basisPositionNames.push(colName);
|
|
738
|
+
}
|
|
739
|
+
this._basisPositionSet = new Set(this._basisPositionNames);
|
|
740
|
+
|
|
741
|
+
const resultDf = DG.DataFrame.fromColumns(columns);
|
|
742
|
+
resultDf.name = 'Generated Peptides';
|
|
743
|
+
return resultDf;
|
|
744
|
+
}
|
|
745
|
+
}
|