From a143af5ddc9d17ad6bc5f1f76671740b35f4ad69 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Wed, 19 Mar 2025 15:46:52 +0100 Subject: [PATCH 01/31] Adding Images --- src/graphnet/constants.py | 6 + src/graphnet/models/cnn/__init__.py | 4 + src/graphnet/models/cnn/cnn.py | 35 ++ .../models/cnn/theos_muonE_upgoing.py | 417 ++++++++++++++++++ .../data_representation/images/__init__.py | 10 + .../images/image_definition.py | 115 +++++ .../data_representation/images/images.py | 53 +++ .../images/mappings/__init__.py | 11 + .../images/mappings/pixel_mappings.py | 177 ++++++++ 9 files changed, 828 insertions(+) create mode 100644 src/graphnet/models/cnn/__init__.py create mode 100644 src/graphnet/models/cnn/cnn.py create mode 100644 src/graphnet/models/cnn/theos_muonE_upgoing.py create mode 100644 src/graphnet/models/data_representation/images/__init__.py create mode 100644 src/graphnet/models/data_representation/images/image_definition.py create mode 100644 src/graphnet/models/data_representation/images/images.py create mode 100644 src/graphnet/models/data_representation/images/mappings/__init__.py create mode 100644 src/graphnet/models/data_representation/images/mappings/pixel_mappings.py diff --git a/src/graphnet/constants.py b/src/graphnet/constants.py index 3b49fa774..bd1037d97 100644 --- a/src/graphnet/constants.py +++ b/src/graphnet/constants.py @@ -42,3 +42,9 @@ PROMETHEUS_GEOMETRY_TABLE_DIR = os.path.join(GEOMETRY_TABLE_DIR, "prometheus") LIQUIDO_GEOMETRY_TABLE_DIR = os.path.join(GEOMETRY_TABLE_DIR, "liquid-o") MAGIC_GEOMETRY_TABLE_DIR = os.path.join(GEOMETRY_TABLE_DIR, "magic") + +# Image Mapping Tables +IMAGE_MAPPING_TABLE_DIR = os.path.join(DATA_DIR, "image_mapping_tables") +IC86_CNN_MAPPING = os.path.join( + IMAGE_MAPPING_TABLE_DIR, "IC86_CNN_mapping.parquet" +) diff --git a/src/graphnet/models/cnn/__init__.py b/src/graphnet/models/cnn/__init__.py new file mode 100644 index 000000000..a3f58a75c --- /dev/null +++ b/src/graphnet/models/cnn/__init__.py @@ -0,0 +1,4 @@ +"""CNN-specific modules, for performing the main learnable operations.""" + +from .cnn import CNN +from .theos_muonE_upgoing.py import Theo_muonE_upgoing diff --git a/src/graphnet/models/cnn/cnn.py b/src/graphnet/models/cnn/cnn.py new file mode 100644 index 000000000..2453790e4 --- /dev/null +++ b/src/graphnet/models/cnn/cnn.py @@ -0,0 +1,35 @@ +"""Base CNN-specific `Model` class(es).""" + +from abc import abstractmethod + +from torch import Tensor +from torch_geometric.data import Data + +from graphnet.models import Model + + +class CNN(Model): + """Base class for all core CNN models in graphnet.""" + + def __init__(self, nb_inputs: int, nb_outputs: int) -> None: + """Construct `CNN`.""" + # Base class constructor + super().__init__() + + # Member variables + self._nb_inputs = nb_inputs + self._nb_outputs = nb_outputs + + @property + def nb_inputs(self) -> int: + """Return number of input features.""" + return self._nb_inputs + + @property + def nb_outputs(self) -> int: + """Return number of output features.""" + return self._nb_outputs + + @abstractmethod + def forward(self, data: Data) -> Tensor: + """Apply learnable forward pass in model.""" diff --git a/src/graphnet/models/cnn/theos_muonE_upgoing.py b/src/graphnet/models/cnn/theos_muonE_upgoing.py new file mode 100644 index 000000000..03011211a --- /dev/null +++ b/src/graphnet/models/cnn/theos_muonE_upgoing.py @@ -0,0 +1,417 @@ +"""CNN used for muon energy reconstruction in IceCube. + +Mimics `upgoing_muon_energy` model from +https://github.com/IceCubeOpenSource/i3deepice/tree/master +""" + +from typing import Tuple + +import torch +from torch import nn +from pytorch_lightning import LightningModule +from torch_geometric.data import Data +from .cnn import CNN + + +class Conv3dBN(LightningModule): + """The Conv3dBN module from Theos CNN model.""" + + def __init__( + self, + in_channels: int, + out_channels: int, + kernel_size: Tuple[int, int, int], + padding: Tuple[int, int, int], + bias: bool = False, + ): + """Create a Conv3dBN module. + + Args: + in_channels: Number of input channels. + out_channels: Number of output channels. + kernel_size: Size of the kernel. + padding: Padding of the kernel. + bias: If True, bias is used in the Convolution. + """ + super().__init__() + + self.conv = nn.Conv3d( + in_channels=in_channels, + out_channels=out_channels, + kernel_size=kernel_size, + padding=padding, + bias=bias, + ) + + self.bn = nn.BatchNorm3d(out_channels) + self.activation = nn.ReLU() + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """Forward pass of the Conv3dBN.""" + return self.activation(self.bn(self.conv(x))) + + +class InceptionBlock4(LightningModule): + """The inception_block4 module from Theos CNN model.""" + + def __init__( + self, + in_channels: int, + out_channels: int, + t0: int = 2, + t1: int = 4, + t2: int = 5, + n_pool: int = 3, + ): + """Create a InceptionBlock4 module. + + Args: + in_channels: Number of input channels. + out_channels: Number of output channels. + t0: Size of the first kernel sequence. + t1: Size of the second kernel sequence. + t2: Size of the third kernel sequence. + n_pool: Size of the pooling kernel. + """ + super().__init__() + + self.tower0 = nn.Sequential( + Conv3dBN( + in_channels=in_channels, + out_channels=out_channels, + kernel_size=(t0, 1, 1), + padding=(t0 // 2, 0, 0), + ), + Conv3dBN( + in_channels=out_channels, + out_channels=out_channels, + kernel_size=(1, t0, 1), + padding=(0, t0 // 2, 0), + ), + Conv3dBN( + in_channels=out_channels, + out_channels=out_channels, + kernel_size=(1, 1, t0), + padding=(0, 0, t0 // 2), + ), + ) + + self.tower1 = nn.Sequential( + Conv3dBN( + in_channels=in_channels, + out_channels=out_channels, + kernel_size=(t1, 1, 1), + padding=(t1 // 2, 0, 0), + ), + Conv3dBN( + in_channels=out_channels, + out_channels=out_channels, + kernel_size=(1, t1, 1), + padding=(0, t1 // 2, 0), + ), + Conv3dBN( + in_channels=out_channels, + out_channels=out_channels, + kernel_size=(1, 1, t1), + padding=(0, 0, t1 // 2), + ), + ) + + self.tower_4 = nn.Sequential( + Conv3dBN( + in_channels=in_channels, + out_channels=out_channels, + kernel_size=(1, 1, t2), + padding=(0, 0, t2 // 2), + ), + ) + + self.tower3 = nn.Sequential( + nn.MaxPool3d( + kernel_size=(n_pool, n_pool, n_pool), + stride=(1, 1, 1), + padding=(n_pool // 2, n_pool // 2, n_pool // 2), + ), + Conv3dBN( + in_channels=in_channels, + out_channels=out_channels, + kernel_size=(1, 1, 1), + padding=(0, 0, 0), + ), + ) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """Forward pass of the ConvResBlock.""" + ret = torch.cat( + [ + self.tower0(x), + self.tower1(x), + self.tower3(x), + self.tower4(x), + ], + dim=1, + ) + return ret + + +class InceptionResnet(LightningModule): + """The inception_resnet module from Theos CNN model.""" + + def __init__( + self, + in_channels: int, + out_channels: int, + t1: int = 2, + t2: int = 4, + n_pool: int = 3, + scale: float = 0.1, + ): + """Create a InceptionResnet module. + + Args: + in_channels: Number of input channels. + out_channels: Number of output channels. + t1: Size of the first kernel sequence. + t2: Size of the second kernel sequence. + n_pool: Size of the pooling kernel. + scale: Scaling factor for the residual connection. + """ + super().__init__() + self.scale = scale + self.tower1 = nn.Sequential( + Conv3dBN( + in_channels=in_channels, + out_channels=out_channels, + kernel_size=(1, 1, 1), + padding=(0, 0, 0), + ), + Conv3dBN( + in_channels=in_channels, + out_channels=out_channels, + kernel_size=(t1, 1, 1), + padding=(t1 // 2, 0, 0), + ), + Conv3dBN( + in_channels=out_channels, + out_channels=out_channels, + kernel_size=(1, t1, 1), + padding=(0, t1 // 2, 0), + ), + Conv3dBN( + in_channels=out_channels, + out_channels=out_channels, + kernel_size=(1, 1, t1), + padding=(0, 0, t1 // 2), + ), + ) + self.tower2 = nn.Sequential( + Conv3dBN( + in_channels=in_channels, + out_channels=out_channels, + kernel_size=(1, 1, 1), + padding=(0, 0, 0), + ), + Conv3dBN( + in_channels=in_channels, + out_channels=out_channels, + kernel_size=(t2, 1, 1), + padding=(t2 // 2, 0, 0), + ), + Conv3dBN( + in_channels=out_channels, + out_channels=out_channels, + kernel_size=(1, t2, 1), + padding=(0, t2 // 2, 0), + ), + Conv3dBN( + in_channels=out_channels, + out_channels=out_channels, + kernel_size=(1, 1, t2), + padding=(0, 0, t2 // 2), + ), + ) + self.tower3 = nn.Sequential( + nn.MaxPool3d( + kernel_size=(n_pool, n_pool, n_pool), + stride=(1, 1, 1), + padding=(n_pool // 2, n_pool // 2, n_pool // 2), + ), + Conv3dBN( + in_channels=in_channels, + out_channels=out_channels, + kernel_size=(1, 1, 1), + padding=(0, 0, 0), + ), + ) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """Forward pass of the Conv".""" + tmp = torch.cat( + [ + self.tower1(x), + self.tower2(x), + self.tower3(x), + ], + dim=1, + ) + return x + self._scale * tmp + + +class TheosMuonEUpgoing(CNN): + """The TheosMuonEUpgoing module.""" + + def __init__(self, nb_inputs: int = 15, nb_outputs: int = 16) -> None: + """Construct `TheosMuonEUpgoing`. + + Args: + nb_inputs: Number of input features. + nb_outputs: Number of output features. + """ + super().__init__(nb_inputs, nb_outputs) + self.inceptionblocks4 = nn.Sequential( + InceptionBlock4( + in_channels=nb_inputs, + out_channels=18, + t0=2, + t1=5, + t2=8, + ), + InceptionBlock4( + in_channels=18, + out_channels=18, + t0=2, + t1=3, + t2=7, + ), + InceptionBlock4( + in_channels=18, + out_channels=18, + t0=2, + t1=4, + t2=8, + ), + InceptionBlock4( + in_channels=18, + out_channels=18, + t0=3, + t1=5, + t2=9, + ), + InceptionBlock4( + in_channels=18, + out_channels=18, + t0=2, + t1=8, + t2=9, + ), + ) + self.avgpool1 = nn.AvgPool3d((2, 2, 3)) + self.bn1 = nn.BatchNorm3d(18) + tmp = [ + InceptionResnet( + in_channels=18, + out_channels=24, + t2=3, + ), + InceptionResnet( + in_channels=24, + out_channels=24, + t2=4, + ), + InceptionResnet( + in_channels=24, + out_channels=24, + t2=5, + ), + ] + for _ in range(5): + tmp = tmp + [ + InceptionResnet( + in_channels=18, + out_channels=24, + t2=3, + ), + InceptionResnet( + in_channels=24, + out_channels=24, + t2=4, + ), + InceptionResnet( + in_channels=24, + out_channels=24, + t2=5, + ), + ] + + self.resblocks1 = nn.Sequential(*tmp) + self.avgpool2 = nn.AvgPool3d((1, 1, 2)) + self.bn2 = nn.BatchNorm3d(24) + tmp = [] + for _ in range(6): + tmp = tmp + [ + InceptionResnet( + in_channels=24, + out_channels=24, + t2=3, + ), + InceptionResnet( + in_channels=24, + out_channels=24, + t2=4, + ), + InceptionResnet( + in_channels=24, + out_channels=24, + t2=5, + ), + ] + self.resblocks2 = nn.Sequential(*tmp) + self.convs111 = nn.Sequential( + nn.Conv3d( + in_channels=24, + out_channels=64, + kernel_size=(1, 1, 1), + padding=(0, 0, 0), + ), + nn.ReLU(), + nn.Conv3d( + in_channels=64, + out_channels=4, + kernel_size=(1, 1, 1), + padding=(0, 0, 0), + ), + nn.ReLU(), + ) + self.avgpool3 = nn.AvgPool3d((1, 1, 2)) + self.mlps = nn.Sequential( + nn.LazyLinear(120), + nn.Linear(120, 64), + nn.Linear(64, 16), + ) + + def forward(self, data: Data) -> torch.Tensor: + """Apply learnable forward pass in model.""" + x = data.x + print(f"At beginning {x.size()}") + x = self.inceptionblocks4(x) + print(f"After inceptionblocks4 {x.size()}") + x = self.avgpool1(x) + print(f"After avgpool1 {x.size()}") + x = self.bn1(x) + print(f"After bn1 {x.size()}") + x = self.resblocks1(x) + print(f"After resblocks1 {x.size()}") + x = self.avgpool2(x) + print(f"After avgpool2 {x.size()}") + x = self.bn2(x) + print(f"After bn2 {x.size()}") + x = self.resblocks2(x) + print(f"After resblocks2 {x.size()}") + x = self.convs111(x) + print(f"After convs111 {x.size()}") + x = self.avgpool3(x) + print(f"After avgpool3 {x.size()}") + x = nn.Flatten()(x) + print(f"After flatten {x.size()}") + x = self.mlps(x) + return x diff --git a/src/graphnet/models/data_representation/images/__init__.py b/src/graphnet/models/data_representation/images/__init__.py new file mode 100644 index 000000000..d4f84e57c --- /dev/null +++ b/src/graphnet/models/data_representation/images/__init__.py @@ -0,0 +1,10 @@ +"""Modules for mapping images. + +´ImageDefinition´ defines the nodes and the mapping, and contains general +image-manipulation.´PixelMapping´ defines how raw data is mapped into the +regular sized image. +""" + +from .image_definition import ImageDefinition +from .images import IC86DNNImage +from .mappings import IC86DNNMapping diff --git a/src/graphnet/models/data_representation/images/image_definition.py b/src/graphnet/models/data_representation/images/image_definition.py new file mode 100644 index 000000000..f3d31a536 --- /dev/null +++ b/src/graphnet/models/data_representation/images/image_definition.py @@ -0,0 +1,115 @@ +"""Modules for defining images. + +These are self-contained image definitions that hold all the image-altering +code in graphnet. These modules define what image-based models sees as input +and can be passed to dataloaders during training and deployment. +""" + +from typing import List, Optional, Dict, Union, Tuple +import torch +from numpy.random import Generator + +from graphnet.models.detector import Detector +from graphnet.models.data_representation import DataRepresentation +from graphnet.models.data_representation.graphs import NodeDefinition +from torch_geometric.data import Data +from .mappings import PixelMapping + + +class ImageDefinition(DataRepresentation): + """An Abstract class to create Imagedefinitions from.""" + + def __init__( + self, + detector: Detector, + node_definition: NodeDefinition, + pixel_mapping: PixelMapping, + input_feature_names: Optional[List[str]] = None, + dtype: Optional[torch.dtype] = torch.float, + perturbation_dict: Optional[Dict[str, float]] = None, + seed: Optional[Union[int, Generator]] = None, + add_inactive_sensors: bool = False, + sensor_mask: Optional[List[int]] = None, + string_mask: Optional[List[int]] = None, + ): + """Construct `ImageDefinition`. + + ´Detector´-specific code. E.g. scaling/standardization and geometry + tables. + + ´node_definition´ defines the processing of raw data. + + ´pixel_mapping´ defines the mapping of the processed data to images. + + NOTE: some pixel_mappings require specific node_definitions. + + Args: + detector: The corresponding ´Detector´ representing the data. + node_definition: Definition of nodes. + pixel_mapping: Definition of Mapping form nodes to pixels. + input_feature_names: Names of each column in expected input data + that will be built into a image. If not provided, + it is automatically assumed that all features in `Detector` is + used. + dtype: data type used for node features. e.g. ´torch.float´ + perturbation_dict: Dictionary mapping a feature name to a standard + deviation according to which the values for this + feature should be randomly perturbed. Defaults + to None. + seed: seed or Generator used to randomly sample perturbations. + Defaults to None. + add_inactive_sensors: If True, inactive sensors will be appended + to the graph with padded pulse information. Defaults to False. + sensor_mask: A list of sensor id's to be masked from the graph. Any + sensor listed here will be removed from the graph. + Defaults to None. + string_mask: A list of string id's to be masked from the graph. + Defaults to None. + sort_by: Name of node feature to sort by. Defaults to None. + """ + # Base class constructor + super().__init__( + detector=detector, + input_feature_names=input_feature_names, + dtype=dtype, + perturbation_dict=perturbation_dict, + seed=seed, + add_inactive_sensors=add_inactive_sensors, + sensor_mask=sensor_mask, + string_mask=string_mask, + repeat_labels=False, + ) + + self._node_definition = node_definition + self._pixel_mapping = pixel_mapping + + def _set_output_feature_names( + self, input_feature_names: List[str] + ) -> List[str]: + """Set the final data output feature names.""" + # Set input data column names for pixel definition + self._node_definition.set_output_feature_names(input_feature_names) + + # get output data column names for pixel mapping + self._pixel_mapping._set_image_feature_names( + self._node_definition._output_feature_names + ) + return self._pixel_mapping.image_feature_names + + def _create_data( + self, input_features: torch.Tensor + ) -> Tuple[Data, List[str]]: + # Create image & get new pixel feature names + data, data_feature_names = self._node_definition(input_features) + + data.x = data.x.type(self.dtype) + + data = self._pixel_mapping(data, data_feature_names) + + if not isinstance(data.x, list): + data.x = [data.x] + + for i, x in enumerate(data.x): + data.x[i] = x.type(self.dtype) + + return data, data_feature_names diff --git a/src/graphnet/models/data_representation/images/images.py b/src/graphnet/models/data_representation/images/images.py new file mode 100644 index 000000000..791c54029 --- /dev/null +++ b/src/graphnet/models/data_representation/images/images.py @@ -0,0 +1,53 @@ +"""A module containing different image representations in GraphNeT.""" + +from typing import List, Optional, Any +import torch + +from graphnet.models.data_representation.graphs import NodeDefinition +from graphnet.models.detector import IceCube86 + +from .image_definition import ImageDefinition +from .mappings import IC86DNNMapping + + +class IC86DNNImage(ImageDefinition): + """Class creating a image for IC86 DNN data.""" + + def __init__( + self, + node_definition: NodeDefinition, + input_feature_names: List[str], + include_lower_dc: bool = True, + include_upper_dc: bool = True, + dtype: Optional[torch.dtype] = torch.float, + **kwargs: Any, + ) -> None: + """Construct `IC86DNNImage`. + + Args: + node_definition: Definition of nodes. + input_feature_names: Names of each column in expected input data + that will be built into a image. + include_lower_dc: If True, the lower DeepCore will be included. + include_upper_dc: If True, the upper DeepCore will be included. + dtype: data type used for node features. e.g. ´torch.float´ + """ + node_definition.set_output_feature_names(input_feature_names) + dom_labels = node_definition._cluster_on + + # Base class constructor + pixel_mapping = IC86DNNMapping( + dom_pos_names=dom_labels, + pixel_feature_names=node_definition._output_feature_names, + include_lower_dc=include_lower_dc, + include_upper_dc=include_upper_dc, + dtype=dtype, + ) + super().__init__( + detector=IceCube86(replace_with_identity=dom_labels), + node_definition=node_definition, + pixel_mapping=pixel_mapping, # PixelMapping, + input_feature_names=input_feature_names, + add_inactive_sensors=False, + **kwargs, + ) diff --git a/src/graphnet/models/data_representation/images/mappings/__init__.py b/src/graphnet/models/data_representation/images/mappings/__init__.py new file mode 100644 index 000000000..668a73aaa --- /dev/null +++ b/src/graphnet/models/data_representation/images/mappings/__init__.py @@ -0,0 +1,11 @@ +"""Modules for mapping images. + +´ImageDefinition´ defines the nodes and the mapping, and contains general +image-manipulation.´PixelMapping´ defines how raw data is mapped into the +regular sized image. +""" + +from .pixel_mappings import ( + PixelMapping, + IC86DNNMapping, +) diff --git a/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py b/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py new file mode 100644 index 000000000..2b6ac2f25 --- /dev/null +++ b/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py @@ -0,0 +1,177 @@ +"""Classes for mapping pixel data to images.""" + +from abc import abstractmethod +from typing import List +from torch_geometric.data import Data +import torch +import pandas as pd + +from graphnet.models import Model +from graphnet.constants import IC86_CNN_MAPPING + + +class PixelMapping(Model): + """Abstract class for mapping pixel data to images.""" + + def __init__( + self, + ) -> None: + """Construct `PixelMapping`.""" + super().__init__(name=__name__, class_name=self.__class__.__name__) + + @abstractmethod + def forward(self, data: Data, data_feature_names: List[str]) -> Data: + """Map pixel data to images.""" + raise NotImplementedError + + @abstractmethod + def _set_image_feature_names(self, input_feature_names: List[str]) -> None: + """Set the final image feature names.""" + raise NotImplementedError + + +class IC86DNNMapping(PixelMapping): + """Mapping for the IceCube86. + + This mapping is based on the CNN mapping used in the IceCube86 analysis. + See: https://arxiv.org/abs/2101.11589 + """ + + def __init__( + self, + dtype: torch.dtype, + dom_pos_names: List[str], + pixel_feature_names: List[str], + include_lower_dc: bool = True, + include_upper_dc: bool = True, + ): + """Construct `IC86MircoDNNMapping`. + + Args: + dtype: data type used for node features. e.g. ´torch.float´ + dom_pos_names: Names of the DOM position features. + pixel_feature_names: Names of each column in expected input data + that will be built into a image. + include_lower_dc: If True, the lower DeepCore will be included. + include_upper_dc: If True, the upper DeepCore will be included. + """ + super().__init__() + self._dtype = dtype + self._dom_pos_names = dom_pos_names + self._pixel_feature_names = pixel_feature_names + + self._set_indeces(pixel_feature_names, dom_pos_names) + + self._nb_cnn_features = len(pixel_feature_names) - len(dom_pos_names) + + self._include_lower_dc = include_lower_dc + self._include_upper_dc = include_upper_dc + + self._tensor_mapping = torch.tensor( + pd.read_parquet(IC86_CNN_MAPPING).values, + dtype=dtype, + ) + + def _set_indeces( + self, + feature_names: List[str], + dom_pos_names: List[str], + ) -> None: + self._dom_pos_idx = [] + self._cnn_features_idx = [] + for feature in feature_names: + if feature in dom_pos_names: + self._dom_pos_idx.append(feature_names.index(feature)) + else: + self._cnn_features_idx.append(feature_names.index(feature)) + + def forward( + self, data: Data, data_feature_names: List[str] + ) -> List[torch.Tensor]: + """Map pixel data to images.""" + # Initialize output arrays + + main_arr = torch.zeros( + (self._nb_cnn_features, 10, 10, 60), + dtype=self._dtype, + ) + if self._include_upper_dc: + upper_dc_arr = torch.zeros( + (self._nb_cnn_features, 8, 10), + dtype=self._dtype, + ) + if self._include_lower_dc: + lower_dc_arr = torch.zeros( + (self._nb_cnn_features, 8, 50), + dtype=self._dtype, + ) + + x = data.x + + # Direct coordinate and feature extraction + batch_coords = x[:, self._dom_pos_idx] + batch_row_features = x[:, self._cnn_features_idx] + + # Compute coordinate matches directly + coord_matches = torch.all( + torch.isclose( + batch_coords.unsqueeze(1), + self._tensor_mapping[:, :3].unsqueeze(0), + rtol=1e-5, + ), + dim=-1, + ) + + # Find matching indices + match_indices = coord_matches.nonzero(as_tuple=False) + + assert match_indices.numel() != 0 + + # Process matches efficiently + for match_row, geom_idx in match_indices: + # Retrieve geometric information directly from tensor + string_val = self._tensor_mapping[geom_idx, 6].item() + dom_number = self._tensor_mapping[geom_idx, 7].item() + + # Select appropriate array and indexing + if string_val < 79: # Main Array + main_arr[ + :, + int(self._tensor_mapping[geom_idx, 3]), + int(self._tensor_mapping[geom_idx, 4]), + int(self._tensor_mapping[geom_idx, 5]), + ] = batch_row_features[match_row] + + elif dom_number < 11: # Upper DeepCore + if self._include_upper_dc: + upper_dc_arr[ + :, + int(self._tensor_mapping[geom_idx, 3]), + int(self._tensor_mapping[geom_idx, 4]), + ] = batch_row_features[match_row] + + else: # Lower DeepCore + if self._include_lower_dc: + lower_dc_arr[ + :, + int(self._tensor_mapping[geom_idx, 3]), + int(self._tensor_mapping[geom_idx, 4]), + ] = batch_row_features[match_row] + + ret = [main_arr] + if self._include_upper_dc: + ret.append(upper_dc_arr) + if self._include_lower_dc: + ret.append(lower_dc_arr) + + data.x = ret + + return data + + def _set_image_feature_names(self, input_feature_names: List[str]) -> None: + """Set the final output feature names.""" + self.image_feature_names = [ + infeature + for infeature in input_feature_names + if infeature not in self._dom_pos_names + ] From 983427a722baf5391c39862c8bb55b88795cd947 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Wed, 19 Mar 2025 15:55:39 +0100 Subject: [PATCH 02/31] fix init --- src/graphnet/models/cnn/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/graphnet/models/cnn/__init__.py b/src/graphnet/models/cnn/__init__.py index a3f58a75c..cabbbab95 100644 --- a/src/graphnet/models/cnn/__init__.py +++ b/src/graphnet/models/cnn/__init__.py @@ -1,4 +1,4 @@ """CNN-specific modules, for performing the main learnable operations.""" from .cnn import CNN -from .theos_muonE_upgoing.py import Theo_muonE_upgoing +from .theos_muonE_upgoing import TheosMuonEUpgoing From 6866c10b70fc7c04ac0c4fb247daeaee033dca16 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Thu, 20 Mar 2025 09:18:09 +0100 Subject: [PATCH 03/31] fix logic for detector in IC86 Image --- .../models/data_representation/images/images.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/graphnet/models/data_representation/images/images.py b/src/graphnet/models/data_representation/images/images.py index 791c54029..e3a7d1931 100644 --- a/src/graphnet/models/data_representation/images/images.py +++ b/src/graphnet/models/data_representation/images/images.py @@ -4,7 +4,7 @@ import torch from graphnet.models.data_representation.graphs import NodeDefinition -from graphnet.models.detector import IceCube86 +from graphnet.models.detector import Detector, IceCube86 from .image_definition import ImageDefinition from .mappings import IC86DNNMapping @@ -20,6 +20,7 @@ def __init__( include_lower_dc: bool = True, include_upper_dc: bool = True, dtype: Optional[torch.dtype] = torch.float, + detector: Optional[Detector] = None, **kwargs: Any, ) -> None: """Construct `IC86DNNImage`. @@ -31,7 +32,15 @@ def __init__( include_lower_dc: If True, the lower DeepCore will be included. include_upper_dc: If True, the upper DeepCore will be included. dtype: data type used for node features. e.g. ´torch.float´ + detector: The corresponding ´Detector´ representing the data. """ + # Default detector with unstandardized input features + if detector is None: + detector = IceCube86( + replace_with_identity=input_feature_names, + ) + else: + assert isinstance(detector, IceCube86) node_definition.set_output_feature_names(input_feature_names) dom_labels = node_definition._cluster_on @@ -43,8 +52,9 @@ def __init__( include_upper_dc=include_upper_dc, dtype=dtype, ) + super().__init__( - detector=IceCube86(replace_with_identity=dom_labels), + detector=detector, node_definition=node_definition, pixel_mapping=pixel_mapping, # PixelMapping, input_feature_names=input_feature_names, From cc2dd55d71c0331fee8712edc4692611d66a07af Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Thu, 20 Mar 2025 18:25:49 +0100 Subject: [PATCH 04/31] Fixing bugs TheoCNN --- .../models/cnn/theos_muonE_upgoing.py | 84 ++++++++++--------- .../data_representation/images/__init__.py | 1 + .../images/image_definition.py | 53 ++++++++++-- .../data_representation/images/testing.py | 81 ++++++++++++++++++ 4 files changed, 170 insertions(+), 49 deletions(-) create mode 100644 src/graphnet/models/data_representation/images/testing.py diff --git a/src/graphnet/models/cnn/theos_muonE_upgoing.py b/src/graphnet/models/cnn/theos_muonE_upgoing.py index 03011211a..50e5711ee 100644 --- a/src/graphnet/models/cnn/theos_muonE_upgoing.py +++ b/src/graphnet/models/cnn/theos_muonE_upgoing.py @@ -4,7 +4,7 @@ https://github.com/IceCubeOpenSource/i3deepice/tree/master """ -from typing import Tuple +from typing import Tuple, Union import torch from torch import nn @@ -21,7 +21,7 @@ def __init__( in_channels: int, out_channels: int, kernel_size: Tuple[int, int, int], - padding: Tuple[int, int, int], + padding: Union[str, Tuple[int, int, int]], bias: bool = False, ): """Create a Conv3dBN module. @@ -80,19 +80,19 @@ def __init__( in_channels=in_channels, out_channels=out_channels, kernel_size=(t0, 1, 1), - padding=(t0 // 2, 0, 0), + padding="same", ), Conv3dBN( in_channels=out_channels, out_channels=out_channels, kernel_size=(1, t0, 1), - padding=(0, t0 // 2, 0), + padding="same", ), Conv3dBN( in_channels=out_channels, out_channels=out_channels, kernel_size=(1, 1, t0), - padding=(0, 0, t0 // 2), + padding="same", ), ) @@ -101,28 +101,28 @@ def __init__( in_channels=in_channels, out_channels=out_channels, kernel_size=(t1, 1, 1), - padding=(t1 // 2, 0, 0), + padding="same", ), Conv3dBN( in_channels=out_channels, out_channels=out_channels, kernel_size=(1, t1, 1), - padding=(0, t1 // 2, 0), + padding="same", ), Conv3dBN( in_channels=out_channels, out_channels=out_channels, kernel_size=(1, 1, t1), - padding=(0, 0, t1 // 2), + padding="same", ), ) - self.tower_4 = nn.Sequential( + self.tower4 = nn.Sequential( Conv3dBN( in_channels=in_channels, out_channels=out_channels, kernel_size=(1, 1, t2), - padding=(0, 0, t2 // 2), + padding="same", ), ) @@ -136,12 +136,13 @@ def __init__( in_channels=in_channels, out_channels=out_channels, kernel_size=(1, 1, 1), - padding=(0, 0, 0), + padding="same", ), ) + self.out_channels = out_channels * 4 def forward(self, x: torch.Tensor) -> torch.Tensor: - """Forward pass of the ConvResBlock.""" + """Forward pass of the InceptionBlock4.""" ret = torch.cat( [ self.tower0(x), @@ -177,31 +178,31 @@ def __init__( scale: Scaling factor for the residual connection. """ super().__init__() - self.scale = scale + self._scale = scale self.tower1 = nn.Sequential( Conv3dBN( in_channels=in_channels, out_channels=out_channels, kernel_size=(1, 1, 1), - padding=(0, 0, 0), + padding="same", ), Conv3dBN( - in_channels=in_channels, + in_channels=out_channels, out_channels=out_channels, kernel_size=(t1, 1, 1), - padding=(t1 // 2, 0, 0), + padding="same", ), Conv3dBN( in_channels=out_channels, out_channels=out_channels, kernel_size=(1, t1, 1), - padding=(0, t1 // 2, 0), + padding="same", ), Conv3dBN( in_channels=out_channels, out_channels=out_channels, kernel_size=(1, 1, t1), - padding=(0, 0, t1 // 2), + padding="same", ), ) self.tower2 = nn.Sequential( @@ -209,25 +210,25 @@ def __init__( in_channels=in_channels, out_channels=out_channels, kernel_size=(1, 1, 1), - padding=(0, 0, 0), + padding="same", ), Conv3dBN( - in_channels=in_channels, + in_channels=out_channels, out_channels=out_channels, kernel_size=(t2, 1, 1), - padding=(t2 // 2, 0, 0), + padding="same", ), Conv3dBN( in_channels=out_channels, out_channels=out_channels, kernel_size=(1, t2, 1), - padding=(0, t2 // 2, 0), + padding="same", ), Conv3dBN( in_channels=out_channels, out_channels=out_channels, kernel_size=(1, 1, t2), - padding=(0, 0, t2 // 2), + padding="same", ), ) self.tower3 = nn.Sequential( @@ -240,7 +241,7 @@ def __init__( in_channels=in_channels, out_channels=out_channels, kernel_size=(1, 1, 1), - padding=(0, 0, 0), + padding="same", ), ) @@ -277,28 +278,28 @@ def __init__(self, nb_inputs: int = 15, nb_outputs: int = 16) -> None: t2=8, ), InceptionBlock4( - in_channels=18, + in_channels=18 * 4, out_channels=18, t0=2, t1=3, t2=7, ), InceptionBlock4( - in_channels=18, + in_channels=18 * 4, out_channels=18, t0=2, t1=4, t2=8, ), InceptionBlock4( - in_channels=18, + in_channels=18 * 4, out_channels=18, t0=3, t1=5, t2=9, ), InceptionBlock4( - in_channels=18, + in_channels=18 * 4, out_channels=18, t0=2, t1=8, @@ -306,20 +307,20 @@ def __init__(self, nb_inputs: int = 15, nb_outputs: int = 16) -> None: ), ) self.avgpool1 = nn.AvgPool3d((2, 2, 3)) - self.bn1 = nn.BatchNorm3d(18) + self.bn1 = nn.BatchNorm3d(18 * 4) tmp = [ InceptionResnet( - in_channels=18, + in_channels=18 * 4, out_channels=24, t2=3, ), InceptionResnet( - in_channels=24, + in_channels=24 * 3, out_channels=24, t2=4, ), InceptionResnet( - in_channels=24, + in_channels=24 * 3, out_channels=24, t2=5, ), @@ -327,17 +328,17 @@ def __init__(self, nb_inputs: int = 15, nb_outputs: int = 16) -> None: for _ in range(5): tmp = tmp + [ InceptionResnet( - in_channels=18, + in_channels=24 * 3, out_channels=24, t2=3, ), InceptionResnet( - in_channels=24, + in_channels=24 * 3, out_channels=24, t2=4, ), InceptionResnet( - in_channels=24, + in_channels=24 * 3, out_channels=24, t2=5, ), @@ -345,22 +346,22 @@ def __init__(self, nb_inputs: int = 15, nb_outputs: int = 16) -> None: self.resblocks1 = nn.Sequential(*tmp) self.avgpool2 = nn.AvgPool3d((1, 1, 2)) - self.bn2 = nn.BatchNorm3d(24) + self.bn2 = nn.BatchNorm3d(24 * 3) tmp = [] for _ in range(6): tmp = tmp + [ InceptionResnet( - in_channels=24, + in_channels=24 * 3, out_channels=24, t2=3, ), InceptionResnet( - in_channels=24, + in_channels=24 * 3, out_channels=24, t2=4, ), InceptionResnet( - in_channels=24, + in_channels=24 * 3, out_channels=24, t2=5, ), @@ -368,7 +369,7 @@ def __init__(self, nb_inputs: int = 15, nb_outputs: int = 16) -> None: self.resblocks2 = nn.Sequential(*tmp) self.convs111 = nn.Sequential( nn.Conv3d( - in_channels=24, + in_channels=24 * 3, out_channels=64, kernel_size=(1, 1, 1), padding=(0, 0, 0), @@ -391,7 +392,8 @@ def __init__(self, nb_inputs: int = 15, nb_outputs: int = 16) -> None: def forward(self, data: Data) -> torch.Tensor: """Apply learnable forward pass in model.""" - x = data.x + assert len(data.x) == 1, "Only one image expected" + x = data.x[0] print(f"At beginning {x.size()}") x = self.inceptionblocks4(x) print(f"After inceptionblocks4 {x.size()}") diff --git a/src/graphnet/models/data_representation/images/__init__.py b/src/graphnet/models/data_representation/images/__init__.py index d4f84e57c..c351ed813 100644 --- a/src/graphnet/models/data_representation/images/__init__.py +++ b/src/graphnet/models/data_representation/images/__init__.py @@ -8,3 +8,4 @@ from .image_definition import ImageDefinition from .images import IC86DNNImage from .mappings import IC86DNNMapping +from .testing import TestImageIC86Mapping, TestPixel diff --git a/src/graphnet/models/data_representation/images/image_definition.py b/src/graphnet/models/data_representation/images/image_definition.py index f3d31a536..cef3a3fcf 100644 --- a/src/graphnet/models/data_representation/images/image_definition.py +++ b/src/graphnet/models/data_representation/images/image_definition.py @@ -5,8 +5,9 @@ and can be passed to dataloaders during training and deployment. """ -from typing import List, Optional, Dict, Union, Tuple +from typing import List, Optional, Dict, Union, Any, Callable import torch +import numpy as np from numpy.random import Generator from graphnet.models.detector import Detector @@ -96,15 +97,51 @@ def _set_output_feature_names( ) return self._pixel_mapping.image_feature_names - def _create_data( - self, input_features: torch.Tensor - ) -> Tuple[Data, List[str]]: - # Create image & get new pixel feature names - data, data_feature_names = self._node_definition(input_features) + def forward( # type: ignore + self, + input_features: np.ndarray, + input_feature_names: List[str], + truth_dicts: Optional[List[Dict[str, Any]]] = None, + custom_label_functions: Optional[Dict[str, Callable[..., Any]]] = None, + loss_weight_column: Optional[str] = None, + loss_weight: Optional[float] = None, + loss_weight_default_value: Optional[float] = None, + data_path: Optional[str] = None, + ) -> Data: + """Construct graph as ´Data´ object. + + Args: + input_features: Input features for graph construction. + Shape ´[num_rows, d]´ + input_feature_names: name of each column. Shape ´[,d]´. + truth_dicts: Dictionary containing truth labels. + custom_label_functions: Custom label functions. + loss_weight_column: Name of column that holds loss weight. + Defaults to None. + loss_weight: Loss weight associated with event. Defaults to None. + loss_weight_default_value: default value for loss weight. + Used in instances where some events have + no pre-defined loss weight. Defaults to None. + data_path: Path to dataset data files. Defaults to None. + + Returns: + graph + """ + data = super().forward( + input_features=input_features, + input_feature_names=input_feature_names, + truth_dicts=truth_dicts, + custom_label_functions=custom_label_functions, + loss_weight_column=loss_weight_column, + loss_weight=loss_weight, + loss_weight_default_value=loss_weight_default_value, + data_path=data_path, + ) + data.x = self._node_definition(data.x) data.x = data.x.type(self.dtype) - data = self._pixel_mapping(data, data_feature_names) + data = self._pixel_mapping(data, self.output_feature_names) if not isinstance(data.x, list): data.x = [data.x] @@ -112,4 +149,4 @@ def _create_data( for i, x in enumerate(data.x): data.x[i] = x.type(self.dtype) - return data, data_feature_names + return data diff --git a/src/graphnet/models/data_representation/images/testing.py b/src/graphnet/models/data_representation/images/testing.py new file mode 100644 index 000000000..abd695871 --- /dev/null +++ b/src/graphnet/models/data_representation/images/testing.py @@ -0,0 +1,81 @@ +"""Modules for testing Images and Mappings.""" + +from typing import List, Optional, Any +import torch +from .mappings import IC86DNNMapping +from .image_definition import ImageDefinition +from graphnet.models.detector import IceCube86 +from graphnet.models.data_representation.graphs import NodeDefinition +from torch_geometric.data import Data + + +class TestImageIC86Mapping(ImageDefinition): + """Class creating a test image for IC86 DNN data.""" + + def __init__( + self, + include_lower_dc: bool = True, + include_upper_dc: bool = True, + input_feature_names: List[str] = [ + "dom_x", + "dom_y", + "dom_z", + "string", + "dom_number", + ], + dtype: Optional[torch.dtype] = torch.float, + **kwargs: Any, + ) -> None: + """Construct `TestImageIC86Mapping`. + + Args: + include_lower_dc: If True, the lower DeepCore will be included. + include_upper_dc: If True, the upper DeepCore will be included. + input_feature_names: Names of each column in expected input data + that will be built into a image. + dtype: data type used for node features. e.g. ´torch.float´ + """ + node_definition = TestPixel() + node_definition.set_output_feature_names(input_feature_names) + dom_labels = ["dom_x", "dom_y", "dom_z"] + + # Base class constructor + pixel_mapping = IC86DNNMapping( + dom_pos_names=["dom_x", "dom_y", "dom_z"], + pixel_feature_names=node_definition._output_feature_names, + include_lower_dc=include_lower_dc, + include_upper_dc=include_upper_dc, + dtype=dtype, + ) + super().__init__( + detector=IceCube86( + replace_with_identity=dom_labels + ["string", "dom_number"] + ), + node_definition=node_definition, + pixel_mapping=pixel_mapping, # PixelMapping, + input_feature_names=input_feature_names, + add_inactive_sensors=False, + **kwargs, + ) + + +class TestPixel(NodeDefinition): + """Represent pixels as clusters with percentile summary pixel features. + + If `cluster_on` is set to the xyz coordinates of DOMs + e.g. `cluster_on = ['dom_x', 'dom_y', 'dom_z']`, each pixel will be a + unique DOM and the pulse information (charge, time) is summarized using + percentiles. + """ + + def _define_output_feature_names( + self, input_feature_names: List[str] + ) -> List[str]: + assert set(input_feature_names) == set( + ["dom_x", "dom_y", "dom_z", "string", "dom_number"] + ) + return input_feature_names + + def _construct_nodes(self, x: torch.Tensor) -> Data: + # Cast to Numpy + return x From 16660c9d3154648ca84ee0d7d8321f87496e23b9 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Thu, 20 Mar 2025 18:39:52 +0100 Subject: [PATCH 05/31] more fixes for cnn --- src/graphnet/models/cnn/theos_muonE_upgoing.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/graphnet/models/cnn/theos_muonE_upgoing.py b/src/graphnet/models/cnn/theos_muonE_upgoing.py index 50e5711ee..196afa343 100644 --- a/src/graphnet/models/cnn/theos_muonE_upgoing.py +++ b/src/graphnet/models/cnn/theos_muonE_upgoing.py @@ -385,7 +385,7 @@ def __init__(self, nb_inputs: int = 15, nb_outputs: int = 16) -> None: ) self.avgpool3 = nn.AvgPool3d((1, 1, 2)) self.mlps = nn.Sequential( - nn.LazyLinear(120), + nn.Linear(500, 120), nn.Linear(120, 64), nn.Linear(64, 16), ) @@ -394,26 +394,15 @@ def forward(self, data: Data) -> torch.Tensor: """Apply learnable forward pass in model.""" assert len(data.x) == 1, "Only one image expected" x = data.x[0] - print(f"At beginning {x.size()}") x = self.inceptionblocks4(x) - print(f"After inceptionblocks4 {x.size()}") x = self.avgpool1(x) - print(f"After avgpool1 {x.size()}") x = self.bn1(x) - print(f"After bn1 {x.size()}") x = self.resblocks1(x) - print(f"After resblocks1 {x.size()}") x = self.avgpool2(x) - print(f"After avgpool2 {x.size()}") x = self.bn2(x) - print(f"After bn2 {x.size()}") x = self.resblocks2(x) - print(f"After resblocks2 {x.size()}") x = self.convs111(x) - print(f"After convs111 {x.size()}") x = self.avgpool3(x) - print(f"After avgpool3 {x.size()}") x = nn.Flatten()(x) - print(f"After flatten {x.size()}") x = self.mlps(x) return x From ebef5bf4bce04cbc53b5cae8fd6ec3b2369bf08f Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Wed, 26 Mar 2025 15:35:51 +0100 Subject: [PATCH 06/31] Fixing batching for images --- .../data_representation/images/image_definition.py | 11 +++++++++++ .../images/mappings/pixel_mappings.py | 13 +++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/graphnet/models/data_representation/images/image_definition.py b/src/graphnet/models/data_representation/images/image_definition.py index cef3a3fcf..9cd0dead2 100644 --- a/src/graphnet/models/data_representation/images/image_definition.py +++ b/src/graphnet/models/data_representation/images/image_definition.py @@ -137,16 +137,27 @@ def forward( # type: ignore loss_weight_default_value=loss_weight_default_value, data_path=data_path, ) + + # data processing data.x = self._node_definition(data.x) + # set data type data.x = data.x.type(self.dtype) + # create image data = self._pixel_mapping(data, self.output_feature_names) if not isinstance(data.x, list): data.x = [data.x] + nb_nodes = [] for i, x in enumerate(data.x): data.x[i] = x.type(self.dtype) + # setting number of nodes as product of C*(D*)H*W + nb_nodes.append(np.prod(list(data.x[i].size()[2:]))) + + # set num_nodes to surpress warning + data.num_nodes = torch.tensor(nb_nodes) + return data diff --git a/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py b/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py index 2b6ac2f25..d927c2433 100644 --- a/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py +++ b/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py @@ -21,7 +21,11 @@ def __init__( @abstractmethod def forward(self, data: Data, data_feature_names: List[str]) -> Data: - """Map pixel data to images.""" + """Map pixel data to images. + + Make sure to add a batch dimension to the output. E.g picture with + dimensions CxHxW = 10x64x64 should be returned as 1x10x64x64. + """ raise NotImplementedError @abstractmethod @@ -158,11 +162,12 @@ def forward( int(self._tensor_mapping[geom_idx, 4]), ] = batch_row_features[match_row] - ret = [main_arr] + # unqueeze to add batch dimension + ret = [main_arr.unsqueeze(0)] if self._include_upper_dc: - ret.append(upper_dc_arr) + ret.append(upper_dc_arr.unsqueeze(0)) if self._include_lower_dc: - ret.append(lower_dc_arr) + ret.append(lower_dc_arr.unsqueeze(0)) data.x = ret From 95051cf5b0ab8dac87961d4bebde9f9dc11d045b Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Thu, 17 Apr 2025 15:27:43 +0200 Subject: [PATCH 07/31] Adjusting imports --- src/graphnet/models/data_representation/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/graphnet/models/data_representation/__init__.py b/src/graphnet/models/data_representation/__init__.py index 84dd64331..b05b036b9 100644 --- a/src/graphnet/models/data_representation/__init__.py +++ b/src/graphnet/models/data_representation/__init__.py @@ -18,3 +18,7 @@ NodeAsDOMTimeSeries, IceMixNodes, ) +from .images import ( + ImageDefinition, + IC86DNNImage, +) From b04de2fda46ffc23b1787e0ed013242fd87c3e4d Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Wed, 23 Apr 2025 20:40:36 +0200 Subject: [PATCH 08/31] Fixing gitignore & mapping_table --- .gitignore | 1 + .../IC86_CNN_mapping.parquet | Bin 0 -> 88181 bytes 2 files changed, 1 insertion(+) create mode 100644 data/image_mapping_tables/IC86_CNN_mapping.parquet diff --git a/.gitignore b/.gitignore index 8b679248a..4f2d571f4 100644 --- a/.gitignore +++ b/.gitignore @@ -154,6 +154,7 @@ data/examples/output/ !/graphnet/src/graphnet/models/pretrained/**/**/**/**/**.pth # Exception to geometry tables !/data/geometry_tables/**/**.parquet +!/data/image_mapping_tables/**/**.parquet !/data/tests/sqlite/upgrade_genie_step4_140028_000998_first_5_frames/upgrade_genie_step4_140028_000998_first_5_frames.db !/data/tests/parquet/oscNext_genie_level7_v02/merged/** !data/tests/parquet/oscNext_genie_level7_v02/oscNext_genie_level7_v02_first_5_frames.parquet diff --git a/data/image_mapping_tables/IC86_CNN_mapping.parquet b/data/image_mapping_tables/IC86_CNN_mapping.parquet new file mode 100644 index 0000000000000000000000000000000000000000..ac872fcacc7f916146e4979a5ba8060bf56cc91c GIT binary patch literal 88181 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 literal 0 HcmV?d00001 From a50ff961f0bde4b94c498151b733796496f106f4 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Wed, 23 Apr 2025 21:43:24 +0200 Subject: [PATCH 09/31] fixing image num_nodes --- .../models/data_representation/images/image_definition.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/graphnet/models/data_representation/images/image_definition.py b/src/graphnet/models/data_representation/images/image_definition.py index 9cd0dead2..316d0bde2 100644 --- a/src/graphnet/models/data_representation/images/image_definition.py +++ b/src/graphnet/models/data_representation/images/image_definition.py @@ -157,7 +157,7 @@ def forward( # type: ignore # setting number of nodes as product of C*(D*)H*W nb_nodes.append(np.prod(list(data.x[i].size()[2:]))) - # set num_nodes to surpress warning - data.num_nodes = torch.tensor(nb_nodes) + # set num_nodes equals number of pixels in all imagessurpress warning + data.num_nodes = torch.tensor(np.sum(nb_nodes)) return data From 380a8d1c87fc2f9c26c4f77fd2aca4f7aa275e55 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Wed, 30 Apr 2025 14:14:52 +0200 Subject: [PATCH 10/31] adding_counts to summary features --- src/graphnet/models/data_representation/graphs/nodes/nodes.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/graphnet/models/data_representation/graphs/nodes/nodes.py b/src/graphnet/models/data_representation/graphs/nodes/nodes.py index 064073bdd..ac66de01e 100644 --- a/src/graphnet/models/data_representation/graphs/nodes/nodes.py +++ b/src/graphnet/models/data_representation/graphs/nodes/nodes.py @@ -29,6 +29,7 @@ def __init__( # Base class constructor super().__init__(name=__name__, class_name=self.__class__.__name__) if input_feature_names is not None: + print(input_feature_names) self.set_output_feature_names( input_feature_names=input_feature_names ) From 9ae74b14182b1d0949a5f2e57e69ae27d9c47e4a Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Wed, 30 Apr 2025 18:24:41 +0200 Subject: [PATCH 11/31] change mapping to faster version --- .../data_representation/images/images.py | 14 +++++- .../images/mappings/pixel_mappings.py | 45 ++++++++++++------- .../data_representation/images/testing.py | 3 +- 3 files changed, 43 insertions(+), 19 deletions(-) diff --git a/src/graphnet/models/data_representation/images/images.py b/src/graphnet/models/data_representation/images/images.py index e3a7d1931..2e94abcab 100644 --- a/src/graphnet/models/data_representation/images/images.py +++ b/src/graphnet/models/data_representation/images/images.py @@ -19,6 +19,8 @@ def __init__( input_feature_names: List[str], include_lower_dc: bool = True, include_upper_dc: bool = True, + string_label: str = "string", + dom_number_label: str = "dom_number", dtype: Optional[torch.dtype] = torch.float, detector: Optional[Detector] = None, **kwargs: Any, @@ -31,6 +33,8 @@ def __init__( that will be built into a image. include_lower_dc: If True, the lower DeepCore will be included. include_upper_dc: If True, the upper DeepCore will be included. + string_label: The label for the string number in the data. + dom_number_label: The label for the DOM number in the data. dtype: data type used for node features. e.g. ´torch.float´ detector: The corresponding ´Detector´ representing the data. """ @@ -42,11 +46,17 @@ def __init__( else: assert isinstance(detector, IceCube86) node_definition.set_output_feature_names(input_feature_names) - dom_labels = node_definition._cluster_on + assert ( + string_label in input_feature_names + ), f"String label '{string_label}' not in input feature names" + assert ( + dom_number_label in input_feature_names + ), f"DOM number label '{dom_number_label}' not in input feature names" # Base class constructor pixel_mapping = IC86DNNMapping( - dom_pos_names=dom_labels, + string_label=string_label, + dom_number_label=dom_number_label, pixel_feature_names=node_definition._output_feature_names, include_lower_dc=include_lower_dc, include_upper_dc=include_upper_dc, diff --git a/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py b/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py index d927c2433..ed58f6815 100644 --- a/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py +++ b/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py @@ -44,8 +44,9 @@ class IC86DNNMapping(PixelMapping): def __init__( self, dtype: torch.dtype, - dom_pos_names: List[str], pixel_feature_names: List[str], + string_label: str = "string", + dom_number_label: str = "dom_number", include_lower_dc: bool = True, include_upper_dc: bool = True, ): @@ -53,7 +54,8 @@ def __init__( Args: dtype: data type used for node features. e.g. ´torch.float´ - dom_pos_names: Names of the DOM position features. + string_label: Names of the DOM string feature. + dom_number_label: Names of the DOM number feature. pixel_feature_names: Names of each column in expected input data that will be built into a image. include_lower_dc: If True, the lower DeepCore will be included. @@ -61,31 +63,43 @@ def __init__( """ super().__init__() self._dtype = dtype - self._dom_pos_names = dom_pos_names + self._string_label = string_label + self._dom_number_label = dom_number_label self._pixel_feature_names = pixel_feature_names - self._set_indeces(pixel_feature_names, dom_pos_names) + self._set_indeces(pixel_feature_names, dom_number_label, string_label) - self._nb_cnn_features = len(pixel_feature_names) - len(dom_pos_names) + self._nb_cnn_features = ( + len(pixel_feature_names) - 2 + ) # 2 for string and dom_number self._include_lower_dc = include_lower_dc self._include_upper_dc = include_upper_dc + df = pd.read_parquet(IC86_CNN_MAPPING) + df.sort_values( + by=["string", "dom_number"], + ascending=[True, True], + inplace=True, + ) + self._tensor_mapping = torch.tensor( - pd.read_parquet(IC86_CNN_MAPPING).values, + df.values, dtype=dtype, ) def _set_indeces( self, feature_names: List[str], - dom_pos_names: List[str], + dom_number_label: str, + string_label: str, ) -> None: - self._dom_pos_idx = [] self._cnn_features_idx = [] for feature in feature_names: - if feature in dom_pos_names: - self._dom_pos_idx.append(feature_names.index(feature)) + if feature == dom_number_label: + self._dom_number_idx = feature_names.index(feature) + elif feature == string_label: + self._string_idx = feature_names.index(feature) else: self._cnn_features_idx.append(feature_names.index(feature)) @@ -113,15 +127,14 @@ def forward( x = data.x # Direct coordinate and feature extraction - batch_coords = x[:, self._dom_pos_idx] + string_dom_number = x[:, [self._string_idx, self._dom_number_idx]] batch_row_features = x[:, self._cnn_features_idx] # Compute coordinate matches directly coord_matches = torch.all( - torch.isclose( - batch_coords.unsqueeze(1), - self._tensor_mapping[:, :3].unsqueeze(0), - rtol=1e-5, + torch.eq( + string_dom_number.unsqueeze(1), + self._tensor_mapping[:, [6, 7]].unsqueeze(0), ), dim=-1, ) @@ -178,5 +191,5 @@ def _set_image_feature_names(self, input_feature_names: List[str]) -> None: self.image_feature_names = [ infeature for infeature in input_feature_names - if infeature not in self._dom_pos_names + if infeature not in [self._string_label, self._dom_number_label] ] diff --git a/src/graphnet/models/data_representation/images/testing.py b/src/graphnet/models/data_representation/images/testing.py index abd695871..074914404 100644 --- a/src/graphnet/models/data_representation/images/testing.py +++ b/src/graphnet/models/data_representation/images/testing.py @@ -41,7 +41,8 @@ def __init__( # Base class constructor pixel_mapping = IC86DNNMapping( - dom_pos_names=["dom_x", "dom_y", "dom_z"], + string_label="string", + dom_number_label="dom_number", pixel_feature_names=node_definition._output_feature_names, include_lower_dc=include_lower_dc, include_upper_dc=include_upper_dc, From d3a5d4ab732b79724ac09c1c385a959aab825f5a Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Thu, 19 Jun 2025 14:13:37 +0200 Subject: [PATCH 12/31] Faster Mapping & unit tests --- .../IC86_CNN_mapping.parquet | Bin 88181 -> 5317 bytes data/tests/images/IC86lower_deepcore_test.npy | Bin 0 -> 3328 bytes data/tests/images/IC86main_array_test.npy | Bin 0 -> 48128 bytes data/tests/images/IC86upper_deepcore_test.npy | Bin 0 -> 768 bytes src/graphnet/constants.py | 8 + .../images/mappings/pixel_mappings.py | 133 +++++++----- .../data_representation/images/testing.py | 14 +- tests/models/test_pixel_mapping.py | 192 ++++++++++++++++++ 8 files changed, 281 insertions(+), 66 deletions(-) create mode 100644 data/tests/images/IC86lower_deepcore_test.npy create mode 100644 data/tests/images/IC86main_array_test.npy create mode 100644 data/tests/images/IC86upper_deepcore_test.npy create mode 100644 tests/models/test_pixel_mapping.py diff --git a/data/image_mapping_tables/IC86_CNN_mapping.parquet b/data/image_mapping_tables/IC86_CNN_mapping.parquet index ac872fcacc7f916146e4979a5ba8060bf56cc91c..591cfc785deeb75a264567994c0dbeb1c7203a2c 100644 GIT binary patch literal 5317 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 DdDj0zf~&af?7 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 diff --git a/data/tests/images/IC86lower_deepcore_test.npy b/data/tests/images/IC86lower_deepcore_test.npy new file mode 100644 index 0000000000000000000000000000000000000000..178a090477e280e187e367107920529862886988 GIT binary patch literal 3328 zcmeIyuS)|_0KoAlEQlBu77T_Lgg4=ZLrxg7a2p~Eb6jwsAb5v&V_|s5omhsjXcShK z#t<2Y7>q{aqS3f$Tr~RKzaUs1@8A>emB;t}8h5&<{>6s46GIin`E{m_q^kE^rKRd6 z*=-iyTqRi)ug6cqK^`-n_ro;izj~l$P0K@PU(0*>r*BJ$Y10fdI!y3HhY6nPFu|M- z6BKlq;DrtoywYI;ry+y~9|2N~@PJ25FhhYiyyF84eBujBd}DAO>Co#3U;sy zi9J-I;lM==b$B>N3vKu~!#OSyAVMDlq;QzmgO30yMz9@rKkT`(_rtzd|MBkr0?Ph~ AXaE2J literal 0 HcmV?d00001 diff --git a/data/tests/images/IC86main_array_test.npy b/data/tests/images/IC86main_array_test.npy new file mode 100644 index 0000000000000000000000000000000000000000..628cbfd71896479b3029796b4ba518b235f182fe GIT binary patch literal 48128 zcmeI5v1?ON6o;?iAkra&gM-5x3W<;*N)eHQ&r+mAl?)Xs6ihHF8G;Q-5kZBAh!hbC zDpJHDqhrU889R3Dn6YEWj((T?0~#y5m)|e&4GpBc@8sQc&w1R_etrDpQT^$q@G`tE zx7vf}{qnt}yu7_st|sN}o&IjW`Qq75ztxWKKWugfZQdVrn!PqZS8i96TeT^7YB#IN z+vIOwS3~%|qQySGO8mfAiJ$l?5&Uul905nb5y)i(_Slb<_=v9(pYT=UGrmd;@m1mr zzDj(mdI=)J5;H$(YzDjK2t3(4|C0h6@(ZN@VF1|_>q%69y%UtC9Ric8g5>fSBOBqQlf;f5(&Oa z)bLdz#aD?szDneBlk`P70*=5RMIgJ+^D7}jN?3$QaaAJ2SBVk6N{sPUBIf3Ra|#)Z zUW0*-(qki!VL-f+D!PxXfHXW!4hpWQFyaI5tt z2}Z#Ck@q9-N5$QbynelYy?%?keg#VoDP`wp=V#~V;^t?sU$0-UU$5VyoX?Reu8?(p zc7Aq#c785Oes;cczH+{DzH+`I4{%=C=i7ar-}4Ke*YJFb=W#rLabk+z+}R zbU)~R(EZ?qe%SNf)Bmrvz^r)*JkRd={eSs;1#0z`ny*^FU|;tO?ibuIxLlddOSPr9CTJ(qoAIaUsO_*m=~X4l#&V(cT3DEP6dh= zXCxM+0{I$7Itms#3Wf%nItsN4WC1P)hIvlJ;RQf-#Nb6hb;RH$Ky}36Wk7Yr;1xi1 x#Nbsxb;RH`Ky}36273ku1_vMl;szjI0K^A?_yG_zI5IE@0I>oPkE+8KE&xuSm0kb< literal 0 HcmV?d00001 diff --git a/src/graphnet/constants.py b/src/graphnet/constants.py index bd1037d97..949773e2b 100644 --- a/src/graphnet/constants.py +++ b/src/graphnet/constants.py @@ -21,6 +21,14 @@ TEST_PARQUET_DATA = os.path.join( TEST_DATA_DIR, "parquet", _test_dataset_name, "merged" ) +TEST_IMAGE_DIR = os.path.join(TEST_DATA_DIR, "images") +TEST_IC86MAIN_IMAGE = os.path.join(TEST_IMAGE_DIR, "IC86main_array_test.npy") +TEST_IC86LOWERDC_IMAGE = os.path.join( + TEST_IMAGE_DIR, "IC86lower_deepcore_test.npy" +) +TEST_IC86UPPERDC_IMAGE = os.path.join( + TEST_IMAGE_DIR, "IC86upper_deepcore_test.npy" +) # Example data EXAMPLE_DATA_DIR = os.path.join(DATA_DIR, "examples") diff --git a/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py b/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py index ed58f6815..33c89ad76 100644 --- a/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py +++ b/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py @@ -5,6 +5,7 @@ from torch_geometric.data import Data import torch import pandas as pd +import numpy as np from graphnet.models import Model from graphnet.constants import IC86_CNN_MAPPING @@ -15,9 +16,11 @@ class PixelMapping(Model): def __init__( self, + pixel_feature_names: List[str], ) -> None: """Construct `PixelMapping`.""" super().__init__(name=__name__, class_name=self.__class__.__name__) + self._set_image_feature_names(pixel_feature_names) @abstractmethod def forward(self, data: Data, data_feature_names: List[str]) -> Data: @@ -37,8 +40,9 @@ def _set_image_feature_names(self, input_feature_names: List[str]) -> None: class IC86DNNMapping(PixelMapping): """Mapping for the IceCube86. - This mapping is based on the CNN mapping used in the IceCube86 analysis. - See: https://arxiv.org/abs/2101.11589 + This mapping is based on the CNN mapping used + in the multiple IceCube86 analysis. + For further details see: https://arxiv.org/abs/2101.11589 """ def __init__( @@ -47,6 +51,7 @@ def __init__( pixel_feature_names: List[str], string_label: str = "string", dom_number_label: str = "dom_number", + include_main_array: bool = True, include_lower_dc: bool = True, include_upper_dc: bool = True, ): @@ -54,14 +59,27 @@ def __init__( Args: dtype: data type used for node features. e.g. ´torch.float´ - string_label: Names of the DOM string feature. - dom_number_label: Names of the DOM number feature. + string_label: Name of the feature corresponding + to the DOM string number. Values Integers betweem 1 - 86 + dom_number_label: Name of the feature corresponding + to the DOM number (1 - 60). Values Integers between 1 - 60 + where 1 is the dom with the highest z coordinate. pixel_feature_names: Names of each column in expected input data that will be built into a image. + include_main_array: If True, the main array will be included. include_lower_dc: If True, the lower DeepCore will be included. include_upper_dc: If True, the upper DeepCore will be included. + + Raises: + ValueError: If no array type is included. + + NOTE: Expects input data to be DOMs with aggregated features. """ - super().__init__() + if not np.any( + [include_main_array, include_lower_dc, include_upper_dc] + ): + raise ValueError("Include at least one array type.") + self._dtype = dtype self._string_label = string_label self._dom_number_label = dom_number_label @@ -73,9 +91,11 @@ def __init__( len(pixel_feature_names) - 2 ) # 2 for string and dom_number + self._include_main_array = include_main_array self._include_lower_dc = include_lower_dc self._include_upper_dc = include_upper_dc + # read mapping from parquet file df = pd.read_parquet(IC86_CNN_MAPPING) df.sort_values( by=["string", "dom_number"], @@ -83,17 +103,23 @@ def __init__( inplace=True, ) - self._tensor_mapping = torch.tensor( - df.values, - dtype=dtype, + # Set the index to string and dom_number for faster lookup + df.set_index( + ["string", "dom_number"], + inplace=True, + drop=False, ) + self._mapping = df + super().__init__(pixel_feature_names=pixel_feature_names) + def _set_indeces( self, feature_names: List[str], dom_number_label: str, string_label: str, ) -> None: + """Set the indices for the features.""" self._cnn_features_idx = [] for feature in feature_names: if feature == dom_number_label: @@ -103,16 +129,14 @@ def _set_indeces( else: self._cnn_features_idx.append(feature_names.index(feature)) - def forward( - self, data: Data, data_feature_names: List[str] - ) -> List[torch.Tensor]: + def forward(self, data: Data, data_feature_names: List[str]) -> Data: """Map pixel data to images.""" # Initialize output arrays - - main_arr = torch.zeros( - (self._nb_cnn_features, 10, 10, 60), - dtype=self._dtype, - ) + if self._include_main_array: + main_arr = torch.zeros( + (self._nb_cnn_features, 10, 10, 60), + dtype=self._dtype, + ) if self._include_upper_dc: upper_dc_arr = torch.zeros( (self._nb_cnn_features, 8, 10), @@ -124,70 +148,71 @@ def forward( dtype=self._dtype, ) + # data.x is expected to be a tensor with shape (N, F) + # where N is the number of nodes and F is the number of features. x = data.x # Direct coordinate and feature extraction - string_dom_number = x[:, [self._string_idx, self._dom_number_idx]] + string_dom_number = x[ + :, [self._string_idx, self._dom_number_idx] + ].int() batch_row_features = x[:, self._cnn_features_idx] - # Compute coordinate matches directly - coord_matches = torch.all( - torch.eq( - string_dom_number.unsqueeze(1), - self._tensor_mapping[:, [6, 7]].unsqueeze(0), - ), - dim=-1, + # look up the mapping for string and dom_number + match_indices = self._mapping.loc[ + zip(*string_dom_number.t().tolist()) + ][ + ["string", "dom_number", "mat_ax0", "mat_ax1", "mat_ax2"] + ].values.astype( + int ) - # Find matching indices - match_indices = coord_matches.nonzero(as_tuple=False) - - assert match_indices.numel() != 0 - - # Process matches efficiently - for match_row, geom_idx in match_indices: - # Retrieve geometric information directly from tensor - string_val = self._tensor_mapping[geom_idx, 6].item() - dom_number = self._tensor_mapping[geom_idx, 7].item() - + # Copy CNN features to the appropriate arrays + for i, row in enumerate(match_indices): # Select appropriate array and indexing - if string_val < 79: # Main Array - main_arr[ - :, - int(self._tensor_mapping[geom_idx, 3]), - int(self._tensor_mapping[geom_idx, 4]), - int(self._tensor_mapping[geom_idx, 5]), - ] = batch_row_features[match_row] - - elif dom_number < 11: # Upper DeepCore + if row[0] < 79: # Main Array + if self._include_main_array: + main_arr[ + :, + row[2], # mat_ax0 + row[3], # mat_ax1 + row[4], # mat_ax2 + ] = batch_row_features[i] + + elif row[1] < 11: # Upper DeepCore if self._include_upper_dc: upper_dc_arr[ :, - int(self._tensor_mapping[geom_idx, 3]), - int(self._tensor_mapping[geom_idx, 4]), - ] = batch_row_features[match_row] + row[2], # mat_ax0 + row[3], # mat_ax1 + ] = batch_row_features[i] else: # Lower DeepCore if self._include_lower_dc: lower_dc_arr[ :, - int(self._tensor_mapping[geom_idx, 3]), - int(self._tensor_mapping[geom_idx, 4]), - ] = batch_row_features[match_row] - - # unqueeze to add batch dimension - ret = [main_arr.unsqueeze(0)] + row[2], # mat_ax0 + row[3], # mat_ax1 + ] = batch_row_features[i] + + # unqueeze to add dimension for batching + # with collate_fn Batch.from_data_list + ret: List[torch.Tensor] = [] + if self._include_main_array: + ret.append(main_arr.unsqueeze(0)) if self._include_upper_dc: ret.append(upper_dc_arr.unsqueeze(0)) if self._include_lower_dc: ret.append(lower_dc_arr.unsqueeze(0)) + # Set list of images as data.x data.x = ret - return data def _set_image_feature_names(self, input_feature_names: List[str]) -> None: """Set the final output feature names.""" + # string and dom_number are only used for mapping + # and will not be included in the output features. self.image_feature_names = [ infeature for infeature in input_feature_names diff --git a/src/graphnet/models/data_representation/images/testing.py b/src/graphnet/models/data_representation/images/testing.py index 074914404..35fc5b3e1 100644 --- a/src/graphnet/models/data_representation/images/testing.py +++ b/src/graphnet/models/data_representation/images/testing.py @@ -14,15 +14,9 @@ class TestImageIC86Mapping(ImageDefinition): def __init__( self, + input_feature_names: List[str], include_lower_dc: bool = True, include_upper_dc: bool = True, - input_feature_names: List[str] = [ - "dom_x", - "dom_y", - "dom_z", - "string", - "dom_number", - ], dtype: Optional[torch.dtype] = torch.float, **kwargs: Any, ) -> None: @@ -37,7 +31,6 @@ def __init__( """ node_definition = TestPixel() node_definition.set_output_feature_names(input_feature_names) - dom_labels = ["dom_x", "dom_y", "dom_z"] # Base class constructor pixel_mapping = IC86DNNMapping( @@ -50,7 +43,7 @@ def __init__( ) super().__init__( detector=IceCube86( - replace_with_identity=dom_labels + ["string", "dom_number"] + replace_with_identity=input_feature_names, ), node_definition=node_definition, pixel_mapping=pixel_mapping, # PixelMapping, @@ -72,9 +65,6 @@ class TestPixel(NodeDefinition): def _define_output_feature_names( self, input_feature_names: List[str] ) -> List[str]: - assert set(input_feature_names) == set( - ["dom_x", "dom_y", "dom_z", "string", "dom_number"] - ) return input_feature_names def _construct_nodes(self, x: torch.Tensor) -> Data: diff --git a/tests/models/test_pixel_mapping.py b/tests/models/test_pixel_mapping.py new file mode 100644 index 000000000..1d42b03b3 --- /dev/null +++ b/tests/models/test_pixel_mapping.py @@ -0,0 +1,192 @@ +"""Unit tests for node definitions.""" + +import numpy as np +import pandas as pd +import torch +from torch_geometric.data import Data +from copy import deepcopy +from graphnet.models.data_representation.images import IC86DNNMapping +from graphnet.constants import ( + TEST_IC86MAIN_IMAGE, + IC86_CNN_MAPPING, + TEST_IC86UPPERDC_IMAGE, + TEST_IC86LOWERDC_IMAGE, +) +import pytest + + +def basic_checks_picture(picture: Data, dtype: torch.dtype) -> None: + """Basic checks for the output of pixel mapping.""" + assert isinstance( + picture, Data + ), f"Output should be a Data object got {type(picture)}" + assert isinstance( + picture.x, list + ), f"x should be a list of tensors got {type(picture.x)}" + assert np.all( + [isinstance(picture.x[i], torch.Tensor) for i in range(len(picture.x))] + ), ( + "All tensors in x should be torch.Tensors", + f"got {[type(picture.x[i]) for i in range(len(picture.x))]}", + ) + assert np.all( + [picture.x[i].dtype == dtype for i in range(len(picture.x))] + ), ( + "All tensors in x should have the dtype specified in pixel_mapping", + f"got {[picture.x[i].dtype for i in range(len(picture.x))]}", + ) + + +def test_pixel_mappings() -> None: + """Test pixel mapping for IC86 DNN mapping.""" + # definitions + dtype = torch.float32 + pixel_feature_names = ["string", "dom_number", "data1", "data2"] + string_label = "string" + dom_number_label = "dom_number" + + # Create dummy data + dummy_data = Data( + x=torch.tensor( + [[1, 2, 5.8, 1e-4], [79, 46, 3.7, 1e-18], [84, 9, 6.87, 2e5]], + dtype=dtype, + ), + ) + + # Construct node definition + # This defines each DOM as a cluster, and will summarize pulses seen by + # DOMs using percentiles. + pixel_mapping = IC86DNNMapping( + dtype=dtype, + pixel_feature_names=pixel_feature_names, + string_label=string_label, + dom_number_label=dom_number_label, + include_lower_dc=True, + include_upper_dc=True, + ) + + # Apply node definition to torch tensor with raw pulses + picture = pixel_mapping(dummy_data, pixel_feature_names) + new_features = pixel_mapping.image_feature_names + + # Check the output + basic_checks_picture(picture, dtype) + + # More checks + assert isinstance( + new_features, list + ), f"Output should be a list of feature names got {type(new_features)}" + assert new_features == [ + "data1", + "data2", + ], f"Expected feature to be ['data1', 'data2'] names got: {new_features}" + assert len(picture.x) == 3, ( + "There should be three tensors in x ", + f"got list with length {len(picture.x)}" + "(main array, upper DeepCore, lower DeepCore)", + ) + assert picture.x[0].size() == torch.Size( + [1, 2, 10, 10, 60] + ), f"Main array should have shape (1,2,10,10,60) got {picture.x[0].size()}" + assert picture.x[1].size() == torch.Size( + [1, 2, 8, 10] + ), f"upper DeepCore should have shape (1,2,8,10) got {picture.x[1].size()}" + assert picture.x[2].size() == torch.Size( + [1, 2, 8, 50] + ), f"lower DeepCore should have shape (1,2,8,50) got {picture.x[2].size()}" + assert not torch.all( + picture.x[0] == 0 + ), "Main array should not be all zeros, got all zeros." + assert not torch.all( + picture.x[1] == 0 + ), "Upper DeepCore should not be all zeros, got all zeros." + assert not torch.all( + picture.x[2] == 0 + ), "Lower DeepCore should not be all zeros, got all zeros." + + # Try string and dom_number that does not exist + dummy_data = Data( + x=torch.tensor( + [ + [100, 5, 5.8, 1e-4], + [54, 230, 3.7, 1e-18], + [1294, 500, 6.87, 2e5], + ], + dtype=dtype, + ), + ) + + # should raise KeyError since the string and dom_number + # do not exist in the mapping + with pytest.raises(KeyError): + picture = pixel_mapping(dummy_data, pixel_feature_names) + + +def test_segments_mapping() -> None: + """Test pixel mapping for IC86 main array.""" + # definitions + dtype = torch.float32 + string_label = "string" + dom_number_label = "dom_number" + pixel_feature_names = [ + "string", + "dom_number", + "redundant_string", + "redundant_dom_number", + ] + + # Load the grid mapping + # This is a mapping from string and dom_number to the pixel coordinates + # in the main array, upper DeepCore and lower DeepCore. + # Running the grid mapping through the pixel mapping will + # create the full images for the main array, upper DeepCore + # and lower DeepCore. + grid = pd.read_parquet(IC86_CNN_MAPPING) + grid = grid.loc[:, ["string", "dom_number"]] + grid["redundant_string"] = grid["string"].copy() + grid["redundant_dom_number"] = grid["dom_number"].copy() + grid = Data(x=torch.tensor(grid.to_numpy(), dtype=dtype)) + + # Test the pixel mapping for the main array, upper and lower DeepCore + for image, inc_main, inc_upc, inc_lowdc, label in zip( + [TEST_IC86MAIN_IMAGE, TEST_IC86UPPERDC_IMAGE, TEST_IC86LOWERDC_IMAGE], + [True, False, False], + [False, True, False], + [False, False, True], + ["main array", "upper deepcore", "lower deepcore"], + ): + tmp = deepcopy(grid) + pixel_mapping = IC86DNNMapping( + dtype=dtype, + pixel_feature_names=pixel_feature_names, + string_label=string_label, + dom_number_label=dom_number_label, + include_main_array=inc_main, + include_lower_dc=inc_lowdc, + include_upper_dc=inc_upc, + ) + picture = pixel_mapping(tmp, pixel_feature_names) + tensor_image: torch.tensor = torch.tensor( + np.load(image), dtype=dtype + ).unsqueeze(0) + + # Check the output + basic_checks_picture(picture, dtype) + + # More checks + assert len(picture.x) == 1, ( + "There should be one tensor in x ", + f"got list with length {len(picture.x)}", + ) + assert picture.x[0].size() == tensor_image.size(), ( + f"{label} should have shape {tensor_image.size()} " + f"got {picture.x[0].size()}" + ) + assert not torch.all( + picture.x[0] == 0 + ), f"{label} should not be all zeros, got all zeros." + # Check if the tensor matches the expected image + assert torch.equal(tensor_image, picture.x[0]), ( + f"{label} should match the expected" + " main array from IC86 DNN mapping." + ) From 1bcfd83123b5127d6de0fc582b998a7a032bcdc8 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Thu, 19 Jun 2025 16:25:31 +0200 Subject: [PATCH 13/31] Rename classes & more unit tests --- .../models/data_representation/__init__.py | 2 +- .../data_representation/images/__init__.py | 5 +- .../data_representation/images/images.py | 6 +- .../images/mappings/__init__.py | 2 +- .../images/mappings/pixel_mappings.py | 2 +- .../data_representation/images/testing.py | 72 -------------- tests/models/test_image_definition.py | 94 +++++++++++++++++++ tests/models/test_pixel_mapping.py | 6 +- 8 files changed, 105 insertions(+), 84 deletions(-) delete mode 100644 src/graphnet/models/data_representation/images/testing.py create mode 100644 tests/models/test_image_definition.py diff --git a/src/graphnet/models/data_representation/__init__.py b/src/graphnet/models/data_representation/__init__.py index b05b036b9..47155bed0 100644 --- a/src/graphnet/models/data_representation/__init__.py +++ b/src/graphnet/models/data_representation/__init__.py @@ -20,5 +20,5 @@ ) from .images import ( ImageDefinition, - IC86DNNImage, + IC86Image, ) diff --git a/src/graphnet/models/data_representation/images/__init__.py b/src/graphnet/models/data_representation/images/__init__.py index c351ed813..277d0801d 100644 --- a/src/graphnet/models/data_representation/images/__init__.py +++ b/src/graphnet/models/data_representation/images/__init__.py @@ -6,6 +6,5 @@ """ from .image_definition import ImageDefinition -from .images import IC86DNNImage -from .mappings import IC86DNNMapping -from .testing import TestImageIC86Mapping, TestPixel +from .images import IC86Image +from .mappings import IC86PixelMapping diff --git a/src/graphnet/models/data_representation/images/images.py b/src/graphnet/models/data_representation/images/images.py index 2e94abcab..8527998f0 100644 --- a/src/graphnet/models/data_representation/images/images.py +++ b/src/graphnet/models/data_representation/images/images.py @@ -7,10 +7,10 @@ from graphnet.models.detector import Detector, IceCube86 from .image_definition import ImageDefinition -from .mappings import IC86DNNMapping +from .mappings import IC86PixelMapping -class IC86DNNImage(ImageDefinition): +class IC86Image(ImageDefinition): """Class creating a image for IC86 DNN data.""" def __init__( @@ -54,7 +54,7 @@ def __init__( ), f"DOM number label '{dom_number_label}' not in input feature names" # Base class constructor - pixel_mapping = IC86DNNMapping( + pixel_mapping = IC86PixelMapping( string_label=string_label, dom_number_label=dom_number_label, pixel_feature_names=node_definition._output_feature_names, diff --git a/src/graphnet/models/data_representation/images/mappings/__init__.py b/src/graphnet/models/data_representation/images/mappings/__init__.py index 668a73aaa..64d3f646b 100644 --- a/src/graphnet/models/data_representation/images/mappings/__init__.py +++ b/src/graphnet/models/data_representation/images/mappings/__init__.py @@ -7,5 +7,5 @@ from .pixel_mappings import ( PixelMapping, - IC86DNNMapping, + IC86PixelMapping, ) diff --git a/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py b/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py index 33c89ad76..e0af8889e 100644 --- a/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py +++ b/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py @@ -37,7 +37,7 @@ def _set_image_feature_names(self, input_feature_names: List[str]) -> None: raise NotImplementedError -class IC86DNNMapping(PixelMapping): +class IC86PixelMapping(PixelMapping): """Mapping for the IceCube86. This mapping is based on the CNN mapping used diff --git a/src/graphnet/models/data_representation/images/testing.py b/src/graphnet/models/data_representation/images/testing.py deleted file mode 100644 index 35fc5b3e1..000000000 --- a/src/graphnet/models/data_representation/images/testing.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Modules for testing Images and Mappings.""" - -from typing import List, Optional, Any -import torch -from .mappings import IC86DNNMapping -from .image_definition import ImageDefinition -from graphnet.models.detector import IceCube86 -from graphnet.models.data_representation.graphs import NodeDefinition -from torch_geometric.data import Data - - -class TestImageIC86Mapping(ImageDefinition): - """Class creating a test image for IC86 DNN data.""" - - def __init__( - self, - input_feature_names: List[str], - include_lower_dc: bool = True, - include_upper_dc: bool = True, - dtype: Optional[torch.dtype] = torch.float, - **kwargs: Any, - ) -> None: - """Construct `TestImageIC86Mapping`. - - Args: - include_lower_dc: If True, the lower DeepCore will be included. - include_upper_dc: If True, the upper DeepCore will be included. - input_feature_names: Names of each column in expected input data - that will be built into a image. - dtype: data type used for node features. e.g. ´torch.float´ - """ - node_definition = TestPixel() - node_definition.set_output_feature_names(input_feature_names) - - # Base class constructor - pixel_mapping = IC86DNNMapping( - string_label="string", - dom_number_label="dom_number", - pixel_feature_names=node_definition._output_feature_names, - include_lower_dc=include_lower_dc, - include_upper_dc=include_upper_dc, - dtype=dtype, - ) - super().__init__( - detector=IceCube86( - replace_with_identity=input_feature_names, - ), - node_definition=node_definition, - pixel_mapping=pixel_mapping, # PixelMapping, - input_feature_names=input_feature_names, - add_inactive_sensors=False, - **kwargs, - ) - - -class TestPixel(NodeDefinition): - """Represent pixels as clusters with percentile summary pixel features. - - If `cluster_on` is set to the xyz coordinates of DOMs - e.g. `cluster_on = ['dom_x', 'dom_y', 'dom_z']`, each pixel will be a - unique DOM and the pulse information (charge, time) is summarized using - percentiles. - """ - - def _define_output_feature_names( - self, input_feature_names: List[str] - ) -> List[str]: - return input_feature_names - - def _construct_nodes(self, x: torch.Tensor) -> Data: - # Cast to Numpy - return x diff --git a/tests/models/test_image_definition.py b/tests/models/test_image_definition.py new file mode 100644 index 000000000..302000c4e --- /dev/null +++ b/tests/models/test_image_definition.py @@ -0,0 +1,94 @@ +from graphnet.models.data_representation import IC86Image +from graphnet.models.data_representation import NodesAsPulses +from graphnet.models.detector import IceCube86 +import torch +from torch_geometric.data import Data +import pandas as pd +import numpy as np +from graphnet.constants import ( + IC86_CNN_MAPPING, + TEST_IC86MAIN_IMAGE, + TEST_IC86UPPERDC_IMAGE, + TEST_IC86LOWERDC_IMAGE, +) + + +def test_image_definition() -> None: + """Test the ImageDefinition class for IC86 DNN data.""" + # Define input feature names + + grid = pd.read_parquet(IC86_CNN_MAPPING) + grid = grid.loc[:, ["string", "dom_number"]] + grid["redundant_string"] = grid["string"].copy() + grid["redundant_dom_number"] = grid["dom_number"].copy() + dtype = torch.float32 + + # Create a NodeDefinition instance + node_def = NodesAsPulses( + input_feature_names=grid.columns.tolist(), + ) + + detector = IceCube86(replace_with_identity=grid.columns.tolist()) + + # Create an instance of TestImageIC86 + image_definition = IC86Image( + node_definition=node_def, + input_feature_names=grid.columns.tolist(), + include_lower_dc=True, + include_upper_dc=True, + string_label="string", + dom_number_label="dom_number", + dtype=dtype, + detector=detector, + ) + + assert ( + image_definition.nb_outputs == 2 + ), "Expected 2 outputs, got {}".format(image_definition.nb_outputs) + + output_feature_names = grid.columns.tolist() + output_feature_names.remove("string") + output_feature_names.remove("dom_number") + + assert image_definition.output_feature_names == output_feature_names, ( + f"Output feature names do not match expected output: " + f"{image_definition.output_feature_names} != {output_feature_names}" + ) + + image = image_definition( + grid.values, + input_feature_names=grid.columns.tolist(), + ) + + assert isinstance( + image, Data + ), "Expected output to be a torch_geometric.data.Data object" + assert isinstance(image.x, list), "Expected image.x to be a list" + assert np.all( + [isinstance(x, torch.Tensor) for x in image.x] + ), "Expected all elements in image.x to be torch.Tensor" + assert ( + len(image.x) == 3 + ), "Expected image.x to have 3 elements, got {}".format(len(image.x)) + assert ( + "num_nodes" in image.keys() + ), "Expected 'num_nodes' in image attributes" + + image_list = [ + TEST_IC86MAIN_IMAGE, + TEST_IC86UPPERDC_IMAGE, + TEST_IC86LOWERDC_IMAGE, + ] + for i, img in enumerate(image_list): + expected_image = torch.tensor(np.load(img), dtype=dtype).unsqueeze(0) + assert image.x[i].size() == expected_image.size(), ( + f"Image at index {i} size mismatch: " + f"expected {torch.tensor(expected_image).size()}," + f"got {image.x[i].size()}" + ) + assert torch.equal( + image.x[i], expected_image + ), f"Image at index {i} does not match expected image" + + +test_image_definition() diff --git a/tests/models/test_pixel_mapping.py b/tests/models/test_pixel_mapping.py index 1d42b03b3..b8f2ef573 100644 --- a/tests/models/test_pixel_mapping.py +++ b/tests/models/test_pixel_mapping.py @@ -5,7 +5,7 @@ import torch from torch_geometric.data import Data from copy import deepcopy -from graphnet.models.data_representation.images import IC86DNNMapping +from graphnet.models.data_representation.images import IC86PixelMapping from graphnet.constants import ( TEST_IC86MAIN_IMAGE, IC86_CNN_MAPPING, @@ -56,7 +56,7 @@ def test_pixel_mappings() -> None: # Construct node definition # This defines each DOM as a cluster, and will summarize pulses seen by # DOMs using percentiles. - pixel_mapping = IC86DNNMapping( + pixel_mapping = IC86PixelMapping( dtype=dtype, pixel_feature_names=pixel_feature_names, string_label=string_label, @@ -156,7 +156,7 @@ def test_segments_mapping() -> None: ["main array", "upper deepcore", "lower deepcore"], ): tmp = deepcopy(grid) - pixel_mapping = IC86DNNMapping( + pixel_mapping = IC86PixelMapping( dtype=dtype, pixel_feature_names=pixel_feature_names, string_label=string_label, From 194cce105350c82fc02fc02ece69420181ebb3ca Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Fri, 20 Jun 2025 19:26:19 +0200 Subject: [PATCH 14/31] Adding LCSC model --- src/graphnet/models/cnn/__init__.py | 1 + src/graphnet/models/cnn/lcsc.py | 430 ++++++++++++++++++ .../models/data_representation/__init__.py | 1 + 3 files changed, 432 insertions(+) create mode 100644 src/graphnet/models/cnn/lcsc.py diff --git a/src/graphnet/models/cnn/__init__.py b/src/graphnet/models/cnn/__init__.py index cabbbab95..dfaf35e40 100644 --- a/src/graphnet/models/cnn/__init__.py +++ b/src/graphnet/models/cnn/__init__.py @@ -2,3 +2,4 @@ from .cnn import CNN from .theos_muonE_upgoing import TheosMuonEUpgoing +from .lcsc import LCSC diff --git a/src/graphnet/models/cnn/lcsc.py b/src/graphnet/models/cnn/lcsc.py new file mode 100644 index 000000000..45a9e11b3 --- /dev/null +++ b/src/graphnet/models/cnn/lcsc.py @@ -0,0 +1,430 @@ +"""Module for the Lightning CNN signal classifier (LCSC). + +All credits go to Alexander Harnisch (https://github.com/AlexHarn) +""" + +from .cnn import CNN +import torch +from torch_geometric.data import Data +from typing import List, Union + + +class LCSC(CNN): + """Lightning CNN Signal Classifier (LCSC). + + All credits go to Alexander Harnisch (https://github.com/AlexHarn) + """ + + def __init__( + self, + num_input_features: int, + out_put_dim: int = 2, + input_norm: bool = True, + num_conv_layers: int = 8, + conv_filters_list: List[int] = [50, 50, 50, 50, 50, 50, 50, 10], + kernel_size_list: Union[int, List[Union[int, List[int]]]] = 3, + padding_list: str = "Same", + pooling_type_list: List[Union[None, str]] = [ + None, + "Avg", + None, + "Avg", + None, + "Avg", + None, + "Avg", + ], + pooling_kernel_size_list: List[Union[None, int, List[int]]] = [ + None, + [1, 1, 2], + None, + [2, 2, 2], + None, + [2, 2, 2], + None, + [2, 2, 2], + ], + pooling_stride_list: List[Union[None, int, List[int]]] = [ + None, + [1, 1, 2], + None, + [2, 2, 2], + None, + [2, 2, 2], + None, + [2, 2, 2], + ], + num_fc_neurons: int = 50, + norm_list: bool = True, + norm_type: str = "Batch", + ) -> None: + """Initialize the Lightning CNN signal classifier (LCSC). + + Args: + num_input_features (int): Number of input features. + out_put_dim (int): Number of output dimensions of final MLP. + Defaults to 2. + input_norm (bool): Whether to apply normalization to the input. + Defaults to True. + num_conv_layers (int): Number of convolutional layers. + Defaults to 8. + conv_filters_list (List[int]): List of number ofconvolutional + filters to use in hidden layers. + Defaults to [50, 50, 50, 50, 50, 50, 50, 50, 10]. + kernel_size_list (int, List[int], or List[List[int]]): + Size of the convolutional kernels. + Options are: + int: single integer for all dimensions + and all layers, + e.g. 3 would equal [3, 3, 3]. + list: list of integers specifying the kernel size, + for each layer for all dimensions equally, + e.g. [3, 5, 6]. + If a list of lists is provided, each list will be used + for the corresponding layer as kernel size. + If an integer is provided, it will be used for all layers. + Defaults to 3. + padding_list (str or int): Padding for the convolutional layers. + Either 'Same' or an integer which will be used for all layers. + Defaults to 'Same'. + pooling_type_list (List[str]): List of pooling types for layers. + Options are + 'None' : No pooling is used, + 'Avg' : Average pooling is used, + 'Max' : Max pooling is used + Defaults to [ + None, 'Avg', + None, 'Avg', + None, 'Avg', + None, 'Avg' + ]. + pooling_kernel_size_list (List[Union[int,List[int]]]): + List of pooling kernel sizes for each layer. + If an integer is provided, it will be used for all layers. + Options of list elements are: + list: list of integers for each dimension, e.g. [1, 1, 2]. + int: single integer for all dimensions, + e.g. 2 would equal [2, 2, 2]. + If None, no pooling is applied. + Defaults to [ + None, [1, 1, 2], + None, [2, 2, 2], + None, [2, 2, 2], + None, [2, 2, 2] + ]. + pooling_stride_list (List[List[int]]): List of pooling strides + for each layer. + If an integer is provided, it will be used for all layers. + Defaults to [ + None, [1, 1, 2], + None, [2, 2, 2], + None, [2, 2, 2], + None, [2, 2, 2] + ]. + num_fc_neurons (int): Number of neurons in the + fully connected layers. + Defaults to 50. + norm_list (bool or List[bool]): Whether to apply normalization + for each convolutional layer. + If a boolean is provided, it will be used for all layers. + Defaults to True. + norm_type (str): Type of normalization to use. + Options are 'Batch' or 'Instance'. + Defaults to 'Batch'. + """ + super().__init__(nb_inputs=num_input_features, nb_outputs=out_put_dim) + + # Check input parameters + if isinstance(conv_filters_list, int): + conv_filters_list = [ + conv_filters_list for _ in range(num_conv_layers) + ] + else: + if not isinstance(conv_filters_list, list): + raise TypeError( + ( + f"`conv_filters_list` must be a " + f"list or an integer, not {type(conv_filters_list)}!" + ) + ) + if len(conv_filters_list) != num_conv_layers: + raise ValueError( + f"`conv_filters_list` must have {num_conv_layers} " + f"elements, not {len(conv_filters_list)}!" + ) + + if isinstance(kernel_size_list, int): + kernel_size_list = [ # type: ignore[assignment] + [kernel_size_list, kernel_size_list, kernel_size_list] + for _ in range(num_conv_layers) + ] + else: + if not isinstance(kernel_size_list, list): + raise TypeError( + ( + "`kernel_size_list` must be a list or an " + f"integer, not {type(kernel_size_list)}!" + ) + ) + if len(kernel_size_list) != num_conv_layers: + raise ValueError( + ( + f"`kernel_size_list` must have {num_conv_layers} " + f"elements, not {len(kernel_size_list)}!" + ) + ) + + if isinstance(padding_list, int): + padding_list = [padding_list for _ in range(num_conv_layers)] + elif padding_list.lower() == "same": + self._padding_list = ["same" for i in range(num_conv_layers)] + else: + if not isinstance(padding_list, list): + raise TypeError( + ( + f"`padding_list` must be a list or " + f"an integer, not {type(padding_list)}!" + ) + ) + if len(padding_list) != num_conv_layers: + raise ValueError( + f"`padding_list` must have {num_conv_layers} " + f"elements, not {len(padding_list)}!" + ) + self._padding_list = padding_list + + if isinstance(pooling_kernel_size_list, int): + pooling_kernel_size_list = [ + pooling_kernel_size_list for i in range(num_conv_layers) + ] + else: + if not isinstance(pooling_kernel_size_list, list): + raise TypeError( + ( + "`pooling_kernel_size_list` must be a list or " + f"an integer, not {type(pooling_kernel_size_list)}!" + ) + ) + if len(pooling_kernel_size_list) != num_conv_layers: + raise ValueError( + ( + f"`pooling_kernel_size_list` must have " + f"{num_conv_layers} elements, not " + f"{len(pooling_kernel_size_list)}!" + ) + ) + + if isinstance(pooling_stride_list, int): + pooling_stride_list = [ + pooling_stride_list for i in range(num_conv_layers) + ] + else: + if not isinstance(pooling_stride_list, list): + raise TypeError( + ( + "`pooling_stride_list` must be a list or an integer, " + f"not {type(pooling_stride_list)}!" + ) + ) + if len(pooling_stride_list) != num_conv_layers: + raise ValueError( + ( + f"`pooling_stride_list` must have {num_conv_layers} " + f"elements, not {len(pooling_stride_list)}!" + ) + ) + + if isinstance(norm_list, bool): + self._norm_list = [norm_list for i in range(num_conv_layers)] + else: + if not isinstance(norm_list, list): + raise TypeError( + ( + "`norm_list` must be a list or a boolean, " + f"not {type(norm_list)}!" + ) + ) + if len(norm_list) != num_conv_layers: + raise ValueError( + ( + f"`norm_list` must have {num_conv_layers} " + f"elements, not {len(norm_list)}!" + ) + ) + self._norm_list = norm_list + + if norm_type.lower() == "instance": + norm_class = torch.nn.InstanceNorm3d + if input_norm: + self.input_normal = torch.nn.InstanceNorm3d(num_input_features) + elif norm_type.lower() == "batch": + norm_class = torch.nn.BatchNorm3d + if input_norm: + # No momentum or learnable parameters for input normalization, + # just use the average + self.input_normal = torch.nn.BatchNorm3d( + num_input_features, momentum=None, affine=False + ) + else: + raise ValueError( + ( + "`norm_type` has to be 'instance' or " + f"'batch, not '{norm_type}'!" + ) + ) + + # Initialize layers + self.conv = torch.nn.ModuleList() + self.pool = torch.nn.ModuleList() + self.input_norm = input_norm + + self.normal = torch.nn.ModuleList() + dimensions: List[int] = [ + num_input_features, + 10, + 10, + 60, + ] # (nb_features per pixel, height, width, depth) + for i in range(num_conv_layers): + self.conv.append( + torch.nn.Conv3d( + dimensions[0], + conv_filters_list[i], + kernel_size=kernel_size_list[i], + padding=self._padding_list[i], + ) + ) + dimensions = self._calc_output_dimension( + dimensions, + conv_filters_list[i], + kernel_size_list[i], + self._padding_list[i], + ) + if pooling_type_list[i] is None or pooling_type_list[i] == "None": + self.pool.append(None) + elif pooling_type_list[i] == "Avg": + self.pool.append( + torch.nn.AvgPool3d( + kernel_size=pooling_kernel_size_list[i], + stride=pooling_stride_list[i], + ) + ) + dimensions = self._calc_output_dimension( + dimensions, + out_channels=dimensions[ + 0 + ], # same out channels as input channels for pooling + kernel_size=pooling_kernel_size_list[i], + stride=pooling_stride_list[i], + ) + elif pooling_type_list[i] == "Max": + self.pool.append( + torch.nn.MaxPool3d( + kernel_size=pooling_kernel_size_list[i], + stride=pooling_stride_list[i], + ) + ) + dimensions = self._calc_output_dimension( + dimensions, + out_channels=dimensions[ + 0 + ], # same out channels as input channels for pooling + kernel_size=pooling_kernel_size_list[i], + stride=pooling_stride_list[i], + ) + else: + raise ValueError( + "Pooling type must be 'None', 'Avg' or 'Max'!" + ) + if self._norm_list[i]: + self.normal.append(norm_class(dimensions[0])) + else: + self.normal.append(None) + + latent_dim = ( + dimensions[0] * dimensions[1] * dimensions[2] * dimensions[3] + ) + + self.flatten = torch.nn.Flatten() + self.fc1 = torch.nn.Linear(latent_dim, num_fc_neurons) + self.fc2 = torch.nn.Linear(num_fc_neurons, out_put_dim) + + def _calc_output_dimension( + self, + dimensions: List[int], + out_channels: int, + kernel_size: Union[None, int, List[int]], + padding: Union[str, int, List[int]] = 0, + stride: Union[None, int, List[int]] = 1, + ) -> List[int]: + """Calculate the output dimension after a CNN layers. + + Works for Conv3D, MaxPool3D and AvgPool3D layers. + + Args: + dimensions (Tuple[int]): Current dimensions of the input tensor. + (C,H,W,D) where C is the number of channels, + H is the height, W is the width and D is the depth. + out_channels (int): Number of output channels. + kernel_size (Union[int,List[int]]): Size of the kernel. + If an integer is provided, it will be used for all dimensions. + padding (Union[int,List[int]]): Padding size. + If an integer is provided, it will be used for all dimensions. + If 'Same', the padding will be calculated to keep the + output size the same as the input size. + Defaults to 0. + stride (Union[int,List[int]]): Stride size. + If an integer is provided, it will be used for all dimensions. + Defaults to 1. + + Returns: + Tuple[int]: New dimensions after the layer. + + NOTE: For the pooling layers, set out_channels equal to the + input channels. Since they do not change the number of channels. + """ + krnl_sz: int + if isinstance(padding, str): + if not padding.lower() == "same": + raise ValueError( + f"`padding` must be 'Same' or an integer, not {padding}!" + ) + dimensions[0] = out_channels + else: + for i in range(1, 4): + if isinstance(kernel_size, list): + krnl_sz = kernel_size[i - 1] + else: + assert isinstance(kernel_size, int) + krnl_sz = kernel_size + if isinstance(padding, list): + pad = padding[i - 1] + else: + pad = padding + if isinstance(stride, list): + strd = stride[i - 1] + else: + assert isinstance(stride, int) + strd = stride + dimensions[i] = (dimensions[i] + 2 * pad - krnl_sz) // strd + 1 + + return dimensions + + def forward(self, data: Data) -> torch.Tensor: + """Forward pass of the LCSC.""" + assert len(data.x) == 1, "Only Main Array image is supported for LCSC" + x = data.x[0] + if self.input_norm: + x = self.input_normal(x) + for i in range(len(self.conv)): + x = self.conv[i](x) + if self.pool[i] is not None: + x = self.pool[i](x) + x = torch.nn.functional.elu(x) + if self.normal[i] is not None: + x = self.normal[i](x) + + x = self.flatten(x) + x = torch.nn.functional.elu(self.fc1(x)) + x = torch.nn.functional.elu(self.fc2(x)) + return x diff --git a/src/graphnet/models/data_representation/__init__.py b/src/graphnet/models/data_representation/__init__.py index 47155bed0..0eb8b7898 100644 --- a/src/graphnet/models/data_representation/__init__.py +++ b/src/graphnet/models/data_representation/__init__.py @@ -17,6 +17,7 @@ PercentileClusters, NodeAsDOMTimeSeries, IceMixNodes, + ClusterSummaryFeatures, ) from .images import ( ImageDefinition, From e52b215f87c1270ab8914941265304353daa9f5c Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Fri, 20 Jun 2025 19:59:23 +0200 Subject: [PATCH 15/31] Changing annotations and docstrings --- src/graphnet/models/cnn/lcsc.py | 180 +++++++++++++++++--------------- 1 file changed, 98 insertions(+), 82 deletions(-) diff --git a/src/graphnet/models/cnn/lcsc.py b/src/graphnet/models/cnn/lcsc.py index 45a9e11b3..7d0e997c5 100644 --- a/src/graphnet/models/cnn/lcsc.py +++ b/src/graphnet/models/cnn/lcsc.py @@ -21,10 +21,10 @@ def __init__( out_put_dim: int = 2, input_norm: bool = True, num_conv_layers: int = 8, - conv_filters_list: List[int] = [50, 50, 50, 50, 50, 50, 50, 10], - kernel_size_list: Union[int, List[Union[int, List[int]]]] = 3, - padding_list: str = "Same", - pooling_type_list: List[Union[None, str]] = [ + conv_filters: List[int] = [50, 50, 50, 50, 50, 50, 50, 10], + kernel_size: Union[int, List[Union[int, List[int]]]] = 3, + padding: Union[str, int, List[Union[str, int]]] = "Same", + pooling_type: List[Union[None, str]] = [ None, "Avg", None, @@ -34,7 +34,7 @@ def __init__( None, "Avg", ], - pooling_kernel_size_list: List[Union[None, int, List[int]]] = [ + pooling_kernel_size: List[Union[None, int, List[int]]] = [ None, [1, 1, 2], None, @@ -44,7 +44,7 @@ def __init__( None, [2, 2, 2], ], - pooling_stride_list: List[Union[None, int, List[int]]] = [ + pooling_stride: Union[int, List[Union[None, int, List[int]]]] = [ None, [1, 1, 2], None, @@ -68,10 +68,10 @@ def __init__( Defaults to True. num_conv_layers (int): Number of convolutional layers. Defaults to 8. - conv_filters_list (List[int]): List of number ofconvolutional + conv_filters (List[int]): List of number ofconvolutional filters to use in hidden layers. Defaults to [50, 50, 50, 50, 50, 50, 50, 50, 10]. - kernel_size_list (int, List[int], or List[List[int]]): + kernel_size (int, List[int], or List[List[int]]): Size of the convolutional kernels. Options are: int: single integer for all dimensions @@ -84,12 +84,20 @@ def __init__( for the corresponding layer as kernel size. If an integer is provided, it will be used for all layers. Defaults to 3. - padding_list (str or int): Padding for the convolutional layers. - Either 'Same' or an integer which will be used for all layers. + padding (str, int, or List[int]]): Padding for the + convolutional layers. + Options are: + 'Same' for same convolutional padding, + int: single integer for all dimensions and all layers, + e.g. 1 would equal [1, 1, 1]. + list: list of integers specifying the padding for each + dimension, for each layer equally, + e.g. [1, 2, 3]. Defaults to 'Same'. - pooling_type_list (List[str]): List of pooling types for layers. + pooling_type (List[None,str]): List of pooling types + for layers. Options are - 'None' : No pooling is used, + None : No pooling is used, 'Avg' : Average pooling is used, 'Max' : Max pooling is used Defaults to [ @@ -98,10 +106,10 @@ def __init__( None, 'Avg', None, 'Avg' ]. - pooling_kernel_size_list (List[Union[int,List[int]]]): + pooling_kernel_size (List[Union[int,List[int]]]): List of pooling kernel sizes for each layer. If an integer is provided, it will be used for all layers. - Options of list elements are: + In case of a list the options for its elements are: list: list of integers for each dimension, e.g. [1, 1, 2]. int: single integer for all dimensions, e.g. 2 would equal [2, 2, 2]. @@ -112,9 +120,14 @@ def __init__( None, [2, 2, 2], None, [2, 2, 2] ]. - pooling_stride_list (List[List[int]]): List of pooling strides - for each layer. + pooling_stride (int or List[Union[None,int]]): + List of pooling strides for each layer. If an integer is provided, it will be used for all layers. + In case of a list the options for its elements are: + list: list of integers for each dimension, e.g. [1, 1, 2]. + int: single integer for all dimensions, + e.g. 2 would equal [2, 2, 2]. + If None, no pooling is applied. Defaults to [ None, [1, 1, 2], None, [2, 2, 2], @@ -135,102 +148,105 @@ def __init__( super().__init__(nb_inputs=num_input_features, nb_outputs=out_put_dim) # Check input parameters - if isinstance(conv_filters_list, int): - conv_filters_list = [ - conv_filters_list for _ in range(num_conv_layers) - ] + if isinstance(conv_filters, int): + conv_filters = [conv_filters for _ in range(num_conv_layers)] else: - if not isinstance(conv_filters_list, list): + if not isinstance(conv_filters, list): raise TypeError( ( - f"`conv_filters_list` must be a " - f"list or an integer, not {type(conv_filters_list)}!" + f"`conv_filters` must be a " + f"list or an integer, not {type(conv_filters)}!" ) ) - if len(conv_filters_list) != num_conv_layers: + if len(conv_filters) != num_conv_layers: raise ValueError( - f"`conv_filters_list` must have {num_conv_layers} " - f"elements, not {len(conv_filters_list)}!" + f"`conv_filters` must have {num_conv_layers} " + f"elements, not {len(conv_filters)}!" ) - if isinstance(kernel_size_list, int): - kernel_size_list = [ # type: ignore[assignment] - [kernel_size_list, kernel_size_list, kernel_size_list] + if isinstance(kernel_size, int): + kernel_size = [ # type: ignore[assignment] + [kernel_size, kernel_size, kernel_size] for _ in range(num_conv_layers) ] else: - if not isinstance(kernel_size_list, list): + if not isinstance(kernel_size, list): raise TypeError( ( - "`kernel_size_list` must be a list or an " - f"integer, not {type(kernel_size_list)}!" + "`kernel_size` must be a list or an " + f"integer, not {type(kernel_size)}!" ) ) - if len(kernel_size_list) != num_conv_layers: + if len(kernel_size) != num_conv_layers: raise ValueError( ( - f"`kernel_size_list` must have {num_conv_layers} " - f"elements, not {len(kernel_size_list)}!" + f"`kernel_size` must have {num_conv_layers} " + f"elements, not {len(kernel_size)}!" ) ) - if isinstance(padding_list, int): - padding_list = [padding_list for _ in range(num_conv_layers)] - elif padding_list.lower() == "same": - self._padding_list = ["same" for i in range(num_conv_layers)] + if isinstance(padding, int): + padding = [padding for _ in range(num_conv_layers)] + elif isinstance(padding, str): + if padding.lower() == "same": + padding = ["same" for i in range(num_conv_layers)] + else: + raise ValueError( + ( + "`padding` must be 'Same' or an integer, " + f"not {padding}!" + ) + ) else: - if not isinstance(padding_list, list): + if not isinstance(padding, list): raise TypeError( ( - f"`padding_list` must be a list or " - f"an integer, not {type(padding_list)}!" + f"`padding` must be a list or " + f"an integer, not {type(padding)}!" ) ) - if len(padding_list) != num_conv_layers: + if len(padding) != num_conv_layers: raise ValueError( - f"`padding_list` must have {num_conv_layers} " - f"elements, not {len(padding_list)}!" + f"`padding` must have {num_conv_layers} " + f"elements, not {len(padding)}!" ) - self._padding_list = padding_list - if isinstance(pooling_kernel_size_list, int): - pooling_kernel_size_list = [ - pooling_kernel_size_list for i in range(num_conv_layers) + if isinstance(pooling_kernel_size, int): + pooling_kernel_size = [ + pooling_kernel_size for i in range(num_conv_layers) ] else: - if not isinstance(pooling_kernel_size_list, list): + if not isinstance(pooling_kernel_size, list): raise TypeError( ( - "`pooling_kernel_size_list` must be a list or " - f"an integer, not {type(pooling_kernel_size_list)}!" + "`pooling_kernel_size` must be a list or " + f"an integer, not {type(pooling_kernel_size)}!" ) ) - if len(pooling_kernel_size_list) != num_conv_layers: + if len(pooling_kernel_size) != num_conv_layers: raise ValueError( ( - f"`pooling_kernel_size_list` must have " + f"`pooling_kernel_size` must have " f"{num_conv_layers} elements, not " - f"{len(pooling_kernel_size_list)}!" + f"{len(pooling_kernel_size)}!" ) ) - if isinstance(pooling_stride_list, int): - pooling_stride_list = [ - pooling_stride_list for i in range(num_conv_layers) - ] + if isinstance(pooling_stride, int): + pooling_stride = [pooling_stride for i in range(num_conv_layers)] else: - if not isinstance(pooling_stride_list, list): + if not isinstance(pooling_stride, list): raise TypeError( ( - "`pooling_stride_list` must be a list or an integer, " - f"not {type(pooling_stride_list)}!" + "`pooling_stride` must be a list or an integer, " + f"not {type(pooling_stride)}!" ) ) - if len(pooling_stride_list) != num_conv_layers: + if len(pooling_stride) != num_conv_layers: raise ValueError( ( - f"`pooling_stride_list` must have {num_conv_layers} " - f"elements, not {len(pooling_stride_list)}!" + f"`pooling_stride` must have {num_conv_layers} " + f"elements, not {len(pooling_stride)}!" ) ) @@ -289,24 +305,24 @@ def __init__( self.conv.append( torch.nn.Conv3d( dimensions[0], - conv_filters_list[i], - kernel_size=kernel_size_list[i], - padding=self._padding_list[i], + conv_filters[i], + kernel_size=kernel_size[i], + padding=padding[i], ) ) dimensions = self._calc_output_dimension( dimensions, - conv_filters_list[i], - kernel_size_list[i], - self._padding_list[i], + conv_filters[i], + kernel_size[i], + padding[i], ) - if pooling_type_list[i] is None or pooling_type_list[i] == "None": + if pooling_type[i] is None or pooling_type[i] == "None": self.pool.append(None) - elif pooling_type_list[i] == "Avg": + elif pooling_type[i] == "Avg": self.pool.append( torch.nn.AvgPool3d( - kernel_size=pooling_kernel_size_list[i], - stride=pooling_stride_list[i], + kernel_size=pooling_kernel_size[i], + stride=pooling_stride[i], ) ) dimensions = self._calc_output_dimension( @@ -314,14 +330,14 @@ def __init__( out_channels=dimensions[ 0 ], # same out channels as input channels for pooling - kernel_size=pooling_kernel_size_list[i], - stride=pooling_stride_list[i], + kernel_size=pooling_kernel_size[i], + stride=pooling_stride[i], ) - elif pooling_type_list[i] == "Max": + elif pooling_type[i] == "Max": self.pool.append( torch.nn.MaxPool3d( - kernel_size=pooling_kernel_size_list[i], - stride=pooling_stride_list[i], + kernel_size=pooling_kernel_size[i], + stride=pooling_stride[i], ) ) dimensions = self._calc_output_dimension( @@ -329,8 +345,8 @@ def __init__( out_channels=dimensions[ 0 ], # same out channels as input channels for pooling - kernel_size=pooling_kernel_size_list[i], - stride=pooling_stride_list[i], + kernel_size=pooling_kernel_size[i], + stride=pooling_stride[i], ) else: raise ValueError( From 58e2898ddcd81e645562810b9de08ad88cd79539 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Mon, 7 Jul 2025 16:14:49 +0200 Subject: [PATCH 16/31] Adding example script --- examples/04_training/09_train_cnn.py | 306 ++++++++++++++++++ src/graphnet/models/cnn/lcsc.py | 3 +- .../data_representation/images/__init__.py | 6 +- .../images/image_definition.py | 7 +- .../images/mappings/__init__.py | 6 +- .../images/mappings/pixel_mappings.py | 5 +- tests/models/test_image_definition.py | 3 - 7 files changed, 321 insertions(+), 15 deletions(-) create mode 100644 examples/04_training/09_train_cnn.py diff --git a/examples/04_training/09_train_cnn.py b/examples/04_training/09_train_cnn.py new file mode 100644 index 000000000..486b6bf92 --- /dev/null +++ b/examples/04_training/09_train_cnn.py @@ -0,0 +1,306 @@ +"""Example of training Model.""" + +import os +from typing import Any, Dict, List, Optional + +from pytorch_lightning.loggers import WandbLogger +import torch +from torch.optim.adam import Adam + +from graphnet.constants import EXAMPLE_DATA_DIR, EXAMPLE_OUTPUT_DIR +from graphnet.data.constants import TRUTH +from graphnet.models import StandardModel +from graphnet.models.detector.icecube import IceCube86 +from graphnet.models.cnn import LCSC +from graphnet.models.data_representation import IC86Image +from graphnet.models.data_representation import PercentileClusters +from graphnet.models.task.reconstruction import EnergyReconstruction +from graphnet.training.callbacks import PiecewiseLinearLR +from graphnet.training.loss_functions import LogCoshLoss +from graphnet.utilities.argparse import ArgumentParser +from graphnet.utilities.logging import Logger +from graphnet.data.dataset import SQLiteDataset +from graphnet.data.dataset import ParquetDataset +from torch_geometric.data import Batch + +# Constants +features = ["sensor_id", "sensor_string_id", "t"] +truth = TRUTH.PROMETHEUS + + +def main( + path: str, + pulsemap: str, + target: str, + truth_table: str, + gpus: Optional[List[int]], + max_epochs: int, + early_stopping_patience: int, + batch_size: int, + num_workers: int, + wandb: bool = False, +) -> None: + """Run example.""" + # Construct Logger + logger = Logger() + + # Initialise Weights & Biases (W&B) run + if wandb: + # Make sure W&B output directory exists + wandb_dir = "./wandb/" + os.makedirs(wandb_dir, exist_ok=True) + wandb_logger = WandbLogger( + project="example-script", + entity="graphnet-team", + save_dir=wandb_dir, + log_model=True, + ) + + logger.info(f"features: {features}") + logger.info(f"truth: {truth}") + + # Configuration + config: Dict[str, Any] = { + "path": path, + "pulsemap": pulsemap, + "batch_size": batch_size, + "num_workers": num_workers, + "target": target, + "early_stopping_patience": early_stopping_patience, + "fit": { + "gpus": gpus, + "max_epochs": max_epochs, + }, + "dataset_reference": ( + SQLiteDataset if path.endswith(".db") else ParquetDataset + ), + } + + archive = os.path.join(EXAMPLE_OUTPUT_DIR, "train_model_without_configs") + run_name = "dynedge_{}_example".format(config["target"]) + if wandb: + # Log configuration to W&B + wandb_logger.experiment.config.update(config) + + # An ImageDefinition combines two components: + + # 1. A pixel definition, which defines how the pixel data is + # represented. Since an image has always fixed dimensions this + # pixel definition is also responsible to represent the data in + # a way such that this fixed dimensions can be achieved. + # Normally, this could mean that light pulses that arrive at + # the same optical module must be aggregated to a + # fixed-dimensional vector. + # A pixel definition is exactly the same as the + # a node definition in the graph scenerio. + + # 2. A pixel mapping, which defines where each pixel is located + # in the final image. This is highly detector specific, as it + # depends on the geometry of the detector. + + # An ImageDefinition can be used to create multiple images, + # in the example of IceCube, you can e.g. create three images, + # one for the so called main array, one for the upper deep core + # and one for the lower deep core. Essentially, these are just + # different areas in the detector. + + # Here we use the PercentileClusters pixel definition, which + # aggregates the light pulses that arrive at the same optical + # module (or sensor) with percentiles. + print(features) + pixel_definition = PercentileClusters( + cluster_on=["sensor_id", "sensor_string_id"], + percentiles=[10, 50, 90], + add_counts=True, + input_feature_names=features, + ) + + # The final image definition used here is the IC86Image, + # which is a detector specific pixel mapping for the IceCube + # detector. It maps optical modules (sensors) into the image + # using the string and DOM number (number of the optical module). + # The detector standardizes the input features, so that the + # features are in a ML friendly range. + # For the mapping of the optical modules to the image it is + # essential to not change the value of the string and DOM number + # Therefore we need to make sure that these features are not + # standardized, which is done by the `replace_with_identity` + # argument of the detector. + image_definition = IC86Image( + detector=IceCube86( + replace_with_identity=features, + ), + node_definition=pixel_definition, + input_feature_names=features, + include_lower_dc=False, + include_upper_dc=False, + string_label="sensor_string_id", + dom_number_label="sensor_id", + ) + + # Use GraphNetDataModule to load in data and create dataloaders + # The input here depends on the dataset being used, + # in this case the Prometheus dataset. + dataset = SQLiteDataset( + path=config["path"], + pulsemaps=config["pulsemap"], + truth_table=truth_table, + features=features, + truth=truth, + data_representation=image_definition, + ) + + training_dataloader = torch.utils.data.DataLoader( + dataset=dataset, + batch_size=config["batch_size"], + num_workers=config["num_workers"], + collate_fn=Batch.from_data_list, + ) + + validation_dataloader = torch.utils.data.DataLoader( + dataset=dataset, + batch_size=config["batch_size"], + num_workers=config["num_workers"], + collate_fn=Batch.from_data_list, + ) + + # Building model + + # Define architecture of the backbone, in this example + # the LCSC architecture from Alexander Harnisch is used. + backbone = LCSC( + num_input_features=image_definition.nb_outputs, + ) + # Define the task. + # Here an energy reconstruction, with a LogCoshLoss function. + # The target and prediction are transformed using the log10 function. + # When infering the prediction is transformed back to the + # original scale using 10^x. + task = EnergyReconstruction( + hidden_size=backbone.nb_outputs, + target_labels=config["target"], + loss_function=LogCoshLoss(), + transform_prediction_and_target=lambda x: torch.log10(x), + transform_inference=lambda x: torch.pow(10, x), + ) + # Define the full model, which includes the backbone, task(s), + # along with typical machine learning options such as + # learning rate optimizers and schedulers. + model = StandardModel( + data_representation=image_definition, + backbone=backbone, + tasks=[task], + optimizer_class=Adam, + optimizer_kwargs={"lr": 1e-03, "eps": 1e-03}, + scheduler_class=PiecewiseLinearLR, + scheduler_kwargs={ + "milestones": [ + 0, + len(training_dataloader) / 2, + len(training_dataloader) * config["fit"]["max_epochs"], + ], + "factors": [1e-2, 1, 1e-02], + }, + scheduler_config={ + "interval": "step", + }, + ) + + # Training model + model.fit( + training_dataloader, + validation_dataloader, + early_stopping_patience=config["early_stopping_patience"], + logger=wandb_logger if wandb else None, + **config["fit"], + ) + + # Get predictions + additional_attributes = model.target_labels + assert isinstance(additional_attributes, list) # mypy + + results = model.predict_as_dataframe( + validation_dataloader, + additional_attributes=additional_attributes + ["event_no"], + gpus=config["fit"]["gpus"], + ) + + # Save predictions and model to file + db_name = path.split("/")[-1].split(".")[0] + path = os.path.join(archive, db_name, run_name) + logger.info(f"Writing results to {path}") + os.makedirs(path, exist_ok=True) + + # Save results as .csv + results.to_csv(f"{path}/results.csv") + + # Save model config and state dict - Version safe save method. + # This method of saving models is the safest way. + model.save_state_dict(f"{path}/state_dict.pth") + model.save_config(f"{path}/model_config.yml") + + +if __name__ == "__main__": + + # Parse command-line arguments + parser = ArgumentParser( + description=""" +Train GNN model without the use of config files. +""" + ) + + parser.add_argument( + "--path", + help="Path to dataset file (default: %(default)s)", + default=f"{EXAMPLE_DATA_DIR}/sqlite/prometheus/prometheus-events.db", + ) + + parser.add_argument( + "--pulsemap", + help="Name of pulsemap to use (default: %(default)s)", + default="total", + ) + + parser.add_argument( + "--target", + help=( + "Name of feature to use as regression target (default: " + "%(default)s)" + ), + default="total_energy", + ) + + parser.add_argument( + "--truth-table", + help="Name of truth table to be used (default: %(default)s)", + default="mc_truth", + ) + + parser.with_standard_arguments( + "gpus", + ("max-epochs", 1), + "early-stopping-patience", + ("batch-size", 16), + ("num-workers", 2), + ) + + parser.add_argument( + "--wandb", + action="store_true", + help="If True, Weights & Biases are used to track the experiment.", + ) + + args, unknown = parser.parse_known_args() + + main( + args.path, + args.pulsemap, + args.target, + args.truth_table, + args.gpus, + args.max_epochs, + args.early_stopping_patience, + args.batch_size, + args.num_workers, + args.wandb, + ) diff --git a/src/graphnet/models/cnn/lcsc.py b/src/graphnet/models/cnn/lcsc.py index 7d0e997c5..90c2485c8 100644 --- a/src/graphnet/models/cnn/lcsc.py +++ b/src/graphnet/models/cnn/lcsc.py @@ -12,7 +12,8 @@ class LCSC(CNN): """Lightning CNN Signal Classifier (LCSC). - All credits go to Alexander Harnisch (https://github.com/AlexHarn) + All credits go to Alexander Harnisch ( + https://github.com/AlexHarn) """ def __init__( diff --git a/src/graphnet/models/data_representation/images/__init__.py b/src/graphnet/models/data_representation/images/__init__.py index 277d0801d..bedd1ca01 100644 --- a/src/graphnet/models/data_representation/images/__init__.py +++ b/src/graphnet/models/data_representation/images/__init__.py @@ -1,8 +1,8 @@ """Modules for mapping images. -´ImageDefinition´ defines the nodes and the mapping, and contains general -image-manipulation.´PixelMapping´ defines how raw data is mapped into the -regular sized image. +´ImageDefinition´ defines the nodes and the mapping, and contains +general image-manipulation.´PixelMapping´ defines how raw data is +mapped into the regular sized image. """ from .image_definition import ImageDefinition diff --git a/src/graphnet/models/data_representation/images/image_definition.py b/src/graphnet/models/data_representation/images/image_definition.py index 316d0bde2..e50aa6870 100644 --- a/src/graphnet/models/data_representation/images/image_definition.py +++ b/src/graphnet/models/data_representation/images/image_definition.py @@ -1,8 +1,9 @@ """Modules for defining images. -These are self-contained image definitions that hold all the image-altering -code in graphnet. These modules define what image-based models sees as input -and can be passed to dataloaders during training and deployment. +These are self-contained image definitions that hold all the image- +altering code in graphnet. These modules define what image-based models +sees as input and can be passed to dataloaders during training and +deployment. """ from typing import List, Optional, Dict, Union, Any, Callable diff --git a/src/graphnet/models/data_representation/images/mappings/__init__.py b/src/graphnet/models/data_representation/images/mappings/__init__.py index 64d3f646b..1a748be5a 100644 --- a/src/graphnet/models/data_representation/images/mappings/__init__.py +++ b/src/graphnet/models/data_representation/images/mappings/__init__.py @@ -1,8 +1,8 @@ """Modules for mapping images. -´ImageDefinition´ defines the nodes and the mapping, and contains general -image-manipulation.´PixelMapping´ defines how raw data is mapped into the -regular sized image. +´ImageDefinition´ defines the nodes and the mapping, and contains +general image-manipulation.´PixelMapping´ defines how raw data is +mapped into the regular sized image. """ from .pixel_mappings import ( diff --git a/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py b/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py index e0af8889e..731d044d3 100644 --- a/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py +++ b/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py @@ -26,8 +26,9 @@ def __init__( def forward(self, data: Data, data_feature_names: List[str]) -> Data: """Map pixel data to images. - Make sure to add a batch dimension to the output. E.g picture with - dimensions CxHxW = 10x64x64 should be returned as 1x10x64x64. + Make sure to add a batch dimension to the output. E.g picture + with dimensions CxHxW = 10x64x64 should be returned as + 1x10x64x64. """ raise NotImplementedError diff --git a/tests/models/test_image_definition.py b/tests/models/test_image_definition.py index 302000c4e..0c00b596d 100644 --- a/tests/models/test_image_definition.py +++ b/tests/models/test_image_definition.py @@ -89,6 +89,3 @@ def test_image_definition() -> None: assert torch.equal( image.x[i], expected_image ), f"Image at index {i} does not match expected image" - - -test_image_definition() From 7ebc59212922e687abe040e19cc350554e4a6285 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Fri, 18 Jul 2025 16:18:16 +0200 Subject: [PATCH 17/31] adding cnn example --- .../prometheus_CNN_mapping.parquet | Bin 0 -> 10484 bytes examples/04_training/09_train_cnn.py | 68 ++++++--- src/graphnet/constants.py | 3 + src/graphnet/models/cnn/lcsc.py | 14 +- .../data_representation/images/__init__.py | 4 +- .../data_representation/images/images.py | 66 ++++++++- .../images/mappings/__init__.py | 1 + .../images/mappings/pixel_mappings.py | 138 +++++++++++++++++- 8 files changed, 263 insertions(+), 31 deletions(-) create mode 100644 data/image_mapping_tables/prometheus_CNN_mapping.parquet diff --git a/data/image_mapping_tables/prometheus_CNN_mapping.parquet b/data/image_mapping_tables/prometheus_CNN_mapping.parquet new file mode 100644 index 0000000000000000000000000000000000000000..aba42350ebfd7aafad4615d1bdd44cffc6619264 GIT binary patch literal 10484 zcmeI&2~<3iAY+_JUik70HvWQA+s{sUpfQpsPDv}T+m;_`I&{`GuwTkKVO6Q}BDEHTVn zV!Bdls&k}|`;olv7xU)aDx7<3z_q6X>N^&BIEr%fy+<$b9)F|t_y(HdhMK7ywwQwq zb8i(c@E*N~reS3SbWJ$PGjp?;KTmKeIN_5Z&Qoy32j-O&Td=!q0)p+hS4NJB5A!+;F*h7l&1k%=tyK{j%bi#+s2J_=BXe&~<)FaYmk zAO>MDiZBF2F$}{o0wXaBqcH|!F%IML0VZG~iZKb3F$GgG4bw3LGcgOZF$Z%o4<(q7 z1z3nhSd1lDie*@i6#|fOoDV)X`oW+MYhx53AkMJ=r;}cxLr?`si_#9v2E8N5_e2qK!7WeT05Ag_3 z@EyL#Q~ZQq@Gtz^+3}%4E|ogfusm@qk>mhJR7O=eqdHvRikeWM7F4K%x~K;ayb4b= zfET>c2u;uw&ESU?Xo=PcLL0P&8o>xbD8ivZB%%BrW2Ype1e&~<)F&INI6vHqaqc9fZF#!`X8B;M0GcXIYF$W?v|0pC^khcn} zu^t<+86woSV<&b&gw}qD@H&L&Lra9!X`I1Xe28-p;q);?7<~p2KDN*iVRIWIY`(>P zJitRd!efZAv4zX8Y!fOHyV~2TSf*nUAWjfL;tUsvz<33=ps3HbEeIO3-3)#Rgl)jv zv27dV4s6>7HI8lDV5YEb8$=`9Sr7x358FT$v27ciacob77?kM{10e=sA(ldP`Wo0e zdmG!fPCm%?QHV|z9ct^uPuR9~;8$$hI!<(&t)qTs+uBJ#I$PE&w$7 zC8A?qM-zyS@kdLDj(H2>E)X3PfhdTM=>ieyJs_gn01?f(D8N7r!Dx)f6wE*g7GWjU zq7?gZ5*P6W?&1e$N4ZR{bg{0c+(>E2il({Tx=4AW?3)$MAScCWr*bPATW0wcO~q7B zwO9V6tzFrY#%Z9$&QaB_WYA!lqpNqq-jcyX99%pjrnj44G^~osKmF3)`9nrj_Xr-T zQZE=fs-}1BvV?sLhK;G^t35hhy>R%rI<2#BU)r~D#0T};6*;;EFB&=u)0+3(KhiB^$=DeI>F1Uu9#}GNR;#Qpj?M^KI)2WZ z1&?lDKCtwId2I*TIn@qbHer6qNY_S52bWD;7(T&MGc$B~@!}5C{SBWSTs~=O$CBVt zwcD?lyu8b@*yTxwR!mtLzfOB>X8V;>S9jZ*{q-k@R!&=+ytl}yPS~pH>vczq8+ALp zYR1N1XBTK@g{_{sx%Z_FhAW3x&)SlCegCLB;cI4Z%ej4SdAB2L=IqFS@WruN;cMsa z>i@%|udf_gJ8#cGiLA1F#JZAwLmX>1e)s6Q`3FY0G>DuXv3|j!F{*%!Pmiu&c;o|* zkkRg%4U3LV^6s+Y-D4XTpP1&WJ3d>pamlG!t#iKl^w`FwXXdpVQn_yArez;4jF{B8 z`|(Z7&oAw?Fmg`h<`o~UOxT$5+40RQFRtx*V07IMrK>J)Oh3P(J1e~_Te5B(pVMK> zn$LC=JpSgh6I<3^+cQX3rC!w5b)O#?S+hxxlUvu{I5MF@hq+PPHhgtrdO+{1C%0|9 zb*3a_Ougvso4!83tjo$Cr?zjtb8(&S#N6l|rFXAv&AD^+)Q&Cpuk9UDrGCuLtq*S; zozx`x^v-RMZ=GG(VP4Fx?cd$Gw6XWK)4O&&y?_0{nED-e@BH!c?ei;>&+Ojy^V0`6 zPR#4LXZHXJ>(4<_MN||^YO(rkCznZ_q!PK4gZ$Y%CMACDogHgP-5sm}EFp6DN)?Bt z36|w}g4Em2OXhCpE~zb3$yG9?q_&-sEnlfpuBzl} z$GW)0)UCMT7L!aesNu0vlcqSTIQ2@)E2URTr!>?QFDf;e`d=wNSUS)({jQRBO^_~ zUNc1dWIlE=Qrae~ZKsmZHhFE@M=L9-zU?b`JG)6M&^fl=JQCDE3Vry98n4%OJmnbU-m0Rn^&k-d=%C% z5dkABDGtlZMSv}{d@of@zbrd-W$A&0*F7pVeR^F!q~tp3DZUD^dT0cuAj=DTExyGB zS`V*5Or+lMgqTnp!wa<`CQu)=z}pa$Yjd=Mm}K9Cm}q^`6k-Av6K-1sK&(nyL#$NV z;0=hCOJ@Y*9mGOR&Y_4#B*Y{ghxQ0T47x&0+!};IOyC{R31VUoM*sVv$e;Glrl)#6m$V65c~!6e1sekO{G}8iWDJLpI(= z0s3JuaxfGVF&R@Z8Y3_jV^NIh7>QvRjv1JSF&KyO_yChI0i!Szv#=IRF&`_j3=6Rs zi!c|nF$b%#1`99`%drAWuo@-Ufpyr6o!E@^*pCC)f^FDvjy$8i#eaRH}r8Xw{q&fo;j;s|cwb6mkie1>bdgfH<0KE-8R#dUmw2e^kj zxP`m;8eicd?&DkB#v^=#oA?#K;0HX$6a0wp@NfJJKjVA+gr_VDoup1qmK#}4LM3O% zdT!z#cd<-El}U1hJ*q$k1!}?twNMpGyn^be0arMq8tS7iYNHNbg$f?12X}bF4b9+# z*Wrx@cn!_b6kceA#%O|uXoWxoAPBx_jh6673%mh8v_}Zu!rOQgZ4ri0w1XPKXafx* z(E(A2MhwEy5uMQqv4}ttx*-nn=z;{ii|*)(MD)NrNP!kQq(YBgNJbhANJmd(p%1c= zgA5p9f*F~}LoRxw5ChO3ebEmEcpvW}A4M39p%{oE7=}R@iP0F2Q5b^}7>@}Uiw`gn z<1iUhQH&{=hDn%-*_e)5n1dN8!2-<1d@RH~EWt7?!cr{9Vywa%tiWol#Y$|zCal9o zY{q(Q#defp8+Kp|c4IGgVh{FV7Y^bu_Tvza-~f)}B#z<)PT?5N;v7!nL!8GMe2h!D zfQz_{kMJoz!DqOF>$r-~aSdPM3w(texQ(0m8n^Hb?%-S8#Xa1|13bhdJjN4V>%L>P zB|hJmeZTOv&X!qzX;d+l^ZfUirU#!j6W7fK^%9QJ5 zUN`-`e(|NQif_Q8u8OIDvo1etUE}4-OERyyfnKXxP*=q_Qc+jM)W2DmzqPKBPRdpG zUf%?H4Q)+b72oSbT@_RRYF!m$>DkEr)X1x(hu6BdskP!GQ`A~9_0MWm{<=y^0D3tp z7o<~8WuKK|PEE;C9e3#OX$@yxrZLZC&hdQ1qnl@i5cllvOCoK&NF2dKFi3|X6SXfBHg6VO-W73 zO`&*yPraGq+_~b|uJ433ZS6T=z;72*{wh3wf3J(PmAj@FmiXNj8qB#ZTU5NhXUWf9 zo@n*}AFi^j$+puQQva}KYt~Dwvh2ZCvBKvvUV3$Ye^i|%9T3b!UiA2erC|I=Gie>!l9qWl0MT zE|;G#65BtQ&$ig-V>yc@U!zv%t2vHuagxwPcE!G09i17~-gHgq%uYcy?WM-vj6q4h8D)5Ztd${2Np znwFZdT+SDmu8r?1&XJcIUyu_OqBbO%j7DvEY?{^-mYdXta^Zn~+VFgFp8Q1WNKA-H zON}t*Tk=98{j??{=Z`gN4Een{pPx1%I4~g~Hc%HX&Z{}A0&m8ZY_-q^tf}%o19R1cW+W0Wv1plxW z(bl$_QsU!slEQ-W665{K+Ul2{8WxnU4^>;P`yaigE|&56lWS`K&tH?_f4U~kKXgrh za?gfDrg1NnJu3=~siwG`gnq$k35EYx@$Q!@VmdJ((#XBtS7+c}7V#f$H0VrWg?bTx zJU{gDVTC$VP=MtgH76N$=9nz4e+$cVB058z7ah_fUt_l1_rE=BOmT%ee`8;*IA@5a zFe*cn7hM<`#8WX`JQ{8BXxr}~9;+QiPDqaUsTV)mTOP93=WQpR-{NuGSz}3z6#35{ zAcE0F&DoVEQ)XI9iWeQNMJN@o_mWz7se8HlwaN9kkQEP1F%Z}ID_RFs4&yR1f z?rJMvcD&iP|Cf$uX0eqoJHDT7zwG!3(a7hnKScCWM2t~uj_Ip2^2`+3QSZ=+t;JTb zr4yN?nEaTx64kUfT4d1KmMl?5oi0+;mhrI6XO>-S{tKVW)_lu+7((sV`OuPRtD|rfE|L81ujFDiiKG+%-|;^Yc+gY; literal 0 HcmV?d00001 diff --git a/examples/04_training/09_train_cnn.py b/examples/04_training/09_train_cnn.py index 486b6bf92..cb14ceba1 100644 --- a/examples/04_training/09_train_cnn.py +++ b/examples/04_training/09_train_cnn.py @@ -10,9 +10,7 @@ from graphnet.constants import EXAMPLE_DATA_DIR, EXAMPLE_OUTPUT_DIR from graphnet.data.constants import TRUTH from graphnet.models import StandardModel -from graphnet.models.detector.icecube import IceCube86 from graphnet.models.cnn import LCSC -from graphnet.models.data_representation import IC86Image from graphnet.models.data_representation import PercentileClusters from graphnet.models.task.reconstruction import EnergyReconstruction from graphnet.training.callbacks import PiecewiseLinearLR @@ -22,6 +20,7 @@ from graphnet.data.dataset import SQLiteDataset from graphnet.data.dataset import ParquetDataset from torch_geometric.data import Batch +from graphnet.models.data_representation.images import ExamplePrometheusImage # Constants features = ["sensor_id", "sensor_string_id", "t"] @@ -76,7 +75,7 @@ def main( ), } - archive = os.path.join(EXAMPLE_OUTPUT_DIR, "train_model_without_configs") + archive = os.path.join(EXAMPLE_OUTPUT_DIR, "train_cnn_model") run_name = "dynedge_{}_example".format(config["target"]) if wandb: # Log configuration to W&B @@ -115,30 +114,26 @@ def main( input_feature_names=features, ) - # The final image definition used here is the IC86Image, - # which is a detector specific pixel mapping for the IceCube - # detector. It maps optical modules (sensors) into the image - # using the string and DOM number (number of the optical module). + # The final image definition used here is the ExamplePrometheusImage, + # which is a detector specific pixel mapping for the IceCube. + # It maps optical modules (sensors) into the image + # using the sensor_string_id and sensor_id + # (number of the optical module). # The detector standardizes the input features, so that the # features are in a ML friendly range. # For the mapping of the optical modules to the image it is - # essential to not change the value of the string and DOM number - # Therefore we need to make sure that these features are not - # standardized, which is done by the `replace_with_identity` - # argument of the detector. - image_definition = IC86Image( - detector=IceCube86( - replace_with_identity=features, - ), + # essential to not change the value of the sensor_id and + # sensor_string_id. Therefore we need to make sure that + # these features are not standardized, which is done by the + # `replace_with_identity` argument of the detector. + image_definition = ExamplePrometheusImage( node_definition=pixel_definition, input_feature_names=features, - include_lower_dc=False, - include_upper_dc=False, string_label="sensor_string_id", dom_number_label="sensor_id", ) - # Use GraphNetDataModule to load in data and create dataloaders + # Use SQLiteDataset to load in data # The input here depends on the dataset being used, # in this case the Prometheus dataset. dataset = SQLiteDataset( @@ -150,6 +145,7 @@ def main( data_representation=image_definition, ) + # Create the training and validation dataloaders. training_dataloader = torch.utils.data.DataLoader( dataset=dataset, batch_size=config["batch_size"], @@ -170,6 +166,36 @@ def main( # the LCSC architecture from Alexander Harnisch is used. backbone = LCSC( num_input_features=image_definition.nb_outputs, + out_put_dim=2, + input_norm=True, + num_conv_layers=5, + conv_filters=[5, 10, 20, 40, 60], + kernel_size=3, + image_size=(8, 9, 22), # dimensions of the example image + pooling_type=[ + "Avg", + None, + "Avg", + None, + "Avg", + ], + pooling_kernel_size=[ + [1, 1, 2], + None, + [2, 2, 2], + None, + [2, 2, 2], + ], + pooling_stride=[ + [1, 1, 2], + None, + [2, 2, 2], + None, + [2, 2, 2], + ], + num_fc_neurons=50, + norm_list=True, + norm_type="Batch", ) # Define the task. # Here an energy reconstruction, with a LogCoshLoss function. @@ -232,12 +258,12 @@ def main( os.makedirs(path, exist_ok=True) # Save results as .csv - results.to_csv(f"{path}/results.csv") + results.to_csv(f"{path}/cnn_results.csv") # Save model config and state dict - Version safe save method. # This method of saving models is the safest way. - model.save_state_dict(f"{path}/state_dict.pth") - model.save_config(f"{path}/model_config.yml") + model.save_state_dict(f"{path}/cnn_state_dict.pth") + model.save_config(f"{path}/cnn_model_config.yml") if __name__ == "__main__": diff --git a/src/graphnet/constants.py b/src/graphnet/constants.py index 949773e2b..baafcc38e 100644 --- a/src/graphnet/constants.py +++ b/src/graphnet/constants.py @@ -56,3 +56,6 @@ IC86_CNN_MAPPING = os.path.join( IMAGE_MAPPING_TABLE_DIR, "IC86_CNN_mapping.parquet" ) +PROMETHEUS_CNN_MAPPING = os.path.join( + IMAGE_MAPPING_TABLE_DIR, "prometheus_CNN_mapping.parquet" +) diff --git a/src/graphnet/models/cnn/lcsc.py b/src/graphnet/models/cnn/lcsc.py index 90c2485c8..65df313ac 100644 --- a/src/graphnet/models/cnn/lcsc.py +++ b/src/graphnet/models/cnn/lcsc.py @@ -6,7 +6,7 @@ from .cnn import CNN import torch from torch_geometric.data import Data -from typing import List, Union +from typing import List, Union, Tuple class LCSC(CNN): @@ -14,6 +14,9 @@ class LCSC(CNN): All credits go to Alexander Harnisch ( https://github.com/AlexHarn) + + Intended to be used with the IceCube 86 image containing + only the Main Array image. """ def __init__( @@ -58,6 +61,7 @@ def __init__( num_fc_neurons: int = 50, norm_list: bool = True, norm_type: str = "Batch", + image_size: Tuple[int, int, int] = (10, 10, 60), ) -> None: """Initialize the Lightning CNN signal classifier (LCSC). @@ -145,6 +149,10 @@ def __init__( norm_type (str): Type of normalization to use. Options are 'Batch' or 'Instance'. Defaults to 'Batch'. + image_size (Tuple[int, int, int]): Size of the input image + in the format (height, width, depth). + NOTE: Only needs to be changed if the input image is not + the standard IceCube 86 image size. """ super().__init__(nb_inputs=num_input_features, nb_outputs=out_put_dim) @@ -298,9 +306,7 @@ def __init__( self.normal = torch.nn.ModuleList() dimensions: List[int] = [ num_input_features, - 10, - 10, - 60, + *image_size, ] # (nb_features per pixel, height, width, depth) for i in range(num_conv_layers): self.conv.append( diff --git a/src/graphnet/models/data_representation/images/__init__.py b/src/graphnet/models/data_representation/images/__init__.py index bedd1ca01..fe7c1ea54 100644 --- a/src/graphnet/models/data_representation/images/__init__.py +++ b/src/graphnet/models/data_representation/images/__init__.py @@ -6,5 +6,5 @@ """ from .image_definition import ImageDefinition -from .images import IC86Image -from .mappings import IC86PixelMapping +from .images import IC86Image, ExamplePrometheusImage +from .mappings import IC86PixelMapping, ExamplePrometheusMapping diff --git a/src/graphnet/models/data_representation/images/images.py b/src/graphnet/models/data_representation/images/images.py index 8527998f0..d06befd09 100644 --- a/src/graphnet/models/data_representation/images/images.py +++ b/src/graphnet/models/data_representation/images/images.py @@ -4,10 +4,10 @@ import torch from graphnet.models.data_representation.graphs import NodeDefinition -from graphnet.models.detector import Detector, IceCube86 +from graphnet.models.detector import Detector, IceCube86, ORCA150 from .image_definition import ImageDefinition -from .mappings import IC86PixelMapping +from .mappings import IC86PixelMapping, ExamplePrometheusMapping class IC86Image(ImageDefinition): @@ -71,3 +71,65 @@ def __init__( add_inactive_sensors=False, **kwargs, ) + + +class ExamplePrometheusImage(ImageDefinition): + """Class creating a image for Prometheus. + + This Image was created to be used in the example scripts. This is + not intended to be used for purposes beyond that. + """ + + def __init__( + self, + node_definition: NodeDefinition, + input_feature_names: List[str], + string_label: str = "sensor_string_id", + dom_number_label: str = "sensor_id", + dtype: Optional[torch.dtype] = torch.float, + detector: Optional[Detector] = None, + **kwargs: Any, + ) -> None: + """Construct `IC86DNNImage`. + + Args: + node_definition: Definition of nodes. + input_feature_names: Names of each column in expected input data + that will be built into a image. + include_lower_dc: If True, the lower DeepCore will be included. + include_upper_dc: If True, the upper DeepCore will be included. + string_label: The label for the string number in the data. + dom_number_label: The label for the DOM number in the data. + dtype: data type used for node features. e.g. ´torch.float´ + detector: The corresponding ´Detector´ representing the data. + """ + # Default detector with unstandardized input features + if detector is None: + detector = ORCA150( + replace_with_identity=input_feature_names, + ) + + node_definition.set_output_feature_names(input_feature_names) + assert ( + string_label in input_feature_names + ), f"String label '{string_label}' not in input feature names" + assert ( + dom_number_label in input_feature_names + ), f"DOM number label '{dom_number_label}' not in input feature names" + + # Base class constructor + pixel_mapping = ExamplePrometheusMapping( + string_label=string_label, + sensor_number_label=dom_number_label, + pixel_feature_names=node_definition._output_feature_names, + dtype=dtype, + ) + + super().__init__( + detector=detector, + node_definition=node_definition, + pixel_mapping=pixel_mapping, # PixelMapping, + input_feature_names=input_feature_names, + add_inactive_sensors=False, + **kwargs, + ) diff --git a/src/graphnet/models/data_representation/images/mappings/__init__.py b/src/graphnet/models/data_representation/images/mappings/__init__.py index 1a748be5a..f23dfe52a 100644 --- a/src/graphnet/models/data_representation/images/mappings/__init__.py +++ b/src/graphnet/models/data_representation/images/mappings/__init__.py @@ -8,4 +8,5 @@ from .pixel_mappings import ( PixelMapping, IC86PixelMapping, + ExamplePrometheusMapping, ) diff --git a/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py b/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py index 731d044d3..6dc8d2b6c 100644 --- a/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py +++ b/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py @@ -8,7 +8,7 @@ import numpy as np from graphnet.models import Model -from graphnet.constants import IC86_CNN_MAPPING +from graphnet.constants import IC86_CNN_MAPPING, PROMETHEUS_CNN_MAPPING class PixelMapping(Model): @@ -56,7 +56,7 @@ def __init__( include_lower_dc: bool = True, include_upper_dc: bool = True, ): - """Construct `IC86MircoDNNMapping`. + """Construct `IC86PixelMapping`. Args: dtype: data type used for node features. e.g. ´torch.float´ @@ -219,3 +219,137 @@ def _set_image_feature_names(self, input_feature_names: List[str]) -> None: for infeature in input_feature_names if infeature not in [self._string_label, self._dom_number_label] ] + + +class ExamplePrometheusMapping(PixelMapping): + """Mapping for the Prometheus detector. + + This mapping is made for example purposes and is not optimized for + any specific use case. There is no guarantee that this mapping will + work with all Prometheus data. + """ + + def __init__( + self, + dtype: torch.dtype, + pixel_feature_names: List[str], + string_label: str = "sensor_string_id", + sensor_number_label: str = "sensor_id", + ): + """Construct `ExamplePrometheusMapping`. + + Args: + dtype: data type used for node features. e.g. ´torch.float´ + string_label: Name of the feature corresponding + to the sensor string number. + sensor_number_label: Name of the feature corresponding + to the sensor number + pixel_feature_names: Names of each column in expected input data + that will be built into a image. + + Raises: + ValueError: If no array type is included. + + NOTE: Expects input data to be sensors with aggregated features. + """ + self._dtype = dtype + self._string_label = string_label + self._sensor_number_label = sensor_number_label + self._pixel_feature_names = pixel_feature_names + + self._set_indeces( + pixel_feature_names, sensor_number_label, string_label + ) + + self._nb_cnn_features = ( + len(pixel_feature_names) - 2 + ) # 2 for string and sensor number + + # read mapping from parquet file + df = pd.read_parquet(PROMETHEUS_CNN_MAPPING) + df.sort_values( + by=["sensor_string_id", "sensor_id"], + ascending=[True, True], + inplace=True, + ) + + # Set the index to string and sensor_number for faster lookup + df.set_index( + ["sensor_string_id", "sensor_id"], + inplace=True, + drop=False, + ) + + self._mapping = df + super().__init__(pixel_feature_names=pixel_feature_names) + + def _set_indeces( + self, + feature_names: List[str], + sensor_number_label: str, + string_label: str, + ) -> None: + """Set the indices for the features.""" + self._cnn_features_idx = [] + for feature in feature_names: + if feature == sensor_number_label: + self._sensor_number_idx = feature_names.index(feature) + elif feature == string_label: + self._string_idx = feature_names.index(feature) + else: + self._cnn_features_idx.append(feature_names.index(feature)) + + def forward(self, data: Data, data_feature_names: List[str]) -> Data: + """Map pixel data to images.""" + # Initialize output arrays + image_tensor = torch.zeros( + (self._nb_cnn_features, 8, 9, 22), + dtype=self._dtype, + ) + + # data.x is expected to be a tensor with shape (N, F) + # where N is the number of nodes and F is the number of features. + x = data.x + + # Direct coordinate and feature extraction + string_sensor_number = x[ + :, [self._string_idx, self._sensor_number_idx] + ].int() + batch_row_features = x[:, self._cnn_features_idx] + + # look up the mapping for string and sensor_number + match_indices = self._mapping.loc[ + zip(*string_sensor_number.t().tolist()) + ][ + ["sensor_string_id", "sensor_id", "mat_ax0", "mat_ax1", "mat_ax2"] + ].values.astype( + int + ) + + # Copy CNN features to the appropriate arrays + for i, row in enumerate(match_indices): + # Select appropriate array and indexing + image_tensor[ + :, + row[2], # mat_ax0 + row[3], # mat_ax1 + row[4], # mat_ax2 + ] = batch_row_features[i] + + # unqueeze to add dimension for batching + # with collate_fn Batch.from_data_list + ret: List[torch.Tensor] = [image_tensor.unsqueeze(0)] + + # Set list of images as data.x + data.x = ret + return data + + def _set_image_feature_names(self, input_feature_names: List[str]) -> None: + """Set the final output feature names.""" + # string and sensor_number are only used for mapping + # and will not be included in the output features. + self.image_feature_names = [ + infeature + for infeature in input_feature_names + if infeature not in [self._string_label, self._sensor_number_label] + ] From ad608e24d20c2009d543b60e55df563999982ffa Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Fri, 18 Jul 2025 16:25:32 +0200 Subject: [PATCH 18/31] Adjust docstring --- src/graphnet/models/cnn/theos_muonE_upgoing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/graphnet/models/cnn/theos_muonE_upgoing.py b/src/graphnet/models/cnn/theos_muonE_upgoing.py index 196afa343..e6f454ea0 100644 --- a/src/graphnet/models/cnn/theos_muonE_upgoing.py +++ b/src/graphnet/models/cnn/theos_muonE_upgoing.py @@ -1,6 +1,6 @@ """CNN used for muon energy reconstruction in IceCube. -Mimics `upgoing_muon_energy` model from +Copy of `upgoing_muon_energy` model from https://github.com/IceCubeOpenSource/i3deepice/tree/master """ From 7c038a874587a30b8c5d3d1fc205ad1df6829765 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Fri, 18 Jul 2025 16:40:37 +0200 Subject: [PATCH 19/31] Fixing comments in example --- examples/04_training/09_train_cnn.py | 37 ++++++++++++++++++---------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/examples/04_training/09_train_cnn.py b/examples/04_training/09_train_cnn.py index cb14ceba1..7aa837163 100644 --- a/examples/04_training/09_train_cnn.py +++ b/examples/04_training/09_train_cnn.py @@ -1,4 +1,4 @@ -"""Example of training Model.""" +"""Example of training a CNN Model.""" import os from typing import Any, Dict, List, Optional @@ -19,6 +19,7 @@ from graphnet.utilities.logging import Logger from graphnet.data.dataset import SQLiteDataset from graphnet.data.dataset import ParquetDataset +from graphnet.models.detector import ORCA150 from torch_geometric.data import Batch from graphnet.models.data_representation.images import ExamplePrometheusImage @@ -76,11 +77,14 @@ def main( } archive = os.path.join(EXAMPLE_OUTPUT_DIR, "train_cnn_model") - run_name = "dynedge_{}_example".format(config["target"]) + run_name = "lcsc_{}_example".format(config["target"]) if wandb: # Log configuration to W&B wandb_logger.experiment.config.update(config) + # First we need to define how the image is constructed. + # This is done using an ImageDefinition. + # An ImageDefinition combines two components: # 1. A pixel definition, which defines how the pixel data is @@ -90,22 +94,23 @@ def main( # Normally, this could mean that light pulses that arrive at # the same optical module must be aggregated to a # fixed-dimensional vector. - # A pixel definition is exactly the same as the + # A pixel definition works exactly the same as the # a node definition in the graph scenerio. # 2. A pixel mapping, which defines where each pixel is located # in the final image. This is highly detector specific, as it # depends on the geometry of the detector. - # An ImageDefinition can be used to create multiple images, - # in the example of IceCube, you can e.g. create three images, - # one for the so called main array, one for the upper deep core - # and one for the lower deep core. Essentially, these are just - # different areas in the detector. + # An ImageDefinition can be used to create multiple images of + # a single event. In the example of IceCube, you can e.g + # create three images, one for the so called main array, + # one for the upper deep core and one for the lower deep + # core. Essentially, these are just different areas in + # the detector. # Here we use the PercentileClusters pixel definition, which # aggregates the light pulses that arrive at the same optical - # module (or sensor) with percentiles. + # module with percentiles. print(features) pixel_definition = PercentileClusters( cluster_on=["sensor_id", "sensor_string_id"], @@ -115,18 +120,24 @@ def main( ) # The final image definition used here is the ExamplePrometheusImage, - # which is a detector specific pixel mapping for the IceCube. - # It maps optical modules (sensors) into the image + # which is a detector specific pixel mapping. + # It maps optical modules into the image # using the sensor_string_id and sensor_id # (number of the optical module). - # The detector standardizes the input features, so that the - # features are in a ML friendly range. + # The detector class standardizes the input features, + # so that the features are in a ML friendly range. # For the mapping of the optical modules to the image it is # essential to not change the value of the sensor_id and # sensor_string_id. Therefore we need to make sure that # these features are not standardized, which is done by the # `replace_with_identity` argument of the detector. image_definition = ExamplePrometheusImage( + detector=ORCA150( + replace_with_identity=[ + "sensor_id", + "sensor_string_id", + ], + ), node_definition=pixel_definition, input_feature_names=features, string_label="sensor_string_id", From 1e3871864928be113b171c1710220fa4069e69a8 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Fri, 18 Jul 2025 16:48:52 +0200 Subject: [PATCH 20/31] Add more to docstring in LCSC --- src/graphnet/models/cnn/lcsc.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/graphnet/models/cnn/lcsc.py b/src/graphnet/models/cnn/lcsc.py index 65df313ac..1500cbc81 100644 --- a/src/graphnet/models/cnn/lcsc.py +++ b/src/graphnet/models/cnn/lcsc.py @@ -73,22 +73,24 @@ def __init__( Defaults to True. num_conv_layers (int): Number of convolutional layers. Defaults to 8. - conv_filters (List[int]): List of number ofconvolutional + conv_filters (List[int]): List of number of convolutional filters to use in hidden layers. Defaults to [50, 50, 50, 50, 50, 50, 50, 50, 10]. + NOTE needs to have the length of `num_conv_layers`. kernel_size (int, List[int], or List[List[int]]): Size of the convolutional kernels. Options are: int: single integer for all dimensions and all layers, - e.g. 3 would equal [3, 3, 3]. + e.g. 3 would equal [3, 3, 3] for each layer. list: list of integers specifying the kernel size, for each layer for all dimensions equally, - e.g. [3, 5, 6]. + e.g. [3, 5, 6] would equal [[3,3,3], [5,5,5], [6,6,6]]. + NOTE: needs to have the length of `num_conv_layers`. If a list of lists is provided, each list will be used for the corresponding layer as kernel size. - If an integer is provided, it will be used for all layers. - Defaults to 3. + NOTE: If a list if passed it needs to have the length + of `num_conv_layers`. padding (str, int, or List[int]]): Padding for the convolutional layers. Options are: @@ -98,6 +100,8 @@ def __init__( list: list of integers specifying the padding for each dimension, for each layer equally, e.g. [1, 2, 3]. + NOTE: If a list is passed it needs to have the length + of `num_conv_layers`. Defaults to 'Same'. pooling_type (List[None,str]): List of pooling types for layers. @@ -111,6 +115,8 @@ def __init__( None, 'Avg', None, 'Avg' ]. + NOTE: the length of the list must be equal to + `num_conv_layers`. pooling_kernel_size (List[Union[int,List[int]]]): List of pooling kernel sizes for each layer. If an integer is provided, it will be used for all layers. @@ -119,6 +125,8 @@ def __init__( int: single integer for all dimensions, e.g. 2 would equal [2, 2, 2]. If None, no pooling is applied. + NOTE: If a list is passed it needs to have the length + of `num_conv_layers`. Defaults to [ None, [1, 1, 2], None, [2, 2, 2], @@ -133,6 +141,8 @@ def __init__( int: single integer for all dimensions, e.g. 2 would equal [2, 2, 2]. If None, no pooling is applied. + NOTE: If a list is passed it needs to have the length + of `num_conv_layers`. Defaults to [ None, [1, 1, 2], None, [2, 2, 2], @@ -146,6 +156,8 @@ def __init__( for each convolutional layer. If a boolean is provided, it will be used for all layers. Defaults to True. + NOTE: If a list is passed it needs to have the length + of `num_conv_layers`. norm_type (str): Type of normalization to use. Options are 'Batch' or 'Instance'. Defaults to 'Batch'. From 62d510e3dc3023379ea0601365ad56a7d28c497e Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Fri, 18 Jul 2025 16:54:47 +0200 Subject: [PATCH 21/31] adjust docstrings theos cnn --- src/graphnet/models/cnn/theos_muonE_upgoing.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/graphnet/models/cnn/theos_muonE_upgoing.py b/src/graphnet/models/cnn/theos_muonE_upgoing.py index e6f454ea0..3bbb0ed03 100644 --- a/src/graphnet/models/cnn/theos_muonE_upgoing.py +++ b/src/graphnet/models/cnn/theos_muonE_upgoing.py @@ -2,6 +2,9 @@ Copy of `upgoing_muon_energy` model from https://github.com/IceCubeOpenSource/i3deepice/tree/master + +Class and variable names are kept for +compatibility with the original code. """ from typing import Tuple, Union From 008961f34b6f92666f9652895397df02053754cc Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Fri, 18 Jul 2025 17:54:36 +0200 Subject: [PATCH 22/31] docstring clean ups --- .../data_representation/graphs/nodes/nodes.py | 1 - .../data_representation/images/image_definition.py | 13 ++++++------- .../models/data_representation/images/images.py | 4 +--- .../data_representation/images/mappings/__init__.py | 7 +++---- .../images/mappings/pixel_mappings.py | 12 +++++++++++- 5 files changed, 21 insertions(+), 16 deletions(-) diff --git a/src/graphnet/models/data_representation/graphs/nodes/nodes.py b/src/graphnet/models/data_representation/graphs/nodes/nodes.py index ac66de01e..064073bdd 100644 --- a/src/graphnet/models/data_representation/graphs/nodes/nodes.py +++ b/src/graphnet/models/data_representation/graphs/nodes/nodes.py @@ -29,7 +29,6 @@ def __init__( # Base class constructor super().__init__(name=__name__, class_name=self.__class__.__name__) if input_feature_names is not None: - print(input_feature_names) self.set_output_feature_names( input_feature_names=input_feature_names ) diff --git a/src/graphnet/models/data_representation/images/image_definition.py b/src/graphnet/models/data_representation/images/image_definition.py index e50aa6870..0fbbe0a10 100644 --- a/src/graphnet/models/data_representation/images/image_definition.py +++ b/src/graphnet/models/data_representation/images/image_definition.py @@ -19,7 +19,7 @@ class ImageDefinition(DataRepresentation): - """An Abstract class to create Imagedefinitions from.""" + """An Abstract class to create ImageDefinitions from.""" def __init__( self, @@ -36,14 +36,13 @@ def __init__( ): """Construct `ImageDefinition`. - ´Detector´-specific code. E.g. scaling/standardization and geometry - tables. + ´node_definition´ defines the processing of raw data into + what will later be saved in the individual pixels - ´node_definition´ defines the processing of raw data. + ´pixel_mapping´ defines the mapping of the processed + data to the images. - ´pixel_mapping´ defines the mapping of the processed data to images. - - NOTE: some pixel_mappings require specific node_definitions. + NOTE: pixel_mappings may require specific node_definitions. Args: detector: The corresponding ´Detector´ representing the data. diff --git a/src/graphnet/models/data_representation/images/images.py b/src/graphnet/models/data_representation/images/images.py index d06befd09..25cd104d8 100644 --- a/src/graphnet/models/data_representation/images/images.py +++ b/src/graphnet/models/data_representation/images/images.py @@ -90,14 +90,12 @@ def __init__( detector: Optional[Detector] = None, **kwargs: Any, ) -> None: - """Construct `IC86DNNImage`. + """Construct `ExamplePrometheusImage`. Args: node_definition: Definition of nodes. input_feature_names: Names of each column in expected input data that will be built into a image. - include_lower_dc: If True, the lower DeepCore will be included. - include_upper_dc: If True, the upper DeepCore will be included. string_label: The label for the string number in the data. dom_number_label: The label for the DOM number in the data. dtype: data type used for node features. e.g. ´torch.float´ diff --git a/src/graphnet/models/data_representation/images/mappings/__init__.py b/src/graphnet/models/data_representation/images/mappings/__init__.py index f23dfe52a..5f246f891 100644 --- a/src/graphnet/models/data_representation/images/mappings/__init__.py +++ b/src/graphnet/models/data_representation/images/mappings/__init__.py @@ -1,8 +1,7 @@ -"""Modules for mapping images. +"""Modules for mapping images for different detectors. -´ImageDefinition´ defines the nodes and the mapping, and contains -general image-manipulation.´PixelMapping´ defines how raw data is -mapped into the regular sized image. +´PixelMapping´ defines how raw data is mapped into the regular sized +image. """ from .pixel_mappings import ( diff --git a/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py b/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py index 6dc8d2b6c..5d007d75a 100644 --- a/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py +++ b/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py @@ -26,7 +26,17 @@ def __init__( def forward(self, data: Data, data_feature_names: List[str]) -> Data: """Map pixel data to images. - Make sure to add a batch dimension to the output. E.g picture + Args: + data: The input data containing pixel features. + data_feature_names: Names of each column in expected input data + that will be built into a image. + + Returns: + Data: The output data with images as features. + NOTE: The output data.x should be a list of tensors, + where each tensor corresponds to an image. + + Make sure to add a batch dimension to the tensors. E.g a picture with dimensions CxHxW = 10x64x64 should be returned as 1x10x64x64. """ From 7d02215c45b9c508569f3c0749dcf5c6cd8f87b8 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Tue, 22 Jul 2025 12:42:23 +0200 Subject: [PATCH 23/31] add info to docstring --- src/graphnet/models/data_representation/graphs/nodes/nodes.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/graphnet/models/data_representation/graphs/nodes/nodes.py b/src/graphnet/models/data_representation/graphs/nodes/nodes.py index 064073bdd..1730e79fa 100644 --- a/src/graphnet/models/data_representation/graphs/nodes/nodes.py +++ b/src/graphnet/models/data_representation/graphs/nodes/nodes.py @@ -502,6 +502,8 @@ class ClusterSummaryFeatures(NodeDefinition): For more details on some of the features see Theo Glauchs thesis (chapter 5.3): https://mediatum.ub.tum.de/node?id=1584755 + + NOTE: number of pulses per cluster is not mentioned/used in the thesis """ def __init__( From ddcb8dbe2c57d458c0493810b0c1299ed528747f Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Mon, 11 Aug 2025 19:43:43 +0200 Subject: [PATCH 24/31] add shape property --- .../images/mappings/pixel_mappings.py | 33 +++++++++++++++++++ tests/models/test_pixel_mapping.py | 13 ++++++++ 2 files changed, 46 insertions(+) diff --git a/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py b/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py index 5d007d75a..558b90c04 100644 --- a/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py +++ b/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py @@ -47,6 +47,18 @@ def _set_image_feature_names(self, input_feature_names: List[str]) -> None: """Set the final image feature names.""" raise NotImplementedError + @property + @abstractmethod + def shape( + self, + ) -> List[List[int]]: + """Return the shape of the output images as a list of tuples. + + In the dimensions (F,D,H,W) where F is the number of features + per pixel. And D,H,W are the dimension of the image + """ + pass + class IC86PixelMapping(PixelMapping): """Mapping for the IceCube86. @@ -230,6 +242,20 @@ def _set_image_feature_names(self, input_feature_names: List[str]) -> None: if infeature not in [self._string_label, self._dom_number_label] ] + @property + def shape( + self, + ) -> List[List[int]]: + """Return the shape of the output images as a list of tuples.""" + ret = [] + if self._include_main_array: + ret.append([self._nb_cnn_features, 10, 10, 60]) + if self._include_upper_dc: + ret.append([self._nb_cnn_features, 1, 8, 10]) + if self._include_lower_dc: + ret.append([self._nb_cnn_features, 1, 8, 50]) + return ret + class ExamplePrometheusMapping(PixelMapping): """Mapping for the Prometheus detector. @@ -363,3 +389,10 @@ def _set_image_feature_names(self, input_feature_names: List[str]) -> None: for infeature in input_feature_names if infeature not in [self._string_label, self._sensor_number_label] ] + + @property + def shape( + self, + ) -> List[List[int]]: + """Return the shape of the output images as a list of tuples.""" + return [[self._nb_cnn_features, 8, 9, 22]] diff --git a/tests/models/test_pixel_mapping.py b/tests/models/test_pixel_mapping.py index b8f2ef573..49e5157e5 100644 --- a/tests/models/test_pixel_mapping.py +++ b/tests/models/test_pixel_mapping.py @@ -68,11 +68,24 @@ def test_pixel_mappings() -> None: # Apply node definition to torch tensor with raw pulses picture = pixel_mapping(dummy_data, pixel_feature_names) new_features = pixel_mapping.image_feature_names + n_features = len(new_features) # Check the output basic_checks_picture(picture, dtype) # More checks + assert ( + len(pixel_mapping.shape) == 3 + ), f"Expected shape to be 3 got {len(pixel_mapping.shape)}" + assert pixel_mapping.shape == [ + (n_features, 10, 10, 60), + (n_features, 1, 8, 10), + (n_features, 1, 8, 50), + ], ( + f"Expected shape to be [({n_features},10,10,60), " + f"({n_features},1,8,10), ({n_features},1,8,50)] got " + f"{pixel_mapping.shape}" + ) assert isinstance( new_features, list ), f"Output should be a list of feature names got {type(new_features)}" From 43dc067da33f02002d0ec2a7b055823d0acb34ad Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Sun, 22 Feb 2026 13:47:48 +0100 Subject: [PATCH 25/31] renaming the module to IceCubeDNN --- src/graphnet/models/cnn/__init__.py | 2 +- ...{theos_muonE_upgoing.py => icecube_dnn.py} | 24 ++++++++++--------- 2 files changed, 14 insertions(+), 12 deletions(-) rename src/graphnet/models/cnn/{theos_muonE_upgoing.py => icecube_dnn.py} (94%) diff --git a/src/graphnet/models/cnn/__init__.py b/src/graphnet/models/cnn/__init__.py index dfaf35e40..d44dd9f83 100644 --- a/src/graphnet/models/cnn/__init__.py +++ b/src/graphnet/models/cnn/__init__.py @@ -1,5 +1,5 @@ """CNN-specific modules, for performing the main learnable operations.""" from .cnn import CNN -from .theos_muonE_upgoing import TheosMuonEUpgoing +from .icecube_dnn import IceCubeDNN from .lcsc import LCSC diff --git a/src/graphnet/models/cnn/theos_muonE_upgoing.py b/src/graphnet/models/cnn/icecube_dnn.py similarity index 94% rename from src/graphnet/models/cnn/theos_muonE_upgoing.py rename to src/graphnet/models/cnn/icecube_dnn.py index 3bbb0ed03..a6c49f327 100644 --- a/src/graphnet/models/cnn/theos_muonE_upgoing.py +++ b/src/graphnet/models/cnn/icecube_dnn.py @@ -1,10 +1,7 @@ -"""CNN used for muon energy reconstruction in IceCube. +"""Implementation of the IceCube DNN image convolution model by Theo Glauch. -Copy of `upgoing_muon_energy` model from +Based on the `upgoing_muon_energy` model from https://github.com/IceCubeOpenSource/i3deepice/tree/master - -Class and variable names are kept for -compatibility with the original code. """ from typing import Tuple, Union @@ -17,7 +14,7 @@ class Conv3dBN(LightningModule): - """The Conv3dBN module from Theos CNN model.""" + """3D convolution with batch normalization from Theo Glauch's DNN.""" def __init__( self, @@ -55,7 +52,7 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: class InceptionBlock4(LightningModule): - """The inception_block4 module from Theos CNN model.""" + """Inception block with 4 parallel towers from Theo Glauch's DNN.""" def __init__( self, @@ -159,7 +156,7 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: class InceptionResnet(LightningModule): - """The inception_resnet module from Theos CNN model.""" + """Inception block with residual connections from Theo Glauch's DNN.""" def __init__( self, @@ -261,11 +258,16 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: return x + self._scale * tmp -class TheosMuonEUpgoing(CNN): - """The TheosMuonEUpgoing module.""" +class IceCubeDNN(CNN): + """Implementation of the IceCube DNN by Theo Glauch. + + An inception-based 3D CNN originally used within IceCube. Based on + the model from + https://github.com/IceCubeOpenSource/i3deepice/tree/master + """ def __init__(self, nb_inputs: int = 15, nb_outputs: int = 16) -> None: - """Construct `TheosMuonEUpgoing`. + """Construct `IceCubeDNN`. Args: nb_inputs: Number of input features. From 8e5ff860a5cd6efb679a336080cdd11429630e23 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Sun, 22 Feb 2026 14:44:16 +0100 Subject: [PATCH 26/31] removing unecessary typing annotations in docstring --- src/graphnet/models/cnn/lcsc.py | 60 +++++++++++++++------------------ 1 file changed, 27 insertions(+), 33 deletions(-) diff --git a/src/graphnet/models/cnn/lcsc.py b/src/graphnet/models/cnn/lcsc.py index 1500cbc81..ae665cfea 100644 --- a/src/graphnet/models/cnn/lcsc.py +++ b/src/graphnet/models/cnn/lcsc.py @@ -66,19 +66,18 @@ def __init__( """Initialize the Lightning CNN signal classifier (LCSC). Args: - num_input_features (int): Number of input features. - out_put_dim (int): Number of output dimensions of final MLP. + num_input_features: Number of input features. + out_put_dim: Number of output dimensions of final MLP. Defaults to 2. - input_norm (bool): Whether to apply normalization to the input. + input_norm: Whether to apply normalization to the input. Defaults to True. - num_conv_layers (int): Number of convolutional layers. + num_conv_layers: Number of convolutional layers. Defaults to 8. - conv_filters (List[int]): List of number of convolutional + conv_filters: List of number of convolutional filters to use in hidden layers. Defaults to [50, 50, 50, 50, 50, 50, 50, 50, 10]. NOTE needs to have the length of `num_conv_layers`. - kernel_size (int, List[int], or List[List[int]]): - Size of the convolutional kernels. + kernel_size: Size of the convolutional kernels. Options are: int: single integer for all dimensions and all layers, @@ -91,8 +90,7 @@ def __init__( for the corresponding layer as kernel size. NOTE: If a list if passed it needs to have the length of `num_conv_layers`. - padding (str, int, or List[int]]): Padding for the - convolutional layers. + padding: Padding for the convolutional layers. Options are: 'Same' for same convolutional padding, int: single integer for all dimensions and all layers, @@ -103,8 +101,7 @@ def __init__( NOTE: If a list is passed it needs to have the length of `num_conv_layers`. Defaults to 'Same'. - pooling_type (List[None,str]): List of pooling types - for layers. + pooling_type: List of pooling types for layers. Options are None : No pooling is used, 'Avg' : Average pooling is used, @@ -117,10 +114,10 @@ def __init__( ]. NOTE: the length of the list must be equal to `num_conv_layers`. - pooling_kernel_size (List[Union[int,List[int]]]): - List of pooling kernel sizes for each layer. - If an integer is provided, it will be used for all layers. - In case of a list the options for its elements are: + pooling_kernel_size: List of pooling kernel sizes for each + layer. If an integer is provided, it will be used for + all layers. In case of a list the options for its + elements are: list: list of integers for each dimension, e.g. [1, 1, 2]. int: single integer for all dimensions, e.g. 2 would equal [2, 2, 2]. @@ -133,8 +130,7 @@ def __init__( None, [2, 2, 2], None, [2, 2, 2] ]. - pooling_stride (int or List[Union[None,int]]): - List of pooling strides for each layer. + pooling_stride: List of pooling strides for each layer. If an integer is provided, it will be used for all layers. In case of a list the options for its elements are: list: list of integers for each dimension, e.g. [1, 1, 2]. @@ -149,20 +145,18 @@ def __init__( None, [2, 2, 2], None, [2, 2, 2] ]. - num_fc_neurons (int): Number of neurons in the - fully connected layers. - Defaults to 50. - norm_list (bool or List[bool]): Whether to apply normalization - for each convolutional layer. - If a boolean is provided, it will be used for all layers. - Defaults to True. + num_fc_neurons: Number of neurons in the fully connected + layers. Defaults to 50. + norm_list: Whether to apply normalization for each + convolutional layer. If a boolean is provided, it will + be used for all layers. Defaults to True. NOTE: If a list is passed it needs to have the length of `num_conv_layers`. - norm_type (str): Type of normalization to use. + norm_type: Type of normalization to use. Options are 'Batch' or 'Instance'. Defaults to 'Batch'. - image_size (Tuple[int, int, int]): Size of the input image - in the format (height, width, depth). + image_size: Size of the input image in the format + (height, width, depth). NOTE: Only needs to be changed if the input image is not the standard IceCube 86 image size. """ @@ -397,23 +391,23 @@ def _calc_output_dimension( Works for Conv3D, MaxPool3D and AvgPool3D layers. Args: - dimensions (Tuple[int]): Current dimensions of the input tensor. + dimensions: Current dimensions of the input tensor. (C,H,W,D) where C is the number of channels, H is the height, W is the width and D is the depth. - out_channels (int): Number of output channels. - kernel_size (Union[int,List[int]]): Size of the kernel. + out_channels: Number of output channels. + kernel_size: Size of the kernel. If an integer is provided, it will be used for all dimensions. - padding (Union[int,List[int]]): Padding size. + padding: Padding size. If an integer is provided, it will be used for all dimensions. If 'Same', the padding will be calculated to keep the output size the same as the input size. Defaults to 0. - stride (Union[int,List[int]]): Stride size. + stride: Stride size. If an integer is provided, it will be used for all dimensions. Defaults to 1. Returns: - Tuple[int]: New dimensions after the layer. + New dimensions after the layer. NOTE: For the pooling layers, set out_channels equal to the input channels. Since they do not change the number of channels. From 696d05345b3fda898946c9978d00855afe36fdb5 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Sun, 22 Feb 2026 14:56:05 +0100 Subject: [PATCH 27/31] cleaned up the __init__ of LCSC and defined parsing private functions --- src/graphnet/models/cnn/icecube_dnn.py | 2 +- src/graphnet/models/cnn/lcsc.py | 245 +++++++++++++++++-------- 2 files changed, 169 insertions(+), 78 deletions(-) diff --git a/src/graphnet/models/cnn/icecube_dnn.py b/src/graphnet/models/cnn/icecube_dnn.py index a6c49f327..503c01305 100644 --- a/src/graphnet/models/cnn/icecube_dnn.py +++ b/src/graphnet/models/cnn/icecube_dnn.py @@ -4,7 +4,7 @@ https://github.com/IceCubeOpenSource/i3deepice/tree/master """ -from typing import Tuple, Union +from typing import List, Tuple, Union import torch from torch import nn diff --git a/src/graphnet/models/cnn/lcsc.py b/src/graphnet/models/cnn/lcsc.py index ae665cfea..8df4c4aa5 100644 --- a/src/graphnet/models/cnn/lcsc.py +++ b/src/graphnet/models/cnn/lcsc.py @@ -162,16 +162,74 @@ def __init__( """ super().__init__(nb_inputs=num_input_features, nb_outputs=out_put_dim) - # Check input parameters + # Check and parse input parameters + conv_filters, kernel_size, padding = self._parse_conv_arguments( + num_conv_layers=num_conv_layers, + conv_filters=conv_filters, + kernel_size=kernel_size, + padding=padding, + ) + pooling_kernel_size, pooling_stride = self._parse_pooling_arguments( + num_conv_layers=num_conv_layers, + pooling_kernel_size=pooling_kernel_size, + pooling_stride=pooling_stride, + ) + self._norm_list, norm_class = self._parse_norm_arguments( + num_conv_layers=num_conv_layers, + num_input_features=num_input_features, + input_norm=input_norm, + norm_list=norm_list, + norm_type=norm_type, + ) + + # Set convolution, pooling, and normalization layers + self.input_norm = input_norm + dimensions = self._set_conv_layers( + num_conv_layers=num_conv_layers, + num_input_features=num_input_features, + image_size=image_size, + conv_filters=conv_filters, + kernel_size=kernel_size, + padding=padding, + pooling_type=pooling_type, + pooling_kernel_size=pooling_kernel_size, + pooling_stride=pooling_stride, + norm_class=norm_class, + ) + + # Set linear layers + latent_dim = ( + dimensions[0] * dimensions[1] * dimensions[2] * dimensions[3] + ) + self.flatten = torch.nn.Flatten() + self.fc1 = torch.nn.Linear(latent_dim, num_fc_neurons) + self.fc2 = torch.nn.Linear(num_fc_neurons, out_put_dim) + + def _parse_conv_arguments( + self, + num_conv_layers: int, + conv_filters: Union[int, List[int]], + kernel_size: Union[int, List[Union[int, List[int]]]], + padding: Union[str, int, List[Union[str, int]]], + ) -> Tuple[List[int], List, List]: + """Parse and validate convolution arguments. + + Args: + num_conv_layers: Number of convolutional layers. + conv_filters: Convolutional filters per layer. + kernel_size: Kernel sizes per layer. + padding: Padding per layer. + + Returns: + Parsed conv_filters, kernel_size, and padding as lists. + """ if isinstance(conv_filters, int): conv_filters = [conv_filters for _ in range(num_conv_layers)] else: if not isinstance(conv_filters, list): raise TypeError( - ( - f"`conv_filters` must be a " - f"list or an integer, not {type(conv_filters)}!" - ) + f"`conv_filters` must be a " + f"list or an integer, not {type(conv_filters)}!" ) if len(conv_filters) != num_conv_layers: raise ValueError( @@ -187,38 +245,30 @@ def __init__( else: if not isinstance(kernel_size, list): raise TypeError( - ( - "`kernel_size` must be a list or an " - f"integer, not {type(kernel_size)}!" - ) + "`kernel_size` must be a list or an " + f"integer, not {type(kernel_size)}!" ) if len(kernel_size) != num_conv_layers: raise ValueError( - ( - f"`kernel_size` must have {num_conv_layers} " - f"elements, not {len(kernel_size)}!" - ) + f"`kernel_size` must have {num_conv_layers} " + f"elements, not {len(kernel_size)}!" ) if isinstance(padding, int): padding = [padding for _ in range(num_conv_layers)] elif isinstance(padding, str): if padding.lower() == "same": - padding = ["same" for i in range(num_conv_layers)] + padding = ["same" for _ in range(num_conv_layers)] else: raise ValueError( - ( - "`padding` must be 'Same' or an integer, " - f"not {padding}!" - ) + "`padding` must be 'Same' or an integer, " + f"not {padding}!" ) else: if not isinstance(padding, list): raise TypeError( - ( - f"`padding` must be a list or " - f"an integer, not {type(padding)}!" - ) + f"`padding` must be a list or " + f"an integer, not {type(padding)}!" ) if len(padding) != num_conv_layers: raise ValueError( @@ -226,63 +276,91 @@ def __init__( f"elements, not {len(padding)}!" ) + return conv_filters, kernel_size, padding + + def _parse_pooling_arguments( + self, + num_conv_layers: int, + pooling_kernel_size: Union[int, List[Union[None, int, List[int]]]], + pooling_stride: Union[int, List[Union[None, int, List[int]]]], + ) -> Tuple[List, List]: + """Parse and validate pooling arguments. + + Args: + num_conv_layers: Number of convolutional layers. + pooling_kernel_size: Pooling kernel sizes per layer. + pooling_stride: Pooling strides per layer. + + Returns: + Parsed pooling_kernel_size and pooling_stride as lists. + """ if isinstance(pooling_kernel_size, int): pooling_kernel_size = [ - pooling_kernel_size for i in range(num_conv_layers) + pooling_kernel_size for _ in range(num_conv_layers) ] else: if not isinstance(pooling_kernel_size, list): raise TypeError( - ( - "`pooling_kernel_size` must be a list or " - f"an integer, not {type(pooling_kernel_size)}!" - ) + "`pooling_kernel_size` must be a list or " + f"an integer, not {type(pooling_kernel_size)}!" ) if len(pooling_kernel_size) != num_conv_layers: raise ValueError( - ( - f"`pooling_kernel_size` must have " - f"{num_conv_layers} elements, not " - f"{len(pooling_kernel_size)}!" - ) + f"`pooling_kernel_size` must have " + f"{num_conv_layers} elements, not " + f"{len(pooling_kernel_size)}!" ) if isinstance(pooling_stride, int): - pooling_stride = [pooling_stride for i in range(num_conv_layers)] + pooling_stride = [pooling_stride for _ in range(num_conv_layers)] else: if not isinstance(pooling_stride, list): raise TypeError( - ( - "`pooling_stride` must be a list or an integer, " - f"not {type(pooling_stride)}!" - ) + "`pooling_stride` must be a list or an integer, " + f"not {type(pooling_stride)}!" ) if len(pooling_stride) != num_conv_layers: raise ValueError( - ( - f"`pooling_stride` must have {num_conv_layers} " - f"elements, not {len(pooling_stride)}!" - ) + f"`pooling_stride` must have {num_conv_layers} " + f"elements, not {len(pooling_stride)}!" ) + return pooling_kernel_size, pooling_stride + + def _parse_norm_arguments( + self, + num_conv_layers: int, + num_input_features: int, + input_norm: bool, + norm_list: Union[bool, List[bool]], + norm_type: str, + ) -> Tuple[List[bool], type]: + """Parse and validate normalization arguments. + + Args: + num_conv_layers: Number of convolutional layers. + num_input_features: Number of input features. + input_norm: Whether to apply input normalization. + norm_list: Per-layer normalization flags. + norm_type: Type of normalization ('Batch' or 'Instance'). + + Returns: + Parsed norm_list and the normalization class. + """ if isinstance(norm_list, bool): - self._norm_list = [norm_list for i in range(num_conv_layers)] + parsed_norm_list = [norm_list for _ in range(num_conv_layers)] else: if not isinstance(norm_list, list): raise TypeError( - ( - "`norm_list` must be a list or a boolean, " - f"not {type(norm_list)}!" - ) + "`norm_list` must be a list or a boolean, " + f"not {type(norm_list)}!" ) if len(norm_list) != num_conv_layers: raise ValueError( - ( - f"`norm_list` must have {num_conv_layers} " - f"elements, not {len(norm_list)}!" - ) + f"`norm_list` must have {num_conv_layers} " + f"elements, not {len(norm_list)}!" ) - self._norm_list = norm_list + parsed_norm_list = norm_list if norm_type.lower() == "instance": norm_class = torch.nn.InstanceNorm3d @@ -291,29 +369,52 @@ def __init__( elif norm_type.lower() == "batch": norm_class = torch.nn.BatchNorm3d if input_norm: - # No momentum or learnable parameters for input normalization, - # just use the average self.input_normal = torch.nn.BatchNorm3d( num_input_features, momentum=None, affine=False ) else: raise ValueError( - ( - "`norm_type` has to be 'instance' or " - f"'batch, not '{norm_type}'!" - ) + "`norm_type` has to be 'instance' or " + f"'batch', not '{norm_type}'!" ) - # Initialize layers + return parsed_norm_list, norm_class + + def _set_conv_layers( + self, + num_conv_layers: int, + num_input_features: int, + image_size: Tuple[int, int, int], + conv_filters: List[int], + kernel_size: List, + padding: List, + pooling_type: List[Union[None, str]], + pooling_kernel_size: List, + pooling_stride: List, + norm_class: type, + ) -> List[int]: + """Build convolution, pooling, and normalization layers. + + Args: + num_conv_layers: Number of convolutional layers. + num_input_features: Number of input features. + image_size: Size of the input image (height, width, depth). + conv_filters: Convolutional filters per layer. + kernel_size: Kernel sizes per layer. + padding: Padding per layer. + pooling_type: Pooling type per layer. + pooling_kernel_size: Pooling kernel sizes per layer. + pooling_stride: Pooling strides per layer. + norm_class: Normalization class to use. + + Returns: + Output dimensions after all layers. + """ self.conv = torch.nn.ModuleList() self.pool = torch.nn.ModuleList() - self.input_norm = input_norm - self.normal = torch.nn.ModuleList() - dimensions: List[int] = [ - num_input_features, - *image_size, - ] # (nb_features per pixel, height, width, depth) + + dimensions: List[int] = [num_input_features, *image_size] for i in range(num_conv_layers): self.conv.append( torch.nn.Conv3d( @@ -340,9 +441,7 @@ def __init__( ) dimensions = self._calc_output_dimension( dimensions, - out_channels=dimensions[ - 0 - ], # same out channels as input channels for pooling + out_channels=dimensions[0], kernel_size=pooling_kernel_size[i], stride=pooling_stride[i], ) @@ -355,9 +454,7 @@ def __init__( ) dimensions = self._calc_output_dimension( dimensions, - out_channels=dimensions[ - 0 - ], # same out channels as input channels for pooling + out_channels=dimensions[0], kernel_size=pooling_kernel_size[i], stride=pooling_stride[i], ) @@ -370,13 +467,7 @@ def __init__( else: self.normal.append(None) - latent_dim = ( - dimensions[0] * dimensions[1] * dimensions[2] * dimensions[3] - ) - - self.flatten = torch.nn.Flatten() - self.fc1 = torch.nn.Linear(latent_dim, num_fc_neurons) - self.fc2 = torch.nn.Linear(num_fc_neurons, out_put_dim) + return dimensions def _calc_output_dimension( self, From 77b73544641e708bb32c7a8dab31f17301444735 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Sun, 22 Feb 2026 15:09:20 +0100 Subject: [PATCH 28/31] make IceCubeDNN configurable --- src/graphnet/models/cnn/icecube_dnn.py | 242 +++++++++++++------------ 1 file changed, 125 insertions(+), 117 deletions(-) diff --git a/src/graphnet/models/cnn/icecube_dnn.py b/src/graphnet/models/cnn/icecube_dnn.py index 503c01305..c8f4ddb2f 100644 --- a/src/graphnet/models/cnn/icecube_dnn.py +++ b/src/graphnet/models/cnn/icecube_dnn.py @@ -266,134 +266,142 @@ class IceCubeDNN(CNN): https://github.com/IceCubeOpenSource/i3deepice/tree/master """ - def __init__(self, nb_inputs: int = 15, nb_outputs: int = 16) -> None: + def __init__( + self, + nb_inputs: int = 15, + nb_outputs: int = 16, + image_size: Tuple[int, int, int] = (10, 10, 60), + inception_out_channels: int = 18, + inception_configs: List[Tuple[int, int, int]] = [ + (2, 5, 8), + (2, 3, 7), + (2, 4, 8), + (3, 5, 9), + (2, 8, 9), + ], + resnet_out_channels: int = 24, + resnet_t2_pattern: List[int] = [3, 4, 5], + num_resblocks1_repeats: int = 6, + num_resblocks2_repeats: int = 6, + avgpool1_size: Tuple[int, int, int] = (2, 2, 3), + avgpool2_size: Tuple[int, int, int] = (1, 1, 2), + avgpool3_size: Tuple[int, int, int] = (1, 1, 2), + pointwise_channels: List[int] = [64, 4], + mlp_hidden_sizes: List[int] = [120, 64], + ) -> None: """Construct `IceCubeDNN`. Args: nb_inputs: Number of input features. nb_outputs: Number of output features. + image_size: Spatial dimensions of the input image + (height, width, depth). + inception_out_channels: Output channels per tower in each + inception block. + inception_configs: List of (t0, t1, t2) kernel size tuples + for each InceptionBlock4 layer. + resnet_out_channels: Output channels per tower in each + inception-resnet block. + resnet_t2_pattern: Pattern of t2 kernel sizes repeated in + each group of resnet blocks. + num_resblocks1_repeats: Number of times to repeat the + resnet_t2_pattern in the first resnet stage. + num_resblocks2_repeats: Number of times to repeat the + resnet_t2_pattern in the second resnet stage. + avgpool1_size: Kernel size for the first average pooling. + avgpool2_size: Kernel size for the second average pooling. + avgpool3_size: Kernel size for the third average pooling. + pointwise_channels: Output channels for each 1x1x1 + convolution layer. + mlp_hidden_sizes: Hidden layer sizes for the final MLP. + The input size is computed from the preceding layers + and the output size is nb_outputs. """ super().__init__(nb_inputs, nb_outputs) - self.inceptionblocks4 = nn.Sequential( - InceptionBlock4( - in_channels=nb_inputs, - out_channels=18, - t0=2, - t1=5, - t2=8, - ), - InceptionBlock4( - in_channels=18 * 4, - out_channels=18, - t0=2, - t1=3, - t2=7, - ), - InceptionBlock4( - in_channels=18 * 4, - out_channels=18, - t0=2, - t1=4, - t2=8, - ), - InceptionBlock4( - in_channels=18 * 4, - out_channels=18, - t0=3, - t1=5, - t2=9, - ), - InceptionBlock4( - in_channels=18 * 4, - out_channels=18, - t0=2, - t1=8, - t2=9, - ), - ) - self.avgpool1 = nn.AvgPool3d((2, 2, 3)) - self.bn1 = nn.BatchNorm3d(18 * 4) - tmp = [ - InceptionResnet( - in_channels=18 * 4, - out_channels=24, - t2=3, - ), - InceptionResnet( - in_channels=24 * 3, - out_channels=24, - t2=4, - ), - InceptionResnet( - in_channels=24 * 3, - out_channels=24, - t2=5, - ), - ] - for _ in range(5): - tmp = tmp + [ - InceptionResnet( - in_channels=24 * 3, - out_channels=24, - t2=3, - ), - InceptionResnet( - in_channels=24 * 3, - out_channels=24, - t2=4, - ), - InceptionResnet( - in_channels=24 * 3, - out_channels=24, - t2=5, - ), - ] + # Inception blocks + inception_blocks = [] + in_ch = nb_inputs + for t0, t1, t2 in inception_configs: + inception_blocks.append( + InceptionBlock4( + in_channels=in_ch, + out_channels=inception_out_channels, + t0=t0, + t1=t1, + t2=t2, + ) + ) + in_ch = inception_out_channels * 4 + self.inceptionblocks4 = nn.Sequential(*inception_blocks) + + # All inception/resnet blocks use "same" padding, so spatial + # dimensions only change at pooling layers. + spatial = list(image_size) + + self.avgpool1 = nn.AvgPool3d(avgpool1_size) + spatial = [s // p for s, p in zip(spatial, avgpool1_size)] + self.bn1 = nn.BatchNorm3d(in_ch) + + # First resnet stage + resnet_in_ch = in_ch + tmp = [] + for _ in range(num_resblocks1_repeats): + for t2 in resnet_t2_pattern: + tmp.append( + InceptionResnet( + in_channels=resnet_in_ch, + out_channels=resnet_out_channels, + t2=t2, + ) + ) + resnet_in_ch = resnet_out_channels * 3 self.resblocks1 = nn.Sequential(*tmp) - self.avgpool2 = nn.AvgPool3d((1, 1, 2)) - self.bn2 = nn.BatchNorm3d(24 * 3) + + self.avgpool2 = nn.AvgPool3d(avgpool2_size) + spatial = [s // p for s, p in zip(spatial, avgpool2_size)] + self.bn2 = nn.BatchNorm3d(resnet_in_ch) + + # Second resnet stage tmp = [] - for _ in range(6): - tmp = tmp + [ - InceptionResnet( - in_channels=24 * 3, - out_channels=24, - t2=3, - ), - InceptionResnet( - in_channels=24 * 3, - out_channels=24, - t2=4, - ), - InceptionResnet( - in_channels=24 * 3, - out_channels=24, - t2=5, - ), - ] + for _ in range(num_resblocks2_repeats): + for t2 in resnet_t2_pattern: + tmp.append( + InceptionResnet( + in_channels=resnet_in_ch, + out_channels=resnet_out_channels, + t2=t2, + ) + ) + resnet_in_ch = resnet_out_channels * 3 self.resblocks2 = nn.Sequential(*tmp) - self.convs111 = nn.Sequential( - nn.Conv3d( - in_channels=24 * 3, - out_channels=64, - kernel_size=(1, 1, 1), - padding=(0, 0, 0), - ), - nn.ReLU(), - nn.Conv3d( - in_channels=64, - out_channels=4, - kernel_size=(1, 1, 1), - padding=(0, 0, 0), - ), - nn.ReLU(), - ) - self.avgpool3 = nn.AvgPool3d((1, 1, 2)) - self.mlps = nn.Sequential( - nn.Linear(500, 120), - nn.Linear(120, 64), - nn.Linear(64, 16), - ) + + # Pointwise 1x1x1 convolutions + pointwise_layers: List[nn.Module] = [] + pw_in = resnet_in_ch + for pw_out in pointwise_channels: + pointwise_layers.append( + nn.Conv3d( + in_channels=pw_in, + out_channels=pw_out, + kernel_size=(1, 1, 1), + padding=(0, 0, 0), + ) + ) + pointwise_layers.append(nn.ReLU()) + pw_in = pw_out + self.convs111 = nn.Sequential(*pointwise_layers) + + self.avgpool3 = nn.AvgPool3d(avgpool3_size) + spatial = [s // p for s, p in zip(spatial, avgpool3_size)] + + # MLP head + latent_dim = pw_in * spatial[0] * spatial[1] * spatial[2] + mlp_sizes = [latent_dim] + mlp_hidden_sizes + [nb_outputs] + mlp_layers: List[nn.Module] = [] + for i in range(len(mlp_sizes) - 1): + mlp_layers.append(nn.Linear(mlp_sizes[i], mlp_sizes[i + 1])) + self.mlps = nn.Sequential(*mlp_layers) def forward(self, data: Data) -> torch.Tensor: """Apply learnable forward pass in model.""" From 7a0eabd1ff93c48242e156d91594c748694cf208 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Sun, 22 Feb 2026 15:13:40 +0100 Subject: [PATCH 29/31] adjusted docstring and assertion logic of LCSC to clarify detector usage --- src/graphnet/models/cnn/lcsc.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/graphnet/models/cnn/lcsc.py b/src/graphnet/models/cnn/lcsc.py index 8df4c4aa5..67e65d8bd 100644 --- a/src/graphnet/models/cnn/lcsc.py +++ b/src/graphnet/models/cnn/lcsc.py @@ -15,8 +15,9 @@ class LCSC(CNN): All credits go to Alexander Harnisch ( https://github.com/AlexHarn) - Intended to be used with the IceCube 86 image containing - only the Main Array image. + Works with any single-image representation. The default + parameters were tested on IceCube simulation using the + Main Array image only. """ def __init__( @@ -532,7 +533,7 @@ def _calc_output_dimension( def forward(self, data: Data) -> torch.Tensor: """Forward pass of the LCSC.""" - assert len(data.x) == 1, "Only Main Array image is supported for LCSC" + assert len(data.x) == 1, "Only a single image is expected" x = data.x[0] if self.input_norm: x = self.input_normal(x) From 9ea5286dfcc8ca67309e9be9ca6576f4d271bacc Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Sun, 22 Feb 2026 15:16:18 +0100 Subject: [PATCH 30/31] fix spelling mistake and clarify comment --- .../images/mappings/pixel_mappings.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py b/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py index 558b90c04..a3d6993f4 100644 --- a/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py +++ b/src/graphnet/models/data_representation/images/mappings/pixel_mappings.py @@ -83,7 +83,7 @@ def __init__( Args: dtype: data type used for node features. e.g. ´torch.float´ string_label: Name of the feature corresponding - to the DOM string number. Values Integers betweem 1 - 86 + to the DOM string number. Values Integers between 1 - 86 dom_number_label: Name of the feature corresponding to the DOM number (1 - 60). Values Integers between 1 - 60 where 1 is the dom with the highest z coordinate. @@ -108,7 +108,7 @@ def __init__( self._dom_number_label = dom_number_label self._pixel_feature_names = pixel_feature_names - self._set_indeces(pixel_feature_names, dom_number_label, string_label) + self._set_indices(pixel_feature_names, dom_number_label, string_label) self._nb_cnn_features = ( len(pixel_feature_names) - 2 @@ -136,7 +136,7 @@ def __init__( self._mapping = df super().__init__(pixel_feature_names=pixel_feature_names) - def _set_indeces( + def _set_indices( self, feature_names: List[str], dom_number_label: str, @@ -172,7 +172,8 @@ def forward(self, data: Data, data_feature_names: List[str]) -> Data: ) # data.x is expected to be a tensor with shape (N, F) - # where N is the number of nodes and F is the number of features. + # where N is the number of pixels (DOMs) and F is the number + # of features. Each row represents a single pixel. x = data.x # Direct coordinate and feature extraction @@ -218,7 +219,7 @@ def forward(self, data: Data, data_feature_names: List[str]) -> Data: row[3], # mat_ax1 ] = batch_row_features[i] - # unqueeze to add dimension for batching + # unsqueeze to add dimension for batching # with collate_fn Batch.from_data_list ret: List[torch.Tensor] = [] if self._include_main_array: @@ -293,7 +294,7 @@ def __init__( self._sensor_number_label = sensor_number_label self._pixel_feature_names = pixel_feature_names - self._set_indeces( + self._set_indices( pixel_feature_names, sensor_number_label, string_label ) @@ -319,7 +320,7 @@ def __init__( self._mapping = df super().__init__(pixel_feature_names=pixel_feature_names) - def _set_indeces( + def _set_indices( self, feature_names: List[str], sensor_number_label: str, @@ -372,7 +373,7 @@ def forward(self, data: Data, data_feature_names: List[str]) -> Data: row[4], # mat_ax2 ] = batch_row_features[i] - # unqueeze to add dimension for batching + # unsqueeze to add dimension for batching # with collate_fn Batch.from_data_list ret: List[torch.Tensor] = [image_tensor.unsqueeze(0)] From c2c38738ee3620307c5a2a49eeb55031f1ca51b9 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Sun, 22 Feb 2026 15:21:40 +0100 Subject: [PATCH 31/31] add shape property to imagedefinition --- .../models/data_representation/images/image_definition.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/graphnet/models/data_representation/images/image_definition.py b/src/graphnet/models/data_representation/images/image_definition.py index 0fbbe0a10..652030d1c 100644 --- a/src/graphnet/models/data_representation/images/image_definition.py +++ b/src/graphnet/models/data_representation/images/image_definition.py @@ -84,6 +84,11 @@ def __init__( self._node_definition = node_definition self._pixel_mapping = pixel_mapping + @property + def shape(self) -> List[List[int]]: + """Return the shape of the output images.""" + return self._pixel_mapping.shape + def _set_output_feature_names( self, input_feature_names: List[str] ) -> List[str]: