| """
|
| BERT Model for CRISPR Off-Target Prediction
|
| Built from scratch using the architecture from Figure S5
|
| """
|
|
|
| import numpy as np
|
|
|
|
|
| class Embedding:
|
| """Embedding layer to convert token IDs to vectors."""
|
|
|
| def __init__(self, vocab_size, embed_dim):
|
| """
|
| Initialize embedding layer.
|
|
|
| Args:
|
| vocab_size: Size of vocabulary
|
| embed_dim: Dimension of embedding vectors
|
| """
|
| self.vocab_size = vocab_size
|
| self.embed_dim = embed_dim
|
|
|
|
|
| self.embeddings = np.random.randn(vocab_size, embed_dim) * 0.01
|
|
|
| def forward(self, x):
|
| """
|
| Forward pass: look up embeddings for input IDs.
|
|
|
| Args:
|
| x: Input token IDs of shape (batch, seq_len)
|
|
|
| Returns:
|
| Embeddings of shape (batch, seq_len, embed_dim)
|
| """
|
| return self.embeddings[x]
|
|
|
|
|
| class LayerNorm:
|
| """
|
| Layer Normalization.
|
| Formula: y = (x - mean) / sqrt(var + eps) * gamma + beta
|
| """
|
|
|
| def __init__(self, normalized_shape, eps=1e-6):
|
| self.eps = eps
|
| self.gamma = np.ones(normalized_shape)
|
| self.beta = np.zeros(normalized_shape)
|
|
|
| def forward(self, x):
|
| """Apply layer normalization."""
|
| mean = np.mean(x, axis=-1, keepdims=True)
|
| var = np.var(x, axis=-1, keepdims=True)
|
| x_norm = (x - mean) / np.sqrt(var + self.eps)
|
| return self.gamma * x_norm + self.beta
|
|
|
|
|
| class MultiHeadAttention:
|
| """
|
| Multi-Head Self-Attention mechanism.
|
|
|
| Formulas:
|
| Q = XW_q, K = XW_k, V = XW_v
|
| Attention(Q,K,V) = softmax(QK^T / sqrt(d_k)) V
|
| MultiHead = Concat(head_1, ..., head_h) W_o
|
| """
|
|
|
| def __init__(self, embed_dim, num_heads):
|
| """
|
| Initialize multi-head attention.
|
|
|
| Args:
|
| embed_dim: Embedding dimension
|
| num_heads: Number of attention heads
|
| """
|
| self.embed_dim = embed_dim
|
| self.num_heads = num_heads
|
| self.head_dim = embed_dim // num_heads
|
|
|
| assert embed_dim % num_heads == 0, "embed_dim must be divisible by num_heads"
|
|
|
|
|
| self.W_q = np.random.randn(embed_dim, embed_dim) * 0.01
|
| self.W_k = np.random.randn(embed_dim, embed_dim) * 0.01
|
| self.W_v = np.random.randn(embed_dim, embed_dim) * 0.01
|
|
|
|
|
| self.W_o = np.random.randn(embed_dim, embed_dim) * 0.01
|
|
|
| def scaled_dot_product_attention(self, Q, K, V):
|
| """
|
| Scaled dot-product attention.
|
|
|
| Args:
|
| Q, K, V: Query, Key, Value matrices
|
|
|
| Returns:
|
| Attention output
|
| """
|
|
|
| d_k = Q.shape[-1]
|
| scores = Q @ K.transpose(0, 1, 3, 2) / np.sqrt(d_k)
|
|
|
|
|
| scores_exp = np.exp(scores - np.max(scores, axis=-1, keepdims=True))
|
| attention_weights = scores_exp / np.sum(scores_exp, axis=-1, keepdims=True)
|
|
|
|
|
| output = attention_weights @ V
|
|
|
| return output
|
|
|
| def forward(self, x):
|
| """
|
| Forward pass through multi-head attention.
|
|
|
| Args:
|
| x: Input of shape (batch, seq_len, embed_dim)
|
|
|
| Returns:
|
| Output of shape (batch, seq_len, embed_dim)
|
| """
|
| batch_size, seq_len, _ = x.shape
|
|
|
|
|
| Q = x @ self.W_q
|
| K = x @ self.W_k
|
| V = x @ self.W_v
|
|
|
|
|
| Q = Q.reshape(batch_size, seq_len, self.num_heads, self.head_dim).transpose(0, 2, 1, 3)
|
| K = K.reshape(batch_size, seq_len, self.num_heads, self.head_dim).transpose(0, 2, 1, 3)
|
| V = V.reshape(batch_size, seq_len, self.num_heads, self.head_dim).transpose(0, 2, 1, 3)
|
|
|
|
|
| attention_output = self.scaled_dot_product_attention(Q, K, V)
|
|
|
|
|
| attention_output = attention_output.transpose(0, 2, 1, 3).reshape(batch_size, seq_len, self.embed_dim)
|
|
|
|
|
| output = attention_output @ self.W_o
|
|
|
| return output
|
|
|
|
|
| class FeedForward:
|
| """
|
| Position-wise Feed-Forward Network.
|
| Formula: FFN(x) = max(0, xW1 + b1)W2 + b2
|
| """
|
|
|
| def __init__(self, embed_dim, ff_dim):
|
| """
|
| Initialize feed-forward network.
|
|
|
| Args:
|
| embed_dim: Embedding dimension
|
| ff_dim: Hidden dimension of feed-forward network
|
| """
|
| self.W1 = np.random.randn(embed_dim, ff_dim) * 0.01
|
| self.b1 = np.zeros(ff_dim)
|
| self.W2 = np.random.randn(ff_dim, embed_dim) * 0.01
|
| self.b2 = np.zeros(embed_dim)
|
|
|
| def gelu(self, x):
|
| """
|
| GELU activation: 0.5 * x * (1 + tanh(sqrt(2/pi) * (x + 0.044715 * x^3)))
|
| Approximation used in BERT
|
| """
|
| return 0.5 * x * (1.0 + np.tanh(np.sqrt(2.0 / np.pi) * (x + 0.044715 * x**3)))
|
|
|
| def forward(self, x):
|
| """Forward pass through feed-forward network."""
|
| hidden = self.gelu(x @ self.W1 + self.b1)
|
| output = hidden @ self.W2 + self.b2
|
| return output
|
|
|
|
|
| class TransformerBlock:
|
| """
|
| Transformer encoder block.
|
|
|
| Architecture:
|
| x -> [Multi-Head Attention -> Add & Norm] -> [FFN -> Add & Norm] -> output
|
| """
|
|
|
| def __init__(self, embed_dim, num_heads, ff_dim, dropout_rate=0.1):
|
| self.attention = MultiHeadAttention(embed_dim, num_heads)
|
| self.norm1 = LayerNorm(embed_dim)
|
|
|
| self.ffn = FeedForward(embed_dim, ff_dim)
|
| self.norm2 = LayerNorm(embed_dim)
|
|
|
| self.dropout_rate = dropout_rate
|
|
|
| def dropout(self, x, training=True):
|
| """Apply dropout."""
|
| if training and self.dropout_rate > 0:
|
| mask = np.random.binomial(1, 1 - self.dropout_rate, x.shape) / (1 - self.dropout_rate)
|
| return x * mask
|
| return x
|
|
|
| def forward(self, x, training=True):
|
| """
|
| Forward pass through transformer block.
|
|
|
| Args:
|
| x: Input of shape (batch, seq_len, embed_dim)
|
| training: Whether in training mode
|
|
|
| Returns:
|
| Output of shape (batch, seq_len, embed_dim)
|
| """
|
|
|
| attn_output = self.attention.forward(x)
|
| attn_output = self.dropout(attn_output, training)
|
| x = self.norm1.forward(x + attn_output)
|
|
|
|
|
| ffn_output = self.ffn.forward(x)
|
| ffn_output = self.dropout(ffn_output, training)
|
| x = self.norm2.forward(x + ffn_output)
|
|
|
| return x
|
|
|
|
|
| class BiGRU:
|
| """
|
| Bidirectional GRU layer.
|
| Same implementation as in CNN model.
|
| """
|
|
|
| def __init__(self, input_size, hidden_size):
|
| self.input_size = input_size
|
| self.hidden_size = hidden_size
|
|
|
|
|
| limit = np.sqrt(1.0 / hidden_size)
|
| self.W_z_fwd = np.random.uniform(-limit, limit, (hidden_size, input_size))
|
| self.U_z_fwd = np.random.uniform(-limit, limit, (hidden_size, hidden_size))
|
| self.b_z_fwd = np.zeros(hidden_size)
|
|
|
| self.W_r_fwd = np.random.uniform(-limit, limit, (hidden_size, input_size))
|
| self.U_r_fwd = np.random.uniform(-limit, limit, (hidden_size, hidden_size))
|
| self.b_r_fwd = np.zeros(hidden_size)
|
|
|
| self.W_h_fwd = np.random.uniform(-limit, limit, (hidden_size, input_size))
|
| self.U_h_fwd = np.random.uniform(-limit, limit, (hidden_size, hidden_size))
|
| self.b_h_fwd = np.zeros(hidden_size)
|
|
|
|
|
| self.W_z_bwd = np.random.uniform(-limit, limit, (hidden_size, input_size))
|
| self.U_z_bwd = np.random.uniform(-limit, limit, (hidden_size, hidden_size))
|
| self.b_z_bwd = np.zeros(hidden_size)
|
|
|
| self.W_r_bwd = np.random.uniform(-limit, limit, (hidden_size, input_size))
|
| self.U_r_bwd = np.random.uniform(-limit, limit, (hidden_size, hidden_size))
|
| self.b_r_bwd = np.zeros(hidden_size)
|
|
|
| self.W_h_bwd = np.random.uniform(-limit, limit, (hidden_size, input_size))
|
| self.U_h_bwd = np.random.uniform(-limit, limit, (hidden_size, hidden_size))
|
| self.b_h_bwd = np.zeros(hidden_size)
|
|
|
| def sigmoid(self, x):
|
| """Sigmoid activation."""
|
| return 1.0 / (1.0 + np.exp(-np.clip(x, -500, 500)))
|
|
|
| def tanh(self, x):
|
| """Tanh activation."""
|
| return np.tanh(np.clip(x, -500, 500))
|
|
|
| def gru_cell_forward(self, x_t, h_prev, direction='fwd'):
|
| """Single GRU cell forward pass."""
|
| if direction == 'fwd':
|
| W_z, U_z, b_z = self.W_z_fwd, self.U_z_fwd, self.b_z_fwd
|
| W_r, U_r, b_r = self.W_r_fwd, self.U_r_fwd, self.b_r_fwd
|
| W_h, U_h, b_h = self.W_h_fwd, self.U_h_fwd, self.b_h_fwd
|
| else:
|
| W_z, U_z, b_z = self.W_z_bwd, self.U_z_bwd, self.b_z_bwd
|
| W_r, U_r, b_r = self.W_r_bwd, self.U_r_bwd, self.b_r_bwd
|
| W_h, U_h, b_h = self.W_h_bwd, self.U_h_bwd, self.b_h_bwd
|
|
|
|
|
| z_t = self.sigmoid(W_z @ x_t + U_z @ h_prev + b_z)
|
| r_t = self.sigmoid(W_r @ x_t + U_r @ h_prev + b_r)
|
| h_tilde = self.tanh(W_h @ x_t + U_h @ (r_t * h_prev) + b_h)
|
| h_t = (1 - z_t) * h_prev + z_t * h_tilde
|
|
|
| return h_t
|
|
|
| def forward(self, x):
|
| """Forward pass through BiGRU."""
|
| batch_size, seq_len, _ = x.shape
|
|
|
|
|
| h_fwd = np.zeros((batch_size, seq_len, self.hidden_size))
|
| h_bwd = np.zeros((batch_size, seq_len, self.hidden_size))
|
|
|
| for b in range(batch_size):
|
|
|
| h_prev = np.zeros(self.hidden_size)
|
| for t in range(seq_len):
|
| h_prev = self.gru_cell_forward(x[b, t], h_prev, 'fwd')
|
| h_fwd[b, t] = h_prev
|
|
|
|
|
| h_prev = np.zeros(self.hidden_size)
|
| for t in range(seq_len - 1, -1, -1):
|
| h_prev = self.gru_cell_forward(x[b, t], h_prev, 'bwd')
|
| h_bwd[b, t] = h_prev
|
|
|
|
|
| output = np.concatenate([h_fwd, h_bwd], axis=-1)
|
| return output
|
|
|
|
|
| class Dense:
|
| """Fully connected layer."""
|
|
|
| def __init__(self, input_size, output_size, activation=None):
|
| self.input_size = input_size
|
| self.output_size = output_size
|
| self.activation = activation
|
|
|
|
|
| limit = np.sqrt(6.0 / (input_size + output_size))
|
| self.weights = np.random.uniform(-limit, limit, (input_size, output_size))
|
| self.bias = np.zeros(output_size)
|
|
|
| def forward(self, x):
|
| """Forward pass."""
|
| output = x @ self.weights + self.bias
|
|
|
| if self.activation == 'relu':
|
| output = np.maximum(0, output)
|
| elif self.activation == 'sigmoid':
|
| output = 1.0 / (1.0 + np.exp(-np.clip(output, -500, 500)))
|
| elif self.activation == 'softmax':
|
| exp_x = np.exp(output - np.max(output, axis=-1, keepdims=True))
|
| output = exp_x / np.sum(exp_x, axis=-1, keepdims=True)
|
|
|
| return output
|
|
|
|
|
| class Dropout:
|
| """Dropout layer."""
|
|
|
| def __init__(self, rate=0.35):
|
| self.rate = rate
|
|
|
| def forward(self, x, training=True):
|
| """Apply dropout."""
|
| if training and self.rate > 0:
|
| mask = np.random.binomial(1, 1 - self.rate, x.shape) / (1 - self.rate)
|
| return x * mask
|
| return x
|
|
|
|
|
| class BERTBranch:
|
| """
|
| BERT Branch for CRISPR-BERT.
|
| Architecture: Token/Position/Segment Embeddings → Transformer → Output (26, 80)
|
|
|
| Note: Original uses BERT-base (768 hidden), but we project to 80 dimensions
|
| """
|
|
|
| def __init__(self, vocab_size=28, embed_dim=256, num_heads=4, num_layers=2, ff_dim=1024, output_dim=80):
|
| """
|
| Initialize BERT branch.
|
|
|
| Args:
|
| vocab_size: Size of token vocabulary (default: 28 for CRISPR tokens)
|
| embed_dim: Embedding dimension (default: 256)
|
| num_heads: Number of attention heads (default: 4)
|
| num_layers: Number of transformer layers (default: 2)
|
| ff_dim: Feed-forward dimension (default: 1024)
|
| output_dim: Output dimension (default: 80)
|
| """
|
|
|
| self.token_embedding = Embedding(vocab_size, embed_dim)
|
| self.position_embedding = Embedding(26, embed_dim)
|
| self.segment_embedding = Embedding(2, embed_dim)
|
|
|
| self.embed_norm = LayerNorm(embed_dim)
|
| self.embed_dropout = Dropout(0.1)
|
|
|
|
|
| self.transformer_layers = [
|
| TransformerBlock(embed_dim, num_heads, ff_dim, dropout_rate=0.1)
|
| for _ in range(num_layers)
|
| ]
|
|
|
|
|
| self.projection = Dense(embed_dim, output_dim, activation=None)
|
|
|
| def forward(self, token_ids, segment_ids, position_ids, training=True):
|
| """
|
| Forward pass through BERT branch.
|
|
|
| Args:
|
| token_ids: Token IDs of shape (batch, 26)
|
| segment_ids: Segment IDs of shape (batch, 26) - all zeros
|
| position_ids: Position IDs of shape (batch, 26) - [0, 1, ..., 25]
|
| training: Whether in training mode
|
|
|
| Returns:
|
| Output of shape (batch, 26, 80)
|
| """
|
| batch_size = token_ids.shape[0]
|
|
|
|
|
| token_embeds = self.token_embedding.forward(token_ids)
|
| position_embeds = self.position_embedding.forward(position_ids)
|
| segment_embeds = self.segment_embedding.forward(segment_ids)
|
|
|
|
|
| embeddings = token_embeds + position_embeds + segment_embeds
|
|
|
|
|
| embeddings = self.embed_norm.forward(embeddings)
|
| embeddings = self.embed_dropout.forward(embeddings, training)
|
|
|
|
|
| x = embeddings
|
| for layer in self.transformer_layers:
|
| x = layer.forward(x, training)
|
|
|
|
|
| output = self.projection.forward(x)
|
|
|
| return output
|
|
|