From 44df8dbfb10031f55b297f6479a8bd2e768f64ab Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Wed, 19 Mar 2025 15:46:52 +0100 Subject: [PATCH 01/24] 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 1b4519bb8..030e34c2b 100644 --- a/src/graphnet/constants.py +++ b/src/graphnet/constants.py @@ -41,3 +41,9 @@ ICECUBE_GEOMETRY_TABLE_DIR = os.path.join(GEOMETRY_TABLE_DIR, "icecube") PROMETHEUS_GEOMETRY_TABLE_DIR = os.path.join(GEOMETRY_TABLE_DIR, "prometheus") LIQUIDO_GEOMETRY_TABLE_DIR = os.path.join(GEOMETRY_TABLE_DIR, "liquid-o") + +# 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 b0f7effea2dbdefa239e01ad1f364d0b1d8736a5 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Wed, 19 Mar 2025 15:55:39 +0100 Subject: [PATCH 02/24] 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 f026e73a94a665593731232d4dffd3f46b2e6b13 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Thu, 20 Mar 2025 09:18:09 +0100 Subject: [PATCH 03/24] 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 908cb544dc89fe6e96fc6eb0e4845fadd774785b Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Thu, 20 Mar 2025 18:25:49 +0100 Subject: [PATCH 04/24] 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 3b8ad845cbbd99076637b5cd57b95c02b3c0ab85 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Thu, 20 Mar 2025 18:39:52 +0100 Subject: [PATCH 05/24] 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 aaeaa9ec3c9e09b86ae66e1421eb7f6a09a1f994 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Wed, 26 Mar 2025 15:35:51 +0100 Subject: [PATCH 06/24] 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 7ed55a8cfb5953233bd685fd7cc2be2e83d88414 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Thu, 17 Apr 2025 15:27:43 +0200 Subject: [PATCH 07/24] 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 e71937972..8b236f0ac 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 d9760c85b815ef454f503511a9443ebed9c86f44 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Wed, 23 Apr 2025 20:40:36 +0200 Subject: [PATCH 08/24] 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 d8d702060..7f98e22cf 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 816a6b3517fec3d7e7ac90ef3645d5c0113d68ac Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Wed, 23 Apr 2025 21:43:24 +0200 Subject: [PATCH 09/24] 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 9226db2a2233029e67c91b63c6c38543d507432e Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Wed, 30 Apr 2025 14:14:52 +0200 Subject: [PATCH 10/24] 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 776d7c86b..bf6c4af04 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 dcb99331419a5c938391bf61c20007b863f1431a Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Wed, 30 Apr 2025 18:24:41 +0200 Subject: [PATCH 11/24] 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 5a900bbb2228903722d6c8ad9a5366d7524355c8 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Thu, 19 Jun 2025 14:13:37 +0200 Subject: [PATCH 12/24] 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 030e34c2b..edb46c99e 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 b45dad7af9e0490b1ef58f90567465c78e0d21cd Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Thu, 19 Jun 2025 16:25:31 +0200 Subject: [PATCH 13/24] 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 8b236f0ac..108db209b 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 00525affbb7bdba5e9f7f9f7f3cf7e40379525f8 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Fri, 20 Jun 2025 19:26:19 +0200 Subject: [PATCH 14/24] 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 108db209b..e51b0f06e 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 dc39d644b1e0b8dc4af40081d675f426d9c42e26 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Fri, 20 Jun 2025 19:59:23 +0200 Subject: [PATCH 15/24] 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 20f7b4fa00e0147ae2ea5e9383f5f4b132775efb Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Mon, 7 Jul 2025 16:14:49 +0200 Subject: [PATCH 16/24] Adding example script --- examples/04_training/09_train_cnn.py | 306 ++++++++++++++++++ .../icecube/i3filtermapextractor.py | 4 +- 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 - 8 files changed, 323 insertions(+), 17 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/data/extractors/icecube/i3filtermapextractor.py b/src/graphnet/data/extractors/icecube/i3filtermapextractor.py index 357cab15a..cba10ed46 100644 --- a/src/graphnet/data/extractors/icecube/i3filtermapextractor.py +++ b/src/graphnet/data/extractors/icecube/i3filtermapextractor.py @@ -11,8 +11,8 @@ class I3FilterMapExtractor(I3Extractor): """Class for extracting I3FilterMap properties. - This class extracts the boolean condition of the I3FilterMask from the - I3FilterMap in the frame. + This class extracts the boolean condition of the I3FilterMask from + the I3FilterMap in the frame. """ def __init__( 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 ee135ac61c7360afc95f859252a251352b6bc7bb Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Fri, 18 Jul 2025 16:18:16 +0200 Subject: [PATCH 17/24] 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 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 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 edb46c99e..a12d37df1 100644 --- a/src/graphnet/constants.py +++ b/src/graphnet/constants.py @@ -55,3 +55,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 ba4f012fa3878d7b87d71e36714644fe081f222f Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Fri, 18 Jul 2025 16:25:32 +0200 Subject: [PATCH 18/24] 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 cc427486f93b3efc5b07966681cdd37e67e00dea Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Fri, 18 Jul 2025 16:40:37 +0200 Subject: [PATCH 19/24] 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 7104df81aa334aebacdd0bbbb30ba97321e63951 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Fri, 18 Jul 2025 16:48:52 +0200 Subject: [PATCH 20/24] 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 7054144f92f30855dff86894afb46b11cd9136a8 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Fri, 18 Jul 2025 16:54:47 +0200 Subject: [PATCH 21/24] 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 bbc9e535acb9d02835fb0f61194135242a49e3db Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Fri, 18 Jul 2025 17:54:36 +0200 Subject: [PATCH 22/24] 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 a693a49a34ed65649aba2f756f13854bd0c852b0 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Tue, 22 Jul 2025 12:42:23 +0200 Subject: [PATCH 23/24] 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 8e94de5ff33338bff21c4589bce78c90b30f1c20 Mon Sep 17 00:00:00 2001 From: Severin Magel Date: Mon, 11 Aug 2025 19:43:43 +0200 Subject: [PATCH 24/24] 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)}"