Add distributed server implementation and protocol definitions
- Introduced `protocol.py` for shared data models used in server/client communication, including request and response schemas for registration, job submission, and status updates. - Implemented `server.py` to manage a SQLite job queue and client registry, handling job polling, status updates, and job completion. - Created a cheat sheet for server usage, detailing commands for starting the server, submitting jobs, and monitoring clients. - Added several experiment configuration files for various training setups, including geometry vector injections and baseline ensembles.
This commit is contained in:
@@ -2,6 +2,25 @@
|
||||
|
||||
Self-contained: no v3 dependencies.
|
||||
Inherits get_sample dispatch from TowerBase.
|
||||
|
||||
Geometry injection (EPC consumption)
|
||||
--------------------------------------
|
||||
When geom_dim > 0, ClinicalEncoder requests the "geometry_vectors" key from EPC
|
||||
during early_pass and appends the geometry features to every clinical vector.
|
||||
The input layer is sized to clinical_data.feature_dim + geom_dim automatically.
|
||||
|
||||
Config example (cd tower consuming geometry):
|
||||
{
|
||||
"name": "cd",
|
||||
"module": "v4.classes.towers.clinical_tower",
|
||||
"class": "ClinicalEncoder",
|
||||
"data_source": "matrix",
|
||||
"epc_requests": ["geometry_vectors"],
|
||||
"args": {
|
||||
"hidden_dim": 128,
|
||||
"geom_dim": 5
|
||||
}
|
||||
}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -14,7 +33,7 @@ from v4.classes.accessory.se_block import SEBlock
|
||||
|
||||
|
||||
class ClinicalEncoder(TowerBase):
|
||||
"""MLP over tabular clinical features.
|
||||
"""MLP over tabular clinical features, with optional geometry vector injection.
|
||||
|
||||
clinical_data : ClinicalDataView — provides feature_dim, vectorize_entity, side_map
|
||||
hidden_dim : output embedding dimensionality
|
||||
@@ -22,21 +41,28 @@ class ClinicalEncoder(TowerBase):
|
||||
use_se : wrap output with SEBlock channel gating
|
||||
se_reduction : SEBlock bottleneck factor
|
||||
se_pre_norm : apply LayerNorm before SEBlock
|
||||
geom_dim : number of geometry features to append from EPC (0 = disabled)
|
||||
requires epc_requests: ["geometry_vectors"] in tower config
|
||||
"""
|
||||
|
||||
EPC_GEOMETRY_KEY = "geometry_vectors"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
clinical_data,
|
||||
hidden_dim: int = 128,
|
||||
dropout: float = 0.1,
|
||||
use_se: bool = False,
|
||||
se_reduction: int = 16,
|
||||
se_pre_norm: bool = True,
|
||||
hidden_dim: int = 128,
|
||||
dropout: float = 0.1,
|
||||
use_se: bool = False,
|
||||
se_reduction: int = 16,
|
||||
se_pre_norm: bool = True,
|
||||
geom_dim: int = 0,
|
||||
):
|
||||
super().__init__()
|
||||
self.clinical_data = clinical_data
|
||||
self._out_dim = hidden_dim
|
||||
feature_dim = clinical_data.feature_dim
|
||||
self.clinical_data = clinical_data
|
||||
self._out_dim = hidden_dim
|
||||
self._geom_dim = geom_dim
|
||||
self._geom_vectors: dict | None = None # filled by early_pass when geom_dim > 0
|
||||
feature_dim = clinical_data.feature_dim + geom_dim
|
||||
|
||||
self.block0 = nn.Sequential(
|
||||
nn.Linear(feature_dim, hidden_dim),
|
||||
@@ -53,6 +79,12 @@ class ClinicalEncoder(TowerBase):
|
||||
self.tower_ln = nn.LayerNorm(hidden_dim) if se_pre_norm else nn.Identity()
|
||||
self.tower_se = SEBlock(hidden_dim, reduction=se_reduction, residual=True) if use_se else None
|
||||
|
||||
# ── EPC early_pass ───────────────────────────────────────────────────────
|
||||
|
||||
def early_pass(self, context) -> None:
|
||||
if self._geom_dim > 0:
|
||||
self._geom_vectors = context.require(self.EPC_GEOMETRY_KEY)
|
||||
|
||||
# ── TowerBase interface ──────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
@@ -65,6 +97,14 @@ class ClinicalEncoder(TowerBase):
|
||||
|
||||
def _get(self, *ids) -> torch.Tensor:
|
||||
arr = self.clinical_data.vectorize_entity(*ids)
|
||||
if self._geom_dim > 0 and self._geom_vectors is not None:
|
||||
pid = int(ids[0])
|
||||
eye = str(ids[1]) if len(ids) > 1 else "OD"
|
||||
geom = self._geom_vectors.get(
|
||||
(pid, eye),
|
||||
np.zeros(self._geom_dim, dtype=np.float32),
|
||||
)
|
||||
arr = np.concatenate([arr, geom[: self._geom_dim]])
|
||||
return torch.from_numpy(arr.astype(np.float32, copy=False))
|
||||
|
||||
# ── nn.Module forward ────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
"""geometry_tower — GeometrySegEncoder for v4.
|
||||
|
||||
A CNN tower that takes a per-eye disc/cup *segmentation map* as input (rather
|
||||
than the raw fundus image) and contributes its pooled embedding to fusion.
|
||||
|
||||
Seg maps are produced by an underlying loader (GT contour rasterisation or
|
||||
UNet inference) during early_pass, then cached per fold.
|
||||
|
||||
UNet fine-tuning lives in early_pass too — the loader's `finetune(train_samples)`
|
||||
call uses only the training split, then precompute() runs inference on all
|
||||
fold samples (train + val + test).
|
||||
|
||||
Config example:
|
||||
{
|
||||
"name": "geom",
|
||||
"module": "v4.classes.towers.geometry_tower",
|
||||
"class": "GeometrySegEncoder",
|
||||
"data_source": "image",
|
||||
"args": {
|
||||
"backbone": "resnet18",
|
||||
"channels": 3,
|
||||
"target_size": 224,
|
||||
"augment": true,
|
||||
"seg_source": "gt",
|
||||
"contour_dir": "Papila/ExpertsSegmentations/Contours"
|
||||
}
|
||||
}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from torch import nn
|
||||
from torchvision import models
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
|
||||
from v4.classes.accessory.backbones import build_backbone
|
||||
from v4.classes.towerbase import TowerBase
|
||||
|
||||
|
||||
class GeometrySegEncoder(TowerBase):
|
||||
"""CNN tower over disc/cup segmentation maps.
|
||||
|
||||
image_data : ImageDataView — provides get_image_path(*ids) and side_map.
|
||||
Must implement build_seg_map_loader(source, **kwargs).
|
||||
backbone : backbone key (see accessory/backbones.py)
|
||||
channels : 1 (label map in [0,1]) or 3 (one-hot bg/rim/cup)
|
||||
target_size : CNN input spatial size (cached arrays already at this size)
|
||||
augment : random flip + 90° rotation at training time
|
||||
freeze_ratio : fraction of early backbone blocks to freeze in [0, 1]
|
||||
seg_source : passed to image_data.build_seg_map_loader (e.g. "gt", "unet")
|
||||
**seg_kwargs : forwarded to build_seg_map_loader
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
image_data,
|
||||
backbone: str = "resnet18",
|
||||
channels: int = 3,
|
||||
target_size: int = 224,
|
||||
augment: bool = True,
|
||||
freeze_ratio: float = 0.0,
|
||||
seg_source: str = "gt",
|
||||
**seg_kwargs: Any,
|
||||
):
|
||||
super().__init__()
|
||||
self.image_data = image_data
|
||||
self._channels = channels
|
||||
self._target_size = target_size
|
||||
self._augment = augment
|
||||
|
||||
if not hasattr(image_data, "build_seg_map_loader"):
|
||||
raise TypeError(
|
||||
f"GeometrySegEncoder requires image_data to implement "
|
||||
f"build_seg_map_loader(), but {type(image_data).__name__} does not."
|
||||
)
|
||||
loader_kwargs = {
|
||||
"channels": channels,
|
||||
"target_size": target_size,
|
||||
**seg_kwargs,
|
||||
}
|
||||
self._loader = image_data.build_seg_map_loader(seg_source, **loader_kwargs)
|
||||
self._seg_cache: dict = {}
|
||||
self._seg_source = seg_source
|
||||
|
||||
self.backbone, self._base_dim, self._blocks = build_backbone(backbone, freeze_ratio)
|
||||
if channels != 3:
|
||||
self._adapt_first_conv(channels)
|
||||
|
||||
print(
|
||||
f"[GeometrySegEncoder] backbone={backbone} channels={channels} "
|
||||
f"target_size={target_size} seg_source={seg_source}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# ── TowerBase interface ──────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def out_dim(self) -> int:
|
||||
return self._base_dim
|
||||
|
||||
@property
|
||||
def _side_map(self) -> dict[str, str]:
|
||||
return self.image_data.side_map
|
||||
|
||||
def _get(self, *ids) -> torch.Tensor:
|
||||
key = tuple(ids)
|
||||
arr = self._seg_cache.get(key)
|
||||
if arr is None:
|
||||
arr = np.zeros(
|
||||
(self._channels, self._target_size, self._target_size),
|
||||
dtype=np.float32,
|
||||
)
|
||||
if self.training and self._augment:
|
||||
arr = self._augment_array(arr)
|
||||
return torch.from_numpy(np.ascontiguousarray(arr))
|
||||
|
||||
# ── EPC early_pass ───────────────────────────────────────────────────────
|
||||
|
||||
def early_pass(self, context) -> None:
|
||||
data = context.require("data")
|
||||
split = context.require("split")
|
||||
|
||||
train_samples = self._collect_samples(split.train, data)
|
||||
all_samples = self._collect_samples(split.train, data)
|
||||
all_samples += self._collect_samples(split.val, data)
|
||||
if split.test is not None:
|
||||
all_samples += self._collect_samples(split.test, data)
|
||||
|
||||
# Reset per-fold state if loader supports it (UNet only).
|
||||
if hasattr(self._loader, "reset_cache"):
|
||||
self._loader.reset_cache()
|
||||
if hasattr(self._loader, "reset_weights"):
|
||||
self._loader.reset_weights()
|
||||
if hasattr(self._loader, "finetune"):
|
||||
self._loader.finetune(train_samples)
|
||||
|
||||
self._loader.precompute(all_samples)
|
||||
self._seg_cache = self._loader.all_seg_maps()
|
||||
print(
|
||||
f"[GeometrySegEncoder] cached {len(self._seg_cache)} seg maps for fold",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# ── nn.Module forward ────────────────────────────────────────────────────
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
y = self.backbone(x)
|
||||
if y.dim() > 2:
|
||||
y = y.flatten(1)
|
||||
return y
|
||||
|
||||
# ── Utilities ────────────────────────────────────────────────────────────
|
||||
|
||||
def set_freeze_ratio(self, ratio: float) -> None:
|
||||
r = max(0.0, min(1.0, float(ratio)))
|
||||
n_freeze = int(math.floor(len(self._blocks) * r))
|
||||
for b in self._blocks:
|
||||
for p in b.parameters():
|
||||
p.requires_grad = True
|
||||
for b in self._blocks[:n_freeze]:
|
||||
for p in b.parameters():
|
||||
p.requires_grad = False
|
||||
|
||||
# ── Internals ────────────────────────────────────────────────────────────
|
||||
|
||||
def _collect_samples(self, df, data) -> list:
|
||||
"""Build (pid, eye, image_path) tuples from a split DataFrame."""
|
||||
if df is None or len(df) == 0:
|
||||
return []
|
||||
pc = data.patient_col
|
||||
out = []
|
||||
for _, row in df.iterrows():
|
||||
pid = int(row[pc])
|
||||
eye = str(row.get("eyeID", "OD"))
|
||||
out.append((pid, eye, data.image.get_image_path(pid, eye)))
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _augment_array(arr: np.ndarray) -> np.ndarray:
|
||||
"""Random flip + 90° rotation on a (C, H, W) seg-map array."""
|
||||
if np.random.rand() < 0.5:
|
||||
arr = arr[:, :, ::-1]
|
||||
if np.random.rand() < 0.5:
|
||||
arr = arr[:, ::-1, :]
|
||||
k = int(np.random.randint(0, 4))
|
||||
if k:
|
||||
arr = np.rot90(arr, k=k, axes=(1, 2))
|
||||
return arr
|
||||
|
||||
def _adapt_first_conv(self, in_channels: int) -> None:
|
||||
"""Replace the first Conv2d to accept a non-3-channel input.
|
||||
|
||||
Pretrained weights are averaged across the original input channels and
|
||||
broadcast across the new ones.
|
||||
"""
|
||||
first = self._find_first_conv(self.backbone)
|
||||
new = nn.Conv2d(
|
||||
in_channels,
|
||||
first.out_channels,
|
||||
kernel_size=first.kernel_size,
|
||||
stride=first.stride,
|
||||
padding=first.padding,
|
||||
bias=first.bias is not None,
|
||||
)
|
||||
with torch.no_grad():
|
||||
new.weight.copy_(
|
||||
first.weight.mean(dim=1, keepdim=True).expand_as(new.weight)
|
||||
)
|
||||
if first.bias is not None:
|
||||
new.bias.copy_(first.bias)
|
||||
self._replace_first_conv(self.backbone, new)
|
||||
|
||||
@staticmethod
|
||||
def _find_first_conv(module: nn.Module) -> nn.Conv2d:
|
||||
for m in module.modules():
|
||||
if isinstance(m, nn.Conv2d):
|
||||
return m
|
||||
raise RuntimeError("No Conv2d found in backbone")
|
||||
|
||||
@classmethod
|
||||
def _replace_first_conv(cls, module: nn.Module, new_conv: nn.Conv2d) -> bool:
|
||||
for name, child in module.named_children():
|
||||
if isinstance(child, nn.Conv2d):
|
||||
setattr(module, name, new_conv)
|
||||
return True
|
||||
if cls._replace_first_conv(child, new_conv):
|
||||
return True
|
||||
return False
|
||||
@@ -2,50 +2,128 @@
|
||||
|
||||
Self-contained: no v3 dependencies.
|
||||
Inherits get_sample dispatch from TowerBase.
|
||||
|
||||
Geometry injection (EPC supply)
|
||||
--------------------------------
|
||||
When geometry_source is set, ImageEncoder asks the image_data view for a loader
|
||||
via image_data.build_geometry_loader(source, **kwargs). The view is responsible
|
||||
for understanding what that source means for its specific domain (fundus contours,
|
||||
U-Net segmentations, cat ear landmarks, etc.).
|
||||
|
||||
During early_pass the loader pre-computes all per-entity geometry vectors and
|
||||
publishes them to the EarlyPassContext under the key "geometry_vectors"
|
||||
({(entity_id...): np.ndarray of length geom_dim}). ClinicalEncoder (or any
|
||||
other tower with epc_requests: ["geometry_vectors"]) can then consume them.
|
||||
|
||||
The tower reads feature_dim and feature_names from the loader instance, so it
|
||||
can log geometry info without knowing anything about CDR, disc masks, or other
|
||||
domain-specific concepts.
|
||||
|
||||
Config example:
|
||||
{
|
||||
"name": "img",
|
||||
"module": "v4.classes.towers.image_tower",
|
||||
"class": "ImageEncoder",
|
||||
"data_source": "image",
|
||||
"epc_supplies": ["geometry_vectors"],
|
||||
"args": {
|
||||
"backbone": "refugelike",
|
||||
"augment": true,
|
||||
"geometry_source": "gt",
|
||||
"contour_dir": "Papila/ExpertsSegmentations/Contours"
|
||||
}
|
||||
}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from torch import nn
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
|
||||
from v4.classes.towerbase import TowerBase
|
||||
from v4.classes.accessory.backbones import build_backbone
|
||||
from v4.classes.accessory.se_block import SEBlock
|
||||
from v4.classes.accessory.transforms import build_backbone_transform, build_eval_transform
|
||||
from v4.classes.accessory.transforms import (
|
||||
build_backbone_transform, build_eval_transform, build_split_transforms,
|
||||
)
|
||||
|
||||
|
||||
class ImageEncoder(TowerBase):
|
||||
"""Vision backbone → pooled feature vector.
|
||||
|
||||
image_data : ImageDataView — provides load_image(*ids) and side_map
|
||||
backbone : backbone key (see accessory/backbones.py)
|
||||
freeze_ratio : fraction of early blocks to freeze in [0, 1]
|
||||
use_se : apply SE attention over the pooled feature vector
|
||||
augment : include random flip/rotation/jitter in the train transform
|
||||
image_data : ImageDataView — provides load_image(*ids) and side_map.
|
||||
Must implement build_geometry_loader(source, **kwargs)
|
||||
if geometry_source is set.
|
||||
backbone : backbone key (see accessory/backbones.py)
|
||||
freeze_ratio : fraction of early blocks to freeze in [0, 1]
|
||||
use_se : apply SE attention over the pooled feature vector
|
||||
augment : include random flip/rotation/jitter in the train transform
|
||||
cache_transformed : if True, cache resized + ToTensor'd float32 [0, 1] CHW
|
||||
tensors per fold. Per-batch cost drops to augment +
|
||||
Normalize on tensors only (no PIL, no Resize, no decode).
|
||||
Memory: ~3 × crop_size² × 4B per cached image.
|
||||
Cache is rebuilt at the start of every fold via early_pass.
|
||||
geometry_source : source key passed to image_data.build_geometry_loader()
|
||||
(e.g. "gt", "unet"). None = geometry disabled.
|
||||
**geom_kwargs : forwarded verbatim to build_geometry_loader() — e.g.
|
||||
contour_dir="Papila/ExpertsSegmentations/Contours"
|
||||
"""
|
||||
|
||||
EPC_GEOMETRY_KEY = "geometry_vectors"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
image_data,
|
||||
backbone: str = "efficientnet_b0",
|
||||
freeze_ratio: float = 0.0,
|
||||
use_se: bool = False,
|
||||
se_reduction: int = 16,
|
||||
se_pre_norm: bool = True,
|
||||
augment: bool = True,
|
||||
backbone: str = "efficientnet_b0",
|
||||
freeze_ratio: float = 0.0,
|
||||
use_se: bool = False,
|
||||
se_reduction: int = 16,
|
||||
se_pre_norm: bool = True,
|
||||
augment: bool = True,
|
||||
cache_transformed: bool = False,
|
||||
geometry_source: str | None = None,
|
||||
**geom_kwargs: Any,
|
||||
):
|
||||
super().__init__()
|
||||
self.image_data = image_data
|
||||
self._name = backbone
|
||||
self.backbone, self._base_dim, self._blocks = build_backbone(backbone, freeze_ratio)
|
||||
self.transform = build_backbone_transform(backbone, augment=augment)
|
||||
self.eval_transform = build_eval_transform(backbone)
|
||||
|
||||
self._cache_transformed = cache_transformed
|
||||
if cache_transformed:
|
||||
self._precache_tf, self._post_train_tf = build_split_transforms(backbone, augment=augment)
|
||||
_, self._post_eval_tf = build_split_transforms(backbone, augment=False)
|
||||
self._tensor_cache: dict[tuple, torch.Tensor] = {}
|
||||
else:
|
||||
self.transform = build_backbone_transform(backbone, augment=augment)
|
||||
self.eval_transform = build_eval_transform(backbone)
|
||||
|
||||
self.tower_ln = nn.LayerNorm(self._base_dim) if se_pre_norm else nn.Identity()
|
||||
self.tower_se = SEBlock(self._base_dim, reduction=se_reduction, residual=True) if use_se else None
|
||||
|
||||
self._geom_loader = None
|
||||
if geometry_source is not None:
|
||||
if not hasattr(image_data, "build_geometry_loader"):
|
||||
raise TypeError(
|
||||
f"ImageEncoder geometry_source={geometry_source!r} requires "
|
||||
f"image_data to implement build_geometry_loader(), "
|
||||
f"but {type(image_data).__name__} does not."
|
||||
)
|
||||
self._geom_loader = image_data.build_geometry_loader(geometry_source, **geom_kwargs)
|
||||
print(
|
||||
f"[ImageEncoder] geometry_source={geometry_source!r} "
|
||||
f"features={self._geom_loader.feature_names}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# ── TowerBase interface ──────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
@@ -57,10 +135,61 @@ class ImageEncoder(TowerBase):
|
||||
return self.image_data.side_map
|
||||
|
||||
def _get(self, *ids) -> torch.Tensor:
|
||||
if self._cache_transformed:
|
||||
key = tuple(ids)
|
||||
cached = self._tensor_cache.get(key)
|
||||
if cached is None:
|
||||
cached = self._precache_tf(self.image_data.load_image(*ids))
|
||||
self._tensor_cache[key] = cached
|
||||
tail = self._post_train_tf if self.training else self._post_eval_tf
|
||||
return tail(cached)
|
||||
img = self.image_data.load_image(*ids)
|
||||
t = self.transform if self.training else self.eval_transform
|
||||
return t(img)
|
||||
|
||||
# ── EPC early_pass ───────────────────────────────────────────────────────
|
||||
|
||||
def early_pass(self, context) -> None:
|
||||
"""Per-fold setup: warm tensor cache (if enabled), publish geometry vectors."""
|
||||
data = context.require("data")
|
||||
|
||||
if self._cache_transformed:
|
||||
self._tensor_cache.clear()
|
||||
n = self._warm_tensor_cache(data, context.require("split"))
|
||||
print(
|
||||
f"[ImageEncoder] warmed transformed-tensor cache for {n} entries "
|
||||
f"({self._name})",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if self._geom_loader is None:
|
||||
return
|
||||
self._geom_loader.precompute(data.df, patient_col=data.patient_col)
|
||||
vecs = self._geom_loader.all_vectors()
|
||||
context.put(self.EPC_GEOMETRY_KEY, vecs)
|
||||
print(
|
||||
f"[ImageEncoder] published {len(vecs)} geometry vectors "
|
||||
f"(dim={self._geom_loader.feature_dim}) to EPC key '{self.EPC_GEOMETRY_KEY}'",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
def _warm_tensor_cache(self, data, split) -> int:
|
||||
"""Pre-fill the per-tower tensor cache for all entries in this fold's splits."""
|
||||
seen: set[tuple] = set()
|
||||
for df in (split.train, split.val, split.test):
|
||||
if df is None or len(df) == 0:
|
||||
continue
|
||||
pc = data.patient_col
|
||||
for _, row in df.iterrows():
|
||||
pid = int(row[pc])
|
||||
eye = str(row.get("eyeID", "OD"))
|
||||
key = (pid, eye)
|
||||
if key in self._tensor_cache or key in seen:
|
||||
continue
|
||||
self._tensor_cache[key] = self._precache_tf(self.image_data.load_image(pid, eye))
|
||||
seen.add(key)
|
||||
return len(self._tensor_cache)
|
||||
|
||||
# ── nn.Module forward ────────────────────────────────────────────────────
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
|
||||
Reference in New Issue
Block a user