File size: 5,377 Bytes
07fcdfe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
"""Gap encoder: fuses source and endpoint embeddings into a 128-dim CLIP-style gap embedding."""

import torch
import torch.nn as nn
import torch.nn.functional as F
from typing import Optional, Dict


class GapEncoder(nn.Module):
    """Encode the 'gap' between source and endpoint population embeddings.

    Concatenates four interaction terms:
        [z_source, z_target, z_target - z_source, z_source * z_target]
    then projects to z_gap via MLP, followed by a CLIP-style projection head
    that produces a normalized 128-dim gap_emb for drug matching.

    Parameters
    ----------
    input_dim      : dimensionality of z_source / z_target (H)
    hidden_dim     : width of intermediate MLP layers
    output_dim     : size of z_gap (pre-projection)
    n_layers       : number of MLP layers
    proj_dim       : dimensionality of the CLIP-style projection head (default 128)
    num_cell_lines : number of distinct cell lines for conditioning embedding;
                     pass 0 to disable cell-line conditioning
    num_genes      : number of genes for the auxiliary reconstruction head;
                     pass 0 to disable the reconstruction head
    """

    def __init__(
        self,
        input_dim: int,
        hidden_dim: int = 256,
        output_dim: int = 256,
        n_layers: int = 2,
        proj_dim: int = 128,
        num_cell_lines: int = 0,
        num_genes: int = 0,
    ) -> None:
        super().__init__()
        self.output_dim = output_dim
        self.proj_dim = proj_dim
        self.num_cell_lines = num_cell_lines
        self.num_genes = num_genes

        # ---- core MLP: 4*H β†’ hidden β†’ ... β†’ output_dim ----
        in_dim = 4 * input_dim
        layers: list = []
        for _ in range(n_layers):
            layers += [nn.Linear(in_dim, hidden_dim), nn.LayerNorm(hidden_dim), nn.GELU()]
            in_dim = hidden_dim
        layers.append(nn.Linear(hidden_dim, output_dim))
        self.mlp = nn.Sequential(*layers)

        # ---- CLIP-style projection head: output_dim β†’ proj_dim, then L2-norm ----
        self.gap_proj_head = nn.Sequential(
            nn.Linear(output_dim, proj_dim),
            nn.LayerNorm(proj_dim),
        )

        # ---- optional cell-line conditioning embedding ----
        # index 0 is reserved as padding / unknown; real cell-line ids start at 1
        if num_cell_lines > 0:
            self.cell_line_emb = nn.Embedding(num_cell_lines + 1, proj_dim, padding_idx=0)
        else:
            self.cell_line_emb = None

        # ---- optional auxiliary reconstruction head (Phase-1 MSE loss) ----
        if num_genes > 0:
            self.recon_head = nn.Linear(proj_dim, num_genes)
        else:
            self.recon_head = None

    # ------------------------------------------------------------------
    # helpers
    # ------------------------------------------------------------------

    def _project(self, z_gap: torch.Tensor) -> torch.Tensor:
        """Project z_gap β†’ normalized 128-dim gap_emb."""
        gap_emb = self.gap_proj_head(z_gap)           # [B, proj_dim]
        gap_emb = F.normalize(gap_emb, p=2, dim=-1)   # L2 normalize
        return gap_emb

    # ------------------------------------------------------------------
    # public API
    # ------------------------------------------------------------------

    def forward(
        self,
        z_source: torch.Tensor,
        z_target: torch.Tensor,
        cell_line_ids: Optional[torch.Tensor] = None,
    ) -> Dict[str, torch.Tensor]:
        """
        Parameters
        ----------
        z_source       : [B, H]
        z_target       : [B, H]
        cell_line_ids  : [B] int64, optional β€” cell-line indices for conditioning;
                         if None and cell_line_emb exists, conditioning is skipped.

        Returns
        -------
        dict with keys:
            'z_gap'    : [B, output_dim]  β€” pre-projection MLP output
            'gap_emb'  : [B, proj_dim]    β€” L2-normalized CLIP embedding
        """
        combined = torch.cat(
            [z_source, z_target, z_target - z_source, z_source * z_target],
            dim=-1,
        )
        z_gap = self.mlp(combined)                   # [B, output_dim]
        gap_emb = self._project(z_gap)               # [B, proj_dim], L2-normalized

        # optional additive cell-line conditioning (re-normalize after addition)
        if self.cell_line_emb is not None and cell_line_ids is not None:
            cl_emb = self.cell_line_emb(cell_line_ids)          # [B, proj_dim]
            gap_emb = F.normalize(gap_emb + cl_emb, p=2, dim=-1)

        return {
            "z_gap": z_gap,
            "gap_emb": gap_emb,
        }

    def reconstruct_expression(self, gap_emb: torch.Tensor) -> torch.Tensor:
        """Decode gap_emb into predicted delta-expression (auxiliary MSE loss).

        Parameters
        ----------
        gap_emb : [B, proj_dim]  β€” L2-normalized CLIP embedding

        Returns
        -------
        delta_expr : [B, num_genes]

        Raises
        ------
        RuntimeError if the reconstruction head was not built (num_genes == 0).
        """
        if self.recon_head is None:
            raise RuntimeError(
                "reconstruct_expression() requires num_genes > 0 at construction time."
            )
        return self.recon_head(gap_emb)