saarantras1 commited on
Commit
eea87c5
·
verified ·
1 Parent(s): 3539d2c

Add the 110 MPAC crossval checkpoints as safetensors

Browse files
Files changed (5) hide show
  1. README.md +75 -0
  2. config.json +26 -0
  3. model.safetensors +3 -0
  4. modeling_malinois.py +531 -0
  5. provenance.json +18 -0
README.md ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ library_name: malinois
4
+ tags:
5
+ - biology
6
+ - genomics
7
+ - dna
8
+ - mpra
9
+ - cis-regulatory
10
+ pipeline_tag: other
11
+ ---
12
+
13
+ # Malinois
14
+
15
+ Malinois predicts cis-regulatory activity of 200 bp human sequences in K562, HepG2
16
+ and SK-N-SH. It is a convolutional network trained on MPRA measurements from 776,474
17
+ sequences, and the model behind the CODA sequence-design framework.
18
+
19
+ **The paper is the source of truth for what this model is and how it was evaluated:**
20
+ [Machine-guided design of cell-type-targeting cis-regulatory
21
+ elements](https://doi.org/10.1038/s41586-024-08070-z) (Gosai et al., Nature 2024).
22
+
23
+ This repository holds the published checkpoint (`20211113_021200`), converted to
24
+ safetensors from `gs://tewhey-public-data/CODA_resources/` with no retraining or
25
+ modification.
26
+
27
+ For genome-wide variant effect prediction, use
28
+ [MPAC](https://huggingface.co/saarantras1/MPAC) instead: it is this architecture
29
+ retrained across chromosome folds, so a query can be scored by models that never
30
+ saw its chromosome. Malinois trained on all chromosomes except its own held-out
31
+ sets (validation chr19, chr21, chrX; test chr7, chr13), so scoring human genomic
32
+ sequence with it will overstate accuracy on anything it trained on.
33
+
34
+ ## Usage
35
+
36
+ ```python
37
+ from modeling_malinois import MalinoisModel
38
+
39
+ model = MalinoisModel.from_pretrained("saarantras1/malinois").eval()
40
+
41
+ preds = model.predict(["ACGT" * 50]) # (n, 3): K562, HepG2, SKNSH
42
+ ```
43
+
44
+ Use `predict` rather than calling the model directly: it adds the MPRA vector
45
+ context the model was trained with (a bare 200mer is not valid input) and averages
46
+ over both strands. Skipping either step returns plausible-looking but wrong numbers
47
+ instead of an error.
48
+
49
+ Note on strands: `predict` reverse-complements the 200 bp insert and re-flanks it in
50
+ the forward orientation, following `src/vcf_predict.py` in the upstream code base.
51
+ The CODA tutorial notebook instead flips the assembled 600 bp construct, which
52
+ scores about 0.035 higher against the training library. Both appear in upstream
53
+ code; this repository uses the former so that it agrees with the MPAC release.
54
+
55
+ ## Citation
56
+
57
+ ```bibtex
58
+ @article{gosai2024coda,
59
+ title = {Machine-guided design of cell-type-targeting cis-regulatory elements},
60
+ author = {Gosai, Sager J. and Castro, Rodrigo I. and Fuentes, Natalia and
61
+ Butts, John C. and Mouri, Kousuke and Alasoadura, Michael and
62
+ Kales, Susan and Nguyen, Thanh Thanh L. and Noche, Ramil R. and
63
+ Rao, Arya S. and Joy, Mary T. and Sabeti, Pardis C. and
64
+ Reilly, Steven K. and Tewhey, Ryan},
65
+ journal = {Nature},
66
+ year = {2024},
67
+ doi = {10.1038/s41586-024-08070-z}
68
+ }
69
+ ```
70
+
71
+ ## License
72
+
73
+ MIT, following the declaration in
74
+ [sjgosai/boda2](https://github.com/sjgosai/boda2) that the model, model weights and
75
+ architecture code are MIT licensed.
config.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "branched_activation": "ReLU",
3
+ "branched_channels": 140,
4
+ "branched_dropout_p": 0.5757068086404574,
5
+ "conv1_channels": 300,
6
+ "conv1_kernel_size": 19,
7
+ "conv2_channels": 200,
8
+ "conv2_kernel_size": 11,
9
+ "conv3_channels": 200,
10
+ "conv3_kernel_size": 7,
11
+ "input_len": 600,
12
+ "linear_activation": "ReLU",
13
+ "linear_channels": 1000,
14
+ "linear_dropout_p": 0.11625456877954289,
15
+ "n_branched_layers": 3,
16
+ "n_linear_layers": 1,
17
+ "n_outputs": 3,
18
+ "output_names": [
19
+ "K562",
20
+ "HepG2",
21
+ "SKNSH"
22
+ ],
23
+ "use_batch_norm": true,
24
+ "use_weight_norm": false,
25
+ "variable_region_len": 200
26
+ }
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8bb7278979ab6993e4c1d0f4b333e189b50fa9c69f1ca2483e1c0f8d3c9044c8
3
+ size 16445652
modeling_malinois.py ADDED
@@ -0,0 +1,531 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Standalone definition of the MPAC model architecture (`BassetBranched`).
3
+
4
+ This module is deliberately self-contained: it depends only on `torch` (plus
5
+ `huggingface_hub` for the `from_pretrained` mixin). It does not import
6
+ `boda`, `lightning`, or any of the training-time machinery. Layer classes and
7
+ the forward pass are transcribed from `boda/model/basset.py` and
8
+ `boda/model/custom_layers.py` so that state dicts load with identical keys and
9
+ produce bitwise-identical outputs.
10
+
11
+ MIT License
12
+
13
+ Copyright (c) 2025 Sagar Gosai, Rodrigo Castro
14
+
15
+ Permission is hereby granted, free of charge, to any person obtaining a copy
16
+ of this software and associated documentation files (the "Software"), to deal
17
+ in the Software without restriction, including without limitation the rights
18
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
19
+ copies of the Software, and to permit persons to whom the Software is
20
+ furnished to do so, subject to the following conditions:
21
+
22
+ The above copyright notice and this permission notice shall be included in all
23
+ copies or substantial portions of the Software.
24
+
25
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
26
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
27
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
28
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
29
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
30
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
31
+ SOFTWARE.
32
+ """
33
+
34
+ import math
35
+ import os
36
+ from collections import OrderedDict
37
+
38
+ import torch
39
+ import torch.nn as nn
40
+ from torch.func import functional_call, stack_module_state, vmap
41
+
42
+ try:
43
+ from huggingface_hub import PyTorchModelHubMixin
44
+ except ImportError: # keeps the file usable as a plain torch module offline
45
+ class PyTorchModelHubMixin:
46
+ def __init_subclass__(cls, **kwargs):
47
+ super().__init_subclass__()
48
+
49
+
50
+ __all__ = [
51
+ 'STANDARD_NT', 'MPRA_UPSTREAM', 'MPRA_DOWNSTREAM', 'CELL_TYPES',
52
+ 'dna2tensor', 'MPACModel', 'MalinoisModel', 'MPACEnsemble', 'fold_for_chromosome',
53
+ ]
54
+
55
+ # -----------------------------------------------------------------------------
56
+ # Assay constants
57
+ # -----------------------------------------------------------------------------
58
+
59
+ STANDARD_NT = ['A', 'C', 'G', 'T']
60
+
61
+ # Vector context flanking the 200 bp variable region in the MPRA library. The
62
+ # model is trained on the full 600 bp construct, so predictions on a bare 200mer
63
+ # are only meaningful once these are attached (see `MPACModel.add_flanks`).
64
+ MPRA_UPSTREAM = 'ACGAAAATGTTGGATGCTCATACTCGTCCTTTTTCAATATTATTGAAGCATTTATCAGGGTTACTAGTACGTCTCTCAAGGATAAGTAAGTAATATTAAGGTACGGGAGGTATTGGACAGGCCGCAATAAAATATCTTTATTTTCATTACATCTGTGTGTTGGTTTTTTGTGTGAATCGATAGTACTAACATACGCTCTCCATCAAAACAAAACGAAACAAAACAAACTAGCAAAATAGGCTGTCCCCAGTGCAAGTGCAGGTGCCAGAACATTTCTCTGGCCTAACTGGCCGCTTGACG'
65
+ MPRA_DOWNSTREAM = 'CACTGCGGCTCCTGCGATCTAACTGGCCGGTACCTGAGCTCGCTAGCCTCGAGGATATCAAGATCTGGCCTCGGCGGCCAAGCTTAGACACTAGAGGGTATATAATGGAAGCTCGACTTCCAGCTTGGCAATCCGGTACTGTTGGTAAAGCCACCATGGTGAGCAAGGGCGAGGAGCTGTTCACCGGGGTGGTGCCCATCCTGGTCGAGCTGGACGGCGACGTAAACGGCCACAAGTTCAGCGTGTCCGGCGAGGGCGAGGGCGATGCCACCTACGGCAAGCTGACCCTGAAGTTCATCT'
66
+
67
+ CELL_TYPES = ['K562', 'HepG2', 'SKNSH']
68
+
69
+
70
+ def dna2tensor(sequence_str, vocab_list=STANDARD_NT):
71
+ """One-hot encode a DNA string as a (4, len) float tensor."""
72
+ seq_tensor = torch.zeros((len(vocab_list), len(sequence_str)))
73
+ for i, letter in enumerate(sequence_str):
74
+ seq_tensor[vocab_list.index(letter), i] = 1.
75
+ return seq_tensor
76
+
77
+
78
+ def get_padding(kernel_size):
79
+ left = (kernel_size - 1) // 2
80
+ right = kernel_size - 1 - left
81
+ return [max(0, x) for x in [left, right]]
82
+
83
+
84
+ # -----------------------------------------------------------------------------
85
+ # Layers
86
+ # -----------------------------------------------------------------------------
87
+
88
+ class Conv1dNorm(nn.Module):
89
+ """Conv1d with optional weight norm and batch norm."""
90
+
91
+ def __init__(self, in_channels, out_channels, kernel_size,
92
+ stride=1, padding=0, dilation=1, groups=1,
93
+ bias=True, batch_norm=True, weight_norm=True):
94
+ super().__init__()
95
+ self.conv = nn.Conv1d(in_channels, out_channels, kernel_size,
96
+ stride, padding, dilation, groups, bias)
97
+ if weight_norm:
98
+ self.conv = nn.utils.weight_norm(self.conv)
99
+ if batch_norm:
100
+ self.bn_layer = nn.BatchNorm1d(out_channels, eps=1e-05, momentum=0.1,
101
+ affine=True, track_running_stats=True)
102
+
103
+ def forward(self, input):
104
+ try:
105
+ return self.bn_layer(self.conv(input))
106
+ except AttributeError:
107
+ return self.conv(input)
108
+
109
+
110
+ class LinearNorm(nn.Module):
111
+ """Linear with optional weight norm and batch norm."""
112
+
113
+ def __init__(self, in_features, out_features, bias=True,
114
+ batch_norm=True, weight_norm=True):
115
+ super().__init__()
116
+ self.linear = nn.Linear(in_features, out_features, bias=True)
117
+ if weight_norm:
118
+ self.linear = nn.utils.weight_norm(self.linear)
119
+ if batch_norm:
120
+ self.bn_layer = nn.BatchNorm1d(out_features, eps=1e-05, momentum=0.1,
121
+ affine=True, track_running_stats=True)
122
+
123
+ def forward(self, input):
124
+ try:
125
+ return self.bn_layer(self.linear(input))
126
+ except AttributeError:
127
+ return self.linear(input)
128
+
129
+
130
+ class GroupedLinear(nn.Module):
131
+ """Independent linear map per group, applied to a (batch, groups*in) tensor."""
132
+
133
+ def __init__(self, in_group_size, out_group_size, groups):
134
+ super().__init__()
135
+
136
+ self.in_group_size = in_group_size
137
+ self.out_group_size = out_group_size
138
+ self.groups = groups
139
+
140
+ self.weight = nn.Parameter(torch.zeros(groups, in_group_size, out_group_size))
141
+ self.bias = nn.Parameter(torch.zeros(groups, 1, out_group_size))
142
+
143
+ self.reset_parameters(self.weight, self.bias)
144
+
145
+ def reset_parameters(self, weights, bias):
146
+ nn.init.kaiming_uniform_(weights, a=math.sqrt(3))
147
+ fan_in, _ = nn.init._calculate_fan_in_and_fan_out(weights)
148
+ bound = 1 / math.sqrt(fan_in)
149
+ nn.init.uniform_(bias, -bound, bound)
150
+
151
+ def forward(self, x):
152
+ reorg = x.permute(1, 0).reshape(self.groups, self.in_group_size, -1).permute(0, 2, 1)
153
+ hook = torch.bmm(reorg, self.weight) + self.bias
154
+ reorg = hook.permute(0, 2, 1).reshape(self.out_group_size * self.groups, -1).permute(1, 0)
155
+ return reorg
156
+
157
+
158
+ class RepeatLayer(nn.Module):
159
+ def __init__(self, *args):
160
+ super().__init__()
161
+ self.args = args
162
+
163
+ def forward(self, x):
164
+ return x.repeat(*self.args)
165
+
166
+
167
+ class BranchedLinear(nn.Module):
168
+ """Per-output-branch MLP tower built from GroupedLinear layers."""
169
+
170
+ def __init__(self, in_features, hidden_group_size, out_group_size,
171
+ n_branches=1, n_layers=1, activation='ReLU', dropout_p=0.5):
172
+ super().__init__()
173
+
174
+ self.in_features = in_features
175
+ self.hidden_group_size = hidden_group_size
176
+ self.out_group_size = out_group_size
177
+ self.n_branches = n_branches
178
+ self.n_layers = n_layers
179
+
180
+ self.branches = OrderedDict()
181
+
182
+ self.nonlin = getattr(nn, activation)()
183
+ self.dropout = nn.Dropout(p=dropout_p)
184
+
185
+ self.intake = RepeatLayer(1, n_branches)
186
+ cur_size = in_features
187
+
188
+ for i in range(n_layers):
189
+ if i + 1 == n_layers:
190
+ setattr(self, f'branched_layer_{i+1}', GroupedLinear(cur_size, out_group_size, n_branches))
191
+ else:
192
+ setattr(self, f'branched_layer_{i+1}', GroupedLinear(cur_size, hidden_group_size, n_branches))
193
+ cur_size = hidden_group_size
194
+
195
+ def forward(self, x):
196
+ hook = self.intake(x)
197
+
198
+ i = -1
199
+ for i in range(self.n_layers - 1):
200
+ hook = getattr(self, f'branched_layer_{i+1}')(hook)
201
+ hook = self.dropout(self.nonlin(hook))
202
+ hook = getattr(self, f'branched_layer_{i+2}')(hook)
203
+
204
+ return hook
205
+
206
+
207
+ # -----------------------------------------------------------------------------
208
+ # Model
209
+ # -----------------------------------------------------------------------------
210
+
211
+ class MPACModel(
212
+ nn.Module,
213
+ PyTorchModelHubMixin,
214
+ library_name='mpac',
215
+ tags=['biology', 'genomics', 'dna', 'mpra', 'cis-regulatory'],
216
+ license='mit',
217
+ ):
218
+ """The `BassetBranched` architecture used by every MPAC checkpoint.
219
+
220
+ Consumes one-hot DNA of shape (batch, 4, input_len) and returns one activity
221
+ value per output branch, shape (batch, n_outputs). For the released weights
222
+ the branches are `CELL_TYPES` and `input_len` is 600.
223
+ """
224
+
225
+ def __init__(self, input_len=600,
226
+ conv1_channels=300, conv1_kernel_size=19,
227
+ conv2_channels=200, conv2_kernel_size=11,
228
+ conv3_channels=200, conv3_kernel_size=7,
229
+ n_linear_layers=2, linear_channels=1000,
230
+ linear_activation='ReLU', linear_dropout_p=0.3,
231
+ n_branched_layers=1, branched_channels=250,
232
+ branched_activation='ReLU6', branched_dropout_p=0.,
233
+ n_outputs=280,
234
+ use_batch_norm=True, use_weight_norm=False,
235
+ variable_region_len=200, output_names=None):
236
+ super().__init__()
237
+
238
+ self.input_len = input_len
239
+
240
+ self.conv1_channels = conv1_channels
241
+ self.conv1_kernel_size = conv1_kernel_size
242
+ self.conv1_pad = get_padding(conv1_kernel_size)
243
+
244
+ self.conv2_channels = conv2_channels
245
+ self.conv2_kernel_size = conv2_kernel_size
246
+ self.conv2_pad = get_padding(conv2_kernel_size)
247
+
248
+ self.conv3_channels = conv3_channels
249
+ self.conv3_kernel_size = conv3_kernel_size
250
+ self.conv3_pad = get_padding(conv3_kernel_size)
251
+
252
+ self.n_linear_layers = n_linear_layers
253
+ self.linear_channels = linear_channels
254
+ self.linear_activation = linear_activation
255
+ self.linear_dropout_p = linear_dropout_p
256
+
257
+ self.n_branched_layers = n_branched_layers
258
+ self.branched_channels = branched_channels
259
+ self.branched_activation = branched_activation
260
+ self.branched_dropout_p = branched_dropout_p
261
+
262
+ self.n_outputs = n_outputs
263
+
264
+ self.use_batch_norm = use_batch_norm
265
+ self.use_weight_norm = use_weight_norm
266
+
267
+ self.variable_region_len = variable_region_len
268
+ self.output_names = list(output_names) if output_names is not None else None
269
+ assert self.output_names is None or len(self.output_names) == n_outputs, \
270
+ f"output_names has {len(self.output_names)} entries but n_outputs is {n_outputs}"
271
+
272
+ self.pad1 = nn.ConstantPad1d(self.conv1_pad, 0.)
273
+ self.conv1 = Conv1dNorm(4, self.conv1_channels, self.conv1_kernel_size,
274
+ stride=1, padding=0, dilation=1, groups=1, bias=True,
275
+ batch_norm=self.use_batch_norm, weight_norm=self.use_weight_norm)
276
+ self.pad2 = nn.ConstantPad1d(self.conv2_pad, 0.)
277
+ self.conv2 = Conv1dNorm(self.conv1_channels, self.conv2_channels, self.conv2_kernel_size,
278
+ stride=1, padding=0, dilation=1, groups=1, bias=True,
279
+ batch_norm=self.use_batch_norm, weight_norm=self.use_weight_norm)
280
+ self.pad3 = nn.ConstantPad1d(self.conv3_pad, 0.)
281
+ self.conv3 = Conv1dNorm(self.conv2_channels, self.conv3_channels, self.conv3_kernel_size,
282
+ stride=1, padding=0, dilation=1, groups=1, bias=True,
283
+ batch_norm=self.use_batch_norm, weight_norm=self.use_weight_norm)
284
+
285
+ self.pad4 = nn.ConstantPad1d((1, 1), 0.)
286
+
287
+ self.maxpool_3 = nn.MaxPool1d(3, padding=0)
288
+ self.maxpool_4 = nn.MaxPool1d(4, padding=0)
289
+
290
+ next_in_channels = self.conv3_channels * self.get_flatten_factor(self.input_len)
291
+
292
+ for i in range(self.n_linear_layers):
293
+ setattr(self, f'linear{i+1}',
294
+ LinearNorm(next_in_channels, self.linear_channels, bias=True,
295
+ batch_norm=self.use_batch_norm, weight_norm=self.use_weight_norm))
296
+ next_in_channels = self.linear_channels
297
+
298
+ self.branched = BranchedLinear(next_in_channels, self.branched_channels,
299
+ self.branched_channels, self.n_outputs,
300
+ self.n_branched_layers, self.branched_activation,
301
+ self.branched_dropout_p)
302
+
303
+ self.output = GroupedLinear(self.branched_channels, 1, self.n_outputs)
304
+
305
+ self.nonlin = getattr(nn, self.linear_activation)()
306
+
307
+ self.dropout = nn.Dropout(p=self.linear_dropout_p)
308
+
309
+ self._register_flanks()
310
+
311
+ def get_flatten_factor(self, input_len):
312
+ hook = input_len
313
+ assert hook % 3 == 0
314
+ hook = hook // 3
315
+ assert hook % 4 == 0
316
+ hook = hook // 4
317
+ assert (hook + 2) % 4 == 0
318
+ return (hook + 2) // 4
319
+
320
+ # -- MPRA vector context ---------------------------------------------------
321
+
322
+ def _register_flanks(self):
323
+ """Precompute the one-hot flanks that pad a variable region up to input_len.
324
+
325
+ Registered non-persistently so they stay out of the state dict, which
326
+ keeps key parity with the original `boda` checkpoints.
327
+ """
328
+ pad_total = self.input_len - self.variable_region_len
329
+ if pad_total <= 0:
330
+ self.register_buffer('left_flank', None, persistent=False)
331
+ self.register_buffer('right_flank', None, persistent=False)
332
+ return
333
+
334
+ left_len = pad_total // 2
335
+ right_len = pad_total - left_len
336
+ assert left_len <= len(MPRA_UPSTREAM) and right_len <= len(MPRA_DOWNSTREAM), \
337
+ f"need {left_len}/{right_len} bp of flank, have {len(MPRA_UPSTREAM)}/{len(MPRA_DOWNSTREAM)}"
338
+
339
+ self.register_buffer('left_flank', dna2tensor(MPRA_UPSTREAM[-left_len:]).unsqueeze(0),
340
+ persistent=False)
341
+ self.register_buffer('right_flank', dna2tensor(MPRA_DOWNSTREAM[:right_len]).unsqueeze(0),
342
+ persistent=False)
343
+
344
+ def add_flanks(self, x):
345
+ """Concatenate MPRA vector context onto a (batch, 4, variable_region_len) tensor."""
346
+ assert x.shape[-1] == self.variable_region_len, \
347
+ f"expected variable region of {self.variable_region_len} bp, got {x.shape[-1]}"
348
+ *batch_dims, _, _ = x.shape
349
+ pieces = []
350
+ if self.left_flank is not None:
351
+ pieces.append(self.left_flank.expand(*batch_dims, -1, -1))
352
+ pieces.append(x)
353
+ if self.right_flank is not None:
354
+ pieces.append(self.right_flank.expand(*batch_dims, -1, -1))
355
+ return torch.cat(pieces, axis=-1)
356
+
357
+ # -- computation -----------------------------------------------------------
358
+
359
+ def encode(self, x):
360
+ hook = self.nonlin(self.conv1(self.pad1(x)))
361
+ hook = self.maxpool_3(hook)
362
+ hook = self.nonlin(self.conv2(self.pad2(hook)))
363
+ hook = self.maxpool_4(hook)
364
+ hook = self.nonlin(self.conv3(self.pad3(hook)))
365
+ hook = self.maxpool_4(self.pad4(hook))
366
+ hook = torch.flatten(hook, start_dim=1)
367
+ return hook
368
+
369
+ def decode(self, x):
370
+ hook = x
371
+ for i in range(self.n_linear_layers):
372
+ hook = self.dropout(self.nonlin(getattr(self, f'linear{i+1}')(hook)))
373
+ hook = self.branched(hook)
374
+ return hook
375
+
376
+ def classify(self, x):
377
+ return self.output(x)
378
+
379
+ def forward(self, x):
380
+ """Predict activity from a fully assembled (batch, 4, input_len) one-hot tensor."""
381
+ return self.classify(self.decode(self.encode(x)))
382
+
383
+ # -- convenience -----------------------------------------------------------
384
+
385
+ @torch.no_grad()
386
+ def predict(self, sequences, batch_size=128, rc_average=True, device=None):
387
+ """Predict activity for a list of bare variable-region DNA strings.
388
+
389
+ Handles the two steps that are easy to get wrong: attaching the MPRA
390
+ vector context, and averaging the forward and reverse-complement passes
391
+ (the convention used throughout the CODA papers).
392
+
393
+ Returns a (len(sequences), n_outputs) float tensor on the CPU, with
394
+ columns ordered as `self.output_names`.
395
+ """
396
+ if isinstance(sequences, str):
397
+ raise TypeError("pass a list of sequences, not a single string")
398
+
399
+ device = device if device is not None else next(self.parameters()).device
400
+ was_training = self.training
401
+ self.eval()
402
+
403
+ results = []
404
+ try:
405
+ for start in range(0, len(sequences), batch_size):
406
+ chunk = sequences[start:start + batch_size]
407
+ batch = torch.stack([dna2tensor(s.upper()) for s in chunk]).to(device)
408
+ preds = self(self.add_flanks(batch))
409
+ if rc_average:
410
+ # The reverse strand is the reverse complement of the INSERT ONLY,
411
+ # re-flanked in the forward orientation -- not a flip of the
412
+ # assembled 600 bp tensor. This looks like a bug and is not: it
413
+ # matches `src/vcf_predict.py` in sjgosai/boda2, which produced the
414
+ # published MPAC predictions, and it models the real experiment
415
+ # (a fixed plasmid with the insert cloned backwards).
416
+ #
417
+ # Flipping the flanked tensor instead scores ~0.035 higher against
418
+ # Table S2, so the temptation to "fix" this is real. Don't: it would
419
+ # silently desynchronise this model from every published MPAC number.
420
+ rc = self.add_flanks(batch.flip(dims=[1, 2]))
421
+ preds = (preds + self(rc)).div(2.)
422
+ results.append(preds.cpu())
423
+ finally:
424
+ self.train(was_training)
425
+
426
+ return torch.cat(results, dim=0)
427
+
428
+
429
+ class MPACEnsemble(nn.Module):
430
+ """Mean prediction over a set of architecturally identical `MPACModel`s.
431
+
432
+ Uses `torch.func.vmap` over stacked parameters, matching `ConsistentModelPool`
433
+ in the CODA inference scripts.
434
+ """
435
+
436
+ def __init__(self, models):
437
+ super().__init__()
438
+
439
+ models = list(models)
440
+ assert len(models) > 0, "need at least one model"
441
+ for m in models:
442
+ m.eval()
443
+
444
+ self._template = models[0]
445
+ self.n_models = len(models)
446
+ self.output_names = self._template.output_names
447
+ self.variable_region_len = self._template.variable_region_len
448
+ self.input_len = self._template.input_len
449
+
450
+ params, buffers = stack_module_state(models)
451
+ # Keep the stacked tensors visible to .to()/.cuda() by registering them.
452
+ self.params = nn.ParameterDict(
453
+ {k.replace('.', '/'): nn.Parameter(v, requires_grad=False) for k, v in params.items()}
454
+ )
455
+ self._buffer_keys = list(buffers.keys())
456
+ for k, v in buffers.items():
457
+ self.register_buffer(k.replace('.', '/'), v)
458
+
459
+ def _unpack(self):
460
+ params = {k.replace('/', '.'): v for k, v in self.params.items()}
461
+ buffers = {k: getattr(self, k.replace('.', '/')) for k in self._buffer_keys}
462
+ return params, buffers
463
+
464
+ def forward(self, x):
465
+ params, buffers = self._unpack()
466
+
467
+ def fmodel(p, b, data):
468
+ return functional_call(self._template, (p, b), (data,))
469
+
470
+ preds = vmap(fmodel, in_dims=(0, 0, None))(params, buffers, x)
471
+ return preds.mean(dim=0)
472
+
473
+ def add_flanks(self, x):
474
+ return self._template.add_flanks(x)
475
+
476
+ predict = MPACModel.predict
477
+
478
+ @classmethod
479
+ def from_pretrained(cls, repo_id, chromosome, device='cpu', **kwargs):
480
+ """Load the ten MPAC models that held `chromosome` out as their test fold.
481
+
482
+ This is the intended entry point. Picking a fold by hand is easy to get
483
+ wrong, and getting it wrong silently leaks training data into your
484
+ predictions rather than raising an error.
485
+
486
+ `chromosome` accepts '7', 7, or 'chr7'.
487
+ """
488
+ import json
489
+
490
+ from huggingface_hub import hf_hub_download, snapshot_download
491
+ from safetensors.torch import load_file
492
+
493
+ chrom = str(chromosome).lower().replace('chr', '')
494
+
495
+ provenance = json.load(open(hf_hub_download(repo_id, 'provenance.json', **kwargs)))
496
+ fold = fold_for_chromosome(provenance, chrom)
497
+
498
+ config = json.load(open(hf_hub_download(repo_id, 'config.json', **kwargs)))
499
+ local = snapshot_download(repo_id, allow_patterns=[f'{fold}/*'], **kwargs)
500
+
501
+ models = []
502
+ for record in sorted(r['file'] for r in provenance
503
+ if os.path.dirname(r['file']) == fold):
504
+ model = MPACModel(**config)
505
+ model.load_state_dict(load_file(os.path.join(local, record)))
506
+ models.append(model.eval().to(device))
507
+
508
+ assert len(models) == 10, \
509
+ f"expected 10 replicates for {fold}, found {len(models)}"
510
+ return cls(models).to(device)
511
+
512
+
513
+ # The architecture is Malinois's `BassetBranched`; the MPAC checkpoints are the same
514
+ # network retrained per chromosome fold. The original single Malinois model is
515
+ # published as a separate Hub repo, which ships this same file under the name
516
+ # `modeling_malinois.py` and imports the alias below. Keeping one source file means a
517
+ # fix to `predict` cannot land in one release and not the other.
518
+ MalinoisModel = MPACModel
519
+
520
+
521
+ def fold_for_chromosome(provenance, chromosome):
522
+ """Return the directory of the fold that held `chromosome` out as test data."""
523
+ chrom = str(chromosome).lower().replace('chr', '')
524
+ folds = {os.path.dirname(r['file']) for r in provenance
525
+ if chrom in [str(c) for c in (r.get('test_chrs') or [])]}
526
+ assert len(folds) == 1, (
527
+ f"chromosome {chrom} maps to {len(folds)} folds ({sorted(folds)}); "
528
+ f"MPAC covers autosomes 1-22 only, so chrX, chrY and non-human sequence "
529
+ f"have no held-out ensemble"
530
+ )
531
+ return folds.pop()
provenance.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "file": "model.safetensors",
4
+ "source": "gs://tewhey-public-data/CODA_resources/malinois_artifacts__20211113_021200__287348.tar.gz",
5
+ "timestamp": "20211113_021200",
6
+ "random_tag": 287348,
7
+ "val_chrs": [
8
+ "19",
9
+ "21",
10
+ "X"
11
+ ],
12
+ "test_chrs": [
13
+ "7",
14
+ "13"
15
+ ],
16
+ "role": "default"
17
+ }
18
+ ]