File size: 11,214 Bytes
c20d956
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
"""
DenseNet3D V5 - Dose-Preserving Architecture.

Key changes from V4:
1. BatchNorm3d to preserve absolute dose magnitude across samples
2. Less pooling - only 2 transitions instead of 3
3. Anisotropic pooling - pools XY more than Z (matches CT spacing)
4. Same API as V4 for drop-in replacement
"""

import torch
from torch import nn
from typing import Optional, Tuple, Union


class DenseBlockV5(nn.Module):
    """DenseBlock with BatchNorm for dose-magnitude preservation."""

    def __init__(
        self,
        num_convs: int,
        in_channels: int,
        growth_rate: int,
        drop_rate: float = 0.0,
        kernel_size: int = 3,
    ):
        super().__init__()
        self.layers = nn.ModuleList()
        self.drop_rate = drop_rate

        for i in range(num_convs):
            current_channels = in_channels + i * growth_rate
            # Pre-activation: BatchNorm -> SiLU -> Conv
            self.layers.append(
                nn.Sequential(
                    nn.BatchNorm3d(current_channels),
                    nn.SiLU(inplace=True),
                    nn.Conv3d(
                        current_channels,
                        growth_rate,
                        kernel_size=kernel_size,
                        padding=kernel_size // 2,
                    ),
                )
            )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        for block in self.layers:
            y = block(x)
            if self.drop_rate > 0 and self.training:
                y = nn.functional.dropout(y, p=self.drop_rate, training=True)
            x = torch.cat((x, y), dim=1)
        return x


class BlurPool3d(nn.Module):
    """Anti-aliased downsampling: Gaussian blur + strided subsampling.

    Uses a fixed binomial-3 ([1,2,1]) kernel to low-pass filter before
    subsampling, preventing checkerboard artifacts in gradients.
    Based on Zhang (2019), "Making Convolutional Networks Shift-Invariant Again".
    """

    def __init__(self, channels: int, stride: Tuple[int, ...] = (1, 2, 2)):
        super().__init__()
        self.channels = channels
        self.stride = stride
        self.padding = 1  # for 3x3x3 kernel

        # Binomial-3 tent kernel — gives uniform gradient scaling with stride 2
        a = torch.tensor([1.0, 2.0, 1.0])
        filt = a[:, None, None] * a[None, :, None] * a[None, None, :]
        filt = filt / filt.sum()
        filt = filt[None, None].repeat(channels, 1, 1, 1, 1)
        self.register_buffer("filt", filt)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return nn.functional.conv3d(
            nn.functional.pad(x, [self.padding] * 6, mode="reflect"),
            self.filt,
            stride=self.stride,
            groups=self.channels,
        )


class DenseNet3DSmooth(nn.Module):
    """
    DenseNet3D V5 - Same API as V4 but with dose-preserving changes.

    Changes from V4:
    - BatchNorm3d to preserve absolute dose magnitude
    - Anisotropic pooling (less Z pooling)
    - 3 dense blocks with 2 transitions (less aggressive pooling)
    - Anti-aliased downsampling (BlurPool) for smooth dose gradients
    """

    def __init__(
        self,
        *,
        in_channels_ct: int = 1,
        in_channels_dose: int = 1,
        in_channels_mask: int = 1,
        stem_out_channels_total: int = 96,
        growth_rate: int = 32,
        arch: Tuple[int, int, int] = (4, 4, 4),
        block_ct_mask_input_grads: bool = True,
        drop_rate: float = 0.0,
        use_skip_connections: bool = True,
        batch_norm_dose: bool = True,
    ) -> None:
        super().__init__()

        if in_channels_dose < 1:
            raise ValueError("in_channels_dose must be >= 1")

        use_ct = in_channels_ct > 0
        use_mask = in_channels_mask > 0
        n_stems = 1 + int(use_ct) + int(use_mask)

        if stem_out_channels_total < n_stems:
            raise ValueError(f"stem_out_channels_total must be >= {n_stems}")

        base = stem_out_channels_total // n_stems
        rem = stem_out_channels_total - base * n_stems
        stem_dose_out = base + (1 if rem > 0 else 0)
        stem_ct_out = (base + (1 if rem > 1 else 0)) if use_ct else 0
        stem_mask_out = base if use_mask else 0

        self.in_channels_ct = in_channels_ct
        self.in_channels_dose = in_channels_dose
        self.in_channels_mask = in_channels_mask
        self.block_ct_mask_input_grads = block_ct_mask_input_grads
        self.use_skip_connections = use_skip_connections
        self.batch_norm_dose = batch_norm_dose

        # --- Anisotropic Stem with BatchNorm + anti-aliased downsampling ---
        def make_stem(in_ch: int, out_ch: int, use_batch_norm: bool = True) -> nn.Sequential:
            layers = [
                nn.Conv3d(
                    in_ch,
                    out_ch,
                    kernel_size=(3, 7, 7),
                    stride=1,  # No stride — avoids checkerboard gradients
                    padding=(1, 3, 3),
                ),
            ]
            if use_batch_norm:
                layers.append(nn.BatchNorm3d(out_ch))
            layers.extend(
                [
                    nn.SiLU(inplace=True),
                    # Anti-aliased 4x downsampling in H,W (two 2x stages)
                    BlurPool3d(out_ch, stride=(1, 2, 2)),
                    BlurPool3d(out_ch, stride=(1, 2, 2)),
                ]
            )
            return nn.Sequential(*layers)

        self.stem_ct = make_stem(in_channels_ct, stem_ct_out) if use_ct else None
        self.stem_dose = make_stem(in_channels_dose, stem_dose_out, use_batch_norm=batch_norm_dose)
        self.stem_mask = make_stem(in_channels_mask, stem_mask_out) if use_mask else None

        init_channels = (
            stem_dose_out + (stem_ct_out if use_ct else 0) + (stem_mask_out if use_mask else 0)
        )

        # Anti-aliased transition: BlurPool instead of AvgPool for smooth gradients
        def make_transition(in_ch: int, out_ch: int, pool_z: bool = False) -> nn.Sequential:
            stride = (2, 2, 2) if pool_z else (1, 2, 2)
            return nn.Sequential(
                nn.BatchNorm3d(in_ch),
                nn.SiLU(inplace=True),
                nn.Conv3d(in_ch, out_ch, kernel_size=1),
                BlurPool3d(out_ch, stride=stride),
            )

        # Build dense blocks and transitions (3 blocks, 2 transitions)
        self.dense_blocks = nn.ModuleList()
        self.transitions = nn.ModuleList()
        self.skip_projections = nn.ModuleList() if use_skip_connections else None

        out_channels = init_channels
        self._block_output_channels = [init_channels]

        # Pool schedule: first transition XY only, second transition includes Z
        pool_z_schedule = [False, True]

        for i, num_convs in enumerate(arch):
            self.dense_blocks.append(
                DenseBlockV5(num_convs, out_channels, growth_rate, drop_rate=drop_rate)
            )
            out_channels += num_convs * growth_rate

            if i != len(arch) - 1:
                new_out = out_channels // 2
                pool_z = pool_z_schedule[i] if i < len(pool_z_schedule) else True

                self.transitions.append(make_transition(out_channels, new_out, pool_z=pool_z))

                if use_skip_connections:
                    skip_stride = (2, 2, 2) if pool_z else (1, 2, 2)
                    self.skip_projections.append(
                        nn.Sequential(
                            nn.Conv3d(self._block_output_channels[-1], new_out, kernel_size=1),
                            BlurPool3d(new_out, stride=skip_stride),
                        )
                    )

                out_channels = new_out
                self._block_output_channels.append(out_channels)

        self._final_channels = out_channels

        # Final head
        self.final_norm = nn.BatchNorm3d(out_channels)
        self.final_act = nn.SiLU(inplace=True)
        self.global_pool = nn.AdaptiveAvgPool3d((1, 1, 1))
        self.classifier = nn.Linear(out_channels, 1)

        self.apply(self._custom_init_weights)
        self._dose_input_ref: Optional[torch.Tensor] = None

    def forward(
        self,
        x_or_dose: Union[torch.Tensor, None],
        mask: Optional[torch.Tensor] = None,
        ct: Optional[torch.Tensor] = None,
    ) -> torch.Tensor:
        """Forward pass - same API as V4."""
        if mask is None and ct is None:
            x = x_or_dose
            if x is None:
                raise ValueError("Input tensor is None")
            b, c, d, h, w = x.shape
            c_ct = self.in_channels_ct
            c_dose = self.in_channels_dose
            c_mask = self.in_channels_mask
            expected = c_ct + c_dose + c_mask
            if c != expected:
                raise ValueError(f"Expected {expected} channels, got {c}")
            off = 0
            x_ct = x[:, off : off + c_ct] if c_ct > 0 else None
            off += c_ct
            x_dose = x[:, off : off + c_dose]
            off += c_dose
            x_mask = x[:, off : off + c_mask] if c_mask > 0 else None
        else:
            x_dose = x_or_dose
            x_mask = mask
            x_ct = ct
            if x_dose is None:
                raise ValueError("Dose tensor must be provided")

        self._dose_input_ref = x_dose

        if self.block_ct_mask_input_grads:
            if x_ct is not None:
                x_ct = x_ct.detach()
            if x_mask is not None:
                x_mask = x_mask.detach()

        parts = []
        if self.stem_ct is not None:
            if x_ct is None:
                raise ValueError("CT input missing")
            parts.append(self.stem_ct(x_ct))

        parts.append(self.stem_dose(x_dose))

        if self.stem_mask is not None:
            if x_mask is None:
                raise ValueError("Mask input missing")
            parts.append(self.stem_mask(x_mask))

        x = torch.cat(parts, dim=1)

        skip_features = [x] if self.use_skip_connections else None

        for i, dense_block in enumerate(self.dense_blocks):
            x = dense_block(x)

            if i < len(self.transitions):
                x = self.transitions[i](x)

                if self.use_skip_connections and i < len(self.skip_projections):
                    skip = self.skip_projections[i](skip_features[-1])
                    x = x + skip
                    skip_features.append(x)

        x = self.final_norm(x)
        x = self.final_act(x)
        x = self.global_pool(x)
        x = x.view(x.size(0), -1)
        out = self.classifier(x)

        return out

    @staticmethod
    def _custom_init_weights(m: nn.Module) -> None:
        if isinstance(m, (nn.Conv3d, nn.Linear)):
            nn.init.xavier_uniform_(m.weight)
            if m.bias is not None:
                nn.init.zeros_(m.bias)
        elif isinstance(m, nn.BatchNorm3d):
            if m.weight is not None:
                nn.init.ones_(m.weight)
            if m.bias is not None:
                nn.init.zeros_(m.bias)

    def enable_dose_gradient_mode(self) -> None:
        self.eval()
        self.block_ct_mask_input_grads = True