From 79bd06ca494b1c74330b4d73674f130150958fad Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Tue, 21 Feb 2023 15:30:07 -0800 Subject: [PATCH 001/180] Merge with release 2.7.1 --- analysis/algorithms/point_matching.py | 2 +- analysis/algorithms/selections/template.py | 2 +- analysis/algorithms/utils.py | 60 +++++++++++++++++++++- analysis/classes/predictor.py | 24 ++++----- analysis/config/nue_selection.cfg | 2 +- mlreco/utils/ppn.py | 2 - 6 files changed, 74 insertions(+), 18 deletions(-) diff --git a/analysis/algorithms/point_matching.py b/analysis/algorithms/point_matching.py index 42e0471c..7d2152b6 100644 --- a/analysis/algorithms/point_matching.py +++ b/analysis/algorithms/point_matching.py @@ -44,7 +44,7 @@ def match_points_to_particles(ppn_points : np.ndarray, for particle in particles: dist = cdist(ppn_coords, particle.points) matches = ppn_points_type[dist.min(axis=1) < ppn_distance_threshold] - particle.ppn_candidates = matches + particle.ppn_candidates = matches.reshape(-1, 7) # Deprecated def get_track_endpoints(particle : Particle, verbose=False): diff --git a/analysis/algorithms/selections/template.py b/analysis/algorithms/selections/template.py index df0e0a58..080b8214 100644 --- a/analysis/algorithms/selections/template.py +++ b/analysis/algorithms/selections/template.py @@ -38,7 +38,7 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): interaction_dict = analysis_cfg['analysis'].get('interaction_dict', {}) particle_dict = analysis_cfg['analysis'].get('particle_dict', {}) - use_primaries_for_vertex = analysis_cfg['analysis']['use_primaries_for_vertex'] + use_primaries_for_vertex = analysis_cfg['analysis'].get('use_primaries_for_vertex', True) # Load data into evaluator if enable_flash_matching: diff --git a/analysis/algorithms/utils.py b/analysis/algorithms/utils.py index facd42f6..19312130 100644 --- a/analysis/algorithms/utils.py +++ b/analysis/algorithms/utils.py @@ -4,6 +4,7 @@ from analysis.algorithms.calorimetry import * from scipy.spatial.distance import cdist +from analysis.algorithms.point_matching import get_track_endpoints_max_dist import numpy as np import ROOT @@ -59,6 +60,54 @@ def correct_track_points(particle): particle.endpoint = x[scores[:, 1].argmax()] +def get_track_points_default(p): + pts = np.vstack([p._node_features[19:22], p._node_features[22:25]]) + correct_track_endpoints_closest(p, pts=pts) + + +def correct_track_endpoints_closest(p, pts=None): + assert p.semantic_type == 1 + if pts is None: + pts = np.vstack(get_track_endpoints_max_dist(p)) + else: + assert pts.shape == (2, 3) + + if p.ppn_candidates.shape[0] == 0: + p.startpoint = pts[0] + p.endpoint = pts[1] + + else: + dist1 = cdist(np.atleast_2d(p.ppn_candidates[:, :3]), np.atleast_2d(pts[0])).reshape(-1) + dist2 = cdist(np.atleast_2d(p.ppn_candidates[:, :3]), np.atleast_2d(pts[1])).reshape(-1) + + pt1_score = p.ppn_candidates[dist1.argmin()][5:] + pt2_score = p.ppn_candidates[dist2.argmin()][5:] + + labels = np.array([pt1_score.argmax(), pt2_score.argmax()]) + + if labels[0] == 0 and labels[1] == 1: + p.startpoint = pts[0] + p.endpoint = pts[1] + elif labels[0] == 1 and labels[1] == 0: + p.startpoint = pts[1] + p.endpoint = pts[0] + elif labels[0] == 0 and labels[1] == 0: + # print("Particle {} has no endpoint".format(p.id)) + # Select point with larger score as startpoint + ix = np.argmax(labels) + iy = np.argmin(labels) + p.startpoint = pts[ix] + p.endpoint = pts[iy] + elif labels[0] == 1 and labels[1] == 1: + # print("Particle {} has no startpoint".format(p.id)) + ix = np.argmax(labels) + iy = np.argmin(labels) + p.startpoint = pts[iy] + p.endpoint = pts[ix] + else: + raise ValueError("Classify endpoints feature dimension must be 2, got something else!") + + def load_range_reco(particle_type='muon', kinetic_energy=True): """ Return a function maps the residual range of a track to the kinetic @@ -104,7 +153,8 @@ def get_interaction_properties(interaction: Interaction, spatial_size, prefix=No 'vertex_z': -1, 'has_vertex': False, 'vertex_valid': 'Default Invalid', - 'count_primary_protons': -1 + 'count_primary_protons': -1, + 'nu_reco_energy': -1 }) if interaction is None: @@ -178,6 +228,8 @@ def get_particle_properties(particle: Particle, prefix=None, save_feats=False): 'particle_endpoint_y': -1, 'particle_endpoint_z': -1, 'particle_startpoint_is_touching': True, + 'particle_creation_process': "Default Invalid", + 'particle_num_ppn_candidates': -1, # 'particle_is_contained': False }) @@ -208,11 +260,17 @@ def get_particle_properties(particle: Particle, prefix=None, save_feats=False): update_dict['particle_endpoint_y'] = particle.endpoint[1] update_dict['particle_endpoint_z'] = particle.endpoint[2] + if hasattr(particle, 'ppn_candidates'): + assert particle.ppn_candidates.shape[1] == 7 + update_dict['particle_num_ppn_candidates'] = len(particle.ppn_candidates) + if isinstance(particle, TruthParticle): dists = np.linalg.norm(particle.points - particle.startpoint.reshape(1, -1), axis=1) min_dist = np.min(dists) if min_dist > 5.0: update_dict['particle_startpoint_is_touching'] = False + creation_process = particle.particle_asis.creation_process() + update_dict['particle_creation_process'] = creation_process # if particle.semantic_type == 1: # update_dict['particle_length'] = compute_track_length(particle.points) # direction = compute_particle_direction(particle, vertex=vertex) diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index 7189d531..c479394c 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -11,14 +11,13 @@ from scipy.special import softmax from analysis.classes import Particle, ParticleFragment, TruthParticleFragment, \ TruthParticle, Interaction, TruthInteraction, FlashManager -from analysis.classes.particle import matrix_counts, matrix_iou, \ - match_particles_fn, match_interactions_fn, group_particles_to_interactions_fn +from analysis.classes.particle import group_particles_to_interactions_fn from analysis.algorithms.point_matching import * from mlreco.utils.groups import type_labels as TYPE_LABELS -from mlreco.utils.vertex import get_vertex from analysis.algorithms.vertex import estimate_vertex -from analysis.algorithms.utils import correct_track_points +from analysis.algorithms.utils import correct_track_endpoints_closest, \ + get_track_points_default from mlreco.utils.deghosting import deghost_labels_and_predictions from mlreco.utils.gnn.cluster import get_cluster_label @@ -105,6 +104,8 @@ def __init__(self, data_blob, result, cfg, predictor_cfg={}, deghosting=False, # Vertex estimation modes self.vertex_mode = predictor_cfg.get('vertex_mode', 'all') self.prune_vertex = predictor_cfg.get('prune_vertex', True) + self.track_endpoints_mode = predictor_cfg.get('track_endpoints_mode', 'node_features') + print(self.track_endpoints_mode) # This is used to apply fiducial volume cuts. # Min/max boundaries in each dimension haev to be specified. @@ -940,14 +941,13 @@ def get_particles(self, entry, only_primaries=True, np.abs(pt - p._node_features[22:25])) < 1e-12) p.startpoint = pt elif p.semantic_type == 1: - startpoint, endpoint = p._node_features[19:22], p._node_features[22:25] - p.startpoint = startpoint - p.endpoint = endpoint - if np.linalg.norm(p.startpoint - p.endpoint) < 1e-6: - startpoint, endpoint = get_track_endpoints_max_dist(p) - p.startpoint = startpoint - p.endpoint = endpoint - correct_track_points(p) + if self.track_endpoints_mode == 'node_features': + get_track_points_default(p) + elif self.track_endpoints_mode == 'max_dist': + correct_track_endpoints_closest(p) + else: + raise ValueError("Track endpoint attachment mode {}\ + not supported!".format(self.track_endpoints_mode)) else: continue out_particle_list.extend(out) diff --git a/analysis/config/nue_selection.cfg b/analysis/config/nue_selection.cfg index 0ddecf97..bc90f629 100644 --- a/analysis/config/nue_selection.cfg +++ b/analysis/config/nue_selection.cfg @@ -5,7 +5,7 @@ analysis: data: False min_overlap_count: 0 overlap_mode: iou - log_dir: /sdf/group/neutrino/koh0207/logs/nu_selection/optimal + log_dir: /sdf/group/neutrino/koh0207/logs/nu_selection/mpvmpr/track_default iteration: 2000 deghosting: True match_primaries: False diff --git a/mlreco/utils/ppn.py b/mlreco/utils/ppn.py index c1175e1b..b3077a34 100644 --- a/mlreco/utils/ppn.py +++ b/mlreco/utils/ppn.py @@ -310,10 +310,8 @@ def uresnet_ppn_type_point_selector(data, out, score_threshold=0.5, type_score_t points = out['points'][entry] enable_classify_endpoints = 'classify_endpoints' in out - print("ENABLE CLASSIFY ENDPOINTS = ", enable_classify_endpoints) if enable_classify_endpoints: classify_endpoints = out['classify_endpoints'][0] - print(classify_endpoints) mask_ppn = out['mask_ppn'][-1] # predicted type labels From db642278edb2596857c6d03dff6b648d6ace9724 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Tue, 21 Feb 2023 16:28:16 -0800 Subject: [PATCH 002/180] Removed superfluous PPN prints --- mlreco/models/layers/common/ppnplus.py | 2 +- mlreco/models/uresnet_ppn_chain.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mlreco/models/layers/common/ppnplus.py b/mlreco/models/layers/common/ppnplus.py index b8038cff..95794d5c 100644 --- a/mlreco/models/layers/common/ppnplus.py +++ b/mlreco/models/layers/common/ppnplus.py @@ -458,7 +458,7 @@ def __init__(self, cfg, name='ppn'): self.point_type_loss_weight = self.loss_config.get('point_type_loss_weight', 1.0) self.classify_endpoints_loss_weight = self.loss_config.get('classify_endpoints_loss_weight', 1.0) - print("Mask Loss Weight = ", self.mask_loss_weight) + #print("Mask Loss Weight = ", self.mask_loss_weight) def forward(self, result, segment_label, particles_label): diff --git a/mlreco/models/uresnet_ppn_chain.py b/mlreco/models/uresnet_ppn_chain.py index b60a82cd..02943b74 100644 --- a/mlreco/models/uresnet_ppn_chain.py +++ b/mlreco/models/uresnet_ppn_chain.py @@ -149,8 +149,8 @@ def forward(self, outputs, segment_label, particles_label, weights=None): res.update({'segmentation_'+k:v for k, v in res_segmentation.items()}) res.update({'ppn_'+k:v for k, v in res_ppn.items()}) - for key, val in res.items(): - if 'ppn' in key: - print('{}: {}'.format(key, val)) + #for key, val in res.items(): + # if 'ppn' in key: + # print('{}: {}'.format(key, val)) return res From fb502e5b736fe5633713279db0141296455db6d5 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 22 Feb 2023 17:03:43 -0800 Subject: [PATCH 003/180] Multi particle post processing, pointnet encoders --- analysis/algorithms/utils.py | 16 ++ analysis/classes/predictor.py | 5 +- mlreco/models/experimental/layers/__init__.py | 0 mlreco/models/experimental/layers/pointmlp.py | 199 ++++++++++++++++++ mlreco/models/experimental/layers/pointnet.py | 69 ++++++ mlreco/models/singlep.py | 77 ++++++- mlreco/post_processing/metrics/__init__.py | 1 + .../post_processing/metrics/multi_particle.py | 52 +++++ mlreco/trainval.py | 2 - 9 files changed, 410 insertions(+), 11 deletions(-) create mode 100644 mlreco/models/experimental/layers/__init__.py create mode 100644 mlreco/models/experimental/layers/pointmlp.py create mode 100644 mlreco/models/experimental/layers/pointnet.py create mode 100644 mlreco/post_processing/metrics/multi_particle.py diff --git a/analysis/algorithms/utils.py b/analysis/algorithms/utils.py index 19312130..cb8234fc 100644 --- a/analysis/algorithms/utils.py +++ b/analysis/algorithms/utils.py @@ -108,6 +108,22 @@ def correct_track_endpoints_closest(p, pts=None): raise ValueError("Classify endpoints feature dimension must be 2, got something else!") +def local_density_correction(p, r=5): + assert p.semantic_type == 1 + dist_st = np.linalg.norm(p.startpoint - p.points, axis=1) < r + if not dist_st.all(): + return + local_d_start = p.depositions[dist_st].sum() / sum(dist_st) + dist_end = np.linalg.norm(p.endpoint - p.points, axis=1) < r + if not dist_end.all(): + return + local_d_end = p.depositions[dist_end].sum() / sum(dist_end) + if local_d_start < local_d_end: + p1, p2 = p.startpoint, p.endpoint + p.startpoint = p2 + p.endpoint = p1 + + def load_range_reco(particle_type='muon', kinetic_energy=True): """ Return a function maps the residual range of a track to the kinetic diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index c479394c..18b1d346 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -17,7 +17,8 @@ from mlreco.utils.groups import type_labels as TYPE_LABELS from analysis.algorithms.vertex import estimate_vertex from analysis.algorithms.utils import correct_track_endpoints_closest, \ - get_track_points_default + get_track_points_default, \ + local_density_correction from mlreco.utils.deghosting import deghost_labels_and_predictions from mlreco.utils.gnn.cluster import get_cluster_label @@ -943,8 +944,10 @@ def get_particles(self, entry, only_primaries=True, elif p.semantic_type == 1: if self.track_endpoints_mode == 'node_features': get_track_points_default(p) + local_density_correction(p) elif self.track_endpoints_mode == 'max_dist': correct_track_endpoints_closest(p) + local_density_correction(p) else: raise ValueError("Track endpoint attachment mode {}\ not supported!".format(self.track_endpoints_mode)) diff --git a/mlreco/models/experimental/layers/__init__.py b/mlreco/models/experimental/layers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mlreco/models/experimental/layers/pointmlp.py b/mlreco/models/experimental/layers/pointmlp.py new file mode 100644 index 00000000..49c6f4c2 --- /dev/null +++ b/mlreco/models/experimental/layers/pointmlp.py @@ -0,0 +1,199 @@ +from typing import List + +import torch +import torch.nn as nn +import torch.nn.functional as F +from torch_geometric.nn import MLP, PointNetConv, fps, global_max_pool, knn +from torch_geometric.nn.conv import MessagePassing + +from torch_geometric.utils import scatter +from .pointnet import GlobalSAModule + + +# Adapted from PointMLP Github, modified to match lartpc_mlreco3d: +# https://github.com/ma-xu/pointMLP-pytorch/blob/main/classification_ModelNet40/models/pointmlp.py + + +class PointMLPConv(MessagePassing): + + def __init__(self, in_features, out_features, k=24, fps_ratio=0.5): + super(PointMLPConv, self).__init__(aggr='max') + + self.phi = PreBlock(in_features, out_features) + self.gamma = PosBlock(out_features) + + self.alpha = nn.Parameter(torch.ones(out_features)) + self.beta = nn.Parameter(torch.zeros(out_features)) + + self.k = k + self.ratio = fps_ratio + + def reset_parameters(self): + super().reset_parameters() + self.phi.reset_parameters() + self.gamma.reset_parameters() + self.alpha.fill(1.0) + self.beta.zero_() + + def message(self, x_i, x_j, norm): + # print(norm.shape, norm.view(1, -1).shape) + return norm.view(1, -1) * (x_i - x_j) + + def forward(self, x, pos, batch): + + x = self.phi(x) + + n, d = x.shape + + idx = fps(pos, batch, ratio=self.ratio) + # row (runs over x[idx]), col (runs over x) + row, col = knn(pos, pos[idx], self.k, batch, batch[idx]) + + # msgs from edge_index[0] are sent to edge_index[1] + edge_index = torch.stack([idx[row], col], dim=0) + x_dst = x[idx] + + # Compute norm (Geometric Affine Module) + var_dst = scatter((x[col] - x_dst[row])**2 / (self.k * n * d), row, reduce='sum') + sigma = torch.sqrt(torch.clamp(var_dst.sum(), min=1e-6)) + norm = self.alpha / sigma + out = self.propagate(edge_index, x=x, norm=norm) + + # Apply Second Residual (Pos) + out = self.gamma(out[idx]) + + return out, pos[idx], batch[idx] + + +class ConvBNReLU1D(torch.nn.Module): + + def __init__(self, in_channels, out_channels, bias=True): + super(ConvBNReLU1D, self).__init__() + self.net = nn.Sequential( + nn.Linear(in_channels, out_channels, bias=bias), + nn.BatchNorm1d(out_channels), + nn.ReLU() + ) + + def forward(self, x): + return self.net(x) + + +class ConvBNReLURes1D(torch.nn.Module): + + def __init__(self, num_features, bn=True): + super(ConvBNReLURes1D, self).__init__() + + self.linear_1 = nn.Linear(num_features, num_features, bias=not bn) + self.linear_2 = nn.Linear(num_features, num_features, bias=not bn) + + self.bn_1 = nn.BatchNorm1d(num_features) + self.bn_2 = nn.BatchNorm1d(num_features) + + def forward(self, x): + + out = self.linear_1(x) + out = self.bn_1(out) + out = F.relu(out) + out = self.linear_2(out) + out = self.bn_2(out) + out = out + x + return out + + +class PreBlock(torch.nn.Module): + + def __init__(self, in_features, out_features, num_blocks=1): + super(PreBlock, self).__init__() + + blocks = [] + self.transfer = ConvBNReLU1D(in_features, out_features) + for _ in range(num_blocks): + blocks.append(ConvBNReLURes1D(out_features)) + self.net = nn.Sequential(*blocks) + + def forward(self, x): + + x = self.transfer(x) + x = self.net(x) + return x + + +class PosBlock(nn.Module): + + def __init__(self, out_features, num_blocks=1): + super(PosBlock, self).__init__() + + blocks = [] + for _ in range(num_blocks): + blocks.append(ConvBNReLURes1D(out_features)) + self.net = nn.Sequential(*blocks) + + def forward(self, x): + x = self.net(x) + return x + + +class GlobalPooling(torch.nn.Module): + + def __init__(self): + super(GlobalPooling, self).__init__() + + def forward(self, x, pos, batch): + x = global_max_pool(x, batch) + pos = pos.new_zeros((x.size(0), 3)) + batch = torch.arange(x.size(0), device=batch.device) + return x, pos, batch + + +class PointMLPEncoder(torch.nn.Module): + + def __init__(self, cfg, name='pointmlp_encoder'): + super(PointMLPEncoder, self).__init__() + self.model_cfg = cfg['pointmlp_encoder'] + + self.k = self.model_cfg.get('num_kneighbors', 24) + self.mlp_specs = self.model_cfg.get('mlp_specs', [64, 128, 256, 512]) + self.ratio_specs = self.model_cfg.get('ratio_specs', [0.25, 0.5, 0.5, 0.5]) + self.classifier_specs = self.model_cfg.get('classifier_specs', [512, 256, 128]) + assert len(self.mlp_specs) == len(self.ratio_specs) + + self.init_embed = nn.Linear(1, self.mlp_specs[0]) + convs = [] + for i in range(len(self.mlp_specs)-1): + convs.append(PointMLPConv(self.mlp_specs[i], + self.mlp_specs[i+1], + k=self.k, + fps_ratio=self.ratio_specs[i])) + + + self.net = nn.Sequential(*convs) + self.latent_size = self.mlp_specs[-1] + + self.global_pooling = GlobalPooling() + + self.classifier = [] + for i in range(len(self.classifier_specs)-1): + fin, fout = self.classifier_specs[i], self.classifier_specs[i+1] + m = nn.Sequential( + nn.Linear(fin, fout), + nn.BatchNorm1d(fout), + nn.ReLU(), + nn.Dropout() + ) + self.classifier.append(m) + self.latent_size = self.classifier_specs[-1] + self.classifier = nn.Sequential(*self.classifier) + + + def forward(self, data): + x, pos, batch = data.x, data.pos, data.batch + x = self.init_embed(x) + for i, layer in enumerate(self.net): + out = layer(x, pos, batch) + x, pos, batch = out + # print("{} = ".format(i), x.shape, pos.shape, batch.shape) + x, pos, batch = self.global_pooling(x, pos, batch) + + out = self.classifier(x) + return out \ No newline at end of file diff --git a/mlreco/models/experimental/layers/pointnet.py b/mlreco/models/experimental/layers/pointnet.py new file mode 100644 index 00000000..1a063c7a --- /dev/null +++ b/mlreco/models/experimental/layers/pointnet.py @@ -0,0 +1,69 @@ +import torch +from torch_geometric.nn import MLP, PointNetConv, fps, global_max_pool, radius + +# From Pytorch Geometric Examples for PointNet: +# https://github.com/pyg-team/pytorch_geometric/blob/master/examples/pointnet2_classification.py + + +class SAModule(torch.nn.Module): + def __init__(self, ratio, r, nn): + super().__init__() + self.ratio = ratio + self.r = r + self.conv = PointNetConv(nn, add_self_loops=False) + + def forward(self, x, pos, batch): + idx = fps(pos, batch, ratio=self.ratio) + row, col = radius(pos, pos[idx], self.r, batch, batch[idx], + max_num_neighbors=64) + edge_index = torch.stack([col, row], dim=0) + x_dst = None if x is None else x[idx] + x = self.conv((x, x_dst), (pos, pos[idx]), edge_index) + pos, batch = pos[idx], batch[idx] + return x, pos, batch + + +class GlobalSAModule(torch.nn.Module): + def __init__(self, nn): + super().__init__() + self.nn = nn + + def forward(self, x, pos, batch): + x = self.nn(torch.cat([x, pos], dim=1)) + x = global_max_pool(x, batch) + pos = pos.new_zeros((x.size(0), 3)) + batch = torch.arange(x.size(0), device=batch.device) + return x, pos, batch + + +class Net(torch.nn.Module): + def __init__(self): + super().__init__() + + # Input channels account for both `pos` and node features. + self.sa1_module = SAModule(0.5, 3, MLP([4, 64, 64, 128])) + self.sa2_module = SAModule(0.25, 6, MLP([128 + 3, 128, 128, 256])) + self.sa3_module = GlobalSAModule(MLP([256 + 3, 256, 512, 1024])) + + self.mlp = MLP([1024, 512, 256, 10], dropout=0.5, norm=None) + + def forward(self, data): + sa0_out = (data.x, data.pos, data.batch) + sa1_out = self.sa1_module(*sa0_out) + sa2_out = self.sa2_module(*sa1_out) + sa3_out = self.sa3_module(*sa2_out) + x, pos, batch = sa3_out + + return self.mlp(x) + + +class PointNetEncoder(torch.nn.Module): + + def __init__(self, cfg, name='pointnet_encoder'): + super(PointNetEncoder, self).__init__() + self.net = Net() + self.latent_size = 10 + + def forward(self, batch): + out = self.net(batch) + return out \ No newline at end of file diff --git a/mlreco/models/singlep.py b/mlreco/models/singlep.py index 66dc180e..712d490e 100644 --- a/mlreco/models/singlep.py +++ b/mlreco/models/singlep.py @@ -4,7 +4,12 @@ import torch.nn as nn import torch.nn.functional as F +from torch_geometric.data import Batch, Data + from mlreco.models.layers.common.cnn_encoder import SparseResidualEncoder +from mlreco.models.experimental.layers.pointnet import PointNetEncoder +from mlreco.models.experimental.layers.pointmlp import PointMLPEncoder + from collections import defaultdict, Counter, OrderedDict from mlreco.models.layers.common.activation_normalization_factories import activations_construct from mlreco.models.layers.common.configuration import setup_cnn_configuration @@ -31,6 +36,10 @@ def __init__(self, cfg, name='particle_image_classifier'): self.encoder = MCDropoutEncoder(cfg) elif self.encoder_type == 'standard': self.encoder = SparseResidualEncoder(cfg) + elif self.encoder_type == 'pointnet': + self.encoder = PointNetEncoder(cfg) + elif self.encoder_type == 'pointmlp': + self.encoder = PointMLPEncoder(cfg) else: raise ValueError('Unrecognized encoder type: {}'.format(self.encoder_type)) @@ -65,6 +74,33 @@ def __init__(self, cfg, name='particle_image_classifier'): self.target_col = model_cfg.get('target_col', 9) self.invalid_id = model_cfg.get('invalid_id', -1) + self.split_input_mode = model_cfg.get('split_input_as_tg_batch', False) + + def split_input_as_tg_batch(self, point_cloud, clusts=None): + point_cloud_cpu = point_cloud.detach().cpu().numpy() + batches, bcounts = np.unique(point_cloud_cpu[:,self.batch_col], return_counts=True) + if clusts is None: + clusts = form_clusters(point_cloud_cpu, column=self.split_col) + if not len(clusts): + return Batch() + + if self.skip_invalid: + target_ids = get_cluster_label(point_cloud_cpu, clusts, column=self.target_col) + clusts = [c for i, c in enumerate(clusts) if target_ids[i] != self.invalid_id] + if not len(clusts): + return Batch() + + data_list = [] + for i, c in enumerate(clusts): + x = point_cloud[c, 4].view(-1, 1) + pos = point_cloud[c, 1:4] + data = Data(x=x, pos=pos) + data_list.append(data) + + split_data = Batch.from_data_list(data_list) + return split_data, clusts + + def split_input(self, point_cloud, clusts=None): point_cloud_cpu = point_cloud.detach().cpu().numpy() batches, bcounts = np.unique(point_cloud_cpu[:,self.batch_col], return_counts=True) @@ -89,18 +125,27 @@ def split_input(self, point_cloud, clusts=None): return split_point_cloud[split_point_cloud[:,self.batch_col] > -1], clusts_split, cbids + def forward(self, input, clusts=None): res = {} point_cloud, = input - point_cloud, clusts_split, cbids = self.split_input(point_cloud, clusts) - res['clusts'] = [clusts_split] + if self.split_input_mode: + batch, clusts = self.split_input_as_tg_batch(point_cloud, clusts) + out = self.encoder(batch) + out = self.final_layer(out) + res['clusts'] = [clusts] + res['logits'] = [out] + else: + point_cloud, clusts_split, cbids = self.split_input(point_cloud, clusts) + res['clusts'] = [clusts_split] - out = self.encoder(point_cloud) - out = self.final_layer(out) - res['logits'] = [[out[b] for b in cbids]] + out = self.encoder(point_cloud) + out = self.final_layer(out) + res['logits'] = [[out[b] for b in cbids]] return res + class DUQParticleClassifier(ParticleImageClassifier): """ Uncertainty Estimation Using a Single Deep Deterministic Neural Network @@ -367,11 +412,27 @@ def __init__(self, cfg, name='particle_type_loss'): reduction = 'mean' if not self.balance_classes else 'sum' self.xentropy = nn.CrossEntropyLoss(ignore_index=-1, reduction=reduction) - def forward(self, out, type_labels): + self.split_input_mode = loss_cfg.get('split_input_as_tg_batch', False) + + def forward_tg(self, out, type_labels): + logits = out['logits'][0] clusts = out['clusts'][0] - labels = [get_cluster_label(type_labels[0][type_labels[0][:, self.batch_col] == b], - clusts[b], self.target_col) for b in range(len(clusts)) if len(clusts[b])] + + labels = get_cluster_label(type_labels[0], clusts, self.target_col) + return [logits], [labels] + + + def forward(self, out, type_labels): + + if self.split_input_mode: + logits, labels = self.forward_tg(out, type_labels) + + else: + logits = out['logits'][0] + clusts = out['clusts'][0] + labels = [get_cluster_label(type_labels[0][type_labels[0][:, self.batch_col] == b], + clusts[b], self.target_col) for b in range(len(clusts)) if len(clusts[b])] if not len(labels): res = { diff --git a/mlreco/post_processing/metrics/__init__.py b/mlreco/post_processing/metrics/__init__.py index 513f07cd..740920f7 100644 --- a/mlreco/post_processing/metrics/__init__.py +++ b/mlreco/post_processing/metrics/__init__.py @@ -19,4 +19,5 @@ from .duq_metrics import duq_metrics from .pid_metrics import pid_metrics from .doublet_metrics import doublet_metrics +from .multi_particle import multi_particle #from .analysis_tools_metrics import analysis_tools_metrics diff --git a/mlreco/post_processing/metrics/multi_particle.py b/mlreco/post_processing/metrics/multi_particle.py new file mode 100644 index 00000000..f2f7ecc2 --- /dev/null +++ b/mlreco/post_processing/metrics/multi_particle.py @@ -0,0 +1,52 @@ +import numpy as np +import pandas as pd +import sys, os, re + +from mlreco.post_processing import post_processing +from mlreco.utils import CSVData +from mlreco.utils.gnn.cluster import form_clusters, get_cluster_label + +from scipy.special import softmax +from scipy.stats import entropy + +import torch + +def multi_particle(cfg, processor_cfg, data_blob, result, logdir, iteration): + + output = pd.DataFrame(columns=['p0', 'p1', 'p2', 'p3', + 'p4', 'prediction', 'truth', 'index', 'entropy']) + + index = data_blob['index'] + logits = result['logits'] + clusts = result['clusts'] + + labels = get_cluster_label(data_blob['input_data'][0], clusts, 9) + logits = np.vstack(logits) + + pred = np.argmax(logits, axis=1) + index = np.asarray(index) + + if iteration: + append = True + else: + append = False + + fout = CSVData( + os.path.join(logdir, 'multi-particle-metrics.csv'), append=append) + + for i in range(len(labels)): + + logit_batch = logits[i] + pred = np.argmax(logit_batch) + label_batch = labels[i] + + probs = softmax(logit_batch) + ent = entropy(probs) + + fout.record(('Index', 'Truth', 'Prediction', + 'p0', 'p1', 'p2', 'p3', 'p4', 'entropy'), + (int(i), int(label_batch), int(pred), + probs[0], probs[1], probs[2], probs[3], probs[4], ent)) + fout.write() + + fout.close() \ No newline at end of file diff --git a/mlreco/trainval.py b/mlreco/trainval.py index 3c55415e..f67c77e2 100644 --- a/mlreco/trainval.py +++ b/mlreco/trainval.py @@ -210,7 +210,6 @@ def forward(self, data_iter, iteration=None): self.tspent_sum['io'] += self._watch.time('io') res = self._forward(input_train, input_loss, iteration=iteration) - # Here, contruct the unwrapped input and output # First, handle the case of a simple list concat concat_keys = self._trainval_config.get('concat_result', []) @@ -236,7 +235,6 @@ def forward(self, data_iter, iteration=None): else: if 'index' in input_data: input_data['index'] = input_data['index'][0] - for key in res.keys(): if key not in res_combined: res_combined[key] = [] From c0ac5d005d2dbd3adbfd2e7dce2488acd709b2ab Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Thu, 23 Feb 2023 16:15:26 -0800 Subject: [PATCH 004/180] Range based reco fixed, analysis tools maintaining --- analysis/algorithms/calorimetry.py | 132 +++++++++++------ analysis/algorithms/selections/__init__.py | 1 + analysis/algorithms/selections/particles.py | 133 ++++++++++++++++++ analysis/algorithms/selections/template.py | 15 +- analysis/algorithms/utils.py | 50 ++++--- analysis/classes/evaluator.py | 14 +- analysis/classes/particle.py | 6 +- analysis/classes/predictor.py | 2 +- .../post_processing/metrics/multi_particle.py | 5 +- 9 files changed, 279 insertions(+), 79 deletions(-) create mode 100644 analysis/algorithms/selections/particles.py diff --git a/analysis/algorithms/calorimetry.py b/analysis/algorithms/calorimetry.py index 8572df4d..76d46d84 100644 --- a/analysis/algorithms/calorimetry.py +++ b/analysis/algorithms/calorimetry.py @@ -1,13 +1,20 @@ +import ROOT + from analysis.classes.particle import Particle import numpy as np import numba as nb from sklearn.decomposition import PCA +from scipy.interpolate import CubicSpline +import pandas as pd - -def compute_sum_deposited(particle : Particle): - assert hasattr(particle, 'deposition') - sum_E = particle.deposition.sum() - return sum_E +# CONSTANTS (MeV) +PROTON_MASS = 938.272 +MUON_MASS = 105.7 +ELECTRON_MASS = 0.511998 +ARGON_DENSITY = 1.396 +ADC_TO_MEV = 1. / 350. +ARGON_MASS = 37211 +PIXELS_TO_CM = 0.3 def compute_track_length(points, bin_size=17): @@ -44,6 +51,40 @@ def compute_track_length(points, bin_size=17): return length +def get_csda_range_spline(particle_type): + ''' + Returns CSDARange (g/cm^2) vs. Kinetic E (MeV/c^2) + ''' + if particle_type == 'proton': + tab = pd.read_csv('/sdf/group/neutrino/koh0207/lartpc_mlreco3d/analysis/algorithms/tables/pE_liquid_argon.txt', + delimiter=' ', + index_col=False) + elif particle_type == 'muon': + tab = pd.read_csv('/sdf/group/neutrino/koh0207/lartpc_mlreco3d/analysis/algorithms/tables/muE_liquid_argon.txt', + delimiter=' ', + index_col=False) + else: + raise ValueError("Range based energy reconstruction for particle type\ + {} is not supported!".format(particle_type)) + # print(tab) + f = CubicSpline(tab['CSDARange'] / ARGON_DENSITY, tab['T']) + return f + + +def compute_range_based_momentum(particle, f, **kwargs): + assert particle.semantic_type == 1 + if particle.pid == 4: m = PROTON_MASS + elif particle.pid == 2: m = MUON_MASS + else: + raise ValueError("For track particle {}, got {}\ + as particle type!".format(particle.pid)) + if not hasattr(particle, 'length'): + particle.length = compute_track_length(particle.points, **kwargs) + T = f(particle.length * PIXELS_TO_CM) + p = np.sqrt(T * (T + 2*m)) + return p + + def compute_particle_direction(p: Particle, start_segment_radius=17, vertex=None, @@ -92,46 +133,6 @@ def compute_particle_direction(p: Particle, return direction, pca.explained_variance_ratio_ -def load_range_reco(particle_type='muon', kinetic_energy=True): - """ - Return a function maps the residual range of a track to the kinetic - energy of the track. The mapping is based on the Bethe-Bloch formula - and stored per particle type in TGraph objects. The TGraph::Eval - function is used to perform the interpolation. - - Parameters - ---------- - particle_type: A string with the particle name. - kinetic_energy: If true (false), return the kinetic energy (momentum) - - Returns - ------- - The kinetic energy or momentum according to Bethe-Bloch. - """ - output_var = ('_RRtoT' if kinetic_energy else '_RRtodEdx') - if particle_type in ['muon', 'pion', 'kaon', 'proton']: - input_file = ROOT.TFile.Open('RRInput.root', 'read') - graph = input_file.Get(f'{particle_type}{output_var}') - return np.vectorize(graph.Eval) - else: - print(f'Range-based reconstruction for particle "{particle_type}" not available.') - - -def make_range_based_momentum_fns(): - f_muon = load_range_reco('muon') - f_pion = load_range_reco('pion') - f_proton = load_range_reco('proton') - return [f_muon, f_pion, f_proton] - - -def compute_range_momentum(particle, f, voxel_to_cm=0.3, **kwargs): - assert particle.semantic_type == 1 - length = compute_track_length(particle.points, - bin_size=kwargs.get('bin_size', 17)) - E = f(length * voxel_to_cm) - return E - - def highland_formula(p, l, m, X_0=14, z=1): ''' Highland formula for angular scattering variance. @@ -249,4 +250,43 @@ def compute_mcs_muon_energy(particle, bin_size=17, einit = i lls = np.array(lls) Es = np.array(Es) - return einit, min_ll \ No newline at end of file + return einit, min_ll + +# def load_range_reco(particle_type='muon', kinetic_energy=True): +# """ +# Return a function maps the residual range of a track to the kinetic +# energy of the track. The mapping is based on the Bethe-Bloch formula +# and stored per particle type in TGraph objects. The TGraph::Eval +# function is used to perform the interpolation. + +# Parameters +# ---------- +# particle_type: A string with the particle name. +# kinetic_energy: If true (false), return the kinetic energy (momentum) + +# Returns +# ------- +# The kinetic energy or momentum according to Bethe-Bloch. +# """ +# output_var = ('_RRtoT' if kinetic_energy else '_RRtodEdx') +# if particle_type in ['muon', 'pion', 'kaon', 'proton']: +# input_file = ROOT.TFile.Open('/sdf/group/neutrino/koh0207/misc/RRInput.root', 'read') +# graph = input_file.Get(f'{particle_type}{output_var}') +# return np.vectorize(graph.Eval) +# else: +# print(f'Range-based reconstruction for particle "{particle_type}" not available.') + + +# def make_range_based_momentum_fns(): +# f_muon = load_range_reco('muon') +# f_pion = load_range_reco('pion') +# f_proton = load_range_reco('proton') +# return [f_muon, f_pion, f_proton] + + +# def compute_range_momentum(particle, f, voxel_to_cm=0.3, **kwargs): +# assert particle.semantic_type == 1 +# length = compute_track_length(particle.points, +# bin_size=kwargs.get('bin_size', 17)) +# E = f(length * voxel_to_cm) +# return E \ No newline at end of file diff --git a/analysis/algorithms/selections/__init__.py b/analysis/algorithms/selections/__init__.py index 5704809c..e32b2452 100644 --- a/analysis/algorithms/selections/__init__.py +++ b/analysis/algorithms/selections/__init__.py @@ -7,3 +7,4 @@ from .flash_matching import flash_matching from .muon_decay import muon_decay from .benchmark import benchmark +from .particles import run_inference_particles \ No newline at end of file diff --git a/analysis/algorithms/selections/particles.py b/analysis/algorithms/selections/particles.py new file mode 100644 index 00000000..588a3fab --- /dev/null +++ b/analysis/algorithms/selections/particles.py @@ -0,0 +1,133 @@ +from collections import OrderedDict +import os, copy, sys + +# Flash Matching +sys.path.append('/sdf/group/neutrino/ldomine/OpT0Finder/python') + + +from analysis.decorator import evaluate +from analysis.classes.evaluator import FullChainEvaluator +from analysis.classes.TruthInteraction import TruthInteraction +from analysis.classes.Interaction import Interaction +from analysis.classes.Particle import Particle +from analysis.classes.TruthParticle import TruthParticle +from analysis.algorithms.utils import get_interaction_properties, \ + get_particle_properties, \ + get_mparticles_from_minteractions + +from analysis.algorithms.calorimetry import get_csda_range_spline + +@evaluate(['particles'], mode='per_batch') +def run_inference_particles(data_blob, res, data_idx, analysis_cfg, cfg): + """ + Analysis tools inference script for particle-level information. + """ + # List of ordered dictionaries for output logging + # Interaction and particle level information + interactions, particles = [], [] + + # Analysis tools configuration + deghosting = analysis_cfg['analysis']['deghosting'] + primaries = analysis_cfg['analysis']['match_primaries'] + enable_flash_matching = analysis_cfg['analysis'].get('enable_flash_matching', False) + ADC_to_MeV = analysis_cfg['analysis'].get('ADC_to_MeV', 1./350.) + compute_vertex = analysis_cfg['analysis']['compute_vertex'] + vertex_mode = analysis_cfg['analysis']['vertex_mode'] + matching_mode = analysis_cfg['analysis']['matching_mode'] + + # FullChainEvaluator config + processor_cfg = analysis_cfg['analysis'].get('processor_cfg', {}) + + # Skeleton for csv output + particle_dict = analysis_cfg['analysis'].get('particle_dict', {}) + + use_primaries_for_vertex = analysis_cfg['analysis'].get('use_primaries_for_vertex', True) + + splines = { + 'proton': get_csda_range_spline('proton'), + 'muon': get_csda_range_spline('muon') + } + + # Load data into evaluator + if enable_flash_matching: + predictor = FullChainEvaluator(data_blob, res, cfg, processor_cfg, + deghosting=deghosting, + enable_flash_matching=enable_flash_matching, + flash_matching_cfg="/sdf/group/neutrino/koh0207/logs/nu_selection/flash_matching/config/flashmatch.cfg", + opflash_keys=['opflash_cryoE', 'opflash_cryoW']) + else: + predictor = FullChainEvaluator(data_blob, res, cfg, processor_cfg, deghosting=deghosting) + + image_idxs = data_blob['index'] + spatial_size = predictor.spatial_size + + # Loop over images + for idx, index in enumerate(image_idxs): + index_dict = { + 'Index': index, + # 'run': data_blob['run_info'][idx][0], + # 'subrun': data_blob['run_info'][idx][1], + # 'event': data_blob['run_info'][idx][2] + } + + particle_matches, particle_matches_values = predictor.match_particles(idx, + only_primaries=primaries, + mode='true_to_pred', + volume=None, + matching_mode=matching_mode, + return_counts=True + ) + + # 3. Process particle level information + for i, mparticles in enumerate(particle_matches): + true_p, pred_p = mparticles[0], mparticles[1] + + assert (type(true_p) is TruthParticle) or true_p is None + assert (type(pred_p) is Particle) or pred_p is None + + part_dict = copy.deepcopy(particle_dict) + + part_dict.update(index_dict) + part_dict['particle_match_value'] = particle_matches_values[i] + + pred_particle_dict = get_particle_properties(pred_p, + prefix='pred', splines=splines) + true_particle_dict = get_particle_properties(true_p, + prefix='true', splines=splines) + + if true_p is not None: + pred_particle_dict['pred_particle_has_match'] = True + true_particle_dict['true_particle_interaction_id'] = true_p.interaction_id + if 'particles_asis' in data_blob: + particles_asis = data_blob['particles_asis'][idx] + if len(particles_asis) > true_p.id: + true_part = particles_asis[true_p.id] + true_particle_dict['true_particle_energy_init'] = true_part.energy_init() + true_particle_dict['true_particle_energy_deposit'] = true_part.energy_deposit() + true_particle_dict['true_particle_creation_process'] = true_part.creation_process() + # If no children other than itself: particle is stopping. + children = true_part.children_id() + children = [x for x in children if x != true_part.id()] + true_particle_dict['true_particle_children_count'] = len(children) + + if pred_p is not None: + true_particle_dict['true_particle_has_match'] = True + pred_particle_dict['pred_particle_interaction_id'] = pred_p.interaction_id + + + for k1, v1 in true_particle_dict.items(): + if k1 in part_dict: + part_dict[k1] = v1 + else: + raise ValueError("{} not in pre-defined fieldnames.".format(k1)) + + for k2, v2 in pred_particle_dict.items(): + if k2 in part_dict: + part_dict[k2] = v2 + else: + raise ValueError("{} not in pre-defined fieldnames.".format(k2)) + + + particles.append(part_dict) + + return [particles] \ No newline at end of file diff --git a/analysis/algorithms/selections/template.py b/analysis/algorithms/selections/template.py index 080b8214..cd59ed51 100644 --- a/analysis/algorithms/selections/template.py +++ b/analysis/algorithms/selections/template.py @@ -11,7 +11,11 @@ from analysis.classes.Interaction import Interaction from analysis.classes.Particle import Particle from analysis.classes.TruthParticle import TruthParticle -from analysis.algorithms.utils import get_interaction_properties, get_particle_properties, get_mparticles_from_minteractions +from analysis.algorithms.utils import get_interaction_properties, \ + get_particle_properties, \ + get_mparticles_from_minteractions + +from analysis.algorithms.calorimetry import get_csda_range_spline @evaluate(['interactions', 'particles'], mode='per_batch') def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): @@ -40,6 +44,11 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): use_primaries_for_vertex = analysis_cfg['analysis'].get('use_primaries_for_vertex', True) + splines = { + 'proton': get_csda_range_spline('proton'), + 'muon': get_csda_range_spline('muon') + } + # Load data into evaluator if enable_flash_matching: predictor = FullChainEvaluator(data_blob, res, cfg, processor_cfg, @@ -170,9 +179,9 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): part_dict['particle_match_value'] = particle_matches_values[i] pred_particle_dict = get_particle_properties(pred_p, - prefix='pred') + prefix='pred', splines=splines) true_particle_dict = get_particle_properties(true_p, - prefix='true') + prefix='true', splines=splines) if true_p is not None: pred_particle_dict['pred_particle_has_match'] = True diff --git a/analysis/algorithms/utils.py b/analysis/algorithms/utils.py index cb8234fc..f817e794 100644 --- a/analysis/algorithms/utils.py +++ b/analysis/algorithms/utils.py @@ -5,8 +5,13 @@ from scipy.spatial.distance import cdist from analysis.algorithms.point_matching import get_track_endpoints_max_dist + +from analysis.algorithms.calorimetry import get_csda_range_spline + import numpy as np -import ROOT +# Splines for ranged based energy reco +f_proton = get_csda_range_spline('proton') +f_muon = get_csda_range_spline('muon') def attach_prefix(update_dict, prefix): @@ -170,7 +175,7 @@ def get_interaction_properties(interaction: Interaction, spatial_size, prefix=No 'has_vertex': False, 'vertex_valid': 'Default Invalid', 'count_primary_protons': -1, - 'nu_reco_energy': -1 + # 'nu_reco_energy': -1 }) if interaction is None: @@ -221,7 +226,10 @@ def get_interaction_properties(interaction: Interaction, spatial_size, prefix=No return out -def get_particle_properties(particle: Particle, prefix=None, save_feats=False): +def get_particle_properties(particle: Particle, + prefix=None, + save_feats=False, + splines=None): update_dict = OrderedDict({ 'particle_id': -1, @@ -229,7 +237,8 @@ def get_particle_properties(particle: Particle, prefix=None, save_feats=False): 'particle_type': -1, 'particle_semantic_type': -1, 'particle_size': -1, - 'particle_E': -1, + 'particle_sum_edep': -1, + 'particle_reco_momentum': -1, 'particle_is_primary': False, 'particle_has_startpoint': False, 'particle_has_endpoint': False, @@ -262,7 +271,7 @@ def get_particle_properties(particle: Particle, prefix=None, save_feats=False): update_dict['particle_type'] = particle.pid update_dict['particle_semantic_type'] = particle.semantic_type update_dict['particle_size'] = particle.size - update_dict['particle_E'] = particle.sum_edep + update_dict['particle_sum_edep'] = particle.sum_edep update_dict['particle_is_primary'] = particle.is_primary # update_dict['particle_is_contained'] = particle.is_contained if particle.startpoint is not None: @@ -287,21 +296,22 @@ def get_particle_properties(particle: Particle, prefix=None, save_feats=False): update_dict['particle_startpoint_is_touching'] = False creation_process = particle.particle_asis.creation_process() update_dict['particle_creation_process'] = creation_process - # if particle.semantic_type == 1: - # update_dict['particle_length'] = compute_track_length(particle.points) - # direction = compute_particle_direction(particle, vertex=vertex) - # assert len(direction) == 3 - # update_dict['particle_dir_x'] = direction[0] - # update_dict['particle_dir_y'] = direction[1] - # update_dict['particle_dir_z'] = direction[2] - # if particle.pid == 2: - # mcs_E = compute_mcs_muon_energy(particle) - # update_dict['particle_mcs_E'] = mcs_E - # if not isinstance(particle, TruthParticle): - # node_dict = OrderedDict({'node_feat_{}'.format(i) : particle.node_features[i] \ - # for i in range(particle.node_features.shape[0])}) - - # update_dict.update(node_dict) + + if particle.semantic_type == 1: + length = compute_track_length(particle.points) + update_dict['particle_length'] = length + particle.length = length + direction = compute_particle_direction(particle) + assert len(direction) == 3 + update_dict['particle_dir_x'] = direction[0] + update_dict['particle_dir_y'] = direction[1] + update_dict['particle_dir_z'] = direction[2] + if splines is not None and particle.pid == 4: + momentum = compute_range_based_momentum(particle, splines['proton']) + update_dict['particle_reco_momentum'] = momentum + if splines is not None and particle.pid == 2: + momentum = compute_range_based_momentum(particle, splines['muon']) + update_dict['particle_reco_momentum'] = momentum out = attach_prefix(update_dict, prefix) diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index 0164bdb9..7b678f5e 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -491,6 +491,7 @@ def match_particles(self, entry, mode='pred_to_true', volume=None, matching_mode='one_way', + return_counts=False, **kwargs): ''' Returns (, None) if no match was found @@ -507,9 +508,10 @@ def match_particles(self, entry, entries = self._get_entries(entry, volume) all_matches = [] + all_counts = [] for e in entries: volume = e % self._num_volumes if self.vb is not None else volume - print('matching', entries, volume) + # print('matching', entries, volume) if mode == 'pred_to_true': # Match each pred to one in true particles_from = self.get_particles(entry, only_primaries=only_primaries, volume=volume) @@ -523,15 +525,19 @@ def match_particles(self, entry, " prediction to truth, use 'pred_to_true' (and vice versa).".format(mode)) all_kwargs = {"min_overlap": self.min_overlap_count, "overlap_mode": self.overlap_mode, **kwargs} if matching_mode == 'one_way': - matched_pairs, _ = match_particles_fn(particles_from, particles_to, + matched_pairs, counts = match_particles_fn(particles_from, particles_to, **all_kwargs) elif matching_mode == 'optimal': - matched_pairs, _ = match_particles_optimal(particles_from, particles_to, + matched_pairs, counts = match_particles_optimal(particles_from, particles_to, **all_kwargs) else: raise ValueError all_matches.extend(matched_pairs) - return all_matches + all_counts.extend(list(counts)) + if return_counts: + return all_matches, all_counts + else: + return all_matches def match_interactions(self, entry, mode='pred_to_true', diff --git a/analysis/classes/particle.py b/analysis/classes/particle.py index 9d82af57..cf6e59c0 100644 --- a/analysis/classes/particle.py +++ b/analysis/classes/particle.py @@ -134,7 +134,7 @@ def match_particles_fn(particles_from : Union[List[Particle], List[TruthParticle if len(particles_y) == 0 or len(particles_x) == 0: if verbose: print("No particles to match.") - return [], 0 + return [], [0] if overlap_mode == 'counts': overlap_matrix = matrix_counts(particles_x, particles_y) @@ -144,7 +144,7 @@ def match_particles_fn(particles_from : Union[List[Particle], List[TruthParticle raise ValueError("Overlap matrix mode {} is not supported.".format(overlap_mode)) # print(overlap_matrix) idx = overlap_matrix.argmax(axis=0) - intersections = overlap_matrix.max(axis=0) + intersections = np.atleast_1d(overlap_matrix.max(axis=0)) matches = [] @@ -191,7 +191,7 @@ def match_particles_optimal(particles_from : Union[List[Particle], List[TruthPar if len(particles_y) == 0 or len(particles_x) == 0: if verbose: print("No particles to match.") - return [], 0 + return [], [0] if overlap_mode == 'counts': overlap_matrix = matrix_counts(particles_y, particles_x) diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index 18b1d346..144e4cee 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -66,7 +66,7 @@ def __init__(self, data_blob, result, cfg, predictor_cfg={}, deghosting=False, # quantities separately from data_blob and result self.deghosting = self.module_config['chain']['enable_ghost'] - self.pred_vtx_positions = self.module_config['grappa_inter']['vertex_net']['pred_vtx_positions'] + self.pred_vtx_positions = self.module_config['grappa_inter']['vertex_net'].get('pred_vtx_positions', None) self.data_blob = data_blob self.result = result diff --git a/mlreco/post_processing/metrics/multi_particle.py b/mlreco/post_processing/metrics/multi_particle.py index f2f7ecc2..f99f6751 100644 --- a/mlreco/post_processing/metrics/multi_particle.py +++ b/mlreco/post_processing/metrics/multi_particle.py @@ -21,6 +21,7 @@ def multi_particle(cfg, processor_cfg, data_blob, result, logdir, iteration): clusts = result['clusts'] labels = get_cluster_label(data_blob['input_data'][0], clusts, 9) + primary_labels = get_cluster_label(data_blob['input_data'][0], clusts, 10) logits = np.vstack(logits) pred = np.argmax(logits, axis=1) @@ -44,9 +45,9 @@ def multi_particle(cfg, processor_cfg, data_blob, result, logdir, iteration): ent = entropy(probs) fout.record(('Index', 'Truth', 'Prediction', - 'p0', 'p1', 'p2', 'p3', 'p4', 'entropy'), + 'p0', 'p1', 'p2', 'p3', 'p4', 'entropy', 'is_primary'), (int(i), int(label_batch), int(pred), - probs[0], probs[1], probs[2], probs[3], probs[4], ent)) + probs[0], probs[1], probs[2], probs[3], probs[4], ent, int(primary_labels[i]))) fout.write() fout.close() \ No newline at end of file From 34f5620c3cd07c78893fbb1621b80d9e84dc5a6c Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Thu, 23 Feb 2023 17:09:01 -0800 Subject: [PATCH 005/180] Local density startpoint corrector bug fix --- analysis/algorithms/utils.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/analysis/algorithms/utils.py b/analysis/algorithms/utils.py index f817e794..51dcf608 100644 --- a/analysis/algorithms/utils.py +++ b/analysis/algorithms/utils.py @@ -116,14 +116,15 @@ def correct_track_endpoints_closest(p, pts=None): def local_density_correction(p, r=5): assert p.semantic_type == 1 dist_st = np.linalg.norm(p.startpoint - p.points, axis=1) < r - if not dist_st.all(): + if not dist_st.any(): return local_d_start = p.depositions[dist_st].sum() / sum(dist_st) dist_end = np.linalg.norm(p.endpoint - p.points, axis=1) < r - if not dist_end.all(): + if not dist_end.any(): return local_d_end = p.depositions[dist_end].sum() / sum(dist_end) - if local_d_start < local_d_end: + # Startpoint must have lowest local density + if local_d_start > local_d_end: p1, p2 = p.startpoint, p.endpoint p.startpoint = p2 p.endpoint = p1 From d4adea685f29d88c409fd9f4ce676ef69ae1b56f Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Thu, 23 Feb 2023 23:46:57 -0800 Subject: [PATCH 006/180] GrapPA no longer runs cdist twice when edge length restriction is applied --- mlreco/models/grappa.py | 9 ++- .../models/layers/gnn/encoders/geometric.py | 4 +- mlreco/utils/gnn/network.py | 56 ++++++++++++++----- mlreco/utils/inference.py | 55 ++++++++++++++++++ mlreco/visualization/plotly_layouts.py | 13 +++++ 5 files changed, 118 insertions(+), 19 deletions(-) create mode 100644 mlreco/utils/inference.py diff --git a/mlreco/models/grappa.py b/mlreco/models/grappa.py index 17f6e5ec..b9019e6f 100644 --- a/mlreco/models/grappa.py +++ b/mlreco/models/grappa.py @@ -337,9 +337,9 @@ def forward(self, data, clusts=None, groups=None, points=None, extra_feats=None, return result # If necessary, compute the cluster distance matrix - dist_mat = None + dist_mat, closest_index = None, None if np.any(self.edge_max_dist > -1) or self.network == 'mst' or self.network == 'knn': - dist_mat = inter_cluster_distance(cluster_data[:,self.coords_index[0]:self.coords_index[1]].float(), clusts, batch_ids, self.edge_dist_metric) + dist_mat, closest_index = inter_cluster_distance(cluster_data[:,self.coords_index[0]:self.coords_index[1]].float(), clusts, batch_ids, self.edge_dist_metric, return_index=True) # Form the requested network if len(clusts) == 1: @@ -375,6 +375,9 @@ def forward(self, data, clusts=None, groups=None, points=None, extra_feats=None, if self.source_col == 6: classes = extra_feats[:,-1].cpu().numpy().astype(int) if extra_feats is not None else get_cluster_primary_label(cluster_data, clusts, -1).astype(int) edge_index = restrict_graph(edge_index, dist_mat, self.edge_max_dist, classes) + # Get index of closest pair of voxels for each pair of clusters + closest_index = closest_index[edge_index[0], edge_index[1]] + # Update result with a list of edges for each batch id edge_index_split, ebids = split_edge_index(edge_index, batch_ids, batches) result['edge_index'] = [edge_index_split] @@ -383,7 +386,7 @@ def forward(self, data, clusts=None, groups=None, points=None, extra_feats=None, # Obtain node and edge features x = self.node_encoder(cluster_data, clusts) - e = self.edge_encoder(cluster_data, clusts, edge_index) + e = self.edge_encoder(cluster_data, clusts, edge_index, closest_index=closest_index) # If extra features are provided separately, add them if extra_feats is not None: diff --git a/mlreco/models/layers/gnn/encoders/geometric.py b/mlreco/models/layers/gnn/encoders/geometric.py index 218ea6b4..fbee7bb2 100644 --- a/mlreco/models/layers/gnn/encoders/geometric.py +++ b/mlreco/models/layers/gnn/encoders/geometric.py @@ -120,7 +120,7 @@ def __init__(self, model_config, batch_col=0, coords_col=(1, 4)): self.batch_col = batch_col self.coords_col = coords_col - def forward(self, data, clusts, edge_index): + def forward(self, data, clusts, edge_index, closest_index=None): # Check if the graph is undirected, select the relevant part of the edge index half_idx = int(edge_index.shape[1] / 2) @@ -130,7 +130,7 @@ def forward(self, data, clusts, edge_index): # If numpy is to be used, bring data to cpu, pass through Numba function # Otherwise use torch-based implementation of cluster_edge_features if self.use_numpy: - feats = cluster_edge_features(data, clusts, edge_index.T, batch_col=self.batch_col, coords_col=self.coords_col) + feats = cluster_edge_features(data, clusts, edge_index.T, closest_index=closest_index, batch_col=self.batch_col, coords_col=self.coords_col) else: # Get the voxel set voxels = data[:, self.coords_col[0]:self.coords_col[1]].float() diff --git a/mlreco/utils/gnn/network.py b/mlreco/utils/gnn/network.py index 11466c49..0deed52e 100644 --- a/mlreco/utils/gnn/network.py +++ b/mlreco/utils/gnn/network.py @@ -259,38 +259,40 @@ def restrict_graph(edge_index: nb.int64[:,:], @numba_wrapper(cast_args=['data'], list_args=['clusts'], keep_torch=True, ref_arg='data') -def get_cluster_edge_features(data, clusts, edge_index, batch_col=0, coords_col=(1, 4)): +def get_cluster_edge_features(data, clusts, edge_index, closest_index=None, batch_col=0, coords_col=(1, 4)): """ Function that returns a tensor of edge features for each of the edges connecting clusters in the graph. Args: - data (np.ndarray) : (N,8) [x, y, z, batchid, value, id, groupid, shape] - clusts ([np.ndarray]) : (C) List of arrays of voxel IDs in each cluster - edge_index (np.ndarray): (2,E) Incidence matrix + data (np.ndarray) : (N,8) [x, y, z, batchid, value, id, groupid, shape] + clusts ([np.ndarray]) : (C) List of arrays of voxel IDs in each cluster + edge_index (np.ndarray) : (2,E) Incidence matrix + closest_index (np.ndarray): (E) Index of closest pair of voxels for each edge Returns: np.ndarray: (E,19) Tensor of edge features (point1, point2, displacement, distance, orientation) """ - return _get_cluster_edge_features(data, clusts, edge_index) + return _get_cluster_edge_features(data, clusts, edge_index, closest_index) #return _get_cluster_edge_features_vec(data, clusts, edge_index) @nb.njit(parallel=True, cache=True) def _get_cluster_edge_features(data: nb.float32[:,:], clusts: nb.types.List(nb.int64[:]), edge_index: nb.int64[:,:], + closest_index: nb.int64[:] = None, batch_col: nb.int64 = 0, coords_col: nb.types.List(nb.int64[:]) = (1, 4)) -> nb.float32[:,:]: feats = np.empty((len(edge_index), 19), dtype=data.dtype) for k in nb.prange(len(edge_index)): # Get the voxels in the clusters connected by the edge - x1 = data[clusts[edge_index[k,0]], coords_col[0]:coords_col[1]] - x2 = data[clusts[edge_index[k,1]], coords_col[0]:coords_col[1]] + c1, c2 = edge_index[k] + x1 = data[clusts[c1], coords_col[0]:coords_col[1]] + x2 = data[clusts[c2], coords_col[0]:coords_col[1]] # Find the closest set point in each cluster - d12 = cdist_nb(x1, x2) - imin = np.argmin(d12) - i1, i2 = imin//d12.shape[1], imin%d12.shape[1] + imin = np.argmin(cdist_nb(x1, x2)) if closest_index is None else closest_index[k] + i1, i2 = imin//len(x2), imin%len(x2) v1 = x1[i1,:] v2 = x2[i2,:] @@ -427,7 +429,7 @@ def _get_edge_distances(voxels: nb.float32[:,:], @numba_wrapper(cast_args=['voxels'], list_args=['clusts']) -def inter_cluster_distance(voxels, clusts, batch_ids=None, mode='voxel'): +def inter_cluster_distance(voxels, clusts, batch_ids=None, mode='voxel', return_index=False): """ Finds the inter-cluster distance between every pair of clusters within each batch, returned as a block-diagonal matrix. @@ -437,6 +439,7 @@ def inter_cluster_distance(voxels, clusts, batch_ids=None, mode='voxel'): clusts ([np.ndarray]) : (C) List of arrays of voxel IDs in each cluster batch_ids (np.ndarray): (C) List of cluster batch IDs mode (str) : Eiher use closest voxel distance (`voxel`) or centroid distance (`centroid`) + return_index (bool) : If True, returns the combined index of the closest voxel pair Returns: torch.tensor: (C,C) Tensor of pair-wise cluster distances """ @@ -444,16 +447,20 @@ def inter_cluster_distance(voxels, clusts, batch_ids=None, mode='voxel'): if batch_ids is None: batch_ids = np.zeros(len(clusts), dtype=np.int64) - return _inter_cluster_distance(voxels, clusts, batch_ids, mode) + if not return_index: + return _inter_cluster_distance(voxels, clusts, batch_ids, mode) + else: + assert mode == 'voxel', 'Cannot return index for centroid method' + return _inter_cluster_distance_index(voxels, clusts, batch_ids) @nb.njit(parallel=True, cache=True) def _inter_cluster_distance(voxels: nb.float32[:,:], clusts: nb.types.List(nb.int64[:]), batch_ids: nb.int64[:], - mode: str = 'voxel') -> nb.float64[:,:]: + mode: str = 'voxel') -> nb.float32[:,:]: assert len(clusts) == len(batch_ids) - dist_mat = np.zeros((len(batch_ids), len(batch_ids)), dtype=voxels.dtype) + dist_mat = np.empty((len(batch_ids), len(batch_ids)), dtype=voxels.dtype) indxi, indxj = complete_graph(batch_ids, directed=True) if mode == 'voxel': for k in nb.prange(len(indxi)): @@ -472,6 +479,27 @@ def _inter_cluster_distance(voxels: nb.float32[:,:], return dist_mat +@nb.njit(parallel=True, cache=True) +def _inter_cluster_distance_index(voxels: nb.float32[:,:], + clusts: nb.types.List(nb.int64[:]), + batch_ids: nb.int64[:]) -> (nb.float32[:,:], nb.int64[:,:]): + + assert len(clusts) == len(batch_ids) + dist_mat = np.empty((len(batch_ids), len(batch_ids)), dtype=voxels.dtype) + closest_index = np.empty((len(batch_ids), len(batch_ids)), dtype=nb.int64) + indxi, indxj = complete_graph(batch_ids, directed=True) + for k in nb.prange(len(indxi)): + i, j = indxi[k], indxj[k] + temp_dist_mat = cdist_nb(voxels[clusts[i]], voxels[clusts[j]]) + index = np.argmin(temp_dist_mat) + ii, jj = index//temp_dist_mat.shape[1], index%temp_dist_mat.shape[1] + + closest_index[i,j] = closest_index[j,i] = index + dist_mat[i,j] = dist_mat[j,i] = temp_dist_mat[ii,jj] + + return dist_mat, closest_index + + @numba_wrapper(cast_args=['graph']) def get_fragment_edges(graph, clust_ids): """ diff --git a/mlreco/utils/inference.py b/mlreco/utils/inference.py new file mode 100644 index 00000000..60e5dcd8 --- /dev/null +++ b/mlreco/utils/inference.py @@ -0,0 +1,55 @@ +import yaml + +def get_inference_cfg(cfg_path, dataset_path=None, weights_path=None, batch_size=None, cpu=False): + ''' + Turns a training configuration into an inference configuration: + - Turn `train` to `False` + - Set sequential sampling + - Load the specified validation dataset_path, if requested + - Load the specified set of weights_path, if requested + - Reset the batch_size to a different value, if requested + - Make the model run in CPU mode, if requested + + Parameters + ---------- + cfg_path : str + Path to the configuration file + dataset_path : str + Path to the dataset to use for inference + weights_path : str + Path to the weigths to use for inference + batch_size: int + Number of data samples per batch + cpu: bool + Whether or not to execute the inference on CPU + + Returns + ------ + dict + Dictionary of parameters to initialize handlers + ''' + # Get the config file from the train file + cfg = open(cfg_path) + + # Convert the string to a dictionary + cfg = yaml.load(cfg, Loader=yaml.Loader) + + # Turn train to False + cfg['trainval']['train'] = False + + # Delete the random sampler + if 'sampler' in cfg['iotool']: + del cfg['iotool']['sampler'] + + # Load weights_path, if requested + if weights_path is not None: + cfg['trainval']['model_path'] = weights_path + + # Change the batch_size, if requested + cfg['iotool']['batch_size'] = batch_size + + # Put the network in CPU mode, if requested + if cpu: + cfg['trainval']['gpus'] = '' + + return cfg diff --git a/mlreco/visualization/plotly_layouts.py b/mlreco/visualization/plotly_layouts.py index 64eed67f..3fad0ee6 100644 --- a/mlreco/visualization/plotly_layouts.py +++ b/mlreco/visualization/plotly_layouts.py @@ -3,6 +3,19 @@ from plotly.subplots import make_subplots +def high_contrast_colorscale(): + import plotly.express as px + colorscale = [] + step = 1./48 + for i, c in enumerate(px.colors.qualitative.Dark24): + colorscale.append([i*step, c]) + colorscale.append([(i+1)*step, c]) + for i, c in enumerate(px.colors.qualitative.Light24): + colorscale.append([(i+24)*step, c]) + colorscale.append([(i+25)*step, c]) + return colorscale + + def white_layout(): bg_color = 'rgba(0,0,0,0)' grid_color = 'rgba(220,220,220,100)' From 1aa19a4ae72e2541aa31b8c8c44cc7b1ed80aa83 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Fri, 24 Feb 2023 12:14:29 -0800 Subject: [PATCH 007/180] Vertex finder fix, calorimetry functions, multi-particle primary id labels --- analysis/algorithms/calorimetry.py | 10 +- analysis/algorithms/point_matching.py | 4 +- analysis/algorithms/selections/template.py | 17 ++- analysis/algorithms/utils.py | 129 +++++++++++------- analysis/algorithms/vertex.py | 2 +- analysis/classes/predictor.py | 2 - .../post_processing/metrics/multi_particle.py | 3 +- 7 files changed, 102 insertions(+), 65 deletions(-) diff --git a/analysis/algorithms/calorimetry.py b/analysis/algorithms/calorimetry.py index 76d46d84..d882cd3c 100644 --- a/analysis/algorithms/calorimetry.py +++ b/analysis/algorithms/calorimetry.py @@ -1,5 +1,3 @@ -import ROOT - from analysis.classes.particle import Particle import numpy as np import numba as nb @@ -71,7 +69,7 @@ def get_csda_range_spline(particle_type): return f -def compute_range_based_momentum(particle, f, **kwargs): +def compute_range_based_energy(particle, f, **kwargs): assert particle.semantic_type == 1 if particle.pid == 4: m = PROTON_MASS elif particle.pid == 2: m = MUON_MASS @@ -80,9 +78,9 @@ def compute_range_based_momentum(particle, f, **kwargs): as particle type!".format(particle.pid)) if not hasattr(particle, 'length'): particle.length = compute_track_length(particle.points, **kwargs) - T = f(particle.length * PIXELS_TO_CM) - p = np.sqrt(T * (T + 2*m)) - return p + kinetic = f(particle.length * PIXELS_TO_CM) + total = kinetic + m + return total def compute_particle_direction(p: Particle, diff --git a/analysis/algorithms/point_matching.py b/analysis/algorithms/point_matching.py index 7d2152b6..69e71ca1 100644 --- a/analysis/algorithms/point_matching.py +++ b/analysis/algorithms/point_matching.py @@ -115,8 +115,8 @@ def get_track_endpoints_max_dist(particle): """ coords = particle.points dist = cdist(coords, coords) - pts = particle.points[np.where(dist == dist.max())[0]] - return pts[0], pts[1] + inds = np.unravel_index(dist.argmax(), dist.shape) + return coords[inds[0]], coords[inds[1]] # Deprecated diff --git a/analysis/algorithms/selections/template.py b/analysis/algorithms/selections/template.py index cd59ed51..472a339b 100644 --- a/analysis/algorithms/selections/template.py +++ b/analysis/algorithms/selections/template.py @@ -34,6 +34,7 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): compute_vertex = analysis_cfg['analysis']['compute_vertex'] vertex_mode = analysis_cfg['analysis']['vertex_mode'] matching_mode = analysis_cfg['analysis']['matching_mode'] + compute_energy = analysis_cfg['analysis'].get('compute_energy', False) # FullChainEvaluator config processor_cfg = analysis_cfg['analysis'].get('processor_cfg', {}) @@ -44,10 +45,14 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): use_primaries_for_vertex = analysis_cfg['analysis'].get('use_primaries_for_vertex', True) - splines = { - 'proton': get_csda_range_spline('proton'), - 'muon': get_csda_range_spline('muon') - } + splines = None + + if compute_energy: + + splines = { + 'proton': get_csda_range_spline('proton'), + 'muon': get_csda_range_spline('muon') + } # Load data into evaluator if enable_flash_matching: @@ -179,9 +184,9 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): part_dict['particle_match_value'] = particle_matches_values[i] pred_particle_dict = get_particle_properties(pred_p, - prefix='pred', splines=splines) + prefix='pred', splines=splines, compute_energy=compute_energy) true_particle_dict = get_particle_properties(true_p, - prefix='true', splines=splines) + prefix='true', splines=splines, compute_energy=compute_energy) if true_p is not None: pred_particle_dict['pred_particle_has_match'] = True diff --git a/analysis/algorithms/utils.py b/analysis/algorithms/utils.py index 51dcf608..687d6a91 100644 --- a/analysis/algorithms/utils.py +++ b/analysis/algorithms/utils.py @@ -70,6 +70,22 @@ def get_track_points_default(p): correct_track_endpoints_closest(p, pts=pts) +def handle_singleton_ppn_candidate(p, pts, ppn_candidates): + assert ppn_candidates.shape[0] == 1 + score = ppn_candidates[0][5:] + label = np.argmax(score) + dist = cdist(pts, ppn_candidates[:, :3]) + pt_near = pts[dist.argmin(axis=0)] + pt_far = pts[dist.argmax(axis=0)] + if label == 0: + p.startpoint = pt_near.reshape(-1) + p.endpoint = pt_far.reshape(-1) + else: + p.endpoint = pt_near.reshape(-1) + p.startpoint = pt_far.reshape(-1) + + + def correct_track_endpoints_closest(p, pts=None): assert p.semantic_type == 1 if pts is None: @@ -80,37 +96,51 @@ def correct_track_endpoints_closest(p, pts=None): if p.ppn_candidates.shape[0] == 0: p.startpoint = pts[0] p.endpoint = pts[1] - + elif p.ppn_candidates.shape[0] == 1: + # If only one ppn candidate, find track endpoint closer to + # ppn candidate and give the candidate's label to that track point + handle_singleton_ppn_candidate(p, pts, p.ppn_candidates) else: - dist1 = cdist(np.atleast_2d(p.ppn_candidates[:, :3]), np.atleast_2d(pts[0])).reshape(-1) - dist2 = cdist(np.atleast_2d(p.ppn_candidates[:, :3]), np.atleast_2d(pts[1])).reshape(-1) - - pt1_score = p.ppn_candidates[dist1.argmin()][5:] - pt2_score = p.ppn_candidates[dist2.argmin()][5:] - - labels = np.array([pt1_score.argmax(), pt2_score.argmax()]) + dist1 = cdist(np.atleast_2d(p.ppn_candidates[:, :3]), + np.atleast_2d(pts[0])).reshape(-1) + dist2 = cdist(np.atleast_2d(p.ppn_candidates[:, :3]), + np.atleast_2d(pts[1])).reshape(-1) - if labels[0] == 0 and labels[1] == 1: - p.startpoint = pts[0] - p.endpoint = pts[1] - elif labels[0] == 1 and labels[1] == 0: - p.startpoint = pts[1] - p.endpoint = pts[0] - elif labels[0] == 0 and labels[1] == 0: - # print("Particle {} has no endpoint".format(p.id)) - # Select point with larger score as startpoint - ix = np.argmax(labels) - iy = np.argmin(labels) - p.startpoint = pts[ix] - p.endpoint = pts[iy] - elif labels[0] == 1 and labels[1] == 1: - # print("Particle {} has no startpoint".format(p.id)) - ix = np.argmax(labels) - iy = np.argmin(labels) - p.startpoint = pts[iy] - p.endpoint = pts[ix] + ind1, ind2 = dist1.argmin(), dist2.argmin() + if ind1 == ind2: + ppn_candidates = p.ppn_candidates[dist1.argmin()].reshape(1, 7) + handle_singleton_ppn_candidate(p, pts, ppn_candidates) else: - raise ValueError("Classify endpoints feature dimension must be 2, got something else!") + pt1_score = p.ppn_candidates[ind1][5:] + pt2_score = p.ppn_candidates[ind2][5:] + + labels = np.array([pt1_score.argmax(), pt2_score.argmax()]) + scores = np.array([pt1_score.max(), pt2_score.max()]) + + if labels[0] == 0 and labels[1] == 1: + p.startpoint = pts[0] + p.endpoint = pts[1] + elif labels[0] == 1 and labels[1] == 0: + p.startpoint = pts[1] + p.endpoint = pts[0] + elif labels[0] == 0 and labels[1] == 0: + # print("Particle {} has no endpoint".format(p.id)) + # Select point with larger score as startpoint + ix = np.argmax(scores) + iy = np.argmin(scores) + # print(ix, iy, pts, scores) + p.startpoint = pts[ix] + p.endpoint = pts[iy] + elif labels[0] == 1 and labels[1] == 1: + ix = np.argmax(scores) # point with higher endpoint score + iy = np.argmin(scores) + p.startpoint = pts[iy] + p.endpoint = pts[ix] + else: + raise ValueError("Classify endpoints feature dimension must be 2, got something else!") + if np.linalg.norm(p.startpoint - p.endpoint) > 1e-6: + p.startpoint = pts[0] + p.endpoint = pts[1] def local_density_correction(p, r=5): @@ -230,7 +260,8 @@ def get_interaction_properties(interaction: Interaction, spatial_size, prefix=No def get_particle_properties(particle: Particle, prefix=None, save_feats=False, - splines=None): + splines=None, + compute_energy=False): update_dict = OrderedDict({ 'particle_id': -1, @@ -238,15 +269,9 @@ def get_particle_properties(particle: Particle, 'particle_type': -1, 'particle_semantic_type': -1, 'particle_size': -1, - 'particle_sum_edep': -1, - 'particle_reco_momentum': -1, 'particle_is_primary': False, 'particle_has_startpoint': False, 'particle_has_endpoint': False, - 'particle_length': -1, - 'particle_dir_x': -1, - 'particle_dir_y': -1, - 'particle_dir_z': -1, 'particle_startpoint_x': -1, 'particle_startpoint_y': -1, 'particle_startpoint_z': -1, @@ -259,6 +284,16 @@ def get_particle_properties(particle: Particle, # 'particle_is_contained': False }) + if compute_energy: + update_dict.update(OrderedDict({ + 'particle_dir_x': -1, + 'particle_dir_y': -1, + 'particle_dir_z': -1, + 'particle_length': -1, + 'particle_reco_energy': -1, + 'particle_sum_edep': -1 + })) + if save_feats: node_dict = OrderedDict({'node_feat_{}'.format(i) : -1 for i in range(28)}) update_dict.update(node_dict) @@ -272,7 +307,6 @@ def get_particle_properties(particle: Particle, update_dict['particle_type'] = particle.pid update_dict['particle_semantic_type'] = particle.semantic_type update_dict['particle_size'] = particle.size - update_dict['particle_sum_edep'] = particle.sum_edep update_dict['particle_is_primary'] = particle.is_primary # update_dict['particle_is_contained'] = particle.is_contained if particle.startpoint is not None: @@ -297,22 +331,23 @@ def get_particle_properties(particle: Particle, update_dict['particle_startpoint_is_touching'] = False creation_process = particle.particle_asis.creation_process() update_dict['particle_creation_process'] = creation_process - - if particle.semantic_type == 1: - length = compute_track_length(particle.points) - update_dict['particle_length'] = length - particle.length = length + if compute_energy: + update_dict['particle_sum_edep'] = particle.sum_edep direction = compute_particle_direction(particle) assert len(direction) == 3 update_dict['particle_dir_x'] = direction[0] update_dict['particle_dir_y'] = direction[1] update_dict['particle_dir_z'] = direction[2] - if splines is not None and particle.pid == 4: - momentum = compute_range_based_momentum(particle, splines['proton']) - update_dict['particle_reco_momentum'] = momentum - if splines is not None and particle.pid == 2: - momentum = compute_range_based_momentum(particle, splines['muon']) - update_dict['particle_reco_momentum'] = momentum + if particle.semantic_type == 1: + length = compute_track_length(particle.points) + update_dict['particle_length'] = length + particle.length = length + if splines is not None and particle.pid == 4: + reco_energy = compute_range_based_energy(particle, splines['proton']) + update_dict['particle_reco_energy'] = reco_energy + if splines is not None and particle.pid == 2: + reco_energy = compute_range_based_energy(particle, splines['muon']) + update_dict['particle_reco_energy'] = reco_energy out = attach_prefix(update_dict, prefix) diff --git a/analysis/algorithms/vertex.py b/analysis/algorithms/vertex.py index 9fc8a7a1..3f60aeb6 100644 --- a/analysis/algorithms/vertex.py +++ b/analysis/algorithms/vertex.py @@ -240,7 +240,7 @@ def estimate_vertex(particles, if pruned.shape[0] > 0: out = pruned.mean(axis=0) else: - out = candidates.mean(axis=0) + out = pseudovtx if return_candidate_count: return out, len(candidates) diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index 144e4cee..aae016a5 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -106,8 +106,6 @@ def __init__(self, data_blob, result, cfg, predictor_cfg={}, deghosting=False, self.vertex_mode = predictor_cfg.get('vertex_mode', 'all') self.prune_vertex = predictor_cfg.get('prune_vertex', True) self.track_endpoints_mode = predictor_cfg.get('track_endpoints_mode', 'node_features') - print(self.track_endpoints_mode) - # This is used to apply fiducial volume cuts. # Min/max boundaries in each dimension haev to be specified. self.volume_boundaries = predictor_cfg.get('volume_boundaries', None) diff --git a/mlreco/post_processing/metrics/multi_particle.py b/mlreco/post_processing/metrics/multi_particle.py index f99f6751..1ca5eadb 100644 --- a/mlreco/post_processing/metrics/multi_particle.py +++ b/mlreco/post_processing/metrics/multi_particle.py @@ -21,7 +21,8 @@ def multi_particle(cfg, processor_cfg, data_blob, result, logdir, iteration): clusts = result['clusts'] labels = get_cluster_label(data_blob['input_data'][0], clusts, 9) - primary_labels = get_cluster_label(data_blob['input_data'][0], clusts, 10) + primary_labels = get_cluster_label(data_blob['input_data'][0], clusts, 15) + logits = np.vstack(logits) pred = np.argmax(logits, axis=1) From 477e0254376d412136916fe105f2ff9366f908a4 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Fri, 24 Feb 2023 12:15:40 -0800 Subject: [PATCH 008/180] Range based energy reco tables --- .../algorithms/tables/muE_liquid_argon.txt | 146 ++++++++++++++++++ .../algorithms/tables/pE_liquid_argon.txt | 133 ++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 analysis/algorithms/tables/muE_liquid_argon.txt create mode 100644 analysis/algorithms/tables/pE_liquid_argon.txt diff --git a/analysis/algorithms/tables/muE_liquid_argon.txt b/analysis/algorithms/tables/muE_liquid_argon.txt new file mode 100644 index 00000000..4b5fb082 --- /dev/null +++ b/analysis/algorithms/tables/muE_liquid_argon.txt @@ -0,0 +1,146 @@ + T p Ionization brems pair photonuc Radloss dE/dx CSDARange delta beta dE/dx_R + 1.000E+00 1.457E+01 2.404E+00 0.000E+00 0.000E+00 4.526E-05 4.526E-05 4.808E+00 2.831E-03 0.0000 0.13661 3.355E+01 + 1.200E+00 1.597E+01 2.920E+01 0.000E+00 0.000E+00 4.534E-05 4.534E-05 2.920E+01 9.238E-03 0.0000 0.14944 2.920E+01 + 1.400E+00 1.726E+01 2.595E+01 0.000E+00 0.000E+00 4.542E-05 4.542E-05 2.595E+01 1.652E-02 0.0000 0.16119 2.595E+01 + 1.700E+00 1.903E+01 2.234E+01 0.000E+00 0.000E+00 4.555E-05 4.555E-05 2.234E+01 2.902E-02 0.0000 0.17725 2.234E+01 + 2.000E+00 2.066E+01 1.970E+01 0.000E+00 0.000E+00 4.568E-05 4.568E-05 1.970E+01 4.335E-02 0.0000 0.19186 1.970E+01 + 2.500E+00 2.312E+01 1.655E+01 0.000E+00 0.000E+00 4.589E-05 4.589E-05 1.655E+01 7.117E-02 0.0000 0.21376 1.655E+01 + 3.000E+00 2.536E+01 1.435E+01 0.000E+00 0.000E+00 4.610E-05 4.610E-05 1.435E+01 1.037E-01 0.0000 0.23336 1.417E+01 + 3.500E+00 2.742E+01 1.272E+01 0.000E+00 0.000E+00 4.632E-05 4.632E-05 1.272E+01 1.408E-01 0.0000 0.25120 1.240E+01 + 4.000E+00 2.935E+01 1.146E+01 0.000E+00 0.000E+00 4.653E-05 4.653E-05 1.146E+01 1.822E-01 0.0000 0.26763 1.106E+01 + 4.500E+00 3.116E+01 1.046E+01 0.000E+00 0.000E+00 4.674E-05 4.674E-05 1.046E+01 2.280E-01 0.0000 0.28290 9.998E+00 + 5.000E+00 3.289E+01 9.635E+00 0.000E+00 0.000E+00 4.695E-05 4.695E-05 9.635E+00 2.778E-01 0.0000 0.29720 9.141E+00 + 5.500E+00 3.453E+01 8.949E+00 0.000E+00 0.000E+00 4.716E-05 4.716E-05 8.949E+00 3.317E-01 0.0000 0.31066 8.434E+00 + 6.000E+00 3.611E+01 8.368E+00 0.000E+00 0.000E+00 4.738E-05 4.738E-05 8.368E+00 3.895E-01 0.0000 0.32339 7.839E+00 + 7.000E+00 3.909E+01 7.435E+00 0.000E+00 0.000E+00 4.780E-05 4.780E-05 7.435E+00 5.166E-01 0.0000 0.34700 6.894E+00 + 8.000E+00 4.189E+01 6.719E+00 0.000E+00 0.000E+00 4.823E-05 4.823E-05 6.719E+00 6.583E-01 0.0000 0.36854 6.177E+00 + 9.000E+00 4.453E+01 6.150E+00 0.000E+00 0.000E+00 4.865E-05 4.865E-05 6.150E+00 8.141E-01 0.0000 0.38836 5.613E+00 + 1.000E+01 4.704E+01 5.687E+00 0.000E+00 0.000E+00 4.907E-05 4.907E-05 5.687E+00 9.833E-01 0.0000 0.40675 5.159E+00 + 1.200E+01 5.177E+01 4.979E+00 0.000E+00 0.000E+00 4.992E-05 4.992E-05 4.979E+00 1.360E+00 0.0000 0.43998 4.469E+00 + 1.400E+01 5.616E+01 4.461E+00 0.000E+00 0.000E+00 5.077E-05 5.077E-05 4.461E+00 1.786E+00 0.0000 0.46937 3.971E+00 + 1.700E+01 6.230E+01 3.901E+00 0.000E+00 0.000E+00 5.204E-05 5.204E-05 3.902E+00 2.507E+00 0.0000 0.50792 3.438E+00 + 2.000E+01 6.802E+01 3.502E+00 0.000E+00 0.000E+00 5.332E-05 5.332E-05 3.502E+00 3.321E+00 0.0000 0.54129 3.061E+00 + 2.500E+01 7.686E+01 3.042E+00 0.000E+00 0.000E+00 5.544E-05 5.544E-05 3.042E+00 4.859E+00 0.0000 0.58827 2.631E+00 + 3.000E+01 8.509E+01 2.731E+00 0.000E+00 0.000E+00 5.756E-05 5.756E-05 2.731E+00 6.598E+00 0.0000 0.62720 2.343E+00 + 3.500E+01 9.285E+01 2.508E+00 0.000E+00 0.000E+00 5.968E-05 5.968E-05 2.508E+00 8.512E+00 0.0000 0.66011 2.136E+00 + 4.000E+01 1.003E+02 2.340E+00 0.000E+00 0.000E+00 6.180E-05 6.180E-05 2.340E+00 1.058E+01 0.0000 0.68834 1.982E+00 + 4.500E+01 1.074E+02 2.210E+00 0.000E+00 0.000E+00 6.392E-05 6.392E-05 2.210E+00 1.278E+01 0.0000 0.71286 1.862E+00 + 5.000E+01 1.143E+02 2.107E+00 0.000E+00 0.000E+00 6.605E-05 6.605E-05 2.107E+00 1.510E+01 0.0000 0.73434 1.767E+00 + 5.500E+01 1.210E+02 2.023E+00 3.229E-07 0.000E+00 6.817E-05 6.849E-05 2.023E+00 1.752E+01 0.0000 0.75332 1.690E+00 + 6.000E+01 1.276E+02 1.954E+00 1.490E-06 0.000E+00 7.029E-05 7.178E-05 1.954E+00 2.004E+01 0.0000 0.77019 1.626E+00 + 7.000E+01 1.403E+02 1.848E+00 3.928E-06 0.000E+00 7.453E-05 7.846E-05 1.848E+00 2.531E+01 0.0000 0.79887 1.528E+00 + 8.000E+01 1.527E+02 1.771E+00 6.495E-06 0.000E+00 7.877E-05 8.527E-05 1.771E+00 3.084E+01 0.0000 0.82227 1.456E+00 + 9.000E+01 1.647E+02 1.713E+00 9.185E-06 0.000E+00 8.302E-05 9.220E-05 1.713E+00 3.659E+01 0.0000 0.84166 1.401E+00 + 1.000E+02 1.764E+02 1.669E+00 1.199E-05 0.000E+00 8.726E-05 9.925E-05 1.670E+00 4.250E+01 0.0010 0.85794 1.359E+00 + 1.200E+02 1.994E+02 1.608E+00 1.793E-05 0.000E+00 9.575E-05 1.137E-04 1.609E+00 5.473E+01 0.0098 0.88361 1.298E+00 + 1.400E+02 2.218E+02 1.570E+00 2.428E-05 0.000E+00 1.042E-04 1.285E-04 1.570E+00 6.732E+01 0.0247 0.90278 1.258E+00 + 1.700E+02 2.546E+02 1.536E+00 3.448E-05 0.000E+00 1.170E-04 1.514E-04 1.536E+00 8.666E+01 0.0541 0.92363 1.219E+00 + 2.000E+02 2.868E+02 1.518E+00 4.544E-05 0.000E+00 1.297E-04 1.751E-04 1.519E+00 1.063E+02 0.0884 0.93835 1.195E+00 + 2.500E+02 3.396E+02 1.508E+00 6.515E-05 0.000E+00 1.509E-04 2.161E-04 1.508E+00 1.394E+02 0.1508 0.95485 1.172E+00 + 3.000E+02 3.917E+02 1.509E+00 8.648E-05 0.000E+00 1.721E-04 2.586E-04 1.510E+00 1.725E+02 0.2157 0.96548 1.162E+00 + 3.500E+02 4.432E+02 1.516E+00 1.092E-04 0.000E+00 1.933E-04 3.025E-04 1.517E+00 2.056E+02 0.2809 0.97274 1.157E+00 + 4.000E+02 4.945E+02 1.526E+00 1.332E-04 0.000E+00 2.146E-04 3.477E-04 1.526E+00 2.385E+02 0.3453 0.97793 1.155E+00 + 4.500E+02 5.455E+02 1.536E+00 1.583E-04 0.000E+00 2.358E-04 3.941E-04 1.537E+00 2.711E+02 0.4084 0.98176 1.155E+00 + 5.000E+02 5.964E+02 1.547E+00 1.845E-04 0.000E+00 2.570E-04 4.414E-04 1.548E+00 3.035E+02 0.4698 0.98467 1.156E+00 + 5.500E+02 6.471E+02 1.558E+00 2.115E-04 0.000E+00 2.782E-04 4.897E-04 1.559E+00 3.357E+02 0.5296 0.98693 1.158E+00 + 6.000E+02 6.977E+02 1.569E+00 2.395E-04 0.000E+00 2.994E-04 5.389E-04 1.570E+00 3.677E+02 0.5876 0.98873 1.160E+00 + 7.000E+02 7.987E+02 1.590E+00 2.978E-04 0.000E+00 3.418E-04 6.396E-04 1.591E+00 4.310E+02 0.6986 0.99136 1.165E+00 + 8.000E+02 8.995E+02 1.610E+00 3.589E-04 0.000E+00 3.843E-04 7.432E-04 1.610E+00 4.934E+02 0.8032 0.99317 1.170E+00 + 9.000E+02 1.000E+03 1.627E+00 4.225E-04 0.000E+00 4.267E-04 8.492E-04 1.628E+00 5.552E+02 0.9021 0.99447 1.174E+00 + 1.000E+03 1.101E+03 1.644E+00 4.884E-04 1.833E-05 4.691E-04 9.759E-04 1.645E+00 6.163E+02 0.9957 0.99542 1.179E+00 + 1.200E+03 1.301E+03 1.673E+00 6.263E-04 1.159E-04 5.540E-04 1.296E-03 1.675E+00 7.368E+02 1.1691 0.99672 1.187E+00 + 1.400E+03 1.502E+03 1.699E+00 7.712E-04 2.268E-04 6.389E-04 1.637E-03 1.700E+00 8.552E+02 1.3269 0.99753 1.194E+00 + 1.700E+03 1.803E+03 1.731E+00 9.996E-04 4.144E-04 7.661E-04 2.180E-03 1.733E+00 1.030E+03 1.5399 0.99829 1.202E+00 + 2.000E+03 2.103E+03 1.758E+00 1.239E-03 6.238E-04 8.967E-04 2.760E-03 1.761E+00 1.202E+03 1.7300 0.99874 1.209E+00 + 2.500E+03 2.604E+03 1.795E+00 1.660E-03 1.013E-03 1.126E-03 3.800E-03 1.799E+00 1.482E+03 2.0079 0.99918 1.219E+00 + 3.000E+03 3.104E+03 1.825E+00 2.103E-03 1.444E-03 1.359E-03 4.906E-03 1.829E+00 1.758E+03 2.2491 0.99942 1.226E+00 + 3.500E+03 3.604E+03 1.849E+00 2.565E-03 1.910E-03 1.594E-03 6.068E-03 1.855E+00 2.029E+03 2.4623 0.99957 1.231E+00 + 4.000E+03 4.104E+03 1.870E+00 3.042E-03 2.407E-03 1.831E-03 7.279E-03 1.877E+00 2.297E+03 2.6536 0.99967 1.236E+00 + 4.500E+03 4.604E+03 1.888E+00 3.533E-03 2.929E-03 2.069E-03 8.532E-03 1.897E+00 2.562E+03 2.8273 0.99974 1.239E+00 + 5.000E+03 5.105E+03 1.904E+00 4.038E-03 3.478E-03 2.305E-03 9.821E-03 1.914E+00 2.825E+03 2.9865 0.99979 1.243E+00 + 5.500E+03 5.605E+03 1.919E+00 4.562E-03 4.058E-03 2.523E-03 1.114E-02 1.930E+00 3.085E+03 3.1334 0.99982 1.245E+00 + 6.000E+03 6.105E+03 1.932E+00 5.097E-03 4.658E-03 2.740E-03 1.249E-02 1.944E+00 3.343E+03 3.2699 0.99985 1.248E+00 + 7.000E+03 7.105E+03 1.954E+00 6.196E-03 5.912E-03 3.171E-03 1.528E-02 1.969E+00 3.854E+03 3.5172 0.99989 1.251E+00 + 8.000E+03 8.105E+03 1.973E+00 7.329E-03 7.232E-03 3.601E-03 1.816E-02 1.991E+00 4.359E+03 3.7367 0.99992 1.254E+00 + 9.000E+03 9.105E+03 1.989E+00 8.493E-03 8.607E-03 4.029E-03 2.113E-02 2.010E+00 4.859E+03 3.9342 0.99993 1.257E+00 + 1.000E+04 1.011E+04 2.003E+00 9.685E-03 1.004E-02 4.454E-03 2.417E-02 2.028E+00 5.354E+03 4.1137 0.99995 1.259E+00 + 1.200E+04 1.211E+04 2.027E+00 1.216E-02 1.307E-02 5.279E-03 3.050E-02 2.058E+00 6.333E+03 4.4307 0.99996 1.262E+00 + 1.400E+04 1.411E+04 2.047E+00 1.471E-02 1.627E-02 6.095E-03 3.707E-02 2.084E+00 7.298E+03 4.7044 0.99997 1.264E+00 + 1.700E+04 1.711E+04 2.071E+00 1.867E-02 2.131E-02 7.306E-03 4.729E-02 2.119E+00 8.726E+03 5.0560 0.99998 1.266E+00 + 2.000E+04 2.011E+04 2.091E+00 2.277E-02 2.661E-02 8.504E-03 5.788E-02 2.149E+00 1.013E+04 5.3556 0.99999 1.268E+00 + 2.500E+04 2.511E+04 2.116E+00 2.985E-02 3.611E-02 1.050E-02 7.646E-02 2.193E+00 1.243E+04 5.7742 0.99999 1.270E+00 + 3.000E+04 3.011E+04 2.137E+00 3.719E-02 4.614E-02 1.247E-02 9.580E-02 2.232E+00 1.469E+04 6.1216 0.99999 1.271E+00 + 3.500E+04 3.511E+04 2.153E+00 4.473E-02 5.660E-02 1.443E-02 1.158E-01 2.269E+00 1.692E+04 6.4186 1.00000 1.271E+00 + 4.000E+04 4.011E+04 2.167E+00 5.246E-02 6.743E-02 1.637E-02 1.363E-01 2.304E+00 1.910E+04 6.6781 1.00000 1.272E+00 + 4.500E+04 4.511E+04 2.179E+00 6.035E-02 7.858E-02 1.829E-02 1.572E-01 2.337E+00 2.126E+04 6.9084 1.00000 1.272E+00 + 5.000E+04 5.011E+04 2.190E+00 6.837E-02 9.001E-02 2.021E-02 1.786E-01 2.369E+00 2.338E+04 7.1154 1.00000 1.272E+00 + 5.500E+04 5.511E+04 2.200E+00 7.648E-02 1.015E-01 2.215E-02 2.001E-01 2.400E+00 2.548E+04 7.3034 1.00000 1.273E+00 + 6.000E+04 6.011E+04 2.208E+00 8.469E-02 1.132E-01 2.409E-02 2.219E-01 2.430E+00 2.755E+04 7.4756 1.00000 1.273E+00 + 7.000E+04 7.011E+04 2.223E+00 1.014E-01 1.371E-01 2.795E-02 2.664E-01 2.490E+00 3.161E+04 7.7816 1.00000 1.273E+00 + 8.000E+04 8.011E+04 2.236E+00 1.185E-01 1.617E-01 3.178E-02 3.119E-01 2.548E+00 3.558E+04 8.0475 1.00000 1.273E+00 + 9.000E+04 9.011E+04 2.248E+00 1.359E-01 1.869E-01 3.560E-02 3.583E-01 2.606E+00 3.946E+04 8.2825 1.00000 1.273E+00 + 1.000E+05 1.001E+05 2.258E+00 1.535E-01 2.126E-01 3.941E-02 4.055E-01 2.663E+00 4.326E+04 8.4929 1.00000 1.273E+00 + 1.200E+05 1.201E+05 2.275E+00 1.891E-01 2.646E-01 4.714E-02 5.009E-01 2.776E+00 5.062E+04 8.8572 1.00000 1.273E+00 + 1.400E+05 1.401E+05 2.289E+00 2.256E-01 3.181E-01 5.484E-02 5.985E-01 2.888E+00 5.768E+04 9.1653 1.00000 1.273E+00 + 1.700E+05 1.701E+05 2.307E+00 2.814E-01 4.006E-01 6.636E-02 7.483E-01 3.055E+00 6.778E+04 9.5533 1.00000 1.273E+00 + 2.000E+05 2.001E+05 2.322E+00 3.384E-01 4.853E-01 7.784E-02 9.016E-01 3.224E+00 7.734E+04 9.8782 1.00000 1.273E+00 + 2.500E+05 2.501E+05 2.343E+00 4.341E-01 6.243E-01 9.727E-02 1.156E+00 3.498E+00 9.222E+04 10.3243 1.00000 1.273E+00 + 3.000E+05 3.001E+05 2.360E+00 5.318E-01 7.663E-01 1.167E-01 1.415E+00 3.774E+00 1.060E+05 10.6888 1.00000 1.273E+00 + 3.500E+05 3.501E+05 2.374E+00 6.312E-01 9.111E-01 1.361E-01 1.678E+00 4.052E+00 1.188E+05 10.9970 1.00000 1.273E+00 + 4.000E+05 4.001E+05 2.386E+00 7.320E-01 1.058E+00 1.556E-01 1.946E+00 4.332E+00 1.307E+05 11.2639 1.00000 1.273E+00 + 4.500E+05 4.501E+05 2.397E+00 8.341E-01 1.207E+00 1.750E-01 2.216E+00 4.613E+00 1.419E+05 11.4995 1.00000 1.273E+00 + 5.000E+05 5.001E+05 2.407E+00 9.373E-01 1.358E+00 1.944E-01 2.489E+00 4.896E+00 1.524E+05 11.7101 1.00000 1.273E+00 + 5.500E+05 5.501E+05 2.416E+00 1.040E+00 1.506E+00 2.143E-01 2.759E+00 5.175E+00 1.623E+05 11.9007 1.00000 1.273E+00 + 6.000E+05 6.001E+05 2.424E+00 1.143E+00 1.654E+00 2.343E-01 3.031E+00 5.455E+00 1.717E+05 12.0747 1.00000 1.273E+00 + 7.000E+05 7.001E+05 2.438E+00 1.351E+00 1.955E+00 2.743E-01 3.580E+00 6.018E+00 1.892E+05 12.3830 1.00000 1.273E+00 + 8.000E+05 8.001E+05 2.451E+00 1.562E+00 2.259E+00 3.144E-01 4.135E+00 6.585E+00 2.051E+05 12.6500 1.00000 1.273E+00 + 9.000E+05 9.001E+05 2.462E+00 1.774E+00 2.565E+00 3.547E-01 4.694E+00 7.156E+00 2.196E+05 12.8855 1.00000 1.273E+00 + 1.000E+06 1.000E+06 2.472E+00 1.989E+00 2.875E+00 3.950E-01 5.258E+00 7.730E+00 2.331E+05 13.0962 1.00000 1.273E+00 + 1.200E+06 1.200E+06 2.489E+00 2.415E+00 3.487E+00 4.773E-01 6.380E+00 8.868E+00 2.572E+05 13.4608 1.00000 1.273E+00 + 1.400E+06 1.400E+06 2.503E+00 2.847E+00 4.105E+00 5.600E-01 7.511E+00 1.001E+01 2.784E+05 13.7691 1.00000 1.273E+00 + 1.700E+06 1.700E+06 2.522E+00 3.501E+00 5.040E+00 6.848E-01 9.226E+00 1.175E+01 3.061E+05 14.1574 1.00000 1.273E+00 + 2.000E+06 2.000E+06 2.538E+00 4.161E+00 5.985E+00 8.104E-01 1.096E+01 1.349E+01 3.299E+05 14.4824 1.00000 1.273E+00 + 2.500E+06 2.500E+06 2.559E+00 5.256E+00 7.542E+00 1.024E+00 1.382E+01 1.638E+01 3.634E+05 14.9287 1.00000 1.273E+00 + 3.000E+06 3.000E+06 2.577E+00 6.360E+00 9.110E+00 1.240E+00 1.671E+01 1.929E+01 3.915E+05 15.2933 1.00000 1.273E+00 + 3.500E+06 3.500E+06 2.592E+00 7.473E+00 1.069E+01 1.458E+00 1.962E+01 2.221E+01 4.157E+05 15.6016 1.00000 1.273E+00 + 4.000E+06 4.000E+06 2.606E+00 8.592E+00 1.227E+01 1.677E+00 2.254E+01 2.515E+01 4.368E+05 15.8686 1.00000 1.273E+00 + 4.500E+06 4.500E+06 2.617E+00 9.717E+00 1.386E+01 1.898E+00 2.548E+01 2.810E+01 4.556E+05 16.1042 1.00000 1.273E+00 + 5.000E+06 5.000E+06 2.628E+00 1.085E+01 1.546E+01 2.120E+00 2.843E+01 3.106E+01 4.725E+05 16.3149 1.00000 1.273E+00 + 5.500E+06 5.500E+06 2.637E+00 1.197E+01 1.704E+01 2.346E+00 3.136E+01 3.400E+01 4.879E+05 16.5055 1.00000 1.273E+00 + 6.000E+06 6.000E+06 2.646E+00 1.309E+01 1.863E+01 2.573E+00 3.429E+01 3.694E+01 5.020E+05 16.6796 1.00000 1.273E+00 + 7.000E+06 7.000E+06 2.662E+00 1.534E+01 2.181E+01 3.031E+00 4.018E+01 4.284E+01 5.272E+05 16.9879 1.00000 1.273E+00 + 8.000E+06 8.000E+06 2.676E+00 1.761E+01 2.500E+01 3.493E+00 4.609E+01 4.877E+01 5.490E+05 17.2549 1.00000 1.273E+00 + 9.000E+06 9.000E+06 2.688E+00 1.988E+01 2.819E+01 3.959E+00 5.203E+01 5.471E+01 5.684E+05 17.4905 1.00000 1.273E+00 + 1.000E+07 1.000E+07 2.698E+00 2.215E+01 3.140E+01 4.427E+00 5.798E+01 6.068E+01 5.857E+05 17.7012 1.00000 1.273E+00 + 1.200E+07 1.200E+07 2.717E+00 2.669E+01 3.777E+01 5.382E+00 6.984E+01 7.255E+01 6.158E+05 18.0658 1.00000 1.273E+00 + 1.400E+07 1.400E+07 2.734E+00 3.124E+01 4.416E+01 6.347E+00 8.174E+01 8.447E+01 6.413E+05 18.3741 1.00000 1.273E+00 + 1.700E+07 1.700E+07 2.754E+00 3.808E+01 5.376E+01 7.811E+00 9.965E+01 1.024E+02 6.735E+05 18.7624 1.00000 1.273E+00 + 2.000E+07 2.000E+07 2.771E+00 4.496E+01 6.339E+01 9.292E+00 1.176E+02 1.204E+02 7.005E+05 19.0875 1.00000 1.273E+00 + 2.500E+07 2.500E+07 2.795E+00 5.635E+01 7.938E+01 1.182E+01 1.476E+02 1.503E+02 7.376E+05 19.5338 1.00000 1.273E+00 + 3.000E+07 3.000E+07 2.815E+00 6.777E+01 9.540E+01 1.439E+01 1.776E+02 1.804E+02 7.679E+05 19.8984 1.00000 1.273E+00 + 3.500E+07 3.500E+07 2.832E+00 7.921E+01 1.114E+02 1.699E+01 2.076E+02 2.105E+02 7.936E+05 20.2067 1.00000 1.273E+00 + 4.000E+07 4.000E+07 2.847E+00 9.067E+01 1.275E+02 1.962E+01 2.378E+02 2.406E+02 8.158E+05 20.4738 1.00000 1.273E+00 + 4.500E+07 4.500E+07 2.860E+00 1.022E+02 1.436E+02 2.227E+01 2.680E+02 2.709E+02 8.354E+05 20.7093 1.00000 1.273E+00 + 5.000E+07 5.000E+07 2.871E+00 1.137E+02 1.597E+02 2.494E+01 2.983E+02 3.011E+02 8.529E+05 20.9200 1.00000 1.273E+00 + 5.500E+07 5.500E+07 2.882E+00 1.251E+02 1.757E+02 2.765E+01 3.285E+02 3.314E+02 8.687E+05 21.1107 1.00000 1.273E+00 + 6.000E+07 6.000E+07 2.892E+00 1.366E+02 1.918E+02 3.038E+01 3.587E+02 3.616E+02 8.831E+05 21.2847 1.00000 1.273E+00 + 7.000E+07 7.000E+07 2.909E+00 1.595E+02 2.239E+02 3.590E+01 4.193E+02 4.222E+02 9.087E+05 21.5930 1.00000 1.273E+00 + 8.000E+07 8.000E+07 2.924E+00 1.825E+02 2.560E+02 4.148E+01 4.800E+02 4.829E+02 9.308E+05 21.8601 1.00000 1.273E+00 + 9.000E+07 9.000E+07 2.938E+00 2.055E+02 2.882E+02 4.711E+01 5.408E+02 5.437E+02 9.503E+05 22.0956 1.00000 1.273E+00 + 1.000E+08 1.000E+08 2.950E+00 2.285E+02 3.203E+02 5.279E+01 6.016E+02 6.046E+02 9.678E+05 22.3063 1.00000 1.273E+00 + 1.200E+08 1.200E+08 2.971E+00 2.742E+02 3.844E+02 6.335E+01 7.220E+02 7.249E+02 9.979E+05 22.6710 1.00000 1.273E+00 + 1.400E+08 1.400E+08 2.989E+00 3.199E+02 4.485E+02 7.391E+01 8.423E+02 8.453E+02 1.023E+06 22.9793 1.00000 1.273E+00 + 1.700E+08 1.700E+08 3.012E+00 3.885E+02 5.446E+02 8.974E+01 1.023E+03 1.026E+03 1.056E+06 23.3676 1.00000 1.273E+00 + 2.000E+08 2.000E+08 3.031E+00 4.570E+02 6.407E+02 1.056E+02 1.203E+03 1.206E+03 1.083E+06 23.6926 1.00000 1.273E+00 + 2.500E+08 2.500E+08 3.058E+00 5.713E+02 8.008E+02 1.320E+02 1.504E+03 1.507E+03 1.120E+06 24.1389 1.00000 1.273E+00 + 3.000E+08 3.000E+08 3.080E+00 6.856E+02 9.610E+02 1.584E+02 1.805E+03 1.808E+03 1.150E+06 24.5036 1.00000 1.273E+00 + 3.500E+08 3.500E+08 3.098E+00 7.998E+02 1.121E+03 1.848E+02 2.106E+03 2.109E+03 1.175E+06 24.8119 1.00000 1.273E+00 + 4.000E+08 4.000E+08 3.115E+00 9.141E+02 1.281E+03 2.112E+02 2.407E+03 2.410E+03 1.198E+06 25.0789 1.00000 1.273E+00 + 4.500E+08 4.500E+08 3.129E+00 1.028E+03 1.441E+03 2.376E+02 2.707E+03 2.711E+03 1.217E+06 25.3145 1.00000 1.273E+00 + 5.000E+08 5.000E+08 3.142E+00 1.143E+03 1.602E+03 2.640E+02 3.008E+03 3.011E+03 1.235E+06 25.5252 1.00000 1.273E+00 + 5.500E+08 5.500E+08 3.154E+00 1.257E+03 1.762E+03 2.903E+02 3.309E+03 3.312E+03 1.250E+06 25.7158 1.00000 1.273E+00 + 6.000E+08 6.000E+08 3.165E+00 1.371E+03 1.922E+03 3.167E+02 3.610E+03 3.613E+03 1.265E+06 25.8899 1.00000 1.273E+00 + 7.000E+08 7.000E+08 3.185E+00 1.600E+03 2.242E+03 3.695E+02 4.211E+03 4.215E+03 1.290E+06 26.1982 1.00000 1.273E+00 + 8.000E+08 8.000E+08 3.202E+00 1.828E+03 2.563E+03 4.223E+02 4.813E+03 4.816E+03 1.313E+06 26.4652 1.00000 1.273E+00 + 9.000E+08 9.000E+08 3.217E+00 2.057E+03 2.883E+03 4.751E+02 5.415E+03 5.418E+03 1.332E+06 26.7008 1.00000 1.273E+00 + 1.000E+09 1.000E+09 3.230E+00 2.285E+03 3.203E+03 5.279E+02 6.016E+03 6.020E+03 1.350E+06 26.9115 1.00000 1.273E+00 diff --git a/analysis/algorithms/tables/pE_liquid_argon.txt b/analysis/algorithms/tables/pE_liquid_argon.txt new file mode 100644 index 00000000..9469da28 --- /dev/null +++ b/analysis/algorithms/tables/pE_liquid_argon.txt @@ -0,0 +1,133 @@ +T eStoppingPower nucStoppingPower dE/dx CSDARange ProjectedRange Detour +1.000E-03 8.608E+01 7.470E+00 9.355E+01 1.741E-05 4.206E-06 0.2416 +1.500E-03 1.054E+02 6.891E+00 1.123E+02 2.223E-05 6.141E-06 0.2762 +2.000E-03 1.217E+02 6.398E+00 1.281E+02 2.639E-05 8.047E-06 0.3049 +2.500E-03 1.361E+02 5.980E+00 1.421E+02 3.009E-05 9.911E-06 0.3294 +3.000E-03 1.491E+02 5.623E+00 1.547E+02 3.346E-05 1.174E-05 0.3507 +4.000E-03 1.722E+02 5.045E+00 1.772E+02 3.949E-05 1.527E-05 0.3867 +5.000E-03 1.925E+02 4.594E+00 1.971E+02 4.483E-05 1.867E-05 0.4164 +6.000E-03 2.109E+02 4.231E+00 2.151E+02 4.968E-05 2.194E-05 0.4415 +7.000E-03 2.277E+02 3.931E+00 2.317E+02 5.416E-05 2.509E-05 0.4632 +8.000E-03 2.435E+02 3.678E+00 2.472E+02 5.834E-05 2.813E-05 0.4823 +9.000E-03 2.582E+02 3.460E+00 2.617E+02 6.227E-05 3.109E-05 0.4992 +1.000E-02 2.722E+02 3.271E+00 2.755E+02 6.599E-05 3.395E-05 0.5145 +1.250E-02 2.997E+02 2.890E+00 3.026E+02 7.463E-05 4.082E-05 0.5470 +1.500E-02 3.235E+02 2.599E+00 3.261E+02 8.258E-05 4.737E-05 0.5736 +1.750E-02 3.445E+02 2.368E+00 3.469E+02 9.001E-05 5.365E-05 0.5961 +2.000E-02 3.633E+02 2.180E+00 3.655E+02 9.703E-05 5.971E-05 0.6154 +2.250E-02 3.802E+02 2.023E+00 3.822E+02 1.037E-04 6.556E-05 0.6322 +2.500E-02 3.953E+02 1.890E+00 3.972E+02 1.101E-04 7.126E-05 0.6470 +2.750E-02 4.090E+02 1.775E+00 4.108E+02 1.163E-04 7.680E-05 0.6603 +3.000E-02 4.214E+02 1.675E+00 4.230E+02 1.223E-04 8.223E-05 0.6723 +3.500E-02 4.425E+02 1.509E+00 4.440E+02 1.338E-04 9.277E-05 0.6932 +4.000E-02 4.594E+02 1.376E+00 4.608E+02 1.449E-04 1.030E-04 0.7108 +4.500E-02 4.728E+02 1.266E+00 4.741E+02 1.556E-04 1.130E-04 0.7261 +5.000E-02 4.831E+02 1.175E+00 4.843E+02 1.660E-04 1.228E-04 0.7395 +5.500E-02 4.907E+02 1.097E+00 4.918E+02 1.762E-04 1.324E-04 0.7514 +6.000E-02 4.960E+02 1.030E+00 4.970E+02 1.864E-04 1.420E-04 0.7622 +6.500E-02 4.992E+02 9.711E-01 5.002E+02 1.964E-04 1.516E-04 0.7719 +7.000E-02 5.007E+02 9.194E-01 5.017E+02 2.064E-04 1.611E-04 0.7809 +7.500E-02 5.008E+02 8.734E-01 5.016E+02 2.163E-04 1.707E-04 0.7891 +8.000E-02 4.995E+02 8.323E-01 5.003E+02 2.263E-04 1.803E-04 0.7967 +8.500E-02 4.972E+02 7.952E-01 4.980E+02 2.363E-04 1.899E-04 0.8037 +9.000E-02 4.940E+02 7.616E-01 4.947E+02 2.464E-04 1.997E-04 0.8104 +9.500E-02 4.900E+02 7.310E-01 4.907E+02 2.565E-04 2.095E-04 0.8166 +1.000E-01 4.855E+02 7.029E-01 4.862E+02 2.668E-04 2.194E-04 0.8224 +1.250E-01 4.574E+02 5.920E-01 4.580E+02 3.197E-04 2.708E-04 0.8472 +1.500E-01 4.267E+02 5.134E-01 4.272E+02 3.762E-04 3.260E-04 0.8666 +1.750E-01 3.977E+02 4.546E-01 3.982E+02 4.368E-04 3.854E-04 0.8822 +2.000E-01 3.719E+02 4.088E-01 3.724E+02 5.018E-04 4.491E-04 0.8949 +2.250E-01 3.495E+02 3.719E-01 3.499E+02 5.711E-04 5.172E-04 0.9056 +2.500E-01 3.301E+02 3.417E-01 3.304E+02 6.447E-04 5.895E-04 0.9144 +2.750E-01 3.132E+02 3.163E-01 3.135E+02 7.224E-04 6.660E-04 0.9220 +3.000E-01 2.985E+02 2.947E-01 2.988E+02 8.041E-04 7.465E-04 0.9284 +3.500E-01 2.742E+02 2.598E-01 2.745E+02 9.789E-04 9.189E-04 0.9387 +4.000E-01 2.549E+02 2.328E-01 2.551E+02 1.168E-03 1.106E-03 0.9465 +4.500E-01 2.390E+02 2.112E-01 2.392E+02 1.371E-03 1.306E-03 0.9525 +5.000E-01 2.256E+02 1.935E-01 2.258E+02 1.586E-03 1.518E-03 0.9574 +5.500E-01 2.144E+02 1.787E-01 2.146E+02 1.813E-03 1.743E-03 0.9613 +6.000E-01 2.047E+02 1.662E-01 2.048E+02 2.052E-03 1.979E-03 0.9645 +6.500E-01 1.961E+02 1.554E-01 1.962E+02 2.301E-03 2.226E-03 0.9672 +7.000E-01 1.884E+02 1.460E-01 1.885E+02 2.561E-03 2.483E-03 0.9694 +7.500E-01 1.813E+02 1.378E-01 1.815E+02 2.832E-03 2.751E-03 0.9714 +8.000E-01 1.749E+02 1.304E-01 1.750E+02 3.112E-03 3.029E-03 0.9731 +8.500E-01 1.689E+02 1.239E-01 1.691E+02 3.403E-03 3.316E-03 0.9745 +9.000E-01 1.634E+02 1.181E-01 1.635E+02 3.704E-03 3.614E-03 0.9758 +9.500E-01 1.582E+02 1.128E-01 1.583E+02 4.015E-03 3.922E-03 0.9770 +1.000E+00 1.533E+02 1.080E-01 1.534E+02 4.336E-03 4.240E-03 0.9780 +1.250E+00 1.330E+02 8.925E-02 1.331E+02 6.090E-03 5.979E-03 0.9818 +1.500E+00 1.182E+02 7.633E-02 1.183E+02 8.087E-03 7.960E-03 0.9843 +1.750E+00 1.068E+02 6.682E-02 1.069E+02 1.031E-02 1.017E-02 0.9860 +2.000E+00 9.772E+01 5.950E-02 9.778E+01 1.276E-02 1.260E-02 0.9872 +2.250E+00 9.027E+01 5.370E-02 9.032E+01 1.543E-02 1.524E-02 0.9881 +2.500E+00 8.401E+01 4.898E-02 8.406E+01 1.830E-02 1.809E-02 0.9889 +2.750E+00 7.867E+01 4.505E-02 7.872E+01 2.137E-02 2.115E-02 0.9895 +3.000E+00 7.405E+01 4.174E-02 7.409E+01 2.465E-02 2.440E-02 0.9900 +3.500E+00 6.643E+01 3.645E-02 6.647E+01 3.179E-02 3.149E-02 0.9907 +4.000E+00 6.039E+01 3.239E-02 6.043E+01 3.969E-02 3.934E-02 0.9913 +4.500E+00 5.547E+01 2.919E-02 5.550E+01 4.833E-02 4.793E-02 0.9917 +5.000E+00 5.138E+01 2.658E-02 5.141E+01 5.770E-02 5.724E-02 0.9921 +5.500E+00 4.791E+01 2.442E-02 4.793E+01 6.778E-02 6.726E-02 0.9924 +6.000E+00 4.493E+01 2.259E-02 4.495E+01 7.856E-02 7.798E-02 0.9926 +6.500E+00 4.233E+01 2.104E-02 4.236E+01 9.002E-02 8.937E-02 0.9928 +7.000E+00 4.006E+01 1.969E-02 4.008E+01 1.022E-01 1.014E-01 0.9930 +7.500E+00 3.804E+01 1.850E-02 3.806E+01 1.150E-01 1.142E-01 0.9931 +8.000E+00 3.624E+01 1.746E-02 3.626E+01 1.284E-01 1.276E-01 0.9933 +8.500E+00 3.462E+01 1.654E-02 3.463E+01 1.425E-01 1.416E-01 0.9934 +9.000E+00 3.315E+01 1.571E-02 3.317E+01 1.573E-01 1.563E-01 0.9935 +9.500E+00 3.182E+01 1.496E-02 3.183E+01 1.727E-01 1.716E-01 0.9936 +1.000E+01 3.060E+01 1.428E-02 3.061E+01 1.887E-01 1.875E-01 0.9937 +1.250E+01 2.579E+01 1.167E-02 2.581E+01 2.780E-01 2.764E-01 0.9940 +1.500E+01 2.241E+01 9.887E-03 2.242E+01 3.823E-01 3.801E-01 0.9943 +1.750E+01 1.989E+01 8.590E-03 1.989E+01 5.009E-01 4.981E-01 0.9945 +2.000E+01 1.792E+01 7.601E-03 1.793E+01 6.335E-01 6.301E-01 0.9946 +2.500E+01 1.506E+01 6.192E-03 1.507E+01 9.390E-01 9.342E-01 0.9949 +2.750E+01 1.398E+01 5.671E-03 1.399E+01 1.111E+00 1.106E+00 0.9949 +3.000E+01 1.306E+01 5.233E-03 1.307E+01 1.296E+00 1.290E+00 0.9950 +3.500E+01 1.158E+01 4.537E-03 1.159E+01 1.704E+00 1.695E+00 0.9952 +4.000E+01 1.044E+01 4.008E-03 1.044E+01 2.159E+00 2.149E+00 0.9953 +4.500E+01 9.525E+00 3.592E-03 9.529E+00 2.661E+00 2.649E+00 0.9954 +5.000E+01 8.780E+00 3.256E-03 8.783E+00 3.208E+00 3.193E+00 0.9954 +5.500E+01 8.159E+00 2.979E-03 8.162E+00 3.799E+00 3.782E+00 0.9955 +6.000E+01 7.633E+00 2.746E-03 7.636E+00 4.433E+00 4.413E+00 0.9956 +6.500E+01 7.182E+00 2.548E-03 7.184E+00 5.108E+00 5.086E+00 0.9956 +7.000E+01 6.790E+00 2.377E-03 6.792E+00 5.824E+00 5.799E+00 0.9957 +7.500E+01 6.446E+00 2.228E-03 6.449E+00 6.580E+00 6.552E+00 0.9957 +8.000E+01 6.142E+00 2.097E-03 6.145E+00 7.375E+00 7.344E+00 0.9958 +8.500E+01 5.872E+00 1.981E-03 5.874E+00 8.207E+00 8.173E+00 0.9958 +9.000E+01 5.629E+00 1.878E-03 5.631E+00 9.077E+00 9.040E+00 0.9959 +9.500E+01 5.410E+00 1.785E-03 5.412E+00 9.983E+00 9.942E+00 0.9959 +1.000E+02 5.212E+00 1.701E-03 5.213E+00 1.092E+01 1.088E+01 0.9959 +1.250E+02 4.443E+00 1.378E-03 4.445E+00 1.614E+01 1.608E+01 0.9961 +1.500E+02 3.918E+00 1.161E-03 3.919E+00 2.215E+01 2.207E+01 0.9962 +1.750E+02 3.536E+00 1.003E-03 3.537E+00 2.888E+01 2.877E+01 0.9963 +2.000E+02 3.246E+00 8.844E-04 3.246E+00 3.627E+01 3.614E+01 0.9964 +2.250E+02 3.017E+00 7.912E-04 3.018E+00 4.426E+01 4.411E+01 0.9965 +2.500E+02 2.834E+00 7.162E-04 2.834E+00 5.282E+01 5.264E+01 0.9966 +2.750E+02 2.683E+00 6.545E-04 2.683E+00 6.189E+01 6.168E+01 0.9966 +3.000E+02 2.556E+00 6.027E-04 2.557E+00 7.144E+01 7.120E+01 0.9967 +3.500E+02 2.358E+00 5.210E-04 2.358E+00 9.184E+01 9.154E+01 0.9968 +4.000E+02 2.210E+00 4.591E-04 2.210E+00 1.138E+02 1.134E+02 0.9969 +4.500E+02 2.095E+00 4.107E-04 2.095E+00 1.370E+02 1.366E+02 0.9970 +5.000E+02 2.004E+00 3.718E-04 2.004E+00 1.614E+02 1.610E+02 0.9971 +5.500E+02 1.931E+00 3.397E-04 1.931E+00 1.869E+02 1.863E+02 0.9972 +6.000E+02 1.871E+00 3.129E-04 1.871E+00 2.132E+02 2.126E+02 0.9972 +6.500E+02 1.821E+00 2.901E-04 1.821E+00 2.403E+02 2.396E+02 0.9973 +7.000E+02 1.779E+00 2.705E-04 1.779E+00 2.681E+02 2.674E+02 0.9974 +7.500E+02 1.744E+00 2.534E-04 1.744E+00 2.965E+02 2.957E+02 0.9974 +8.000E+02 1.713E+00 2.384E-04 1.714E+00 3.254E+02 3.246E+02 0.9975 +8.500E+02 1.688E+00 2.252E-04 1.688E+00 3.548E+02 3.539E+02 0.9975 +9.000E+02 1.665E+00 2.133E-04 1.665E+00 3.846E+02 3.837E+02 0.9976 +9.500E+02 1.646E+00 2.027E-04 1.646E+00 4.148E+02 4.138E+02 0.9976 +1.000E+03 1.629E+00 1.932E-04 1.629E+00 4.454E+02 4.443E+02 0.9977 +1.500E+03 1.542E+00 1.318E-04 1.542E+00 7.626E+02 7.611E+02 0.9980 +2.000E+03 1.521E+00 1.006E-04 1.521E+00 1.090E+03 1.088E+03 0.9983 +2.500E+03 1.523E+00 8.159E-05 1.523E+00 1.418E+03 1.416E+03 0.9984 +3.000E+03 1.535E+00 6.877E-05 1.535E+00 1.745E+03 1.743E+03 0.9986 +4.000E+03 1.567E+00 5.253E-05 1.567E+00 2.391E+03 2.388E+03 0.9988 +5.000E+03 1.601E+00 4.264E-05 1.601E+00 3.022E+03 3.018E+03 0.9989 +6.000E+03 1.634E+00 3.596E-05 1.634E+00 3.640E+03 3.636E+03 0.9990 +7.000E+03 1.665E+00 3.113E-05 1.665E+00 4.246E+03 4.242E+03 0.9991 +8.000E+03 1.693E+00 2.748E-05 1.693E+00 4.842E+03 4.838E+03 0.9992 +9.000E+03 1.719E+00 2.462E-05 1.719E+00 5.428E+03 5.424E+03 0.9992 +1.000E+04 1.743E+00 2.231E-05 1.743E+00 6.005E+03 6.001E+03 0.9993 From 3d985d5daceba099b547ce01a50f9b2fc6b8f8c5 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Sun, 26 Feb 2023 21:37:43 -0800 Subject: [PATCH 009/180] Refactored local numba functions under a local nbl module --- mlreco/utils/gnn/cluster.py | 28 ++-- mlreco/utils/gnn/data.py | 7 +- mlreco/utils/gnn/evaluation.py | 15 ++- mlreco/utils/gnn/network.py | 23 ++-- mlreco/utils/gnn/voxels.py | 7 +- mlreco/utils/inference.py | 9 +- mlreco/utils/numba.py | 229 --------------------------------- mlreco/utils/numba_local.py | 189 +++++++++++++++++++++++++++ mlreco/utils/wrapper.py | 65 ++++++++++ 9 files changed, 307 insertions(+), 265 deletions(-) delete mode 100644 mlreco/utils/numba.py create mode 100644 mlreco/utils/numba_local.py create mode 100644 mlreco/utils/wrapper.py diff --git a/mlreco/utils/gnn/cluster.py b/mlreco/utils/gnn/cluster.py index 56cafe82..0e479012 100644 --- a/mlreco/utils/gnn/cluster.py +++ b/mlreco/utils/gnn/cluster.py @@ -4,7 +4,9 @@ import torch from typing import List -from mlreco.utils.numba import numba_wrapper, cdist_nb, mean_nb, unique_nb +import mlreco.utils.numba_local as nbl +from mlreco.utils.wrapper import numba_wrapper + @numba_wrapper(cast_args=['data'], list_args=['cluster_classes'], keep_torch=True, ref_arg='data') def form_clusters(data, min_size=-1, column=5, batch_index=0, cluster_classes=[-1], shape_index=-1): @@ -140,7 +142,7 @@ def _get_cluster_label(data: nb.float64[:,:], labels = np.empty(len(clusts), dtype=data.dtype) for i, c in enumerate(clusts): - v, cts = unique_nb(data[c, column]) + v, cts = nbl.unique(data[c, column]) labels[i] = v[np.argmax(np.array(cts))] return labels @@ -177,9 +179,9 @@ def _get_cluster_primary_label(data: nb.float64[:,:], cluster_ids = data[clusts[i], cluster_column] primary_mask = cluster_ids == group_ids[i] if len(data[clusts[i][primary_mask]]): - v, cts = unique_nb(data[clusts[i][primary_mask], column]) + v, cts = nbl.unique(data[clusts[i][primary_mask], column]) else: # If the primary is empty, use group - v, cts = unique_nb(data[clusts[i], column]) + v, cts = nbl.unique(data[clusts[i], column]) labels[i] = v[np.argmax(np.array(cts))] return labels @@ -310,7 +312,7 @@ def _get_cluster_features(data: nb.float64[:,:], x = data[clust, coords_col[0]:coords_col[1]] # Center data - center = mean_nb(x, 0) + center = nbl.mean(x, 0) x = x - center # Get orientation matrix @@ -378,7 +380,7 @@ def _get_cluster_features_extended(data: nb.float64[:,:], std_value = np.std(data[clust,4]) # Get the cluster semantic class - types, cnts = unique_nb(data[clust,-1]) + types, cnts = nbl.unique(data[clust,-1]) major_sem_type = types[np.argmax(cnts)] feats[k] = [mean_value, std_value, major_sem_type] @@ -426,7 +428,7 @@ def _get_cluster_points_label(data: nb.float64[:,:], # Bring the start points to the closest point in the corresponding cluster for i, c in enumerate(clusts): - dist_mat = cdist_nb(points[i].reshape(-1,3), data[c,coords_index[0]:coords_index[1]]) + dist_mat = nbl.cdist(points[i].reshape(-1,3), data[c,coords_index[0]:coords_index[1]]) argmins = np.empty(len(dist_mat), dtype=np.int64) for j in range(len(dist_mat)): argmins[j] = np.argmin(dist_mat[j]) @@ -580,7 +582,7 @@ def cluster_direction(voxels: nb.float64[:,:], """ # If max_dist is set, limit the set of voxels to those within a sphere of radius max_dist if not optimize and max_dist > 0: - dist_mat = cdist_nb(start.reshape(1,-1), voxels).flatten() + dist_mat = nbl.cdist(start.reshape(1,-1), voxels).flatten() voxels = voxels[dist_mat <= max_dist] if len(voxels) < 2: return np.zeros(3, dtype=voxels.dtype) @@ -588,14 +590,14 @@ def cluster_direction(voxels: nb.float64[:,:], # If optimize is set, select the radius by minimizing the transverse spread elif optimize: # Order the cluster points by increasing distance to the start point - dist_mat = cdist_nb(start.reshape(1,-1), voxels).flatten() + dist_mat = nbl.cdist(start.reshape(1,-1), voxels).flatten() order = np.argsort(dist_mat) voxels = voxels[order] dist_mat = dist_mat[order] # Find the PCA relative secondary spread for each point labels = np.zeros(len(voxels), dtype=voxels.dtype) - meank = mean_nb(voxels[:3], 0) + meank = nbl.mean(voxels[:3], 0) covk = (np.transpose(voxels[:3]-meank) @ (voxels[:3]-meank))/3 for i in range(2, len(voxels)): # Get the eigenvalues and eigenvectors, identify point of minimum secondary spread @@ -617,7 +619,7 @@ def cluster_direction(voxels: nb.float64[:,:], rel_voxels = np.empty((len(voxels), 3), dtype=voxels.dtype) for i in range(len(voxels)): rel_voxels[i] = voxels[i]-start - mean = mean_nb(rel_voxels, 0) + mean = nbl.mean(rel_voxels, 0) if np.linalg.norm(mean): return mean/np.linalg.norm(mean) return mean @@ -658,7 +660,7 @@ def principal_axis(voxels:nb.float64[:,:]) -> nb.float64[:]: int: (3) Coordinates of the principal axis """ # Center data - center = mean_nb(voxels, 0) + center = nbl.mean(voxels, 0) x = voxels - center # Get orientation matrix @@ -686,7 +688,7 @@ def cluster_dedx(voxels: nb.float64[:,:], torch.tensor: (3) Orientation """ # If max_dist is set, limit the set of voxels to those within a sphere of radius max_dist - dist_mat = cdist_nb(start.reshape(1,-1), voxels).flatten() + dist_mat = nbl.cdist(start.reshape(1,-1), voxels).flatten() if max_dist > 0: voxels = voxels[dist_mat <= max_dist] if len(voxels) < 2: diff --git a/mlreco/utils/gnn/data.py b/mlreco/utils/gnn/data.py index 11414c70..809a4a10 100644 --- a/mlreco/utils/gnn/data.py +++ b/mlreco/utils/gnn/data.py @@ -2,17 +2,18 @@ import numpy as np import numba as nb import torch - from typing import Tuple +import mlreco.utils.numba_local as nbl +from mlreco.utils.wrapper import numba_wrapper from mlreco.utils import local_cdist -from mlreco.utils.numba import numba_wrapper, unique_nb from mlreco.utils.ppn import get_track_endpoints_geo from .cluster import get_cluster_features, get_cluster_features_extended from .network import get_cluster_edge_features, get_voxel_edge_features from .voxels import get_voxel_features + def cluster_features(data, clusts, extra=False, **kwargs): """ Function that returns an array of 16/19 geometric features for @@ -329,7 +330,7 @@ def split_edge_index(edge_index: nb.int64[:,:], # For each batch ID, find the cluster IDs within that batch ecids = np.empty(len(batch_ids), dtype=np.int64) index = 0 - for n in unique_nb(batch_ids)[1]: + for n in nbl.unique(batch_ids)[1]: ecids[index:index+n] = np.arange(n, dtype=np.int64) index += n diff --git a/mlreco/utils/gnn/evaluation.py b/mlreco/utils/gnn/evaluation.py index fca4327a..fe4d8f83 100644 --- a/mlreco/utils/gnn/evaluation.py +++ b/mlreco/utils/gnn/evaluation.py @@ -2,11 +2,12 @@ import numpy as np import numba as nb -from mlreco.utils.numba import submatrix_nb, argmax_nb, softmax_nb, log_loss_nb +import mlreco.utils.numba_local as nbl from mlreco.utils.metrics import SBD, AMI, ARI, purity_efficiency int_array = nb.int64[:] + @nb.njit(cache=True) def edge_assignment(edge_index: nb.int64[:,:], groups: nb.int64[:]) -> nb.int64[:]: @@ -138,10 +139,10 @@ def primary_assignment(node_scores: nb.float32[:,:], np.ndarray: (C) Primary labels """ if group_ids is None: - return argmax_nb(node_scores, axis=1).astype(np.bool_) + return nbl.argmax(node_scores, axis=1).astype(np.bool_) primary_labels = np.zeros(len(node_scores), dtype=np.bool_) - node_scores = softmax_nb(node_scores, axis=1) + node_scores = nbl.softmax(node_scores, axis=1) for g in np.unique(group_ids): mask = np.where(group_ids == g)[0] idx = np.argmax(node_scores[mask][:,1]) @@ -187,7 +188,7 @@ def grouping_loss(pred_mat: nb.float32[:], int: Graph grouping loss """ if loss == 'ce': - return log_loss_nb(target_mat, pred_mat) + return nbl.log_loss(target_mat, pred_mat) elif loss == 'l1': return np.mean(np.absolute(pred_mat-target_mat)) elif loss == 'l2': @@ -222,7 +223,7 @@ def edge_assignment_score(edge_index: nb.int64[:,:], adj_mat = adjacency_matrix(edge_index, n) # Interpret the softmax score as a dense adjacency matrix probability - edge_scores = softmax_nb(edge_scores, axis=1) + edge_scores = nbl.softmax(edge_scores, axis=1) pred_mat = np.eye(n, dtype=np.float32) for k, e in enumerate(edge_index): pred_mat[e[0],e[1]] = edge_scores[k,1] @@ -244,8 +245,8 @@ def edge_assignment_score(edge_index: nb.int64[:,:], # Restrict the adjacency matrix and the predictions to the nodes in the two candidate groups node_mask = np.where((best_groups == group_a) | (best_groups == group_b))[0] - sub_pred = submatrix_nb(pred_mat, node_mask, node_mask).flatten() - sub_adj = submatrix_nb(adj_mat, node_mask, node_mask).flatten() + sub_pred = nbl.submatrix(pred_mat, node_mask, node_mask).flatten() + sub_adj = nbl.submatrix(adj_mat, node_mask, node_mask).flatten() # Compute the current adjacency matrix between the two groups current_adj = (best_groups[node_mask] == best_groups[node_mask].reshape(-1,1)).flatten() diff --git a/mlreco/utils/gnn/network.py b/mlreco/utils/gnn/network.py index 0deed52e..0f3baf0b 100644 --- a/mlreco/utils/gnn/network.py +++ b/mlreco/utils/gnn/network.py @@ -6,7 +6,8 @@ from scipy.spatial import Delaunay from scipy.sparse.csgraph import minimum_spanning_tree -from mlreco.utils.numba import numba_wrapper, submatrix_nb, cdist_nb, mean_nb +import mlreco.utils.numba_local as nbl +from mlreco.utils.wrapper import numba_wrapper @nb.njit(cache=True) @@ -131,7 +132,7 @@ def mst_graph(batch_ids: nb.int64[:], for b in np.unique(batch_ids): clust_ids = np.where(batch_ids == b)[0] if len(clust_ids) > 1: - submat = np.triu(submatrix_nb(dist_mat, clust_ids, clust_ids)) + submat = np.triu(nbl.submatrix(dist_mat, clust_ids, clust_ids)) with nb.objmode(mst_mat = 'float32[:,:]'): # Suboptimal. Ideally want to reimplement in Numba, but tall order... mst_mat = minimum_spanning_tree(submat).toarray().astype(np.float32) edges = np.where(mst_mat > 0.) @@ -168,7 +169,7 @@ def knn_graph(batch_ids: nb.int64[:], clust_ids = np.where(batch_ids == b)[0] if len(clust_ids) > 1: subk = min(k+1, len(clust_ids)) - submat = submatrix_nb(dist_mat, clust_ids, clust_ids) + submat = nbl.submatrix(dist_mat, clust_ids, clust_ids) for i in range(len(submat)): idxs = np.argsort(submat[i])[1:subk] edges = np.empty((subk-1,2), dtype=np.int64) @@ -291,7 +292,7 @@ def _get_cluster_edge_features(data: nb.float32[:,:], x2 = data[clusts[c2], coords_col[0]:coords_col[1]] # Find the closest set point in each cluster - imin = np.argmin(cdist_nb(x1, x2)) if closest_index is None else closest_index[k] + imin = np.argmin(nbl.cdist(x1, x2)) if closest_index is None else closest_index[k] i1, i2 = imin//len(x2), imin%len(x2) v1 = x1[i1,:] v2 = x2[i2,:] @@ -418,7 +419,7 @@ def _get_edge_distances(voxels: nb.float32[:,:], ii = jj = 0 lend[k] = 0. else: - dist_mat = cdist_nb(voxels[clusts[i]], voxels[clusts[j]]) + dist_mat = nbl.cdist(voxels[clusts[i]], voxels[clusts[j]]) idx = np.argmin(dist_mat) ii, jj = idx//len(clusts[j]), idx%len(clusts[j]) lend[k] = dist_mat[ii, jj] @@ -460,16 +461,16 @@ def _inter_cluster_distance(voxels: nb.float32[:,:], mode: str = 'voxel') -> nb.float32[:,:]: assert len(clusts) == len(batch_ids) - dist_mat = np.empty((len(batch_ids), len(batch_ids)), dtype=voxels.dtype) + dist_mat = np.zeros((len(batch_ids), len(batch_ids)), dtype=voxels.dtype) indxi, indxj = complete_graph(batch_ids, directed=True) if mode == 'voxel': for k in nb.prange(len(indxi)): i, j = indxi[k], indxj[k] - dist_mat[i,j] = dist_mat[j,i] = np.min(cdist_nb(voxels[clusts[i]], voxels[clusts[j]])) + dist_mat[i,j] = dist_mat[j,i] = np.min(nbl.cdist(voxels[clusts[i]], voxels[clusts[j]])) elif mode == 'centroid': centroids = np.empty((len(batch_ids), voxels.shape[1]), dtype=voxels.dtype) for i in nb.prange(len(batch_ids)): - centroids[i] = mean_nb(voxels[clusts[i]], axis=0) + centroids[i] = nbl.mean(voxels[clusts[i]], axis=0) for k in nb.prange(len(indxi)): i, j = indxi[k], indxj[k] dist_mat[i,j] = dist_mat[j,i] = np.sqrt(np.sum((centroids[j]-centroids[i])**2)) @@ -485,12 +486,14 @@ def _inter_cluster_distance_index(voxels: nb.float32[:,:], batch_ids: nb.int64[:]) -> (nb.float32[:,:], nb.int64[:,:]): assert len(clusts) == len(batch_ids) - dist_mat = np.empty((len(batch_ids), len(batch_ids)), dtype=voxels.dtype) + dist_mat = np.zeros((len(batch_ids), len(batch_ids)), dtype=voxels.dtype) closest_index = np.empty((len(batch_ids), len(batch_ids)), dtype=nb.int64) + for i in range(len(clusts)): + closest_index[i,i] = i indxi, indxj = complete_graph(batch_ids, directed=True) for k in nb.prange(len(indxi)): i, j = indxi[k], indxj[k] - temp_dist_mat = cdist_nb(voxels[clusts[i]], voxels[clusts[j]]) + temp_dist_mat = nbl.cdist(voxels[clusts[i]], voxels[clusts[j]]) index = np.argmin(temp_dist_mat) ii, jj = index//temp_dist_mat.shape[1], index%temp_dist_mat.shape[1] diff --git a/mlreco/utils/gnn/voxels.py b/mlreco/utils/gnn/voxels.py index 8f80396e..f29bb8d2 100644 --- a/mlreco/utils/gnn/voxels.py +++ b/mlreco/utils/gnn/voxels.py @@ -1,7 +1,10 @@ # Defines voxel feature extraction import numpy as np import numba as nb -from mlreco.utils.numba import numba_wrapper, cdist_nb + +import mlreco.utils.numba_local as nbl +from mlreco.utils.wrapper import numba_wrapper + @numba_wrapper(cast_args=['data'], keep_torch=True, ref_arg='data') def get_voxel_features(data, max_dist=5.0): @@ -22,7 +25,7 @@ def _get_voxel_features(data: nb.float32[:,:], max_dist=5.0): # Compute intervoxel distance matrix voxels = data[:,:3] - dist_mat = cdist_nb(voxels, voxels) + dist_mat = nbl.cdist(voxels, voxels) # Get local geometrical features for each voxel feats = np.empty((len(voxels), 16), dtype=data.dtype) diff --git a/mlreco/utils/inference.py b/mlreco/utils/inference.py index 60e5dcd8..26edd182 100644 --- a/mlreco/utils/inference.py +++ b/mlreco/utils/inference.py @@ -1,6 +1,6 @@ import yaml -def get_inference_cfg(cfg_path, dataset_path=None, weights_path=None, batch_size=None, cpu=False): +def get_inference_cfg(cfg_path, dataset_path=None, weights_path=None, batch_size=None, num_workers=None, cpu=False): ''' Turns a training configuration into an inference configuration: - Turn `train` to `False` @@ -8,6 +8,7 @@ def get_inference_cfg(cfg_path, dataset_path=None, weights_path=None, batch_size - Load the specified validation dataset_path, if requested - Load the specified set of weights_path, if requested - Reset the batch_size to a different value, if requested + - Sets num_workers to a different value, if requested - Make the model run in CPU mode, if requested Parameters @@ -20,6 +21,8 @@ def get_inference_cfg(cfg_path, dataset_path=None, weights_path=None, batch_size Path to the weigths to use for inference batch_size: int Number of data samples per batch + num_workers: + Number of workers that load data cpu: bool Whether or not to execute the inference on CPU @@ -48,6 +51,10 @@ def get_inference_cfg(cfg_path, dataset_path=None, weights_path=None, batch_size # Change the batch_size, if requested cfg['iotool']['batch_size'] = batch_size + # Set the number of workers, if requested + if num_workers is not None: + cfg['iotool']['num_workers'] = num_workers + # Put the network in CPU mode, if requested if cpu: cfg['trainval']['gpus'] = '' diff --git a/mlreco/utils/numba.py b/mlreco/utils/numba.py deleted file mode 100644 index d5b6e60e..00000000 --- a/mlreco/utils/numba.py +++ /dev/null @@ -1,229 +0,0 @@ -import numpy as np -import numba as nb -import torch -import inspect -from functools import wraps - -def numba_wrapper(cast_args=[], list_args=[], keep_torch=False, ref_arg=None): - ''' - Function which wraps a *numba* function with some checks on the input - to make the relevant conversions to numpy where necessary. - - Args: - cast_args ([str]): List of arguments to be cast to numpy - list_args ([str]): List of arguments which need to be cast to a numba typed list - keep_torch (bool): Make the output a torch object, if the reference argument is one - ref_arg (str) : Reference argument used to assign a type and device to the torch output - Returns: - Function - ''' - def outer(fn): - @wraps(fn) - def inner(*args, **kwargs): - # Convert the positional arguments in args into key:value pairs in kwargs - keys = list(inspect.signature(fn).parameters.keys()) - for i, val in enumerate(args): - kwargs[keys[i]] = val - - # Extract the default values for the remaining parameters - for key, val in inspect.signature(fn).parameters.items(): - if key not in kwargs and val.default != inspect.Parameter.empty: - kwargs[key] = val.default - - # If a torch output is request, register the input dtype and device location - if keep_torch: - assert ref_arg in kwargs - dtype, device = None, None - if isinstance(kwargs[ref_arg], torch.Tensor): - dtype = kwargs[ref_arg].dtype - device = kwargs[ref_arg].device - - # If the cast data is not a numpy array, cast it - for arg in cast_args: - assert arg in kwargs - if not isinstance(kwargs[arg], np.ndarray): - assert isinstance(kwargs[arg], torch.Tensor) - kwargs[arg] = kwargs[arg].detach().cpu().numpy() # For now cast to CPU only - - # If there is a reflected list in the input, type it - for arg in list_args: - assert arg in kwargs - kwargs[arg] = nb.typed.List(kwargs[arg]) - - # Get the output - ret = fn(**kwargs) - if keep_torch and dtype: - if isinstance(ret, np.ndarray): - ret = torch.tensor(ret, dtype=dtype, device=device) - elif isinstance(ret, list): - ret = [torch.tensor(r, dtype=dtype, device=device) for r in ret] - else: - raise TypeError('Return type not recognized, cannot cast to torch') - return ret - return inner - return outer - - -@nb.njit(cache=True) -def unique_nb(x: nb.int32[:]) -> (nb.int32[:], nb.int32[:]): - b = np.sort(x.flatten()) - unique = list(b[:1]) - counts = [1 for _ in unique] - for x in b[1:]: - if x != unique[-1]: - unique.append(x) - counts.append(1) - else: - counts[-1] += 1 - return unique, counts - - -@nb.njit(cache=True) -def submatrix_nb(x:nb.float32[:,:], - index1: nb.int32[:], - index2: nb.int32[:]) -> nb.float32[:,:]: - """ - Numba implementation of matrix subsampling - """ - subx = np.empty((len(index1), len(index2)), dtype=x.dtype) - for i, i1 in enumerate(index1): - for j, i2 in enumerate(index2): - subx[i,j] = x[i1,i2] - return subx - - -@nb.njit(cache=True) -def cdist_nb(x1: nb.float32[:,:], - x2: nb.float32[:,:]) -> nb.float32[:,:]: - """ - Numba implementation of Eucleadian cdist in 3D. - """ - res = np.empty((x1.shape[0], x2.shape[0]), dtype=x1.dtype) - for i1 in range(x1.shape[0]): - for i2 in range(x2.shape[0]): - res[i1,i2] = np.sqrt((x1[i1][0]-x2[i2][0])**2+(x1[i1][1]-x2[i2][1])**2+(x1[i1][2]-x2[i2][2])**2) - return res - - -@nb.njit(cache=True) -def mean_nb(x: nb.float32[:,:], - axis: nb.int32) -> nb.float32[:]: - """ - Numba implementation of np.mean(x, axis) - """ - assert axis == 0 or axis == 1 - mean = np.empty(x.shape[1-axis], dtype=x.dtype) - if axis == 0: - for i in range(len(mean)): - mean[i] = np.mean(x[:,i]) - else: - for i in range(len(mean)): - mean[i] = np.mean(x[i]) - return mean - - -@nb.njit(cache=True) -def argmin_nb(x: nb.float32[:,:], - axis: nb.int32) -> nb.int32[:]: - """ - Numba implementation of np.argmin(x, axis) - """ - assert axis == 0 or axis == 1 - argmin = np.empty(x.shape[1-axis], dtype=np.int32) - if axis == 0: - for i in range(len(argmin)): - argmin[i] = np.argmin(x[:,i]) - else: - for i in range(len(argmin)): - argmin[i] = np.argmin(x[i]) - return argmin - - -@nb.njit(cache=True) -def argmax_nb(x: nb.float32[:,:], - axis: nb.int32) -> nb.int32[:]: - """ - Numba implementation of np.argmax(x, axis) - """ - assert axis == 0 or axis == 1 - argmax = np.empty(x.shape[1-axis], dtype=np.int32) - if axis == 0: - for i in range(len(argmax)): - argmax[i] = np.argmax(x[:,i]) - else: - for i in range(len(argmax)): - argmax[i] = np.argmax(x[i]) - return argmax - - -@nb.njit(cache=True) -def min_nb(x: nb.float32[:,:], - axis: nb.int32) -> nb.float32[:]: - """ - Numba implementation of np.max(x, axis) - """ - assert axis == 0 or axis == 1 - xmin = np.empty(x.shape[1-axis], dtype=np.int32) - if axis == 0: - for i in range(len(xmin)): - xmin[i] = np.min(x[:,i]) - else: - for i in range(len(xmax)): - xmin[i] = np.min(x[i]) - return xmin - - -@nb.njit(cache=True) -def max_nb(x: nb.float32[:,:], - axis: nb.int32) -> nb.float32[:]: - """ - Numba implementation of np.max(x, axis) - """ - assert axis == 0 or axis == 1 - xmax = np.empty(x.shape[1-axis], dtype=np.int32) - if axis == 0: - for i in range(len(xmax)): - xmax[i] = np.max(x[:,i]) - else: - for i in range(len(xmax)): - xmax[i] = np.max(x[i]) - return xmax - - -@nb.njit(cache=True) -def all_nb(x: nb.float32[:,:], - axis: nb.int32) -> nb.int32[:]: - """ - Numba implementation of np.all(x, axis) - """ - assert axis == 0 or axis == 1 - all = np.empty(x.shape[1-axis], dtype=np.bool_) - if axis == 0: - for i in range(len(all)): - all[i] = np.all(x[:,i]) - else: - for i in range(len(all)): - all[i] = np.all(x[i]) - return all - - -@nb.njit(cache=True) -def softmax_nb(x: nb.float32[:,:], - axis: nb.int32) -> nb.float32[:,:]: - assert axis == 0 or axis == 1 - if axis == 0: - xmax = max_nb(x, axis=0) - logsumexp = np.log(np.sum(np.exp(x-xmax), axis=0)) + xmax - return np.exp(x - logsumexp) - else: - xmax = max_nb(x, axis=1).reshape(-1,1) - logsumexp = np.log(np.sum(np.exp(x-xmax), axis=1)).reshape(-1,1) + xmax - return np.exp(x - logsumexp) - - -@nb.njit(cache=True) -def log_loss_nb(x1: nb.boolean[:], x2: nb.float32[:]) -> nb.float32: - if len(x1) > 0: - return -(np.sum(np.log(x2[x1])) + np.sum(np.log(1.-x2[~x1])))/len(x1) - else: - return 0. diff --git a/mlreco/utils/numba_local.py b/mlreco/utils/numba_local.py new file mode 100644 index 00000000..16839e86 --- /dev/null +++ b/mlreco/utils/numba_local.py @@ -0,0 +1,189 @@ +import numpy as np +import numba as nb + + +@nb.njit(cache=True) +def unique(x: nb.int32[:]) -> (nb.int32[:], nb.int32[:]): + """ + Numba implementation of np.unique + """ + b = np.sort(x.flatten()) + unique = list(b[:1]) + counts = [1 for _ in unique] + for x in b[1:]: + if x != unique[-1]: + unique.append(x) + counts.append(1) + else: + counts[-1] += 1 + return unique, counts + + +@nb.njit(cache=True) +def submatrix(x: nb.float32[:,:], + index1: nb.int32[:], + index2: nb.int32[:]) -> nb.float32[:,:]: + """ + Numba implementation of matrix subsampling + """ + subx = np.empty((len(index1), len(index2)), dtype=x.dtype) + for i, i1 in enumerate(index1): + for j, i2 in enumerate(index2): + subx[i,j] = x[i1,i2] + return subx + + +@nb.njit(cache=True) +def pdist(x: nb.float32[:,:]) -> nb.float32[:,:]: + """ + Numba implementation of Eucleadian pdist in 3D. + """ + res = np.zeros((x.shape[0], x.shape[0]), dtype=x.dtype) + for i in range(x.shape[0]): + for j in range(x.shape[0]): + res[i,j] = res[j,i] = np.sqrt((x[i][0]-x[j][0])**2+(x[i][1]-x[j][1])**2+(x[i][2]-x[j][2])**2) + return res + + +@nb.njit(cache=True) +def cdist(x1: nb.float32[:,:], + x2: nb.float32[:,:]) -> nb.float32[:,:]: + """ + Numba implementation of Eucleadian cdist in 3D. + """ + res = np.empty((x1.shape[0], x2.shape[0]), dtype=x1.dtype) + for i1 in range(x1.shape[0]): + for i2 in range(x2.shape[0]): + res[i1,i2] = np.sqrt((x1[i1][0]-x2[i2][0])**2+(x1[i1][1]-x2[i2][1])**2+(x1[i1][2]-x2[i2][2])**2) + return res + + +@nb.njit(cache=True) +def mean(x: nb.float32[:,:], + axis: nb.int32) -> nb.float32[:]: + """ + Numba implementation of np.mean(x, axis) + """ + assert axis == 0 or axis == 1 + mean = np.empty(x.shape[1-axis], dtype=x.dtype) + if axis == 0: + for i in range(len(mean)): + mean[i] = np.mean(x[:,i]) + else: + for i in range(len(mean)): + mean[i] = np.mean(x[i]) + return mean + + +@nb.njit(cache=True) +def argmin(x: nb.float32[:,:], + axis: nb.int32) -> nb.int32[:]: + """ + Numba implementation of np.argmin(x, axis) + """ + assert axis == 0 or axis == 1 + argmin = np.empty(x.shape[1-axis], dtype=np.int32) + if axis == 0: + for i in range(len(argmin)): + argmin[i] = np.argmin(x[:,i]) + else: + for i in range(len(argmin)): + argmin[i] = np.argmin(x[i]) + return argmin + + +@nb.njit(cache=True) +def argmax(x: nb.float32[:,:], + axis: nb.int32) -> nb.int32[:]: + """ + Numba implementation of np.argmax(x, axis) + """ + assert axis == 0 or axis == 1 + argmax = np.empty(x.shape[1-axis], dtype=np.int32) + if axis == 0: + for i in range(len(argmax)): + argmax[i] = np.argmax(x[:,i]) + else: + for i in range(len(argmax)): + argmax[i] = np.argmax(x[i]) + return argmax + + +@nb.njit(cache=True) +def min(x: nb.float32[:,:], + axis: nb.int32) -> nb.float32[:]: + """ + Numba implementation of np.max(x, axis) + """ + assert axis == 0 or axis == 1 + xmin = np.empty(x.shape[1-axis], dtype=np.int32) + if axis == 0: + for i in range(len(xmin)): + xmin[i] = np.min(x[:,i]) + else: + for i in range(len(xmax)): + xmin[i] = np.min(x[i]) + return xmin + + +@nb.njit(cache=True) +def max(x: nb.float32[:,:], + axis: nb.int32) -> nb.float32[:]: + """ + Numba implementation of np.max(x, axis) + """ + assert axis == 0 or axis == 1 + xmax = np.empty(x.shape[1-axis], dtype=np.int32) + if axis == 0: + for i in range(len(xmax)): + xmax[i] = np.max(x[:,i]) + else: + for i in range(len(xmax)): + xmax[i] = np.max(x[i]) + return xmax + + +@nb.njit(cache=True) +def all(x: nb.float32[:,:], + axis: nb.int32) -> nb.int32[:]: + """ + Numba implementation of np.all(x, axis) + """ + assert axis == 0 or axis == 1 + all = np.empty(x.shape[1-axis], dtype=np.bool_) + if axis == 0: + for i in range(len(all)): + all[i] = np.all(x[:,i]) + else: + for i in range(len(all)): + all[i] = np.all(x[i]) + return all + + +@nb.njit(cache=True) +def softmax(x: nb.float32[:,:], + axis: nb.int32) -> nb.float32[:,:]: + """ + Numba implementation of SciPy's softmax(x, axis) + """ + assert axis == 0 or axis == 1 + if axis == 0: + xmax = max(x, axis=0) + logsumexp = np.log(np.sum(np.exp(x-xmax), axis=0)) + xmax + return np.exp(x - logsumexp) + else: + xmax = max(x, axis=1).reshape(-1,1) + logsumexp = np.log(np.sum(np.exp(x-xmax), axis=1)).reshape(-1,1) + xmax + return np.exp(x - logsumexp) + + +@nb.njit(cache=True) +def log_loss(x1: nb.boolean[:], + x2: nb.float32[:]) -> nb.float32: + """ + Numba implementation of cross-entropy loss + """ + if len(x1) > 0: + return -(np.sum(np.log(x2[x1])) + np.sum(np.log(1.-x2[~x1])))/len(x1) + else: + return 0. diff --git a/mlreco/utils/wrapper.py b/mlreco/utils/wrapper.py new file mode 100644 index 00000000..b132ef02 --- /dev/null +++ b/mlreco/utils/wrapper.py @@ -0,0 +1,65 @@ +import numpy as np +import numba as nb +import torch +import inspect +from functools import wraps + + +def numba_wrapper(cast_args=[], list_args=[], keep_torch=False, ref_arg=None): + ''' + Function which wraps a *numba* function with some checks on the input + to make the relevant conversions to numpy where necessary. + + Args: + cast_args ([str]): List of arguments to be cast to numpy + list_args ([str]): List of arguments which need to be cast to a numba typed list + keep_torch (bool): Make the output a torch object, if the reference argument is one + ref_arg (str) : Reference argument used to assign a type and device to the torch output + Returns: + Function + ''' + def outer(fn): + @wraps(fn) + def inner(*args, **kwargs): + # Convert the positional arguments in args into key:value pairs in kwargs + keys = list(inspect.signature(fn).parameters.keys()) + for i, val in enumerate(args): + kwargs[keys[i]] = val + + # Extract the default values for the remaining parameters + for key, val in inspect.signature(fn).parameters.items(): + if key not in kwargs and val.default != inspect.Parameter.empty: + kwargs[key] = val.default + + # If a torch output is request, register the input dtype and device location + if keep_torch: + assert ref_arg in kwargs + dtype, device = None, None + if isinstance(kwargs[ref_arg], torch.Tensor): + dtype = kwargs[ref_arg].dtype + device = kwargs[ref_arg].device + + # If the cast data is not a numpy array, cast it + for arg in cast_args: + assert arg in kwargs + if not isinstance(kwargs[arg], np.ndarray): + assert isinstance(kwargs[arg], torch.Tensor) + kwargs[arg] = kwargs[arg].detach().cpu().numpy() # For now cast to CPU only + + # If there is a reflected list in the input, type it + for arg in list_args: + assert arg in kwargs + kwargs[arg] = nb.typed.List(kwargs[arg]) + + # Get the output + ret = fn(**kwargs) + if keep_torch and dtype: + if isinstance(ret, np.ndarray): + ret = torch.tensor(ret, dtype=dtype, device=device) + elif isinstance(ret, list): + ret = [torch.tensor(r, dtype=dtype, device=device) for r in ret] + else: + raise TypeError('Return type not recognized, cannot cast to torch') + return ret + return inner + return outer From c0abba43e5880ef94fb0eaa200767e351d25a6ee Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Sun, 26 Feb 2023 21:53:15 -0800 Subject: [PATCH 010/180] Bug fix in local implementation of pdist --- mlreco/utils/numba_local.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mlreco/utils/numba_local.py b/mlreco/utils/numba_local.py index 16839e86..b0cead10 100644 --- a/mlreco/utils/numba_local.py +++ b/mlreco/utils/numba_local.py @@ -40,7 +40,7 @@ def pdist(x: nb.float32[:,:]) -> nb.float32[:,:]: """ res = np.zeros((x.shape[0], x.shape[0]), dtype=x.dtype) for i in range(x.shape[0]): - for j in range(x.shape[0]): + for j in range(i+1, x.shape[0]): res[i,j] = res[j,i] = np.sqrt((x[i][0]-x[j][0])**2+(x[i][1]-x[j][1])**2+(x[i][2]-x[j][2])**2) return res From 4514908b6b533d6db023fba9809e3e21ff9734a2 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Mon, 27 Feb 2023 16:12:55 -0800 Subject: [PATCH 011/180] Implemented fast farthest pair finding + expanded Numba function docstrings --- mlreco/utils/numba_local.py | 289 ++++++++++++++++++++++++++++++------ 1 file changed, 240 insertions(+), 49 deletions(-) diff --git a/mlreco/utils/numba_local.py b/mlreco/utils/numba_local.py index b0cead10..683d2d9f 100644 --- a/mlreco/utils/numba_local.py +++ b/mlreco/utils/numba_local.py @@ -2,29 +2,26 @@ import numba as nb -@nb.njit(cache=True) -def unique(x: nb.int32[:]) -> (nb.int32[:], nb.int32[:]): - """ - Numba implementation of np.unique - """ - b = np.sort(x.flatten()) - unique = list(b[:1]) - counts = [1 for _ in unique] - for x in b[1:]: - if x != unique[-1]: - unique.append(x) - counts.append(1) - else: - counts[-1] += 1 - return unique, counts - - @nb.njit(cache=True) def submatrix(x: nb.float32[:,:], index1: nb.int32[:], index2: nb.int32[:]) -> nb.float32[:,:]: """ - Numba implementation of matrix subsampling + Numba implementation of matrix subsampling. + + Parameters + ---------- + x : np.ndarray + (N,M) array of values + index1 : np.ndarray + (N') array of indices along axis 0 in the input matrix + index2 : np.ndarray + (M') array of indices along axis 1 in the input matrix + + Returns + ------- + np.ndarray + (N',M') array of values from the original matrix """ subx = np.empty((len(index1), len(index2)), dtype=x.dtype) for i, i1 in enumerate(index1): @@ -34,35 +31,51 @@ def submatrix(x: nb.float32[:,:], @nb.njit(cache=True) -def pdist(x: nb.float32[:,:]) -> nb.float32[:,:]: - """ - Numba implementation of Eucleadian pdist in 3D. +def unique(x: nb.int32[:]) -> (nb.int32[:], nb.int32[:]): """ - res = np.zeros((x.shape[0], x.shape[0]), dtype=x.dtype) - for i in range(x.shape[0]): - for j in range(i+1, x.shape[0]): - res[i,j] = res[j,i] = np.sqrt((x[i][0]-x[j][0])**2+(x[i][1]-x[j][1])**2+(x[i][2]-x[j][2])**2) - return res + Numba implementation of `np.unique(x, return_counts=True)`. + Parameters + ---------- + x : np.ndarray + (N) array of values -@nb.njit(cache=True) -def cdist(x1: nb.float32[:,:], - x2: nb.float32[:,:]) -> nb.float32[:,:]: - """ - Numba implementation of Eucleadian cdist in 3D. + Returns + ------- + np.ndarray + (U) array of unique values + np.ndarray + (U) array of counts of each unique value in the original array """ - res = np.empty((x1.shape[0], x2.shape[0]), dtype=x1.dtype) - for i1 in range(x1.shape[0]): - for i2 in range(x2.shape[0]): - res[i1,i2] = np.sqrt((x1[i1][0]-x2[i2][0])**2+(x1[i1][1]-x2[i2][1])**2+(x1[i1][2]-x2[i2][2])**2) - return res + b = np.sort(x.flatten()) + unique = list(b[:1]) + counts = [1 for _ in unique] + for x in b[1:]: + if x != unique[-1]: + unique.append(x) + counts.append(1) + else: + counts[-1] += 1 + return unique, counts @nb.njit(cache=True) def mean(x: nb.float32[:,:], axis: nb.int32) -> nb.float32[:]: """ - Numba implementation of np.mean(x, axis) + Numba implementation of `np.mean(x, axis)`. + + Parameters + ---------- + x : np.ndarray + (N,M) array of values + axis : int + Array axis ID + + Returns + ------- + np.ndarray + (N) or (M) array of `mean` values """ assert axis == 0 or axis == 1 mean = np.empty(x.shape[1-axis], dtype=x.dtype) @@ -79,7 +92,19 @@ def mean(x: nb.float32[:,:], def argmin(x: nb.float32[:,:], axis: nb.int32) -> nb.int32[:]: """ - Numba implementation of np.argmin(x, axis) + Numba implementation of `np.argmin(x, axis)`. + + Parameters + ---------- + x : np.ndarray + (N,M) array of values + axis : int + Array axis ID + + Returns + ------- + np.ndarray + (N) or (M) array of `argmin` values """ assert axis == 0 or axis == 1 argmin = np.empty(x.shape[1-axis], dtype=np.int32) @@ -96,7 +121,19 @@ def argmin(x: nb.float32[:,:], def argmax(x: nb.float32[:,:], axis: nb.int32) -> nb.int32[:]: """ - Numba implementation of np.argmax(x, axis) + Numba implementation of `np.argmax(x, axis)`. + + Parameters + ---------- + x : np.ndarray + (N,M) array of values + axis : int + Array axis ID + + Returns + ------- + np.ndarray + (N) or (M) array of `argmax` values """ assert axis == 0 or axis == 1 argmax = np.empty(x.shape[1-axis], dtype=np.int32) @@ -113,7 +150,19 @@ def argmax(x: nb.float32[:,:], def min(x: nb.float32[:,:], axis: nb.int32) -> nb.float32[:]: """ - Numba implementation of np.max(x, axis) + Numba implementation of `np.max(x, axis)`. + + Parameters + ---------- + x : np.ndarray + (N,M) array of values + axis : int + Array axis ID + + Returns + ------- + np.ndarray + (N) or (M) array of `min` values """ assert axis == 0 or axis == 1 xmin = np.empty(x.shape[1-axis], dtype=np.int32) @@ -130,7 +179,19 @@ def min(x: nb.float32[:,:], def max(x: nb.float32[:,:], axis: nb.int32) -> nb.float32[:]: """ - Numba implementation of np.max(x, axis) + Numba implementation of `np.max(x, axis)`. + + Parameters + ---------- + x : np.ndarray + (N,M) array of values + axis : int + Array axis ID + + Returns + ------- + np.ndarray + (N) or (M) array of `max` values """ assert axis == 0 or axis == 1 xmax = np.empty(x.shape[1-axis], dtype=np.int32) @@ -145,9 +206,21 @@ def max(x: nb.float32[:,:], @nb.njit(cache=True) def all(x: nb.float32[:,:], - axis: nb.int32) -> nb.int32[:]: + axis: nb.int32) -> nb.boolean[:]: """ - Numba implementation of np.all(x, axis) + Numba implementation of `np.all(x, axis)`. + + Parameters + ---------- + x : np.ndarray + (N,M) array of values + axis : int + Array axis ID + + Returns + ------- + np.ndarray + (N) or (M) array of `all` outputs """ assert axis == 0 or axis == 1 all = np.empty(x.shape[1-axis], dtype=np.bool_) @@ -164,7 +237,19 @@ def all(x: nb.float32[:,:], def softmax(x: nb.float32[:,:], axis: nb.int32) -> nb.float32[:,:]: """ - Numba implementation of SciPy's softmax(x, axis) + Numba implementation of `scipy.special.softmax(x, axis)`. + + Parameters + ---------- + x : np.ndarray + (N,M) array of values + axis : int + Array axis ID + + Returns + ------- + np.ndarray + (N,M) Array of softmax scores """ assert axis == 0 or axis == 1 if axis == 0: @@ -178,12 +263,118 @@ def softmax(x: nb.float32[:,:], @nb.njit(cache=True) -def log_loss(x1: nb.boolean[:], - x2: nb.float32[:]) -> nb.float32: +def log_loss(label: nb.boolean[:], + pred: nb.float32[:]) -> nb.float32: """ - Numba implementation of cross-entropy loss + Numba implementation of cross-entropy loss. + + Parameters + ---------- + label : np.ndarray + (N) array of boolean labels (0 or 1) + pred : np.ndarray + (N) array of float scores (between 0 and 1) + + Returns + ------- + float + Cross-entropy loss """ - if len(x1) > 0: - return -(np.sum(np.log(x2[x1])) + np.sum(np.log(1.-x2[~x1])))/len(x1) + if len(label) > 0: + return -(np.sum(np.log(pred[label])) + np.sum(np.log(1.-pred[~label])))/len(label) else: return 0. + + +@nb.njit(cache=True) +def pdist(x: nb.float32[:,:]) -> nb.float32[:,:]: + """ + Numba implementation of Eucleadian `scipy.spatial.distance.pdist(x, p=2)` in 3D. + + Parameters + ---------- + x : np.ndarray + (N,3) array of point coordinates in the set + + Returns + ------- + np.ndarray + (N,N) array of pair-wise Euclidean distances + """ + res = np.zeros((x.shape[0], x.shape[0]), dtype=x.dtype) + for i in range(x.shape[0]): + for j in range(i+1, x.shape[0]): + res[i,j] = res[j,i] = np.sqrt((x[i][0]-x[j][0])**2+(x[i][1]-x[j][1])**2+(x[i][2]-x[j][2])**2) + return res + + +@nb.njit(cache=True) +def cdist(x1: nb.float32[:,:], + x2: nb.float32[:,:]) -> nb.float32[:,:]: + """ + Numba implementation of Eucleadian `scipy.spatial.distance.cdist(x, p=2)` in 3D. + + Parameters + ---------- + x1 : np.ndarray + (N,3) array of point coordinates in the first set + x2 : np.ndarray + (M,3) array of point coordinates in the second set + + Returns + ------- + np.ndarray + (N,M) array of pair-wise Euclidean distances + """ + res = np.empty((x1.shape[0], x2.shape[0]), dtype=x1.dtype) + for i1 in range(x1.shape[0]): + for i2 in range(x2.shape[0]): + res[i1,i2] = np.sqrt((x1[i1][0]-x2[i2][0])**2+(x1[i1][1]-x2[i2][1])**2+(x1[i1][2]-x2[i2][2])**2) + return res + + +@nb.njit(cache=True) +def farthest_pair(x: nb.float32[:,:], + algorithm: bool = 'brute') -> (nb.int32, nb.int32, nb.float32): + ''' + Algorithm which finds the two furthest points in a set. + + Two algorithms: + - `brute`: compute pdist, use argmax + - `recursive`: Start with the first point, find the farthest + point, move to that point, repeat. This algorithm is + *not* exact, but a good very quick proxy + + Parameters + ---------- + x : np.ndarray + (Nx3) array of point coordinates + algorithm : str + Name of the algorithm to use: `brute` or `recursive` + + Returns + ------- + int + ID of the first point that makes up the pair + int + ID of the second point that makes up the pair + float + Distance between the two points + ''' + if algorithm == 'brute': + dist_mat = pdist(x) + index = np.argmax(dist_mat) + idxs = [index//x.shape[0], index%x.shape[0]] + dist = dist_mat[idxs[0], idxs[1]] + elif algorithm == 'recursive': + idxs, subidx, dist, tempdist = [0, 0], False, 1e9, 1e9+1. + while dist < tempdist: + tempdist = dist + dists = cdist(np.ascontiguousarray(x[idxs[int(subidx)]]).reshape(1,-1), x).flatten() + idxs[int(~subidx)] = np.argmax(dists) + dist = dists[idxs[int(~subidx)]] + subidx = ~subidx + else: + raise ValueError('Algorithm not supported') + + return idxs[0], idxs[1], dist From 65a8e2a12b339f230a0ebd0e55dd200dfd2e7f48 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Tue, 28 Feb 2023 22:09:36 -0800 Subject: [PATCH 012/180] Small bug fix in get_inference_cfg --- mlreco/utils/inference.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mlreco/utils/inference.py b/mlreco/utils/inference.py index 26edd182..e2a9fc28 100644 --- a/mlreco/utils/inference.py +++ b/mlreco/utils/inference.py @@ -44,7 +44,11 @@ def get_inference_cfg(cfg_path, dataset_path=None, weights_path=None, batch_size if 'sampler' in cfg['iotool']: del cfg['iotool']['sampler'] - # Load weights_path, if requested + # Change dataset, if requested + if dataset_path is not None: + cfg['iotool']['dataset']['data_keys'] = [dataset_path] + + # Change weights, if requested if weights_path is not None: cfg['trainval']['model_path'] = weights_path From 858a6a0138088b79a70167a2d4f4a0e6902f7152 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Tue, 28 Feb 2023 23:46:43 -0800 Subject: [PATCH 013/180] [WIP] Writer now in place: allows to store output of the reconstruction chain --- mlreco/iotools/factories.py | 47 +++++- mlreco/iotools/writers.py | 312 ++++++++++++++++++++++++++++++++++++ mlreco/main_funcs.py | 10 +- 3 files changed, 361 insertions(+), 8 deletions(-) create mode 100644 mlreco/iotools/writers.py diff --git a/mlreco/iotools/factories.py b/mlreco/iotools/factories.py index 5ca269a2..972aea07 100644 --- a/mlreco/iotools/factories.py +++ b/mlreco/iotools/factories.py @@ -1,15 +1,22 @@ -""" -These factories instantiate `torch.utils.data.DataLoader` -based on the YAML configuration that was provided. -""" from torch.utils.data import DataLoader -def dataset_factory(cfg,event_list=None): +def dataset_factory(cfg, event_list=None): """ Instantiates dataset based on type specified in configuration under `iotool.dataset.name`. The name must match the name of a class under - mlreco.iotools.datasets. + `mlreco.iotools.datasets`. + + Parameters + ---------- + cfg : dict + Configuration dictionary. Expects a field `iotool`. + event_list: list, optional + List of tree idx. + + Returns + ------- + dataset: torch.utils.data.Dataset Note ---- @@ -22,7 +29,7 @@ def dataset_factory(cfg,event_list=None): return getattr(mlreco.iotools.datasets, params['name']).create(params) -def loader_factory(cfg,event_list=None): +def loader_factory(cfg, event_list=None): """ Instantiates a DataLoader based on configuration. @@ -80,3 +87,29 @@ def loader_factory(cfg,event_list=None): sampler = sampler, num_workers = num_workers) return loader + + +def writer_factory(cfg): + """ + Instantiates writer based on type specified in configuration under + `iotool.writer.name`. The name must match the name of a class under + `mlreco.iotools.writers`. + + Parameters + ---------- + cfg : dict + Configuration dictionary. Expects a field `iotool`. + + Returns + ------- + writer + + Note + ---- + Currently the choice is limited to `HDF5Writer` only. + """ + import mlreco.iotools.writers + params = cfg['iotool'].get('writer', None) + if params is not None: + return getattr(mlreco.iotools.writers, params['name'])(params) + return None diff --git a/mlreco/iotools/writers.py b/mlreco/iotools/writers.py new file mode 100644 index 00000000..ea2786ce --- /dev/null +++ b/mlreco/iotools/writers.py @@ -0,0 +1,312 @@ +import numpy as np +import h5py +import yaml +from collections import defaultdict +from pathlib import Path + + +class HDF5Writer: + ''' + Class which build an HDF5 file to store events which contain: + - Voxel tensors with their values + - Feature tensors + - Particle-level objects + - ... + + More documentation to come. + ''' + + def __init__(self, cfg): + ''' + Initialize the basics of the output file + + Parameters + --------- + cfg : dict + Writer configuration parameter (TODO: turn this into a list of named parameters) + ''' + # Store attributes + self.file_name = cfg.get('file_name', 'output.h5') + self.store_input = cfg.get('store_input', False) + self.input_keys = cfg.get('input_keys', None) + self.skip_input_keys = cfg.get('skip_input_keys', []) + self.result_keys = cfg.get('result_keys', None) + self.skip_result_keys = cfg.get('skip_result_keys', []) + self.created = False + + def create(self, cfg, data_blob, result_blob=None): + ''' + Create the output file structure based on the data and result blobs. + + Parameters + ---------- + cfg : dict + Dictionary containing the ML chain configuration + data_blob : dict + Dictionary containing the input data + result_blob : dict + Dictionary containing the output of the reconstruction chain + ''' + # Get the expected batch_size from the data_blob (index must be present) + self.batch_size = len(data_blob['index']) + + # Initialize a dictionary to store keys and their properties (dtype and shape) + self.key_dict = defaultdict(lambda: {'dtype':None, 'width':0, 'ref':False, 'index':False}) + + # If requested, loop over input_keys and add them to what needs to be tracked + if self.store_input: + if self.input_keys is None: self.input_keys = data_blob.keys() + self.input_keys = set(self.input_keys) + for key in self.input_keys: + if key not in self.skip_input_keys: + self.register_key(data_blob, key) + else: + self.input_keys.pop(key) + else: + self.input_keys = {} + + # Loop over the result_keys and add them to what needs to be tracked + assert self.result_keys is None or result_blob is not None,\ + 'No result provided, cannot request keys from it' + if self.result_keys is None: self.result_keys = result_blob.keys() + self.result_keys = set(self.result_keys) + for key in self.result_keys: + if key not in self.skip_result_keys: + self.register_key(result_blob, key) + else: + self.result_keys.pop(key) + + # Initialize the output HDF5 file + with h5py.File(self.file_name, 'w') as file: + # Initialize the info dataset that stores top-level description of what is stored + # TODO: This needs to be fleshed out, currently dumping the config as a single string... + file.create_dataset('info', (0,), maxshape=(None,), dtype=None) + file['info'].attrs['cfg'] = yaml.dump(cfg) + + # Initialize the event dataset and the corresponding reference array datasets + self.initialize_datasets(file) + + # Mark file as ready for use + self.created = True + + def register_key(self, blob, key): + ''' + Identify the dtype and shape objects to be dealt with. + + Parameters + ---------- + blob : dict + Dictionary containing the information to be stored + key : string + Dictionary key name + ''' + # If the data under the key is a scalar, store it as is + if not isinstance(blob[key], list): + # Single scalar (TODO: Is that thing? If not, why not?) + self.key_dict[key]['dtype'] = type(blob[key]) + else: + if len(blob[key]) != self.batch_size: + # List with a single scalar, regardless of batch_size + # TODO: understand why single scalars are in arrays... + assert len(blob[key]) == 1 and\ + not hasattr(blob[key][0], '__len__'),\ + 'If there is an array of mismatched length, it must contain a single scalar' + self.key_dict[key]['dtype'] = type(blob[key][0]) + elif not hasattr(blob[key][0], '__len__'): + # List containing a single scalar per batch ID + self.key_dict[key]['dtype'] = type(blob[key][0]) + elif isinstance(blob[key][0], np.ndarray) and\ + not blob[key][0].dtype == np.object: + # List containing a single ndarray of scalars per batch ID + self.key_dict[key]['dtype'] = blob[key][0].dtype + self.key_dict[key]['width'] = blob[key][0].shape[1] if len(blob[key][0].shape) == 2 else 0 + self.key_dict[key]['ref'] = True + elif isinstance(blob[key][0], (list, np.ndarray)) and isinstance(blob[key][0][0], np.ndarray): + # List containing a list (or ndarray) of ndarrays per batch ID + widths = [] + for i in range(len(blob[key][0])): + widths.append(blob[key][0][i].shape[1] if len(blob[key][0][i].shape) == 2 else 0) + same_width = np.all([widths[i] == widths[0] for i in range(len(widths))]) + + self.key_dict[key]['dtype'] = blob[key][0][0].dtype + self.key_dict[key]['width'] = widths + self.key_dict[key]['ref'] = True + self.key_dict[key]['index'] = same_width + else: + raise TypeError('Do not know how to store output of type', type(blob[key][0])) + + def initialize_datasets(self, file): + ''' + Create place hodlers for all the datasets to be filled. + + Parameters + ---------- + file : h5py.File + HDF5 file instance + ''' + self.event_dtype = [] + ref_dtype = h5py.special_dtype(ref=h5py.RegionReference) + for key, val in self.key_dict.items(): + if not val['ref'] and not val['index']: + # If the key has <= 1 scalar per batch ID: store in the event dataset + self.event_dtype.append((key, val['dtype'].__name__)) + elif val['ref'] and not isinstance(val['width'], list): + # If the key contains one ndarray: store as its own dataset + store a reference in the event dataset + w = val['width'] + shape, maxshape = [(0, w), (None, w)] if w else [(0,), (None,)] + file.create_dataset(key, shape, maxshape=maxshape, dtype=val['dtype']) + self.event_dtype.append((f'{key}_ref_', ref_dtype)) + elif val['ref'] and not val['index']: + # If the elements of the list are of variable widths, refer to one dataset per element + for i, w in enumerate(val['width']): + name = f'{key}_el{i:d}' + shape, maxshape = [(0, w), (None, w)] if w else [(0,), (None,)] + file.create_dataset(name, shape, maxshape, dtype=val['dtype']) + self.event_dtype.append((f'{name}_ref_', ref_dtype)) + elif val['index']: + # If the elements of the list are of equal width, store them all + # to one dataset. An index is stored alongside the dataset to break + # it into individual elements downstream. + w = val['width'][0] + shape, maxshape = [(0, w), (None, w)] if w else [(0,), (None,)] + file.create_dataset(key, shape, maxshape=maxshape, dtype=val['dtype']) + file.create_dataset(f'{key}_index_', (0,), maxshape=(None,), dtype=ref_dtype) + self.event_dtype.append((f'{key}_index_ref_', ref_dtype)) + + file.create_dataset('events', (0,), maxshape=(None,), dtype=self.event_dtype) + + def append(self, cfg, data_blob, result_blob): + ''' + Append the HDF5 file with the content of a batch. + + Parameters + ---------- + cfg : dict + Dictionary containing the ML chain configuration + data_blob : dict + Dictionary containing the input data + result_blob : dict + Dictionary containing the output of the reconstruction chain + ''' + # If this function has never been called, initialiaze the HDF5 file + if not self.created: + self.create(cfg, data_blob, result_blob) + + # Append file + with h5py.File(self.file_name, 'a') as file: + # Loop over batch IDs + for batch_id in range(self.batch_size): + # Initialize a new event + event = np.empty(1, self.event_dtype) + + # Initialize a dictionary of references to be passed to the event + # dataset and store the relevant array input and result keys + ref_dict = {} + for key in self.input_keys: + self.append_key(file, event, data_blob, key, batch_id) + for key in self.result_keys: + self.append_key(file, event, result_blob, key, batch_id) + + # Append event + event_id = len(file['events']) + events_ds = file['events'] + events_ds.resize(event_id + 1, axis=0) + events_ds[event_id] = event + + def append_key(self, file, event, blob, key, batch_id): + ''' + Stores array in a specific dataset of an HDF5 file + + Parameters + ---------- + file : h5py.File + HDF5 file instance + event : dict + Dictionary of objects that make up one event + blob : dict + Dictionary containing the information to be stored + key : string + Dictionary key name + batch_id : int + Batch ID to be stored + ''' + val = self.key_dict[key] + if not val['ref'] and not val['index']: + # Store the scalar + event[key] = blob[key][batch_id] if len(blob[key]) == self.batch_size else blob[key][0] # Does not handle scalar case. TODO: Useful? + elif val['ref'] and not isinstance(val['width'], list): + # Store the array and its reference + self.store(file, event, key, blob[key][batch_id]) + elif val['ref'] and not val['index']: + # Store the array and its reference for each element in the list + for i in range(len(val['width'])): + self.store(file, event, f'{key}_el{i:d}', blob[key][batch_id][i]) + elif val['index']: + # Store one array of for all in the list and a index to break them + self.store_indexed(file, event, key, blob[key][batch_id]) + + @staticmethod + def store(file, event, key, array): + ''' + Stores an `ndarray`in the file and stores its mapping + in the event dataset. + + Parameters + ---------- + file : h5py.File + HDF5 file instance + event : dict + Dictionary of objects that make up one event + key: str + Name of the dataset in the file + array : np.ndarray + Array to be stored + ''' + # Extend the dataset, store array + dataset = file[key] + current_id = len(dataset) + dataset.resize(current_id + len(array), axis=0) + dataset[current_id:current_id + len(array)] = array + + # Define region reference, store it at the event level + region_ref = dataset.regionref[current_id:current_id + len(array)] + event[f'{key}_ref_'] = region_ref + + @staticmethod + def store_indexed(file, event, key, array_list): + ''' + Stores a list of arrays in the file and stores + its index mapping in the event dataset. + + Parameters + ---------- + file : h5py.File + HDF5 file instance + event : dict + Dictionary of objects that make up one event + key: str + Name of the dataset in the file + array_list : list(np.ndarray) + List of arrays to be stored + ''' + # Extend the dataset, store combined array + array = np.concatenate(array_list) + dataset = file[key] + first_id = len(dataset) + dataset.resize(first_id + len(array), axis=0) + dataset[first_id:first_id + len(array)] = array + + # Loop over arrays in the list, create a reference for each + index = file[f'{key}_index_'] + current_id = len(index) + index.resize(first_id + len(array_list), axis=0) + last_id = first_id + for i, el in enumerate(array_list): + first_id = last_id + last_id += len(el) + el_ref = dataset.regionref[first_id:last_id] + index[current_id + i] = el_ref + + # Define a region reference to all the references, store it at the event level + region_ref = index.regionref[current_id:current_id + len(array_list)] + event[f'{key}_index_ref_'] = region_ref diff --git a/mlreco/main_funcs.py b/mlreco/main_funcs.py index 569bf4fc..04b8d96f 100644 --- a/mlreco/main_funcs.py +++ b/mlreco/main_funcs.py @@ -8,7 +8,7 @@ except ImportError: pass -from mlreco.iotools.factories import loader_factory +from mlreco.iotools.factories import loader_factory, writer_factory # Important: do not import here anything that might # trigger cuda initialization through PyTorch. # We need to set CUDA_VISIBLE_DEVICES first, which @@ -24,6 +24,7 @@ class Handlers: weight_io = None train_logger = None watch = None + writer = None iteration = 0 def keys(self): @@ -169,6 +170,9 @@ def prepare(cfg, event_list=None): # IO iterator handlers.data_io_iter = iter(cycle(handlers.data_io)) + # IO writer + handlers.writer = writer_factory(cfg) + if 'trainval' in cfg: # Set random seed for reproducibility np.random.seed(cfg['trainval']['seed']) @@ -384,6 +388,7 @@ def inference_loop(handlers): # Run inference data_blob, result_blob = handlers.trainer.forward(handlers.data_io_iter) + # Store output if requested if 'post_processing' in handlers.cfg: for processor_name,processor_cfg in handlers.cfg['post_processing'].items(): @@ -397,6 +402,9 @@ def inference_loop(handlers): log(handlers, tstamp_iteration, tsum, result_blob, handlers.cfg, epoch, data_blob['index'][0]) + if handlers.writer: + handlers.writer.append(handlers.cfg, data_blob, result_blob) + handlers.iteration += 1 # Metrics From 7fdf2712d5a2849648baa7d3a579095b2668d13b Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 1 Mar 2023 12:06:05 -0800 Subject: [PATCH 014/180] Add epoch-wise cache clearing --- mlreco/main_funcs.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mlreco/main_funcs.py b/mlreco/main_funcs.py index 569bf4fc..b5b2b89b 100644 --- a/mlreco/main_funcs.py +++ b/mlreco/main_funcs.py @@ -302,8 +302,16 @@ def train_loop(handlers): cfg=handlers.cfg tsum = 0. + epoch_counter = 0 + clear_epoch = cfg['trainval'].get('clear_gpu_cache_at_epoch', False) while handlers.iteration < cfg['trainval']['iterations']: epoch = handlers.iteration / float(len(handlers.data_io)) + epoch_counter += 1.0 / float(len(handlers.data_io)) + if epoch_counter >= clear_epoch: + epoch_counter = 0 + if torch.cuda.is_available(): + torch.cuda.empty_cache() + tstamp_iteration = datetime.datetime.fromtimestamp(time.time()).strftime('%Y-%m-%d %H:%M:%S') handlers.watch.start('iteration') From e66889d6ad1b1bca38ac8d1289fd721396f8837a Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Wed, 1 Mar 2023 23:21:09 -0800 Subject: [PATCH 015/180] [WIP] Improved HDF5 writer to support more output types, wrote first HDF5 reader --- mlreco/{utils => iotools}/data_parallel.py | 0 mlreco/iotools/readers.py | 114 ++++++++++++++ mlreco/iotools/writers.py | 167 +++++++++++++-------- mlreco/trainval.py | 11 +- mlreco/utils/inference.py | 2 +- 5 files changed, 226 insertions(+), 68 deletions(-) rename mlreco/{utils => iotools}/data_parallel.py (100%) create mode 100644 mlreco/iotools/readers.py diff --git a/mlreco/utils/data_parallel.py b/mlreco/iotools/data_parallel.py similarity index 100% rename from mlreco/utils/data_parallel.py rename to mlreco/iotools/data_parallel.py diff --git a/mlreco/iotools/readers.py b/mlreco/iotools/readers.py new file mode 100644 index 00000000..9a8ac9bc --- /dev/null +++ b/mlreco/iotools/readers.py @@ -0,0 +1,114 @@ +import numpy as np +import h5py +import yaml + +class HDF5Reader: + ''' + Class which reads back information stored in HDF5 files. + + More documentation to come. + ''' + + def __init__(self, file_path, entry_list=[], skip_entry_list=[]): + ''' + Load up the HDF5 file. + + Parameters + ---------- + file_path : str + Path to the HDF5 file to be read + entry_list: list(int) + Entry IDs to be accessed + skip_entry_list: list(int) + Entry IDs to be skipped + ''' + # Store attributes + self.file_path = file_path + with h5py.File(self.file_path, 'r') as file: + assert 'events' in file, 'File does not contain an event tree' + self.n_entries = len(file['events']) + + self.entry_list = self.get_entry_list(entry_list, skip_entry_list) + + def __len__(self): + ''' + Returns the number of entries in the file + + Returns + ------- + int + Number of entries in the file + ''' + return self.n_entries + + def __getitem__(self, idx): + ''' + Returns a specific entry in the file + + Returns + ------- + data_blob : dict + Ditionary of input data products corresponding to one event + result_blob : dict + Ditionary of result data products corresponding to one event + ''' + # Get the appropriate entry index + entry_idx = self.entry_list[idx] + + # Use the events tree to find out what needs to be loaded + data_blob, result_blob = {}, {} + with h5py.File(self.file_path, 'r') as file: + event = file['events'][entry_idx] + for key in event.dtype.names: + self.load_key(file, event, data_blob, result_blob, key) + + return data_blob, result_blob + + def get_entry_list(self, entry_list, skip_entry_list): + ''' + Create a list of events that can be accessed by `__getitem__` + ''' + if not entry_list: + entry_list = np.arange(self.n_entries, dtype=int) + if skip_entry_list: + entry_list = set(entry_list) + for s in skip_entry_list: + entry_list.pop(s) + entry_list = list(entry_list) + + assert len(entry_list), 'Must at least have one entry to load' + return entry_list + + def load_key(self, file, event, data_blob, result_blob, key): + ''' + Fetch a specific key for a specific event. + + Parameters + ---------- + file : h5py.File + HDF5 file instance + event : dict + Dictionary of objects that make up one event + data_blob : dict + Dictionary used to store the loaded input data + result_blob : dict + Dictionary used to store the loaded result data + key: str + Name of the dataset in the event + ''' + # The event-level information is a region reference: fetch it + region_ref = event[key] + cat = 'data' if key in file['data'] else 'result' + blob = data_blob if cat == 'data' else result_blob + group = file[cat] + if isinstance(group[key], h5py.Dataset): + # If the reference points at a dataset, return + blob[key] = group[key][region_ref] + else: + # If the reference points at a group, unpack + el_refs = group[key]['index'][region_ref].flatten() + if len(group[key]['index'].shape) == 1: + ret = [group[key]['elements'][r] for r in el_refs] + else: + ret = [group[key][f'element_{i}'][r] for i, r in enumerate(el_refs)] + blob[key] = ret diff --git a/mlreco/iotools/writers.py b/mlreco/iotools/writers.py index ea2786ce..0967ef11 100644 --- a/mlreco/iotools/writers.py +++ b/mlreco/iotools/writers.py @@ -7,11 +7,8 @@ class HDF5Writer: ''' - Class which build an HDF5 file to store events which contain: - - Voxel tensors with their values - - Feature tensors - - Particle-level objects - - ... + Class which build an HDF5 file to store the output + (and optionally input) of the reconstruction chain. More documentation to come. ''' @@ -21,7 +18,7 @@ def __init__(self, cfg): Initialize the basics of the output file Parameters - --------- + ---------- cfg : dict Writer configuration parameter (TODO: turn this into a list of named parameters) ''' @@ -51,17 +48,18 @@ def create(self, cfg, data_blob, result_blob=None): self.batch_size = len(data_blob['index']) # Initialize a dictionary to store keys and their properties (dtype and shape) - self.key_dict = defaultdict(lambda: {'dtype':None, 'width':0, 'ref':False, 'index':False}) + self.key_dict = defaultdict(lambda: {'category': None, 'dtype':None, 'width':0, 'merge':False}) # If requested, loop over input_keys and add them to what needs to be tracked if self.store_input: if self.input_keys is None: self.input_keys = data_blob.keys() self.input_keys = set(self.input_keys) + if 'index' not in self.input_keys: self.input_keys.add('index') + for key in self.skip_input_keys: + if key in self.input_keys: + self.input_keys.remove(key) for key in self.input_keys: - if key not in self.skip_input_keys: - self.register_key(data_blob, key) - else: - self.input_keys.pop(key) + self.register_key(data_blob, key, 'data') else: self.input_keys = {} @@ -70,11 +68,11 @@ def create(self, cfg, data_blob, result_blob=None): 'No result provided, cannot request keys from it' if self.result_keys is None: self.result_keys = result_blob.keys() self.result_keys = set(self.result_keys) + for key in self.skip_result_keys: + if key in self.result_keys: + self.result_keys.remove(key) for key in self.result_keys: - if key not in self.skip_result_keys: - self.register_key(result_blob, key) - else: - self.result_keys.pop(key) + self.register_key(result_blob, key, 'result') # Initialize the output HDF5 file with h5py.File(self.file_name, 'w') as file: @@ -89,7 +87,7 @@ def create(self, cfg, data_blob, result_blob=None): # Mark file as ready for use self.created = True - def register_key(self, blob, key): + def register_key(self, blob, key, category): ''' Identify the dtype and shape objects to be dealt with. @@ -99,8 +97,11 @@ def register_key(self, blob, key): Dictionary containing the information to be stored key : string Dictionary key name + category : string + Data category: `data` or `result` ''' - # If the data under the key is a scalar, store it as is + # Store the necessary information to know how to store a key + self.key_dict[key]['category'] = category if not isinstance(blob[key], list): # Single scalar (TODO: Is that thing? If not, why not?) self.key_dict[key]['dtype'] = type(blob[key]) @@ -110,17 +111,21 @@ def register_key(self, blob, key): # TODO: understand why single scalars are in arrays... assert len(blob[key]) == 1 and\ not hasattr(blob[key][0], '__len__'),\ - 'If there is an array of mismatched length, it must contain a single scalar' + 'If there is an array of length mismatched with batch_size,\ + it must contain a single scalar.' self.key_dict[key]['dtype'] = type(blob[key][0]) elif not hasattr(blob[key][0], '__len__'): # List containing a single scalar per batch ID self.key_dict[key]['dtype'] = type(blob[key][0]) + elif isinstance(blob[key][0], list) and\ + not hasattr(blob[key][0][0], '__len__'): + # List containing a single list of scalars per batch ID + self.key_dict[key]['dtype'] = type(blob[key][0][0]) elif isinstance(blob[key][0], np.ndarray) and\ not blob[key][0].dtype == np.object: # List containing a single ndarray of scalars per batch ID self.key_dict[key]['dtype'] = blob[key][0].dtype self.key_dict[key]['width'] = blob[key][0].shape[1] if len(blob[key][0].shape) == 2 else 0 - self.key_dict[key]['ref'] = True elif isinstance(blob[key][0], (list, np.ndarray)) and isinstance(blob[key][0][0], np.ndarray): # List containing a list (or ndarray) of ndarrays per batch ID widths = [] @@ -130,8 +135,7 @@ def register_key(self, blob, key): self.key_dict[key]['dtype'] = blob[key][0][0].dtype self.key_dict[key]['width'] = widths - self.key_dict[key]['ref'] = True - self.key_dict[key]['index'] = same_width + self.key_dict[key]['merge'] = same_width else: raise TypeError('Do not know how to store output of type', type(blob[key][0])) @@ -147,31 +151,33 @@ def initialize_datasets(self, file): self.event_dtype = [] ref_dtype = h5py.special_dtype(ref=h5py.RegionReference) for key, val in self.key_dict.items(): - if not val['ref'] and not val['index']: - # If the key has <= 1 scalar per batch ID: store in the event dataset - self.event_dtype.append((key, val['dtype'].__name__)) - elif val['ref'] and not isinstance(val['width'], list): - # If the key contains one ndarray: store as its own dataset + store a reference in the event dataset + cat = val['category'] + grp = file[cat] if cat in file else file.create_group(cat) + self.event_dtype.append((key, ref_dtype)) + if not val['merge'] and not isinstance(val['width'], list): + # If the key contains scalars in an array, store it as such w = val['width'] shape, maxshape = [(0, w), (None, w)] if w else [(0,), (None,)] - file.create_dataset(key, shape, maxshape=maxshape, dtype=val['dtype']) - self.event_dtype.append((f'{key}_ref_', ref_dtype)) - elif val['ref'] and not val['index']: - # If the elements of the list are of variable widths, refer to one dataset per element + grp.create_dataset(key, shape, maxshape=maxshape, dtype=val['dtype']) + elif not val['merge']: + # If the elements of the list are of variable widths, refer to one + # dataset per element. An index is stored alongside the dataset to break + # each element downstream. + n_arrays = len(val['width']) + subgrp = grp.create_group(key) + subgrp.create_dataset(f'index', (0, n_arrays), maxshape=(None, n_arrays), dtype=ref_dtype) for i, w in enumerate(val['width']): - name = f'{key}_el{i:d}' shape, maxshape = [(0, w), (None, w)] if w else [(0,), (None,)] - file.create_dataset(name, shape, maxshape, dtype=val['dtype']) - self.event_dtype.append((f'{name}_ref_', ref_dtype)) - elif val['index']: + subgrp.create_dataset(f'element_{i}', shape, maxshape=maxshape, dtype=val['dtype']) + else: # If the elements of the list are of equal width, store them all # to one dataset. An index is stored alongside the dataset to break # it into individual elements downstream. + subgrp = grp.create_group(key) w = val['width'][0] shape, maxshape = [(0, w), (None, w)] if w else [(0,), (None,)] - file.create_dataset(key, shape, maxshape=maxshape, dtype=val['dtype']) - file.create_dataset(f'{key}_index_', (0,), maxshape=(None,), dtype=ref_dtype) - self.event_dtype.append((f'{key}_index_ref_', ref_dtype)) + subgrp.create_dataset('elements', shape, maxshape=maxshape, dtype=val['dtype']) + subgrp.create_dataset('index', (0,), maxshape=(None,), dtype=ref_dtype) file.create_dataset('events', (0,), maxshape=(None,), dtype=self.event_dtype) @@ -231,30 +237,29 @@ def append_key(self, file, event, blob, key, batch_id): Batch ID to be stored ''' val = self.key_dict[key] - if not val['ref'] and not val['index']: - # Store the scalar - event[key] = blob[key][batch_id] if len(blob[key]) == self.batch_size else blob[key][0] # Does not handle scalar case. TODO: Useful? - elif val['ref'] and not isinstance(val['width'], list): - # Store the array and its reference - self.store(file, event, key, blob[key][batch_id]) - elif val['ref'] and not val['index']: + cat = val['category'] + if not val['merge'] and not isinstance(val['width'], list): + # Store the scalar. TODO: Does not handle scalars (useful?) + val = blob[key][batch_id] if len(blob[key]) == self.batch_size else blob[key][0] + if not hasattr(val, '__len__'): val = [val] + self.store(file[cat], event, key, val) + elif not val['merge']: # Store the array and its reference for each element in the list - for i in range(len(val['width'])): - self.store(file, event, f'{key}_el{i:d}', blob[key][batch_id][i]) - elif val['index']: + self.store_jagged(file[cat], event, key, blob[key][batch_id]) + else: # Store one array of for all in the list and a index to break them - self.store_indexed(file, event, key, blob[key][batch_id]) + self.store_flat(file[cat], event, key, blob[key][batch_id]) @staticmethod - def store(file, event, key, array): + def store(group, event, key, array): ''' - Stores an `ndarray`in the file and stores its mapping + Stores an `ndarray` in the file and stores its mapping in the event dataset. Parameters ---------- - file : h5py.File - HDF5 file instance + group : h5py.Group + Dataset group under which to store this array event : dict Dictionary of objects that make up one event key: str @@ -263,25 +268,63 @@ def store(file, event, key, array): Array to be stored ''' # Extend the dataset, store array - dataset = file[key] + dataset = group[key] current_id = len(dataset) dataset.resize(current_id + len(array), axis=0) dataset[current_id:current_id + len(array)] = array # Define region reference, store it at the event level region_ref = dataset.regionref[current_id:current_id + len(array)] - event[f'{key}_ref_'] = region_ref + event[key] = region_ref @staticmethod - def store_indexed(file, event, key, array_list): + def store_jagged(group, event, key, array_list): ''' - Stores a list of arrays in the file and stores - its index mapping in the event dataset. + Stores a jagged list of arrays in the file and stores + an index mapping for each array element in the event dataset. Parameters ---------- - file : h5py.File - HDF5 file instance + group : h5py.Group + Dataset group under which to store this array + event : dict + Dictionary of objects that make up one event + key: str + Name of the dataset in the file + array_list : list(np.ndarray) + List of arrays to be stored + ''' + # Extend the dataset, store combined array + region_refs = [] + for i, array in enumerate(array_list): + dataset = group[key][f'element_{i}'] + current_id = len(dataset) + dataset.resize(current_id + len(array), axis=0) + dataset[current_id:current_id + len(array)] = array + + region_ref = dataset.regionref[current_id:current_id + len(array)] + region_refs.append(region_ref) + + # Define the index which stores a list of region_refs + index = group[key]['index'] + current_id = len(dataset) + index.resize(current_id+1, axis=0) + index[current_id] = region_refs + + # Define a region reference to all the references, store it at the event level + region_ref = index.regionref[current_id:current_id+1] + event[key] = region_ref + + @staticmethod + def store_flat(group, event, key, array_list): + ''' + Stores a concatenated list of arrays in the file and stores + its index mapping in the event dataset to break them. + + Parameters + ---------- + group : h5py.Group + Dataset group under which to store this array event : dict Dictionary of objects that make up one event key: str @@ -291,13 +334,13 @@ def store_indexed(file, event, key, array_list): ''' # Extend the dataset, store combined array array = np.concatenate(array_list) - dataset = file[key] + dataset = group[key]['elements'] first_id = len(dataset) dataset.resize(first_id + len(array), axis=0) dataset[first_id:first_id + len(array)] = array # Loop over arrays in the list, create a reference for each - index = file[f'{key}_index_'] + index = group[key]['index'] current_id = len(index) index.resize(first_id + len(array_list), axis=0) last_id = first_id @@ -309,4 +352,4 @@ def store_indexed(file, event, key, array_list): # Define a region reference to all the references, store it at the event level region_ref = index.regionref[current_id:current_id + len(array_list)] - event[f'{key}_index_ref_'] = region_ref + event[key] = region_ref diff --git a/mlreco/trainval.py b/mlreco/trainval.py index 3c55415e..f9b3036e 100644 --- a/mlreco/trainval.py +++ b/mlreco/trainval.py @@ -1,13 +1,14 @@ import os, re, warnings import torch -from mlreco.models import construct -from mlreco.models.experimental.bayes.calibration import calibrator_construct, calibrator_loss_construct +from .iotools.data_parallel import DataParallel + +from .models import construct +from .models.experimental.bayes.calibration import calibrator_construct, calibrator_loss_construct import mlreco.utils as utils -from mlreco.utils.data_parallel import DataParallel -from mlreco.utils.utils import to_numpy -from mlreco.utils.adabound import AdaBound, AdaBoundW +from .utils.utils import to_numpy +from .utils.adabound import AdaBound, AdaBoundW class trainval(object): diff --git a/mlreco/utils/inference.py b/mlreco/utils/inference.py index e2a9fc28..1040fd05 100644 --- a/mlreco/utils/inference.py +++ b/mlreco/utils/inference.py @@ -27,7 +27,7 @@ def get_inference_cfg(cfg_path, dataset_path=None, weights_path=None, batch_size Whether or not to execute the inference on CPU Returns - ------ + ------- dict Dictionary of parameters to initialize handlers ''' From 5e31db3d678f5077b099b4ae483576740d1e289d Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Thu, 2 Mar 2023 11:55:36 -0800 Subject: [PATCH 016/180] Added pointnet model, analysis tools update for lin fit endpoint correction --- analysis/algorithms/calorimetry.py | 20 ++++++ analysis/algorithms/utils.py | 14 +++- analysis/classes/Particle.py | 3 +- analysis/classes/predictor.py | 13 +++- mlreco/main_funcs.py | 2 +- mlreco/models/experimental/layers/pointnet.py | 72 +++++++++++++++---- 6 files changed, 103 insertions(+), 21 deletions(-) diff --git a/analysis/algorithms/calorimetry.py b/analysis/algorithms/calorimetry.py index d882cd3c..e1d3a940 100644 --- a/analysis/algorithms/calorimetry.py +++ b/analysis/algorithms/calorimetry.py @@ -129,6 +129,26 @@ def compute_particle_direction(p: Particle, return direction else: return direction, pca.explained_variance_ratio_ + + +def compute_track_dedx(p, bin_size=17): + assert len(p.points) >= 2 + vec = p.endpoint - p.startpoint + vec_norm = np.linalg.norm(vec) + vec = (vec / vec_norm).astype(np.float64) + proj = p.points - p.startpoint + proj = np.dot(proj, vec) + bins = np.arange(proj.min(), proj.max(), bin_size) + bin_inds = np.digitize(proj, bins) + dedx = np.zeros(np.unique(bin_inds).shape[0]).astype(np.float64) + for i, b_i in enumerate(np.unique(bin_inds)): + mask = bin_inds == b_i + sum_energy = p.depositions[mask].sum() + if np.count_nonzero(mask) < 2: continue + # Repeat PCA locally for better measurement of dx + dx = proj[mask].max() - proj[mask].min() + dedx[i] = sum_energy / dx + return dedx def highland_formula(p, l, m, X_0=14, z=1): diff --git a/analysis/algorithms/utils.py b/analysis/algorithms/utils.py index 687d6a91..c10f3067 100644 --- a/analysis/algorithms/utils.py +++ b/analysis/algorithms/utils.py @@ -6,7 +6,7 @@ from scipy.spatial.distance import cdist from analysis.algorithms.point_matching import get_track_endpoints_max_dist -from analysis.algorithms.calorimetry import get_csda_range_spline +from analysis.algorithms.calorimetry import get_csda_range_spline, compute_track_dedx import numpy as np # Splines for ranged based energy reco @@ -160,6 +160,18 @@ def local_density_correction(p, r=5): p.endpoint = p1 +def correct_track_endpoints_linfit(p, bin_size=17): + if len(p.points) >= 2: + dedx = compute_track_dedx(p, bin_size=bin_size) + if len(dedx) > 1: + x = np.arange(len(dedx)) + params = np.polyfit(x, dedx, 1) + if params[0] < 0: + p1, p2 = p.startpoint, p.endpoint + p.startpoint = p2 + p.endpoint = p1 + + def load_range_reco(particle_type='muon', kinetic_energy=True): """ Return a function maps the residual range of a track to the kinetic diff --git a/analysis/classes/Particle.py b/analysis/classes/Particle.py index be5b0f38..d3cb1711 100644 --- a/analysis/classes/Particle.py +++ b/analysis/classes/Particle.py @@ -45,7 +45,7 @@ class Particle: (1, 3) array of particle's endpoint, if it could be assigned ''' def __init__(self, coords, group_id, semantic_type, interaction_id, - pid, image_id, voxel_indices=None, depositions=None, volume=0, **kwargs): + pid, image_id, nu_id=-1, voxel_indices=None, depositions=None, volume=0, **kwargs): self.id = group_id self.points = coords self.size = coords.shape[0] @@ -55,6 +55,7 @@ def __init__(self, coords, group_id, semantic_type, interaction_id, self.pid = pid self.pid_conf = kwargs.get('pid_conf', None) self.interaction_id = interaction_id + self.nu_id = nu_id self.image_id = image_id self.is_primary = kwargs.get('is_primary', False) self.match = [] diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index aae016a5..07f70787 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -18,7 +18,7 @@ from analysis.algorithms.vertex import estimate_vertex from analysis.algorithms.utils import correct_track_endpoints_closest, \ get_track_points_default, \ - local_density_correction + local_density_correction, correct_track_endpoints_linfit from mlreco.utils.deghosting import deghost_labels_and_predictions from mlreco.utils.gnn.cluster import get_cluster_label @@ -106,6 +106,14 @@ def __init__(self, data_blob, result, cfg, predictor_cfg={}, deghosting=False, self.vertex_mode = predictor_cfg.get('vertex_mode', 'all') self.prune_vertex = predictor_cfg.get('prune_vertex', True) self.track_endpoints_mode = predictor_cfg.get('track_endpoints_mode', 'node_features') + self.track_point_corrector = predictor_cfg.get('track_point_corrector', 'None') + if self.track_point_corrector == 'linfit': + self.track_point_corrector = correct_track_endpoints_linfit + elif self.track_point_corrector == 'density': + self.track_point_corrector = local_density_correction + else: + def f(x): pass + self.track_point_corrector = f # This is used to apply fiducial volume cuts. # Min/max boundaries in each dimension haev to be specified. self.volume_boundaries = predictor_cfg.get('volume_boundaries', None) @@ -942,13 +950,12 @@ def get_particles(self, entry, only_primaries=True, elif p.semantic_type == 1: if self.track_endpoints_mode == 'node_features': get_track_points_default(p) - local_density_correction(p) elif self.track_endpoints_mode == 'max_dist': correct_track_endpoints_closest(p) - local_density_correction(p) else: raise ValueError("Track endpoint attachment mode {}\ not supported!".format(self.track_endpoints_mode)) + self.track_point_corrector(p) else: continue out_particle_list.extend(out) diff --git a/mlreco/main_funcs.py b/mlreco/main_funcs.py index b5b2b89b..8700f553 100644 --- a/mlreco/main_funcs.py +++ b/mlreco/main_funcs.py @@ -307,7 +307,7 @@ def train_loop(handlers): while handlers.iteration < cfg['trainval']['iterations']: epoch = handlers.iteration / float(len(handlers.data_io)) epoch_counter += 1.0 / float(len(handlers.data_io)) - if epoch_counter >= clear_epoch: + if clear_epoch and (epoch_counter >= clear_epoch): epoch_counter = 0 if torch.cuda.is_available(): torch.cuda.empty_cache() diff --git a/mlreco/models/experimental/layers/pointnet.py b/mlreco/models/experimental/layers/pointnet.py index 1a063c7a..cc8dc5f9 100644 --- a/mlreco/models/experimental/layers/pointnet.py +++ b/mlreco/models/experimental/layers/pointnet.py @@ -1,4 +1,5 @@ import torch +import torch.nn as nn from torch_geometric.nn import MLP, PointNetConv, fps, global_max_pool, radius # From Pytorch Geometric Examples for PointNet: @@ -36,22 +37,63 @@ def forward(self, x, pos, batch): return x, pos, batch -class Net(torch.nn.Module): - def __init__(self): - super().__init__() - - # Input channels account for both `pos` and node features. - self.sa1_module = SAModule(0.5, 3, MLP([4, 64, 64, 128])) - self.sa2_module = SAModule(0.25, 6, MLP([128 + 3, 128, 128, 256])) - self.sa3_module = GlobalSAModule(MLP([256 + 3, 256, 512, 1024])) - - self.mlp = MLP([1024, 512, 256, 10], dropout=0.5, norm=None) +class PointNet(torch.nn.Module): + ''' + Pytorch Geometric's implementation of PointNet, modified for + use in lartpc_mlreco3d and generalized. + ''' + def __init__(self, cfg, name='pointnet'): + super(PointNet, self).__init__() + + self.model_config = cfg[name] + + self.depth = self.model_config.get('depth', 2) + + self.sampling_ratio = self.model_config.get('sampling_ratio', 0.5) + if isinstance(self.sampling_ratio, float): + self.sampling_ratio = [self.sampling_ratio] * self.depth + elif isinstance(self.sampling_ratio, list): + assert len(self.sampling_ratio) == self.depth + else: + raise ValueError("Sampling ratio must either be given as \ + float or list of floats.") + + self.neighbor_radius = self.model_config.get('neighbor_radius', 3.0) + if isinstance(self.neighbor_radius, float): + self.neighbor_radius = [self.neighbor_radius] * self.depth + elif isinstance(self.neighbor_radius, list): + assert len(self.neighbor_radius) == self.depth + else: + raise ValueError("Neighbor aggregation radius must either \ + be given as float or list of floats.") + + self.mlp_specs = [] + self.sa_modules = nn.ModuleList() + + for i in range(self.depth): + mlp_specs = self.model_config['mlp_specs_{}'.format(i)] + self.sa_modules.append( + SAModule(self.sampling_ratio[i], self.neighbor_radius[i], MLP(mlp_specs)) + ) + self.mlp_specs.append(mlp_specs) + + self.mlp_specs_glob = self.model_config.get('mlp_specs_glob', [256 + 3, 256, 512, 1024]) + self.mlp_specs_final = self.model_config.get('mlp_specs_final', [1024, 512, 256, 128]) + self.dropout = self.model_config.get('dropout', 0.5) + self.latent_size = self.mlp_specs_final[-1] + + self.sa3_module = GlobalSAModule(MLP(self.mlp_specs_glob)) + self.mlp = MLP(self.mlp_specs_final, dropout=self.dropout, norm=None) def forward(self, data): sa0_out = (data.x, data.pos, data.batch) - sa1_out = self.sa1_module(*sa0_out) - sa2_out = self.sa2_module(*sa1_out) - sa3_out = self.sa3_module(*sa2_out) + + out = sa0_out + + for m in self.sa_modules: + out = m(*out) + + sa3_out = self.sa3_module(*out) x, pos, batch = sa3_out return self.mlp(x) @@ -61,8 +103,8 @@ class PointNetEncoder(torch.nn.Module): def __init__(self, cfg, name='pointnet_encoder'): super(PointNetEncoder, self).__init__() - self.net = Net() - self.latent_size = 10 + self.net = PointNet(cfg) + self.latent_size = self.net.latent_size def forward(self, batch): out = self.net(batch) From 4f866075d13591339f08192452d74cacb001617b Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Thu, 2 Mar 2023 14:19:31 -0800 Subject: [PATCH 017/180] Removed some unecessary full chain outputs --- mlreco/iotools/writers.py | 4 ++-- mlreco/models/full_chain.py | 8 ++++++-- mlreco/models/layers/common/gnn_full_chain.py | 17 +++++++++-------- .../models/layers/gnn/losses/node_kinematics.py | 6 +++--- mlreco/utils/cluster/fragmenter.py | 2 +- 5 files changed, 21 insertions(+), 16 deletions(-) diff --git a/mlreco/iotools/writers.py b/mlreco/iotools/writers.py index 0967ef11..294cd8df 100644 --- a/mlreco/iotools/writers.py +++ b/mlreco/iotools/writers.py @@ -111,8 +111,8 @@ def register_key(self, blob, key, category): # TODO: understand why single scalars are in arrays... assert len(blob[key]) == 1 and\ not hasattr(blob[key][0], '__len__'),\ - 'If there is an array of length mismatched with batch_size,\ - it must contain a single scalar.' + 'If there is an array of length mismatched with batch_size, '+\ + 'it must contain a single scalar.' self.key_dict[key]['dtype'] = type(blob[key][0]) elif not hasattr(blob[key][0], '__len__'): # List containing a single scalar per batch ID diff --git a/mlreco/models/full_chain.py b/mlreco/models/full_chain.py index 4dfc709a..8fa390b9 100644 --- a/mlreco/models/full_chain.py +++ b/mlreco/models/full_chain.py @@ -258,7 +258,6 @@ def full_chain_cnn(self, input): # else: deghost = result['ghost'][0].argmax(dim=1) == 0 - result['ghost_label'] = [deghost] input = [input[0][deghost]] if label_seg is not None and label_clustering is not None: @@ -369,7 +368,12 @@ def full_chain_cnn(self, input): input[0][:, self.batch_col], batch_size=self.batch_size) - cnn_result.update(fragments_result) + cnn_result.update({'frag_dict':fragments_result}) + + cnn_result.update({ + 'fragments': fragments_result['fragments'], + 'fragments_seg': fragments_result['fragments_seg'] + }) if self.enable_cnn_clust or self.enable_dbscan: cnn_result.update({ 'semantic_labels': [semantic_labels] }) diff --git a/mlreco/models/layers/common/gnn_full_chain.py b/mlreco/models/layers/common/gnn_full_chain.py index 4cc9c3ef..e700421b 100644 --- a/mlreco/models/layers/common/gnn_full_chain.py +++ b/mlreco/models/layers/common/gnn_full_chain.py @@ -154,9 +154,9 @@ def get_all_fragments(self, result, input): fragments, batch_index=self.batch_col) else: - fragments = result['frags'][0] - frag_seg = result['frag_seg'][0] - frag_batch_ids = result['frag_batch_ids'][0] + fragments = result['frag_dict']['frags'][0] + frag_seg = result['frag_dict']['frag_seg'][0] + frag_batch_ids = result['frag_dict']['frag_batch_ids'][0] semantic_labels = result['semantic_labels'][0] frag_dict = { @@ -168,8 +168,8 @@ def get_all_fragments(self, result, input): # Since and depend on the batch column of the input # tensor, they are shared between the two settings. - frag_dict['vids'] = result['vids'][0] - frag_dict['counts'] = result['counts'][0] + frag_dict['vids'] = result['frag_dict']['vids'][0] + frag_dict['counts'] = result['frag_dict']['counts'][0] return frag_dict @@ -184,6 +184,7 @@ def run_fragment_gnns(self, result, input): """ frag_dict = self.get_all_fragments(result, input) + del result['frag_dict'] fragments = frag_dict['frags'] frag_seg = frag_dict['frag_seg'] @@ -583,7 +584,7 @@ def forward(self, input): """ result, input, revert_func = self.full_chain_cnn(input) - if len(input[0]) and 'frags' in result and self.process_fragments and (self.enable_gnn_track or self.enable_gnn_shower or self.enable_gnn_inter or self.enable_gnn_particle): + if len(input[0]) and 'frag_dict' in result and self.process_fragments and (self.enable_gnn_track or self.enable_gnn_shower or self.enable_gnn_inter or self.enable_gnn_particle): result = self.full_chain_gnn(result, input) result = revert_func(result) @@ -673,7 +674,7 @@ def forward(self, out, seg_label, ppn_label=None, cluster_label=None, kinematics accuracy += res_ppn['accuracy'] loss += self.ppn_weight*res_ppn['loss'] - if self.enable_ghost and 'ghost_label' in out \ + if self.enable_ghost and 'ghost' in out \ and (self.enable_cnn_clust or \ self.enable_gnn_track or \ self.enable_gnn_shower or \ @@ -681,7 +682,7 @@ def forward(self, out, seg_label, ppn_label=None, cluster_label=None, kinematics self.enable_gnn_kinematics or \ self.enable_cosmic): - deghost = out['ghost_label'][0] + deghost = out['ghost'][0].argmax(dim=1) == 0 if self.cheat_ghost: true_mask = deghost diff --git a/mlreco/models/layers/gnn/losses/node_kinematics.py b/mlreco/models/layers/gnn/losses/node_kinematics.py index 85e88996..fb0ee586 100644 --- a/mlreco/models/layers/gnn/losses/node_kinematics.py +++ b/mlreco/models/layers/gnn/losses/node_kinematics.py @@ -240,6 +240,7 @@ def forward(self, out, types): input_node_features = out['input_node_features'][i][j] node_assn_vtx = np.stack([get_cluster_label(labels, clusts, column=c) for c in range(self.vtx_col, self.vtx_col+3)], axis=1) node_assn_vtx_pos = get_cluster_label(labels, clusts, column=self.vtx_positives_col) + compute_vtx_pos = node_pred_vtx.shape[-1] == 5 # Do not apply loss to nodes labeled -1 or nodes with vertices outside of volume (TODO: this is weak if the volume is not a cube) valid_mask_vtx = (node_assn_vtx >= 0.).all(axis=1) & (node_assn_vtx <= self.spatial_size).all(axis=1) & (node_assn_vtx_pos > -1) @@ -255,7 +256,6 @@ def forward(self, out, types): pos_mask_vtx = np.where(node_assn_vtx_pos[valid_mask_vtx])[0] if len(pos_mask_vtx): # Compute the primary score loss on all valid nodes - compute_vtx_pos = node_pred_vtx.shape[-1] == 5 node_pred_vtx = node_pred_vtx[valid_mask_vtx] node_assn_vtx_pos = torch.tensor(node_assn_vtx_pos[valid_mask_vtx], dtype=torch.long, device=node_pred_vtx.device) if not compute_vtx_pos: @@ -294,7 +294,7 @@ def forward(self, out, types): n_clusts_vtx += len(valid_mask_vtx) n_clusts_vtx_pos += len(pos_mask_vtx) else: - vtx_labels.append(np.empty((0,3))) + vtx_labels.append(np.empty((0,3), dtype=np.float32)) if self.use_anchor_points: anchors.append(np.empty((0,3))) # Compute the accuracy of assignment (fraction of correctly assigned nodes) @@ -336,12 +336,12 @@ def forward(self, out, types): }) if compute_vtx: result.update({ - 'vtx_labels': vtx_labels if n_clusts_vtx_pos else [], 'vtx_score_loss': vtx_score_loss/n_clusts_vtx if n_clusts_vtx else 0., 'vtx_score_accuracy': vtx_score_acc/n_clusts_vtx if n_clusts_vtx else 1., 'vtx_position_loss': vtx_position_loss/n_clusts_vtx_pos if n_clusts_vtx_pos else 0., 'vtx_position_accuracy': vtx_position_acc/n_clusts_vtx_pos if n_clusts_vtx_pos else 1. }) + if compute_vtx_pos: result['vtx_labels'] = vtx_labels, if self.use_anchor_points: result['vtx_anchors'] = vtx_anchors return result diff --git a/mlreco/utils/cluster/fragmenter.py b/mlreco/utils/cluster/fragmenter.py index 15dd69a0..69c5e9a5 100644 --- a/mlreco/utils/cluster/fragmenter.py +++ b/mlreco/utils/cluster/fragmenter.py @@ -41,7 +41,7 @@ def format_fragments(fragments, frag_batch_ids, frag_seg, batch_column, batch_si dtype=object if not same_length[idx] else np.int64) \ for idx, b in enumerate(bcids)] - frags_seg = [frag_seg_np[b] for idx, b in enumerate(bcids)] + frags_seg = [frag_seg_np[b].astype(np.int32) for idx, b in enumerate(bcids)] out = { 'frags' : [fragments_np], From 8c34f3fd73963ae3ec473633cdaea72a612cf9b9 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Thu, 2 Mar 2023 17:45:30 -0800 Subject: [PATCH 018/180] Completed first path at a combow HDF5 writer/reader --- mlreco/iotools/parsers/particles.py | 2 +- mlreco/iotools/readers.py | 8 ++- mlreco/iotools/writers.py | 99 +++++++++++++++++++++++++++-- 3 files changed, 102 insertions(+), 7 deletions(-) diff --git a/mlreco/iotools/parsers/particles.py b/mlreco/iotools/parsers/particles.py index 87fe7132..71dad8cf 100644 --- a/mlreco/iotools/parsers/particles.py +++ b/mlreco/iotools/parsers/particles.py @@ -37,7 +37,7 @@ def parse_particles(particle_event, cluster_event=None, voxel_coordinates=True): if voxel_coordinates: assert cluster_event is not None meta = cluster_event.meta() - funcs = ['first_step', 'last_step', 'position', 'end_position', 'ancestor_position'] + funcs = ['first_step', 'last_step', 'position', 'end_position', 'parent_position', 'ancestor_position'] for p in particles: for f in funcs: pos = getattr(p,f)() diff --git a/mlreco/iotools/readers.py b/mlreco/iotools/readers.py index 9a8ac9bc..ac1dd2d6 100644 --- a/mlreco/iotools/readers.py +++ b/mlreco/iotools/readers.py @@ -103,7 +103,13 @@ def load_key(self, file, event, data_blob, result_blob, key): group = file[cat] if isinstance(group[key], h5py.Dataset): # If the reference points at a dataset, return - blob[key] = group[key][region_ref] + if not group[key].dtype.names: + blob[key] = group[key][region_ref] + else: + names = group[key].dtype.names + blob[key] = [] + for i in range(len(group[key][region_ref])): + blob[key].append(dict(zip(names, group[key][region_ref][i]))) else: # If the reference points at a group, unpack el_refs = group[key]['index'][region_ref].flatten() diff --git a/mlreco/iotools/writers.py b/mlreco/iotools/writers.py index 294cd8df..6039dfe4 100644 --- a/mlreco/iotools/writers.py +++ b/mlreco/iotools/writers.py @@ -1,8 +1,9 @@ import numpy as np import h5py import yaml +import inspect from collections import defaultdict -from pathlib import Path +from larcv import larcv class HDF5Writer: @@ -117,6 +118,10 @@ def register_key(self, blob, key, category): elif not hasattr(blob[key][0], '__len__'): # List containing a single scalar per batch ID self.key_dict[key]['dtype'] = type(blob[key][0]) + elif isinstance(blob[key][0], (list, np.ndarray)) and\ + isinstance(blob[key][0][0], larcv.Particle): + # List containing a single list of larcv.Particle object per batch ID + self.key_dict[key]['dtype'] = self.get_particle_dtype(blob[key][0][0]) elif isinstance(blob[key][0], list) and\ not hasattr(blob[key][0][0], '__len__'): # List containing a single list of scalars per batch ID @@ -139,6 +144,43 @@ def register_key(self, blob, key, category): else: raise TypeError('Do not know how to store output of type', type(blob[key][0])) + def get_particle_dtype(self, particle): + ''' + Loop over the members of a particle to figure out what to store. + + Parameters + ---------- + particle : larcv.Particle + LArCV particle object used to identify attribute types + + Returns + ------- + list + List of (key, dtype) pairs + ''' + particle_dtype = [] + members = inspect.getmembers(larcv.Particle) + attr_names = [k for k, _ in members if '__' not in k and k != 'dump'] + for key in attr_names: + try: + val = getattr(particle, key)() + if isinstance(val, (int, float)): + particle_dtype.append((key, type(val))) + elif isinstance(val, str): + particle_dtype.append((key, h5py.string_dtype())) + elif isinstance(val, larcv.Vertex): + particle_dtype.append((key, h5py.vlen_dtype(np.float32))) + elif hasattr(val, '__len__') and len(val) and isinstance(val[0], (int, float)): + particle_dtype.append((key, h5py.vlen_dtype(type(val[0])))) + else: + pass # Skipping larcv.BBox2D, larcv.BBox3D (nothing in there) + except TypeError: + # This member takes arguments, no need to store + pass + + self.particle_dtype = particle_dtype + return particle_dtype + def initialize_datasets(self, file): ''' Create place hodlers for all the datasets to be filled. @@ -155,7 +197,7 @@ def initialize_datasets(self, file): grp = file[cat] if cat in file else file.create_group(cat) self.event_dtype.append((key, ref_dtype)) if not val['merge'] and not isinstance(val['width'], list): - # If the key contains scalars in an array, store it as such + # If the key contains a list of objects of identical shape w = val['width'] shape, maxshape = [(0, w), (None, w)] if w else [(0,), (None,)] grp.create_dataset(key, shape, maxshape=maxshape, dtype=val['dtype']) @@ -240,9 +282,12 @@ def append_key(self, file, event, blob, key, batch_id): cat = val['category'] if not val['merge'] and not isinstance(val['width'], list): # Store the scalar. TODO: Does not handle scalars (useful?) - val = blob[key][batch_id] if len(blob[key]) == self.batch_size else blob[key][0] - if not hasattr(val, '__len__'): val = [val] - self.store(file[cat], event, key, val) + obj = blob[key][batch_id] if len(blob[key]) == self.batch_size else blob[key][0] + if not hasattr(obj, '__len__'): obj = [obj] + if not hasattr(self, 'particle_dtype') or val['dtype'] != self.particle_dtype: + self.store(file[cat], event, key, obj) + else: + self.store_particles(file[cat], event, key, obj, self.particle_dtype) elif not val['merge']: # Store the array and its reference for each element in the list self.store_jagged(file[cat], event, key, blob[key][batch_id]) @@ -277,6 +322,7 @@ def store(group, event, key, array): region_ref = dataset.regionref[current_id:current_id + len(array)] event[key] = region_ref + @staticmethod def store_jagged(group, event, key, array_list): ''' @@ -353,3 +399,46 @@ def store_flat(group, event, key, array_list): # Define a region reference to all the references, store it at the event level region_ref = index.regionref[current_id:current_id + len(array_list)] event[key] = region_ref + + @staticmethod + def store_particles(group, event, key, array, particle_dtype): + ''' + Stores a list of `larcv.Particle` in the file and stores its mapping + in the event dataset. + + Parameters + ---------- + group : h5py.Group + Dataset group under which to store this array + event : dict + Dictionary of objects that make up one event + key: str + Name of the dataset in the file + array : np.ndarray + Array to be stored + particle_dtype : list + List of (key, dtype) pairs which specify what's to store + ''' + # Convert list of larcv.Particle to list of storable objects + particles = np.empty(len(array), particle_dtype) + for i, p in enumerate(array): + for k, dtype in particle_dtype: + attr = getattr(p, k)() + if isinstance(attr, (int, float, str)): + particles[i][k] = attr + elif isinstance(attr, larcv.Vertex): + vertex = np.array([getattr(attr, a)() for a in ['x', 'y', 'z', 't']], dtype=np.float32) + particles[i][k] = vertex + elif hasattr(attr, '__len__'): + vals = np.array([attr[i] for i in range(len(attr))], dtype=np.int32) + particles[i][k] = vals + + # Extend the dataset, store array + dataset = group[key] + current_id = len(dataset) + dataset.resize(current_id + len(array), axis=0) + dataset[current_id:current_id + len(array)] = particles + + # Define region reference, store it at the event level + region_ref = dataset.regionref[current_id:current_id + len(array)] + event[key] = region_ref From 87c83b8b8fe7eee3462a597b281b0a42877c38dd Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Thu, 2 Mar 2023 22:20:13 -0800 Subject: [PATCH 019/180] Add option to load HDF5 particle info as larcv.Particle objects --- mlreco/iotools/readers.py | 57 ++++++++++++++++++++++++++++++++++----- mlreco/iotools/writers.py | 30 ++++++++++----------- 2 files changed, 65 insertions(+), 22 deletions(-) diff --git a/mlreco/iotools/readers.py b/mlreco/iotools/readers.py index ac1dd2d6..fd13e837 100644 --- a/mlreco/iotools/readers.py +++ b/mlreco/iotools/readers.py @@ -9,7 +9,7 @@ class HDF5Reader: More documentation to come. ''' - def __init__(self, file_path, entry_list=[], skip_entry_list=[]): + def __init__(self, file_path, entry_list=[], skip_entry_list=[], larcv_particles=False): ''' Load up the HDF5 file. @@ -29,6 +29,7 @@ def __init__(self, file_path, entry_list=[], skip_entry_list=[]): self.n_entries = len(file['events']) self.entry_list = self.get_entry_list(entry_list, skip_entry_list) + self.larcv_particles = larcv_particles def __len__(self): ''' @@ -102,14 +103,19 @@ def load_key(self, file, event, data_blob, result_blob, key): blob = data_blob if cat == 'data' else result_blob group = file[cat] if isinstance(group[key], h5py.Dataset): - # If the reference points at a dataset, return if not group[key].dtype.names: + # If the reference points at a simple dataset, return blob[key] = group[key][region_ref] else: - names = group[key].dtype.names - blob[key] = [] - for i in range(len(group[key][region_ref])): - blob[key].append(dict(zip(names, group[key][region_ref][i]))) + # If the dataset has multiple attributes, it contains particle info + array = group[key][region_ref] + names = array.dtype.names + if self.larcv_particles: + blob[key] = self.make_larcv_particles(array, names) + else: + blob[key] = [] + for i in range(len(array)): + blob[key].append(dict(zip(names, array[i]))) else: # If the reference points at a group, unpack el_refs = group[key]['index'][region_ref].flatten() @@ -118,3 +124,42 @@ def load_key(self, file, event, data_blob, result_blob, key): else: ret = [group[key][f'element_{i}'][r] for i, r in enumerate(el_refs)] blob[key] = ret + + @staticmethod + def make_larcv_particles(array, names): + ''' + Rebuild `larcv.Particle` objects from the stored information + + Parameters + ---------- + array : list + List of dictionary of particle information + names: + List of class attribute names + + Returns + ------- + list + List of filled larcv.Particle objects + ''' + from larcv import larcv + ret = [] + for i in range(len(array)): + # Initialize new larcv.Particle object + part_dict = array[i] + particle = larcv.Particle() + + # Momentum is particular, deal with it first + particle.momentum(part_dict['px'], part_dict['py'], part_dict['pz']) + for name in names: + if name in ['px', 'py', 'pz', 'p']: + continue # Addressed by the momentum setter + if 'position' in name or 'step' in name: + getattr(particle, name)(*part_dict[name]) + else: + cast = lambda x: x.item() if type(x) != bytes and not isinstance(x, np.ndarray) else x + getattr(particle, name)(cast(part_dict[name])) + + ret.append(particle) + + return ret diff --git a/mlreco/iotools/writers.py b/mlreco/iotools/writers.py index 6039dfe4..f2089b7d 100644 --- a/mlreco/iotools/writers.py +++ b/mlreco/iotools/writers.py @@ -160,23 +160,21 @@ def get_particle_dtype(self, particle): ''' particle_dtype = [] members = inspect.getmembers(larcv.Particle) - attr_names = [k for k, _ in members if '__' not in k and k != 'dump'] + skip_keys = ['dump', 'momentum', 'boundingbox_2d', 'boundingbox_3d'] +\ + [k+a for k in ['', 'parent_', 'ancestor_'] for a in ['x', 'y', 'z', 't']] + attr_names = [k for k, _ in members if '__' not in k and k not in skip_keys] for key in attr_names: - try: - val = getattr(particle, key)() - if isinstance(val, (int, float)): - particle_dtype.append((key, type(val))) - elif isinstance(val, str): - particle_dtype.append((key, h5py.string_dtype())) - elif isinstance(val, larcv.Vertex): - particle_dtype.append((key, h5py.vlen_dtype(np.float32))) - elif hasattr(val, '__len__') and len(val) and isinstance(val[0], (int, float)): - particle_dtype.append((key, h5py.vlen_dtype(type(val[0])))) - else: - pass # Skipping larcv.BBox2D, larcv.BBox3D (nothing in there) - except TypeError: - # This member takes arguments, no need to store - pass + val = getattr(particle, key)() + if isinstance(val, (int, float)): + particle_dtype.append((key, type(val))) + elif isinstance(val, str): + particle_dtype.append((key, h5py.string_dtype())) + elif isinstance(val, larcv.Vertex): + particle_dtype.append((key, h5py.vlen_dtype(np.float32))) + elif hasattr(val, '__len__') and len(val) and isinstance(val[0], (int, float)): + particle_dtype.append((key, h5py.vlen_dtype(type(val[0])))) + else: + raise ValueError('Unexpected key') self.particle_dtype = particle_dtype return particle_dtype From b70c1d7d4974969be10f582affd7097728b72afb Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Fri, 3 Mar 2023 08:40:57 -0800 Subject: [PATCH 020/180] Sped up GNN node start/end point label fetching --- mlreco/utils/gnn/cluster.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/mlreco/utils/gnn/cluster.py b/mlreco/utils/gnn/cluster.py index 0e479012..ef32ba6a 100644 --- a/mlreco/utils/gnn/cluster.py +++ b/mlreco/utils/gnn/cluster.py @@ -419,12 +419,15 @@ def _get_cluster_points_label(data: nb.float64[:,:], # Get start and end points (one and the same for all but track class) batch_ids = _get_cluster_batch(data, clusts) points = np.empty((len(clusts), 6), dtype=data.dtype) - for i, c in enumerate(clusts): - batch_mask = np.where(particles[:,batch_col] == batch_ids[i])[0] - clust_ids = np.unique(data[c, 5]).astype(np.int64) - minid = np.argmin(particles[batch_mask][clust_ids,-2]) # Pick the first cluster in time - order = np.arange(6) if (np.random.choice(2) or not random_order) else np.array([3, 4, 5, 0, 1, 2]) - points[i] = particles[batch_mask][clust_ids[minid]][order+1] # The first column is the batch ID + for b in np.unique(batch_ids): + batch_mask = np.where(particles[:,batch_col] == b)[0] + clust_mask = np.where(batch_ids == b)[0] + for i in clust_mask: + c = clusts[i] + clust_ids = np.unique(data[c, 5]).astype(np.int64) + minid = np.argmin(particles[batch_mask][clust_ids,-2]) + order = np.arange(6) if (np.random.choice(2) or not random_order) else np.array([3, 4, 5, 0, 1, 2]) + points[i] = particles[batch_mask][clust_ids[minid]][order+1] # The first column is the batch ID # Bring the start points to the closest point in the corresponding cluster for i, c in enumerate(clusts): From b65e417faec0221ac5f07d4b87e0daa7b13fde87 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Fri, 3 Mar 2023 09:14:52 -0800 Subject: [PATCH 021/180] Further speed improvement for get_cluster_point_labels --- mlreco/utils/gnn/cluster.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/mlreco/utils/gnn/cluster.py b/mlreco/utils/gnn/cluster.py index ef32ba6a..ec6a60dc 100644 --- a/mlreco/utils/gnn/cluster.py +++ b/mlreco/utils/gnn/cluster.py @@ -420,14 +420,13 @@ def _get_cluster_points_label(data: nb.float64[:,:], batch_ids = _get_cluster_batch(data, clusts) points = np.empty((len(clusts), 6), dtype=data.dtype) for b in np.unique(batch_ids): - batch_mask = np.where(particles[:,batch_col] == b)[0] - clust_mask = np.where(batch_ids == b)[0] - for i in clust_mask: + batch_particles = particles[particles[:,batch_col] == b] + for i in np.where(batch_ids == b)[0]: c = clusts[i] clust_ids = np.unique(data[c, 5]).astype(np.int64) - minid = np.argmin(particles[batch_mask][clust_ids,-2]) + minid = np.argmin(batch_particles[clust_ids,-2]) order = np.arange(6) if (np.random.choice(2) or not random_order) else np.array([3, 4, 5, 0, 1, 2]) - points[i] = particles[batch_mask][clust_ids[minid]][order+1] # The first column is the batch ID + points[i] = batch_particles[clust_ids[minid]][order+1] # The first column is the batch ID # Bring the start points to the closest point in the corresponding cluster for i, c in enumerate(clusts): From e3cd078e76c5d9c0b03a65b47ec532a47321122f Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Fri, 3 Mar 2023 18:04:09 -0800 Subject: [PATCH 022/180] Remove redundant parameter of HDF5Writer --- mlreco/iotools/writers.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/mlreco/iotools/writers.py b/mlreco/iotools/writers.py index f2089b7d..d4be6f23 100644 --- a/mlreco/iotools/writers.py +++ b/mlreco/iotools/writers.py @@ -25,7 +25,6 @@ def __init__(self, cfg): ''' # Store attributes self.file_name = cfg.get('file_name', 'output.h5') - self.store_input = cfg.get('store_input', False) self.input_keys = cfg.get('input_keys', None) self.skip_input_keys = cfg.get('skip_input_keys', []) self.result_keys = cfg.get('result_keys', None) @@ -52,17 +51,14 @@ def create(self, cfg, data_blob, result_blob=None): self.key_dict = defaultdict(lambda: {'category': None, 'dtype':None, 'width':0, 'merge':False}) # If requested, loop over input_keys and add them to what needs to be tracked - if self.store_input: - if self.input_keys is None: self.input_keys = data_blob.keys() - self.input_keys = set(self.input_keys) - if 'index' not in self.input_keys: self.input_keys.add('index') - for key in self.skip_input_keys: - if key in self.input_keys: - self.input_keys.remove(key) - for key in self.input_keys: - self.register_key(data_blob, key, 'data') - else: - self.input_keys = {} + if self.input_keys is None: self.input_keys = data_blob.keys() + self.input_keys = set(self.input_keys) + if 'index' not in self.input_keys: self.input_keys.add('index') + for key in self.skip_input_keys: + if key in self.input_keys: + self.input_keys.remove(key) + for key in self.input_keys: + self.register_key(data_blob, key, 'data') # Loop over the result_keys and add them to what needs to be tracked assert self.result_keys is None or result_blob is not None,\ From 1ae9470d1eb9ddf3a366da08def5a4c631633d09 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Fri, 3 Mar 2023 18:06:18 -0800 Subject: [PATCH 023/180] Faster (x3 for large batches) complete_graph building --- mlreco/utils/gnn/data.py | 5 ++--- mlreco/utils/gnn/network.py | 9 +++++---- mlreco/utils/numba_local.py | 9 +++++---- mlreco/utils/ppn.py | 13 +++++++++---- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/mlreco/utils/gnn/data.py b/mlreco/utils/gnn/data.py index 809a4a10..49339f09 100644 --- a/mlreco/utils/gnn/data.py +++ b/mlreco/utils/gnn/data.py @@ -165,6 +165,7 @@ def _get_extra_gnn_features(fragments, input, result, use_ppn=False, + use_proxy=True, use_supp=False, enhance=False, allow_outside=False, @@ -211,8 +212,6 @@ def _get_extra_gnn_features(fragments, mask |= (frag_seg == c) mask = np.where(mask)[0] - #print("INPUT = ", input) - # If requested, extract PPN-related features kwargs = {} if use_ppn: @@ -222,7 +221,7 @@ def _get_extra_gnn_features(fragments, for i, f in enumerate(fragments[mask]): fragment_voxels = input[0][f][:,coords_col[0]:coords_col[1]] if frag_seg[mask][i] == 1: - end_points = get_track_endpoints_geo(input[0], f, points_tensor if enhance else None) + end_points = get_track_endpoints_geo(input[0], f, points_tensor if enhance else None, use_proxy=use_proxy) else: scores = torch.softmax(points_tensor[f, -2:], dim=1)[:,-1] # scores = torch.sigmoid(points_tensor[f, -1]) diff --git a/mlreco/utils/gnn/network.py b/mlreco/utils/gnn/network.py index 0f3baf0b..27b77223 100644 --- a/mlreco/utils/gnn/network.py +++ b/mlreco/utils/gnn/network.py @@ -53,10 +53,11 @@ def complete_graph(batch_ids: nb.int64[:], # Create the sparse incidence matrix ret = np.empty((edge_count,2), dtype=np.int64) k = 0 - for i in range(len(batch_ids)): - for j in range(i+1, len(batch_ids)): - if batch_ids[i] == batch_ids[j]: - ret[k] = [i,j] + for b in np.unique(batch_ids): + clust_ids = np.where(batch_ids == b)[0] + for i in range(len(clust_ids)): + for j in range(i+1, len(clust_ids)): + ret[k] = [clust_ids[i], clust_ids[j]] k += 1 # Add the reciprocal edges as to create an undirected graph, if requested diff --git a/mlreco/utils/numba_local.py b/mlreco/utils/numba_local.py index 683d2d9f..bf1c22db 100644 --- a/mlreco/utils/numba_local.py +++ b/mlreco/utils/numba_local.py @@ -337,13 +337,14 @@ def cdist(x1: nb.float32[:,:], def farthest_pair(x: nb.float32[:,:], algorithm: bool = 'brute') -> (nb.int32, nb.int32, nb.float32): ''' - Algorithm which finds the two furthest points in a set. + Algorithm which finds the two points which are + farthest from each other in a set. Two algorithms: - `brute`: compute pdist, use argmax - - `recursive`: Start with the first point, find the farthest - point, move to that point, repeat. This algorithm is - *not* exact, but a good very quick proxy + - `recursive`: Start with the first point in one set, find the farthest + point in the other, move to that point, repeat. This + algorithm is *not* exact, but a good and very quick proxy. Parameters ---------- diff --git a/mlreco/utils/ppn.py b/mlreco/utils/ppn.py index c1175e1b..b902dab6 100644 --- a/mlreco/utils/ppn.py +++ b/mlreco/utils/ppn.py @@ -4,6 +4,7 @@ from mlreco.utils import local_cdist from mlreco.utils.dbscan import dbscan_types, dbscan_points +from mlreco.utils.numba_local import farthest_pair def contains(meta, point, point_type="3d"): """ @@ -483,7 +484,7 @@ def uresnet_ppn_point_selector(data, out, nms_score_threshold=0.8, entry=0, return pts_out -def get_track_endpoints_geo(data, f, points_tensor=None, use_numpy=False): +def get_track_endpoints_geo(data, f, points_tensor=None, use_numpy=False, use_proxy=True): """ Compute endpoints of a track-like cluster f based on PPN point predictions (coordinates @@ -513,9 +514,13 @@ def get_track_endpoints_geo(data, f, points_tensor=None, use_numpy=False): sigmoid = torch.sigmoid cat = torch.cat - dist_mat = cdist(data[f,1:4], data[f,1:4]) - idx = argmax(dist_mat) - idxs = int(idx)//len(f), int(idx)%len(f) + if not use_numpy or not use_proxy: + dist_mat = cdist(data[f,1:4], data[f,1:4]) + idx = argmax(dist_mat) + idxs = int(idx)//len(f), int(idx)%len(f) + else: + idxs = [0, 0] + idxs[0], idxs[1], _ = farthest_pair(data[f,1:4], 'brute' if not use_proxy else 'recursive') correction0, correction1 = 0.0, 0.0 if points_tensor is not None: scores = sigmoid(points_tensor[f, -1]) From 7f06353dd17cfd9702c7d5ea6cc07172e88bc003 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Mon, 6 Mar 2023 00:19:02 -0800 Subject: [PATCH 024/180] Implemented a faster intercluster distance algorithm --- mlreco/models/grappa.py | 3 +- mlreco/utils/gnn/network.py | 22 ++++++------ mlreco/utils/numba_local.py | 72 ++++++++++++++++++++++++++++++++++--- 3 files changed, 82 insertions(+), 15 deletions(-) diff --git a/mlreco/models/grappa.py b/mlreco/models/grappa.py index b9019e6f..6523e400 100644 --- a/mlreco/models/grappa.py +++ b/mlreco/models/grappa.py @@ -159,6 +159,7 @@ def __init__(self, cfg, name='grappa', batch_col=0, coords_col=(1, 4)): self.network = base_config.get('network', 'complete') self.edge_max_dist = base_config.get('edge_max_dist', -1) self.edge_dist_metric = base_config.get('edge_dist_metric', 'voxel') + self.edge_dist_algorithm = base_config.get('edge_dist_algorithm', 'brute') self.edge_knn_k = base_config.get('edge_knn_k', 5) self.edge_max_count = base_config.get('edge_max_count', 2e6) @@ -339,7 +340,7 @@ def forward(self, data, clusts=None, groups=None, points=None, extra_feats=None, # If necessary, compute the cluster distance matrix dist_mat, closest_index = None, None if np.any(self.edge_max_dist > -1) or self.network == 'mst' or self.network == 'knn': - dist_mat, closest_index = inter_cluster_distance(cluster_data[:,self.coords_index[0]:self.coords_index[1]].float(), clusts, batch_ids, self.edge_dist_metric, return_index=True) + dist_mat, closest_index = inter_cluster_distance(cluster_data[:,self.coords_index[0]:self.coords_index[1]].float(), clusts, batch_ids, self.edge_dist_metric, self.edge_dist_algorithm, return_index=True) # Form the requested network if len(clusts) == 1: diff --git a/mlreco/utils/gnn/network.py b/mlreco/utils/gnn/network.py index 27b77223..ffe21024 100644 --- a/mlreco/utils/gnn/network.py +++ b/mlreco/utils/gnn/network.py @@ -431,7 +431,7 @@ def _get_edge_distances(voxels: nb.float32[:,:], @numba_wrapper(cast_args=['voxels'], list_args=['clusts']) -def inter_cluster_distance(voxels, clusts, batch_ids=None, mode='voxel', return_index=False): +def inter_cluster_distance(voxels, clusts, batch_ids=None, mode='voxel', algorithm='brute', return_index=False): """ Finds the inter-cluster distance between every pair of clusters within each batch, returned as a block-diagonal matrix. @@ -441,6 +441,7 @@ def inter_cluster_distance(voxels, clusts, batch_ids=None, mode='voxel', return_ clusts ([np.ndarray]) : (C) List of arrays of voxel IDs in each cluster batch_ids (np.ndarray): (C) List of cluster batch IDs mode (str) : Eiher use closest voxel distance (`voxel`) or centroid distance (`centroid`) + algorithm (str) : `brute` is exact but slow, `recursive` uses a fast but approximate proxy return_index (bool) : If True, returns the combined index of the closest voxel pair Returns: torch.tensor: (C,C) Tensor of pair-wise cluster distances @@ -450,16 +451,17 @@ def inter_cluster_distance(voxels, clusts, batch_ids=None, mode='voxel', return_ batch_ids = np.zeros(len(clusts), dtype=np.int64) if not return_index: - return _inter_cluster_distance(voxels, clusts, batch_ids, mode) + return _inter_cluster_distance(voxels, clusts, batch_ids, mode, algorithm) else: assert mode == 'voxel', 'Cannot return index for centroid method' - return _inter_cluster_distance_index(voxels, clusts, batch_ids) + return _inter_cluster_distance_index(voxels, clusts, batch_ids, algorithm) @nb.njit(parallel=True, cache=True) def _inter_cluster_distance(voxels: nb.float32[:,:], clusts: nb.types.List(nb.int64[:]), batch_ids: nb.int64[:], - mode: str = 'voxel') -> nb.float32[:,:]: + mode: str = 'voxel', + algorithm: str = 'brute') -> nb.float32[:,:]: assert len(clusts) == len(batch_ids) dist_mat = np.zeros((len(batch_ids), len(batch_ids)), dtype=voxels.dtype) @@ -467,7 +469,7 @@ def _inter_cluster_distance(voxels: nb.float32[:,:], if mode == 'voxel': for k in nb.prange(len(indxi)): i, j = indxi[k], indxj[k] - dist_mat[i,j] = dist_mat[j,i] = np.min(nbl.cdist(voxels[clusts[i]], voxels[clusts[j]])) + dist_mat[i,j] = dist_mat[j,i] = nbl.closest_pair(voxels[clusts[i]], voxels[clusts[j]], algorithm)[-1] elif mode == 'centroid': centroids = np.empty((len(batch_ids), voxels.shape[1]), dtype=voxels.dtype) for i in nb.prange(len(batch_ids)): @@ -484,7 +486,8 @@ def _inter_cluster_distance(voxels: nb.float32[:,:], @nb.njit(parallel=True, cache=True) def _inter_cluster_distance_index(voxels: nb.float32[:,:], clusts: nb.types.List(nb.int64[:]), - batch_ids: nb.int64[:]) -> (nb.float32[:,:], nb.int64[:,:]): + batch_ids: nb.int64[:], + algorithm: str = 'brute') -> (nb.float32[:,:], nb.int64[:,:]): assert len(clusts) == len(batch_ids) dist_mat = np.zeros((len(batch_ids), len(batch_ids)), dtype=voxels.dtype) @@ -494,12 +497,11 @@ def _inter_cluster_distance_index(voxels: nb.float32[:,:], indxi, indxj = complete_graph(batch_ids, directed=True) for k in nb.prange(len(indxi)): i, j = indxi[k], indxj[k] - temp_dist_mat = nbl.cdist(voxels[clusts[i]], voxels[clusts[j]]) - index = np.argmin(temp_dist_mat) - ii, jj = index//temp_dist_mat.shape[1], index%temp_dist_mat.shape[1] + ii, jj, dist = nbl.closest_pair(voxels[clusts[i]], voxels[clusts[j]], algorithm) + index = ii*len(clusts[j]) + jj closest_index[i,j] = closest_index[j,i] = index - dist_mat[i,j] = dist_mat[j,i] = temp_dist_mat[ii,jj] + dist_mat[i,j] = dist_mat[j,i] = dist return dist_mat, closest_index diff --git a/mlreco/utils/numba_local.py b/mlreco/utils/numba_local.py index bf1c22db..fe6f613e 100644 --- a/mlreco/utils/numba_local.py +++ b/mlreco/utils/numba_local.py @@ -368,12 +368,76 @@ def farthest_pair(x: nb.float32[:,:], idxs = [index//x.shape[0], index%x.shape[0]] dist = dist_mat[idxs[0], idxs[1]] elif algorithm == 'recursive': - idxs, subidx, dist, tempdist = [0, 0], False, 1e9, 1e9+1. + idxs, subidx, dist, tempdist = [0, 0], 0, 1e9, 1e9+1. while dist < tempdist: tempdist = dist - dists = cdist(np.ascontiguousarray(x[idxs[int(subidx)]]).reshape(1,-1), x).flatten() - idxs[int(~subidx)] = np.argmax(dists) - dist = dists[idxs[int(~subidx)]] + dists = cdist(np.ascontiguousarray(x[idxs[subidx]]).reshape(1,-1), x).flatten() + idxs[~subidx] = np.argmax(dists) + dist = dists[idxs[~subidx]] + subidx = ~subidx + else: + raise ValueError('Algorithm not supported') + + return idxs[0], idxs[1], dist + + +@nb.njit(cache=True) +def closest_pair(x1: nb.float32[:,:], + x2: nb.float32[:,:], + algorithm: bool = 'brute', + seed: bool = True) -> (nb.int32, nb.int32, nb.float32): + ''' + Algorithm which finds the two points which are + closest to each other from two separate sets. + + Two algorithms: + - `brute`: compute cdist, use argmin + - `recursive`: Start with one point in one set, find the closest + point in the other set, move to theat point, repeat. This + algorithm is *not* exact, but a good and very quick proxy. + + Parameters + ---------- + x1 : np.ndarray + (Nx3) array of point coordinates in the first set + x1 : np.ndarray + (Nx3) array of point coordinates in the second set + algorithm : str + Name of the algorithm to use: `brute` or `recursive` + seed : bool + Whether or not to use the two farthest points in one set to seed the recursion + + Returns + ------- + int + ID of the first point that makes up the pair + int + ID of the second point that makes up the pair + float + Distance between the two points + ''' + if algorithm == 'brute': + dist_mat = cdist(x1, x2) + index = np.argmin(dist_mat) + idxs = [index//dist_mat.shape[1], index%dist_mat.shape[1]] + dist = dist_mat[idxs[0], idxs[1]] + elif algorithm == 'recursive': + xarr = [x1, x2] + idxs, subidx, dist, tempdist = [0, 0], 0, 1e9, 1e9+1. + if seed: + seed_idxs = np.array(farthest_pair(xarr[~subidx], 'recursive')[:2]) + seed_dists = cdist(xarr[~subidx][seed_idxs], xarr[subidx]) + seed_argmins = argmin(seed_dists, axis=1) + seed_mins = np.array([seed_dists[0][seed_argmins[0]], seed_dists[1][seed_argmins[1]]]) + seed_choice = np.argmin(seed_mins) + idxs[int(~subidx)] = seed_idxs[seed_choice] + idxs[int(subidx) ] = seed_argmins[seed_choice] + dist = seed_mins[seed_choice] + while dist < tempdist: + tempdist = dist + dists = cdist(np.ascontiguousarray(xarr[subidx][idxs[subidx]]).reshape(1,-1), xarr[~subidx]).flatten() + idxs[~subidx] = np.argmin(dists) + dist = dists[idxs[~subidx]] subidx = ~subidx else: raise ValueError('Algorithm not supported') From c3d5a58a39c82d0d7d51f894e6dd67fdd08bb9f3 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Mon, 6 Mar 2023 21:46:56 -0800 Subject: [PATCH 025/180] Bug fix to make it possible to freeze subcomponents of a module --- mlreco/trainval.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/mlreco/trainval.py b/mlreco/trainval.py index f9b3036e..bfb37213 100644 --- a/mlreco/trainval.py +++ b/mlreco/trainval.py @@ -369,8 +369,11 @@ def freeze_weights(self, module_config): model_name = config.get('model_name', module) model_path = config.get('model_path', None) - # Make sure BN and DO layers are set to eval mode - getattr(self._model, model_name).eval() + # Make sure BN and DO layers are set to eval mode when the weights are frozen + model = self._model + for m in module.split('.'): + model = getattr(model, m) + model.eval() # Freeze all weights count = 0 From f093c645bf891fdde877857f0eb26d1b003b1a74 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Mon, 6 Mar 2023 21:58:28 -0800 Subject: [PATCH 026/180] Make GNN (and full chain) return cluster end points --- mlreco/main_funcs.py | 1 + mlreco/models/grappa.py | 2 ++ mlreco/models/layers/common/gnn_full_chain.py | 6 +++++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/mlreco/main_funcs.py b/mlreco/main_funcs.py index 04b8d96f..6816f974 100644 --- a/mlreco/main_funcs.py +++ b/mlreco/main_funcs.py @@ -69,6 +69,7 @@ def process_config(cfg, verbose=True): default_concat_result = ['input_edge_features', 'input_node_features','points', 'coordinates', 'particle_node_features', 'particle_edge_features', 'track_node_features', 'shower_node_features', + 'input_node_points', 'shower_points', 'track_points', 'particle_points', 'ppn_coords', 'mask_ppn', 'ppn_layers', 'classify_endpoints', 'vertex_layers', 'vertex_coords', 'primary_label_scales', 'segment_label_scales', 'seediness', 'margins', 'embeddings', 'fragments', diff --git a/mlreco/models/grappa.py b/mlreco/models/grappa.py index 6523e400..e6ebca13 100644 --- a/mlreco/models/grappa.py +++ b/mlreco/models/grappa.py @@ -419,6 +419,8 @@ def forward(self, data, clusts=None, groups=None, points=None, extra_feats=None, result['input_node_features'] = [[x[b] for b in cbids]] result['input_edge_features'] = [[e[b] for b in ebids]] + if points is not None: + result['input_node_points'] = [[points[b] for b in cbids]] # Pass through the model, update results out = self.gnn_model(x, index, e, xbatch) diff --git a/mlreco/models/layers/common/gnn_full_chain.py b/mlreco/models/layers/common/gnn_full_chain.py index e700421b..33d60f93 100644 --- a/mlreco/models/layers/common/gnn_full_chain.py +++ b/mlreco/models/layers/common/gnn_full_chain.py @@ -184,7 +184,6 @@ def run_fragment_gnns(self, result, input): """ frag_dict = self.get_all_fragments(result, input) - del result['frag_dict'] fragments = frag_dict['frags'] frag_seg = frag_dict['frag_seg'] @@ -204,6 +203,7 @@ def run_fragment_gnns(self, result, input): 'edge_pred' : 'shower_edge_pred', 'edge_index': 'shower_edge_index', 'group_pred': 'shower_group_pred', + 'input_node_points' : 'shower_points', 'input_node_features': 'shower_node_features'} # shower_grappa_input = input # if self.use_true_fragments and 'points' not in kwargs: @@ -235,6 +235,7 @@ def run_fragment_gnns(self, result, input): 'edge_pred' : 'track_edge_pred', 'edge_index': 'track_edge_index', 'group_pred': 'track_group_pred', + 'input_node_points' : 'track_points', 'input_node_features': 'track_node_features'} self.run_gnn(self.grappa_track, @@ -444,6 +445,7 @@ def run_particle_gnns(self, result, input, frag_result): 'node_pred_type': 'node_pred_type', 'node_pred_p': 'node_pred_p', 'node_pred_vtx': 'node_pred_vtx', + 'input_node_points' : 'particle_points', 'input_node_features': 'particle_node_features', 'input_edge_features': 'particle_edge_features'} @@ -586,6 +588,8 @@ def forward(self, input): result, input, revert_func = self.full_chain_cnn(input) if len(input[0]) and 'frag_dict' in result and self.process_fragments and (self.enable_gnn_track or self.enable_gnn_shower or self.enable_gnn_inter or self.enable_gnn_particle): result = self.full_chain_gnn(result, input) + if 'frag_dict' in result: + del result['frag_dict'] result = revert_func(result) return result From 00c0b31bff043b34cfcd78237e6929fa89dffedb Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Mon, 6 Mar 2023 22:02:13 -0800 Subject: [PATCH 027/180] Got rid of UResNetPPN-specific linear layer which does not transfer to the full chain --- mlreco/models/uresnet_ppn_chain.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/mlreco/models/uresnet_ppn_chain.py b/mlreco/models/uresnet_ppn_chain.py index 02943b74..7c4d7398 100644 --- a/mlreco/models/uresnet_ppn_chain.py +++ b/mlreco/models/uresnet_ppn_chain.py @@ -74,10 +74,6 @@ def __init__(self, cfg): assert self.ghost == cfg.get('ppn', {}).get('ghost', False) self.backbone = UResNet_Chain(cfg) self.ppn = PPN(cfg) - self.num_classes = self.backbone.num_classes - self.num_filters = self.backbone.F - self.segmentation = ME.MinkowskiLinear( - self.num_filters, self.num_classes) def forward(self, input): @@ -94,9 +90,9 @@ def forward(self, input): out = defaultdict(list) for igpu, x in enumerate(input_tensors): - # input_data = x[:, :5] res = self.backbone([x]) - out.update({'ghost': res['ghost']}) + out.update({'ghost': res['ghost'], + 'segmentation': res['segmentation']}) if self.ghost: if self.ppn.use_true_ghost_mask: res_ppn = self.ppn(res['finalTensor'][igpu], @@ -111,12 +107,6 @@ def forward(self, input): else: res_ppn = self.ppn(res['finalTensor'][igpu], res['decoderTensors'][igpu]) - # if self.training: - # res_ppn = self.ppn(res['finalTensor'], res['encoderTensors'], particles_label) - # else: - # res_ppn = self.ppn(res['finalTensor'], res['encoderTensors']) - segmentation = self.segmentation(res['decoderTensors'][igpu][-1]) - out['segmentation'].append(segmentation.F) out.update(res_ppn) return out From 60236e0228baaf4858e1468b287be6bbd7a0dbfa Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Tue, 7 Mar 2023 11:42:15 -0800 Subject: [PATCH 028/180] Got rid of print statements --- mlreco/utils/ppn.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mlreco/utils/ppn.py b/mlreco/utils/ppn.py index b902dab6..682d835f 100644 --- a/mlreco/utils/ppn.py +++ b/mlreco/utils/ppn.py @@ -311,10 +311,10 @@ def uresnet_ppn_type_point_selector(data, out, score_threshold=0.5, type_score_t points = out['points'][entry] enable_classify_endpoints = 'classify_endpoints' in out - print("ENABLE CLASSIFY ENDPOINTS = ", enable_classify_endpoints) + #print("ENABLE CLASSIFY ENDPOINTS = ", enable_classify_endpoints) if enable_classify_endpoints: classify_endpoints = out['classify_endpoints'][0] - print(classify_endpoints) + #print(classify_endpoints) mask_ppn = out['mask_ppn'][-1] # predicted type labels From a5c9de35e939a0467a4bcb356ad75cb32392c760 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Tue, 7 Mar 2023 14:29:11 -0800 Subject: [PATCH 029/180] Analysis tools update --- analysis/algorithms/calorimetry.py | 2 +- analysis/algorithms/selections/template.py | 23 +++++++++++++++++++++- analysis/algorithms/utils.py | 3 +++ analysis/algorithms/vertex.py | 2 +- mlreco/visualization/plotly_layouts.py | 16 +++++++-------- 5 files changed, 35 insertions(+), 11 deletions(-) diff --git a/analysis/algorithms/calorimetry.py b/analysis/algorithms/calorimetry.py index e1d3a940..c0229a4f 100644 --- a/analysis/algorithms/calorimetry.py +++ b/analysis/algorithms/calorimetry.py @@ -135,7 +135,7 @@ def compute_track_dedx(p, bin_size=17): assert len(p.points) >= 2 vec = p.endpoint - p.startpoint vec_norm = np.linalg.norm(vec) - vec = (vec / vec_norm).astype(np.float64) + vec = (vec / (vec_norm + 1e-6)).astype(np.float64) proj = p.points - p.startpoint proj = np.dot(proj, vec) bins = np.arange(proj.min(), proj.max(), bin_size) diff --git a/analysis/algorithms/selections/template.py b/analysis/algorithms/selections/template.py index 472a339b..ca2178df 100644 --- a/analysis/algorithms/selections/template.py +++ b/analysis/algorithms/selections/template.py @@ -16,6 +16,7 @@ get_mparticles_from_minteractions from analysis.algorithms.calorimetry import get_csda_range_spline +from analysis.algorithms.vertex import estimate_vertex @evaluate(['interactions', 'particles'], mode='per_batch') def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): @@ -44,8 +45,11 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): particle_dict = analysis_cfg['analysis'].get('particle_dict', {}) use_primaries_for_vertex = analysis_cfg['analysis'].get('use_primaries_for_vertex', True) + run_reco_vertex = analysis_cfg['analysis'].get('run_reco_vertex', False) + test_containment = analysis_cfg['analysis'].get('test_containment', False) splines = None + skip_classes = set([3, 4]) if compute_energy: @@ -90,7 +94,7 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): compute_vertex=compute_vertex, vertex_mode=vertex_mode, overlap_mode=predictor.overlap_mode, - matching_mode='optimal') + matching_mode=matching_mode) # 1 a) Check outputs from interaction matching if len(matches) == 0: @@ -119,6 +123,19 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): # this predicted interaction. Hence: pred_int_dict['pred_interaction_has_match'] = True true_int_dict['true_nu_id'] = true_int.nu_id + + if run_reco_vertex: + + reco_vtx, _ = estimate_vertex(true_int.particles, + use_primaries=use_primaries_for_vertex, + mode=vertex_mode, + prune_candidates=predictor.prune_vertex, + return_candidate_count=True) + + true_int_dict['true_reco_vtx_x'] = reco_vtx[0] + true_int_dict['true_reco_vtx_y'] = reco_vtx[1] + true_int_dict['true_reco_vtx_z'] = reco_vtx[2] + if 'neutrino_asis' in data_blob and true_int.nu_id > 0: # assert 'particles_asis' in data_blob # particles = data_blob['particles_asis'][i] @@ -190,6 +207,8 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): if true_p is not None: pred_particle_dict['pred_particle_has_match'] = True + if test_containment: + true_particle_dict['true_particle_is_contained'] = predictor.is_contained(true_p.points) true_particle_dict['true_particle_interaction_id'] = true_p.interaction_id if 'particles_asis' in data_blob: particles_asis = data_blob['particles_asis'][idx] @@ -204,6 +223,8 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): true_particle_dict['true_particle_children_count'] = len(children) if pred_p is not None: + if test_containment: + pred_particle_dict['pred_particle_is_contained'] = predictor.is_contained(pred_p.points) true_particle_dict['true_particle_has_match'] = True pred_particle_dict['pred_particle_interaction_id'] = pred_p.interaction_id diff --git a/analysis/algorithms/utils.py b/analysis/algorithms/utils.py index c10f3067..261f616e 100644 --- a/analysis/algorithms/utils.py +++ b/analysis/algorithms/utils.py @@ -343,6 +343,9 @@ def get_particle_properties(particle: Particle, update_dict['particle_startpoint_is_touching'] = False creation_process = particle.particle_asis.creation_process() update_dict['particle_creation_process'] = creation_process + update_dict['particle_px'] = float(particle.particle_asis.px()) + update_dict['particle_py'] = float(particle.particle_asis.py()) + update_dict['particle_pz'] = float(particle.particle_asis.pz()) if compute_energy: update_dict['particle_sum_edep'] = particle.sum_edep direction = compute_particle_direction(particle) diff --git a/analysis/algorithms/vertex.py b/analysis/algorithms/vertex.py index 3f60aeb6..58d3a6b4 100644 --- a/analysis/algorithms/vertex.py +++ b/analysis/algorithms/vertex.py @@ -253,7 +253,7 @@ def correct_primary_with_vertex(ia, r_adj=10, r_bt=10, start_segment_radius=10): for p in ia.particles: if p.semantic_type == 1: dist = np.linalg.norm(p.startpoint - ia.vertex) - print(p.id, p.is_primary, p.semantic_type, dist) + # print(p.id, p.is_primary, p.semantic_type, dist) if dist < r_adj: p.is_primary = True else: diff --git a/mlreco/visualization/plotly_layouts.py b/mlreco/visualization/plotly_layouts.py index 64eed67f..ea4e3ba7 100644 --- a/mlreco/visualization/plotly_layouts.py +++ b/mlreco/visualization/plotly_layouts.py @@ -73,7 +73,7 @@ def trace_particles(particles, color='id', size=1, scatter_points=False, scatter_ppn=False, highlight_primaries=False, - colorscale='rainbow'): + colorscale='rainbow', prefix=''): ''' Get Scatter3d traces for a list of instances. Each will be drawn with the color specified @@ -110,7 +110,7 @@ def trace_particles(particles, color='id', size=1, # reversescale=True, opacity=opacity), hovertext=int(getattr(p, color)), - name='Particle {}'.format(p.id) + name='{}Particle {}'.format(prefix, p.id) ) traces.append(plot) if scatter_points: @@ -125,7 +125,7 @@ def trace_particles(particles, color='id', size=1, # colorscale=colorscale, opacity=0.6), # hovertext=p.ppn_candidates[:, 4], - name='Startpoint {}'.format(p.id)) + name='{}Startpoint {}'.format(prefix, p.id)) traces.append(plot) if p.endpoint is not None: plot = go.Scatter3d(x=np.array([p.endpoint[0]]), @@ -140,7 +140,7 @@ def trace_particles(particles, color='id', size=1, # colorscale=colorscale, opacity=0.6), # hovertext=p.ppn_candidates[:, 4], - name='Endpoint {}'.format(p.id)) + name='Endpoint {}'.format(prefix, p.id)) traces.append(plot) elif scatter_ppn: plot = go.Scatter3d(x=p.ppn_candidates[:, 0], @@ -153,12 +153,12 @@ def trace_particles(particles, color='id', size=1, # colorscale=colorscale, opacity=1), # hovertext=p.ppn_candidates[:, 4], - name='PPN {}'.format(p.id)) + name='{}PPN {}'.format(prefix, p.id)) traces.append(plot) return traces -def trace_interactions(interactions, color='id', colorscale="rainbow"): +def trace_interactions(interactions, color='id', colorscale="rainbow", prefix=''): ''' Get Scatter3d traces for a list of instances. Each will be drawn with the color specified @@ -195,7 +195,7 @@ def trace_interactions(interactions, color='id', colorscale="rainbow"): reversescale=True, opacity=1), hovertext=int(getattr(inter, color)), - name='Interaction {}'.format(getattr(inter, color)) + name='{}Interaction {}'.format(prefix, getattr(inter, color)) ) traces.append(plot) if inter.vertex is not None and (inter.vertex > -1).all(): @@ -209,7 +209,7 @@ def trace_interactions(interactions, color='id', colorscale="rainbow"): # colorscale=colorscale, opacity=0.6), # hovertext=p.ppn_candidates[:, 4], - name='Vertex {}'.format(inter.id)) + name='{}Vertex {}'.format(prefix, inter.id)) traces.append(plot) return traces From 4c940f24a1b65ea7d70c9c554bfe696160c47bd2 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Tue, 7 Mar 2023 14:35:20 -0800 Subject: [PATCH 030/180] Change range based splines so that path to tabular data is an option --- analysis/algorithms/calorimetry.py | 6 +++--- analysis/algorithms/selections/template.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/analysis/algorithms/calorimetry.py b/analysis/algorithms/calorimetry.py index c0229a4f..93cca492 100644 --- a/analysis/algorithms/calorimetry.py +++ b/analysis/algorithms/calorimetry.py @@ -49,16 +49,16 @@ def compute_track_length(points, bin_size=17): return length -def get_csda_range_spline(particle_type): +def get_csda_range_spline(particle_type, table_path): ''' Returns CSDARange (g/cm^2) vs. Kinetic E (MeV/c^2) ''' if particle_type == 'proton': - tab = pd.read_csv('/sdf/group/neutrino/koh0207/lartpc_mlreco3d/analysis/algorithms/tables/pE_liquid_argon.txt', + tab = pd.read_csv(table_path, delimiter=' ', index_col=False) elif particle_type == 'muon': - tab = pd.read_csv('/sdf/group/neutrino/koh0207/lartpc_mlreco3d/analysis/algorithms/tables/muE_liquid_argon.txt', + tab = pd.read_csv(table_path, delimiter=' ', index_col=False) else: diff --git a/analysis/algorithms/selections/template.py b/analysis/algorithms/selections/template.py index ca2178df..5363866d 100644 --- a/analysis/algorithms/selections/template.py +++ b/analysis/algorithms/selections/template.py @@ -54,8 +54,8 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): if compute_energy: splines = { - 'proton': get_csda_range_spline('proton'), - 'muon': get_csda_range_spline('muon') + 'proton': get_csda_range_spline('proton', '/sdf/group/neutrino/koh0207/lartpc_mlreco3d/analysis/algorithms/tables/pE_liquid_argon.txt'), + 'muon': get_csda_range_spline('muon', '/sdf/group/neutrino/koh0207/lartpc_mlreco3d/analysis/algorithms/tables/muE_liquid_argon.txt') } # Load data into evaluator From a2f54c8d6904ef902f48cfe2861cbd66eaf745f5 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Tue, 7 Mar 2023 15:01:20 -0800 Subject: [PATCH 031/180] Make run_info a list instead of a tuple --- mlreco/iotools/parsers/misc.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mlreco/iotools/parsers/misc.py b/mlreco/iotools/parsers/misc.py index 8826c551..f493be07 100644 --- a/mlreco/iotools/parsers/misc.py +++ b/mlreco/iotools/parsers/misc.py @@ -120,7 +120,9 @@ def parse_run_info(sparse_event): tuple (run, subrun, event) """ - return sparse_event.run(), sparse_event.subrun(), sparse_event.event() + return [sparse_event.run(), + sparse_event.subrun(), + sparse_event.event()] def parse_opflash(opflash_event): From bae6599a56178f604736da4176b227d14eade08e Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Wed, 8 Mar 2023 11:56:42 -0800 Subject: [PATCH 032/180] Small bug fix in training curve visualization tools --- mlreco/visualization/training.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mlreco/visualization/training.py b/mlreco/visualization/training.py index 475ad7df..205dabde 100644 --- a/mlreco/visualization/training.py +++ b/mlreco/visualization/training.py @@ -141,7 +141,7 @@ def get_validation_df(log_dir, keys, prefix='inference'): for log_file in log_files: df = pd.read_csv(log_file) it = int(log_file.split('/')[-1].split('-')[-1].split('.')[0]) - val_data['iter'].append(it) + val_data['iter'].append(it-1) for key_list in keys: key, key_name = find_key(df, key_list) val_data[f'{key_name}_mean'].append(df[key].mean()) From 4ceaa194a9949fda635d1ae7707932e302241b7a Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 8 Mar 2023 12:00:52 -0800 Subject: [PATCH 033/180] Analysis tools crash hotfix --- analysis/algorithms/utils.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/analysis/algorithms/utils.py b/analysis/algorithms/utils.py index 261f616e..a2b084a7 100644 --- a/analysis/algorithms/utils.py +++ b/analysis/algorithms/utils.py @@ -9,9 +9,6 @@ from analysis.algorithms.calorimetry import get_csda_range_spline, compute_track_dedx import numpy as np -# Splines for ranged based energy reco -f_proton = get_csda_range_spline('proton') -f_muon = get_csda_range_spline('muon') def attach_prefix(update_dict, prefix): @@ -197,13 +194,6 @@ def load_range_reco(particle_type='muon', kinetic_energy=True): print(f'Range-based reconstruction for particle "{particle_type}" not available.') -def make_range_based_momentum_fns(): - f_muon = load_range_reco('muon') - f_pion = load_range_reco('pion') - f_proton = load_range_reco('proton') - return [f_muon, f_pion, f_proton] - - def get_interaction_properties(interaction: Interaction, spatial_size, prefix=None): update_dict = OrderedDict({ From d07679a671ee4a8ac463322a247f7f0a87f60194 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 8 Mar 2023 13:26:09 -0800 Subject: [PATCH 034/180] Port existing direction estimator to analysis tools --- analysis/algorithms/calorimetry.py | 138 +++++++----------- .../algorithms/selections/michel_electrons.py | 2 +- analysis/algorithms/selections/muon_decay.py | 2 +- analysis/algorithms/selections/statistics.py | 4 +- analysis/algorithms/utils.py | 54 ++++--- analysis/algorithms/vertex.py | 12 +- analysis/classes/predictor.py | 38 ++--- mlreco/utils/vertex.py | 2 +- 8 files changed, 112 insertions(+), 140 deletions(-) diff --git a/analysis/algorithms/calorimetry.py b/analysis/algorithms/calorimetry.py index 93cca492..f322683c 100644 --- a/analysis/algorithms/calorimetry.py +++ b/analysis/algorithms/calorimetry.py @@ -3,6 +3,7 @@ import numba as nb from sklearn.decomposition import PCA from scipy.interpolate import CubicSpline +from mlreco.utils.gnn.cluster import cluster_direction import pandas as pd # CONSTANTS (MeV) @@ -83,52 +84,59 @@ def compute_range_based_energy(particle, f, **kwargs): return total -def compute_particle_direction(p: Particle, - start_segment_radius=17, - vertex=None, - return_explained_variance=False): - """ - Given a Particle, compute the start direction. Within `start_segment_radius` - of the start point, find PCA axis and measure direction. +def get_particle_direction(p: Particle, **kwargs): + startpoint = p.startpoint + v = cluster_direction(p.points, p.startpoint, **kwargs) + return v - If not start point is found, returns (-1, -1, -1). - Parameters - ---------- - p: Particle - start_segment_radius: float, optional +# # Deprecated +# def compute_particle_direction(p: Particle, +# start_segment_radius=17, +# vertex=None, +# return_explained_variance=False): +# """ +# Given a Particle, compute the start direction. Within `start_segment_radius` +# of the start point, find PCA axis and measure direction. - Returns - ------- - np.ndarray - Shape (3,) - """ - pca = PCA(n_components=2) - direction = None - if p.startpoint is not None and p.startpoint[0] >= 0.: - startpoint = p.startpoint - if p.endpoint is not None and vertex is not None: # make sure we pick the one closest to vertex - use_end = np.argmin([ - np.sqrt(((vertex-p.startpoint)**2).sum()), - np.sqrt(((vertex-p.endpoint)**2).sum()) - ]) - startpoint = p.endpoint if use_end else p.startpoint - d = np.sqrt(((p.points - startpoint)**2).sum(axis=1)) - if (d < start_segment_radius).sum() >= 2: - direction = pca.fit(p.points[d < start_segment_radius]).components_[0, :] - if direction is None: # we could not find a startpoint - if len(p.points) >= 2: # just all voxels - direction = pca.fit(p.points).components_[0, :] - else: - direction = np.array([-1, -1, -1]) - if not return_explained_variance: - return direction - else: - return direction, np.array([-1, -1]) - if not return_explained_variance: - return direction - else: - return direction, pca.explained_variance_ratio_ +# If not start point is found, returns (-1, -1, -1). + +# Parameters +# ---------- +# p: Particle +# start_segment_radius: float, optional + +# Returns +# ------- +# np.ndarray +# Shape (3,) +# """ +# pca = PCA(n_components=2) +# direction = None +# if p.startpoint is not None and p.startpoint[0] >= 0.: +# startpoint = p.startpoint +# if p.endpoint is not None and vertex is not None: # make sure we pick the one closest to vertex +# use_end = np.argmin([ +# np.sqrt(((vertex-p.startpoint)**2).sum()), +# np.sqrt(((vertex-p.endpoint)**2).sum()) +# ]) +# startpoint = p.endpoint if use_end else p.startpoint +# d = np.sqrt(((p.points - startpoint)**2).sum(axis=1)) +# if (d < start_segment_radius).sum() >= 2: +# direction = pca.fit(p.points[d < start_segment_radius]).components_[0, :] +# if direction is None: # we could not find a startpoint +# if len(p.points) >= 2: # just all voxels +# direction = pca.fit(p.points).components_[0, :] +# else: +# direction = np.array([-1, -1, -1]) +# if not return_explained_variance: +# return direction +# else: +# return direction, np.array([-1, -1]) +# if not return_explained_variance: +# return direction +# else: +# return direction, pca.explained_variance_ratio_ def compute_track_dedx(p, bin_size=17): @@ -217,8 +225,7 @@ def compute_mcs_muon_energy(particle, bin_size=17, pca = PCA(n_components=3) coords_pca = pca.fit_transform(particle.points) proj = coords_pca[:, 0] - global_dir = compute_particle_direction(particle, - start_segment_radius=bin_size) + global_dir = get_particle_direction(particle, optimize=True) if global_dir[0] < 0: global_dir = pca.components_[0] perm = np.argsort(proj.squeeze()) @@ -268,43 +275,4 @@ def compute_mcs_muon_energy(particle, bin_size=17, einit = i lls = np.array(lls) Es = np.array(Es) - return einit, min_ll - -# def load_range_reco(particle_type='muon', kinetic_energy=True): -# """ -# Return a function maps the residual range of a track to the kinetic -# energy of the track. The mapping is based on the Bethe-Bloch formula -# and stored per particle type in TGraph objects. The TGraph::Eval -# function is used to perform the interpolation. - -# Parameters -# ---------- -# particle_type: A string with the particle name. -# kinetic_energy: If true (false), return the kinetic energy (momentum) - -# Returns -# ------- -# The kinetic energy or momentum according to Bethe-Bloch. -# """ -# output_var = ('_RRtoT' if kinetic_energy else '_RRtodEdx') -# if particle_type in ['muon', 'pion', 'kaon', 'proton']: -# input_file = ROOT.TFile.Open('/sdf/group/neutrino/koh0207/misc/RRInput.root', 'read') -# graph = input_file.Get(f'{particle_type}{output_var}') -# return np.vectorize(graph.Eval) -# else: -# print(f'Range-based reconstruction for particle "{particle_type}" not available.') - - -# def make_range_based_momentum_fns(): -# f_muon = load_range_reco('muon') -# f_pion = load_range_reco('pion') -# f_proton = load_range_reco('proton') -# return [f_muon, f_pion, f_proton] - - -# def compute_range_momentum(particle, f, voxel_to_cm=0.3, **kwargs): -# assert particle.semantic_type == 1 -# length = compute_track_length(particle.points, -# bin_size=kwargs.get('bin_size', 17)) -# E = f(length * voxel_to_cm) -# return E \ No newline at end of file + return einit, min_ll \ No newline at end of file diff --git a/analysis/algorithms/selections/michel_electrons.py b/analysis/algorithms/selections/michel_electrons.py index b8410cb4..056894f2 100644 --- a/analysis/algorithms/selections/michel_electrons.py +++ b/analysis/algorithms/selections/michel_electrons.py @@ -5,7 +5,7 @@ from analysis.classes.predictor import FullChainPredictor from analysis.classes.evaluator import FullChainEvaluator from analysis.decorator import evaluate -from analysis.algorithms.calorimetry import compute_track_length, compute_particle_direction +from analysis.algorithms.calorimetry import compute_track_length from pprint import pprint import time diff --git a/analysis/algorithms/selections/muon_decay.py b/analysis/algorithms/selections/muon_decay.py index 670f2999..ae49dbcd 100644 --- a/analysis/algorithms/selections/muon_decay.py +++ b/analysis/algorithms/selections/muon_decay.py @@ -1,7 +1,7 @@ from collections import OrderedDict from analysis.classes.predictor import FullChainPredictor from analysis.classes.evaluator import FullChainEvaluator -from analysis.algorithms.calorimetry import compute_track_length, compute_particle_direction +from analysis.algorithms.calorimetry import compute_track_length from analysis.decorator import evaluate from analysis.classes.particle import match_particles_fn, matrix_iou diff --git a/analysis/algorithms/selections/statistics.py b/analysis/algorithms/selections/statistics.py index dd0c85f6..9a2909a0 100644 --- a/analysis/algorithms/selections/statistics.py +++ b/analysis/algorithms/selections/statistics.py @@ -2,7 +2,7 @@ from turtle import update from sklearn.decomposition import PCA -from analysis.algorithms.calorimetry import compute_track_length, compute_particle_direction +from analysis.algorithms.calorimetry import compute_track_length, get_particle_direction from analysis.classes.predictor import FullChainPredictor from analysis.classes.evaluator import FullChainEvaluator from analysis.decorator import evaluate @@ -99,7 +99,7 @@ def statistics(data_blob, res, data_idx, analysis_cfg, cfg): # Loop over predicted particles for p in pred_particles: - direction = compute_particle_direction(p, start_segment_radius=start_segment_radius) + direction = get_particle_direction(p, start_segment_radius=start_segment_radius) length = -1 if p.semantic_type == track_label: diff --git a/analysis/algorithms/utils.py b/analysis/algorithms/utils.py index a2b084a7..786c339e 100644 --- a/analysis/algorithms/utils.py +++ b/analysis/algorithms/utils.py @@ -3,10 +3,10 @@ from analysis.classes.particle import Interaction, Particle, TruthParticle from analysis.algorithms.calorimetry import * +from sklearn.decomposition import PCA from scipy.spatial.distance import cdist from analysis.algorithms.point_matching import get_track_endpoints_max_dist - -from analysis.algorithms.calorimetry import get_csda_range_spline, compute_track_dedx +from analysis.algorithms.calorimetry import compute_track_dedx, get_particle_direction import numpy as np @@ -62,11 +62,6 @@ def correct_track_points(particle): particle.endpoint = x[scores[:, 1].argmax()] -def get_track_points_default(p): - pts = np.vstack([p._node_features[19:22], p._node_features[22:25]]) - correct_track_endpoints_closest(p, pts=pts) - - def handle_singleton_ppn_candidate(p, pts, ppn_candidates): assert ppn_candidates.shape[0] == 1 score = ppn_candidates[0][5:] @@ -83,12 +78,9 @@ def handle_singleton_ppn_candidate(p, pts, ppn_candidates): -def correct_track_endpoints_closest(p, pts=None): +def correct_track_endpoints_ppn(p): assert p.semantic_type == 1 - if pts is None: - pts = np.vstack(get_track_endpoints_max_dist(p)) - else: - assert pts.shape == (2, 3) + pts = np.vstack([p.startpoint, p.endpoint]) if p.ppn_candidates.shape[0] == 0: p.startpoint = pts[0] @@ -135,21 +127,26 @@ def correct_track_endpoints_closest(p, pts=None): p.endpoint = pts[ix] else: raise ValueError("Classify endpoints feature dimension must be 2, got something else!") - if np.linalg.norm(p.startpoint - p.endpoint) > 1e-6: + if np.linalg.norm(p.startpoint - p.endpoint) < 1e-6: p.startpoint = pts[0] p.endpoint = pts[1] -def local_density_correction(p, r=5): +def correct_track_endpoints_local_density(p, r=5): + pca = PCA(n_components=2) assert p.semantic_type == 1 - dist_st = np.linalg.norm(p.startpoint - p.points, axis=1) < r - if not dist_st.any(): + mask_st = np.linalg.norm(p.startpoint - p.points, axis=1) < r + if np.count_nonzero(mask_st) < 2: return - local_d_start = p.depositions[dist_st].sum() / sum(dist_st) - dist_end = np.linalg.norm(p.endpoint - p.points, axis=1) < r - if not dist_end.any(): + pca_axis = pca.fit_transform(p.points[mask_st]) + length = pca_axis[:, 0].max() - pca_axis[:, 0].min() + local_d_start = p.depositions[mask_st].sum() / length + mask_end = np.linalg.norm(p.endpoint - p.points, axis=1) < r + if np.count_nonzero(mask_end) < 2: return - local_d_end = p.depositions[dist_end].sum() / sum(dist_end) + pca_axis = pca.fit_transform(p.points[mask_end]) + length = pca_axis[:, 0].max() - pca_axis[:, 0].min() + local_d_end = p.depositions[mask_end].sum() / length # Startpoint must have lowest local density if local_d_start > local_d_end: p1, p2 = p.startpoint, p.endpoint @@ -169,6 +166,21 @@ def correct_track_endpoints_linfit(p, bin_size=17): p.endpoint = p1 +def get_track_points(p, correction_mode='ppn', brute_force=False): + if brute_force: + pts = np.vstack(get_track_endpoints_max_dist(p)) + else: + pts = np.vstack([p.startpoint, p.endpoint]) + if correction_mode == 'ppn': + correct_track_endpoints_ppn(p, pts=pts) + elif correction_mode == 'local_density': + correct_track_endpoints_local_density(p) + elif correction_mode == 'linfit': + correct_track_endpoints_linfit(p) + else: + raise ValueError("Track extrema correction mode {} not defined!".format(correction_mode)) + + def load_range_reco(particle_type='muon', kinetic_energy=True): """ Return a function maps the residual range of a track to the kinetic @@ -338,7 +350,7 @@ def get_particle_properties(particle: Particle, update_dict['particle_pz'] = float(particle.particle_asis.pz()) if compute_energy: update_dict['particle_sum_edep'] = particle.sum_edep - direction = compute_particle_direction(particle) + direction = get_particle_direction(particle, optimize=True) assert len(direction) == 3 update_dict['particle_dir_x'] = direction[0] update_dict['particle_dir_y'] = direction[1] diff --git a/analysis/algorithms/vertex.py b/analysis/algorithms/vertex.py index 58d3a6b4..f91a105f 100644 --- a/analysis/algorithms/vertex.py +++ b/analysis/algorithms/vertex.py @@ -1,7 +1,7 @@ import numpy as np import numba as nb from scipy.spatial.distance import cdist -from analysis.algorithms.calorimetry import compute_particle_direction +from analysis.algorithms.calorimetry import get_particle_direction from mlreco.utils.utils import func_timer from analysis.classes.Interaction import Interaction @@ -61,7 +61,7 @@ def get_track_shower_poca(particles, return_annot=False, start_segment_radius=10 track_starts = np.array([p.startpoint for p in particles if p.semantic_type == 1]) shower_starts, shower_dirs = [], [] for p in particles: - vec = compute_particle_direction(p, start_segment_radius=start_segment_radius) + vec = get_particle_direction(p, optimize=True) if p.semantic_type == 0 and (vec != -1).all(): shower_dirs.append(vec) shower_starts.append(p.startpoint) @@ -132,10 +132,10 @@ def compute_vertex_matrix_inversion(particles, C = np.zeros((dim, )) for p in particles: - vec, var = compute_particle_direction(p, return_explained_variance=True) + vec = get_particle_direction(p, optimize=True) w = 1.0 - if weight: - w = np.exp(-(var[0] - 1)**2 / (2.0 * var_sigma)**2) + # if weight: + # w = np.exp(-(var[0] - 1)**2 / (2.0 * var_sigma)**2) S += w * (np.outer(vec, vec) - np.eye(dim)) C += w * (np.outer(vec, vec) - np.eye(dim)) @ p.startpoint # print(S, C) @@ -259,7 +259,7 @@ def correct_primary_with_vertex(ia, r_adj=10, r_bt=10, start_segment_radius=10): else: p.is_primary = False if p.semantic_type == 0: - vec = compute_particle_direction(p, start_segment_radius=start_segment_radius) + vec = get_particle_direction(p, start_segment_radius=start_segment_radius) dist = point_to_line_distance_(ia.vertex, p.startpoint, vec) if np.linalg.norm(p.startpoint - ia.vertex) < r_adj: p.is_primary = True diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index 07f70787..71fa97e8 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -16,9 +16,7 @@ from mlreco.utils.groups import type_labels as TYPE_LABELS from analysis.algorithms.vertex import estimate_vertex -from analysis.algorithms.utils import correct_track_endpoints_closest, \ - get_track_points_default, \ - local_density_correction, correct_track_endpoints_linfit +from analysis.algorithms.utils import get_track_points from mlreco.utils.deghosting import deghost_labels_and_predictions from mlreco.utils.gnn.cluster import get_cluster_label @@ -106,14 +104,7 @@ def __init__(self, data_blob, result, cfg, predictor_cfg={}, deghosting=False, self.vertex_mode = predictor_cfg.get('vertex_mode', 'all') self.prune_vertex = predictor_cfg.get('prune_vertex', True) self.track_endpoints_mode = predictor_cfg.get('track_endpoints_mode', 'node_features') - self.track_point_corrector = predictor_cfg.get('track_point_corrector', 'None') - if self.track_point_corrector == 'linfit': - self.track_point_corrector = correct_track_endpoints_linfit - elif self.track_point_corrector == 'density': - self.track_point_corrector = local_density_correction - else: - def f(x): pass - self.track_point_corrector = f + self.track_point_corrector = predictor_cfg.get('track_point_corrector', 'ppn') # This is used to apply fiducial volume cuts. # Min/max boundaries in each dimension haev to be specified. self.volume_boundaries = predictor_cfg.get('volume_boundaries', None) @@ -871,9 +862,7 @@ def get_particles(self, entry, only_primaries=True, particles_seg = self.result['particles_seg'][entry] type_logits = self.result['node_pred_type'][entry] - input_node_features = [None] * type_logits.shape[0] - if 'particle_node_features' in self.result: - input_node_features = self.result['particle_node_features'][entry] + particle_points = self.result['particle_points'][entry] pids = np.argmax(type_logits, axis=1) out = [] @@ -881,7 +870,7 @@ def get_particles(self, entry, only_primaries=True, return out assert len(particles_seg) == len(particles) assert len(pids) == len(particles) - assert len(input_node_features) == len(particles) + assert len(particle_points) == len(particles) assert point_cloud.shape[0] == depositions.shape[0] node_pred_vtx = self.result['node_pred_vtx'][entry] @@ -921,7 +910,9 @@ def get_particles(self, entry, only_primaries=True, pid_conf=softmax(type_logits[i])[pids[i]], volume=volume) - part._node_features = input_node_features[i] + part.startpoint = particle_points[i][:3] + part.endpoint = particle_points[i][3:] + out.append(part) if only_primaries: @@ -942,20 +933,21 @@ def get_particles(self, entry, only_primaries=True, if p.size < min_particle_voxel_count: continue if p.semantic_type == 0: - pt = p._node_features[19:22] # Check startpoint is replicated assert(np.sum( - np.abs(pt - p._node_features[22:25])) < 1e-12) - p.startpoint = pt + np.abs(p.startpoint - p.endpoint)) < 1e-12) + p.endpoint = None elif p.semantic_type == 1: if self.track_endpoints_mode == 'node_features': - get_track_points_default(p) - elif self.track_endpoints_mode == 'max_dist': - correct_track_endpoints_closest(p) + get_track_points(p, + correction_mode=self.track_point_corrector) + elif self.track_endpoints_mode == 'brute_force': + get_track_points(p, + correction_mode=self.track_point_corrector, + brute_force=True) else: raise ValueError("Track endpoint attachment mode {}\ not supported!".format(self.track_endpoints_mode)) - self.track_point_corrector(p) else: continue out_particle_list.extend(out) diff --git a/mlreco/utils/vertex.py b/mlreco/utils/vertex.py index 3207a655..67e7aca8 100644 --- a/mlreco/utils/vertex.py +++ b/mlreco/utils/vertex.py @@ -8,7 +8,7 @@ from sklearn.decomposition import PCA from mlreco.utils.gnn.evaluation import primary_assignment from mlreco.utils.groups import type_labels -from analysis.algorithms.calorimetry import compute_particle_direction +from analysis.algorithms.calorimetry import get_particle_direction def find_closest_points_of_approach(point1, direction1, point2, direction2): From 9e5affe338e7358355cb159508b2f635d8ddd488 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Thu, 9 Mar 2023 00:07:25 -0800 Subject: [PATCH 035/180] Harmonized PPN output nomenclature, decoupled VolumeBoundaries from collates.py --- analysis/classes/predictor.py | 8 +- mlreco/iotools/collates.py | 206 +----------------- mlreco/main_funcs.py | 12 +- mlreco/models/full_chain.py | 14 +- mlreco/models/layers/common/dbscan.py | 4 +- mlreco/models/layers/common/gnn_full_chain.py | 14 +- mlreco/models/layers/common/ppnplus.py | 66 +++--- mlreco/models/layers/common/vertex_ppn.py | 14 +- mlreco/utils/cluster/fragmenter.py | 8 +- mlreco/utils/gnn/data.py | 2 +- mlreco/utils/ppn.py | 83 +------ mlreco/utils/unwrap.py | 66 ++++-- mlreco/utils/volumes.py | 202 +++++++++++++++++ 13 files changed, 330 insertions(+), 369 deletions(-) create mode 100644 mlreco/utils/volumes.py diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index 7189d531..6a172b2b 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -22,7 +22,7 @@ from mlreco.utils.deghosting import deghost_labels_and_predictions from mlreco.utils.gnn.cluster import get_cluster_label -from mlreco.iotools.collates import VolumeBoundaries +from mlreco.utils.volumes import VolumeBoundaries class FullChainPredictor: @@ -300,7 +300,7 @@ def _fit_predict_ppn(self, entry): ppn_voxels = ppn[:, 1:4] ppn_score = ppn[:, 5] ppn_type = ppn[:, 12] - if 'classify_endpoints' in self.result: + if 'ppn_classify_endpoints' in self.result: ppn_endpoint = ppn[:, 13:] assert ppn_endpoint.shape[1] == 2 @@ -308,7 +308,7 @@ def _fit_predict_ppn(self, entry): for i, pred_point in enumerate(ppn_voxels): pred_point_type, pred_point_score = ppn_type[i], ppn_score[i] x, y, z = ppn_voxels[i][0], ppn_voxels[i][1], ppn_voxels[i][2] - if 'classify_endpoints' in self.result: + if 'ppn_classify_endpoints' in self.result: ppn_candidates.append(np.array([x, y, z, pred_point_score, pred_point_type, @@ -320,7 +320,7 @@ def _fit_predict_ppn(self, entry): if len(ppn_candidates): ppn_candidates = np.vstack(ppn_candidates) else: - enable_classify_endpoints = 'classify_endpoints' in self.result + enable_classify_endpoints = 'ppn_classify_endpoints' in self.result ppn_candidates = np.empty((0, 5 if not enable_classify_endpoints else 6), dtype=np.float32) return ppn_candidates diff --git a/mlreco/iotools/collates.py b/mlreco/iotools/collates.py index 718fd23c..bcfd1d51 100644 --- a/mlreco/iotools/collates.py +++ b/mlreco/iotools/collates.py @@ -6,208 +6,10 @@ """ import numpy as np - -class VolumeBoundaries: - """ - VolumeBoundaries is a helper class to deal with multiple detector volumes. Assume you have N - volumes that you want to process independently, but your input data file does not separate - between them (maybe it is hard to make the separation at simulation level, e.g. in Supera). - You can specify in the configuration of the collate function where the volume boundaries are - and this helper class will take care of the following: - - 1. Relabel batch ids: this will introduce "virtual" batch ids to account for each volume in - each batch. - - 2. Shift coordinates: voxel coordinates are shifted such that the origin is always the bottom - left corner of a volume. In other words, it ensures the voxel coordinate phase space is the - same regardless of which volume we are processing. That way you can train on a single volume - (subpart of the detector, e.g. cryostat or TPC) and process later however many volumes make up - your detector. - - 3. Sort coordinates: there is no guarantee that concatenating coordinates of N volumes vs the - stored coordinates for label tensors which cover all volumes already by default will yield the - same ordering. Hence we do a np.lexsort on coordinates after 1. and 2. have happened. We sort - by: batch id, z, y, x in this order. - - An example of configuration would be : - - ```yaml - collate: - collate_fn: Collatesparse - boundaries: [[1376.3], None, None] - ``` - - `boundaries` is what defines the different volumes. It has a length equal to the spatial dimension. - For each spatial dimension, `None` means that there is no boundary along that axis. - A list of floating numbers specifies the volume boundaries along that axis in voxel units. - The list of volumes will be inferred from this list of boundaries ("meshgrid" style, taking - all possible combinations of the boundaries to generate all the volumes). - """ - def __init__(self, definitions): - """ - See explanation of `boundaries` above. - - Parameters - ========== - definitions: list - """ - self.dim = len(definitions) - self.boundaries = definitions - - # Quick sanity check - for i in range(self.dim): - assert self.boundaries[i] == 'None' or self.boundaries[i] is None or (isinstance(self.boundaries[i], list) and len(self.boundaries[i]) > 0) - if self.boundaries[i] == 'None': - self.boundaries[i] = None - continue - if self.boundaries[i] is None: continue - self.boundaries[i].sort() # Ascending order - - n_boundaries = [len(self.boundaries[n]) if self.boundaries[n] is not None else 0 for n in range(self.dim)] - # Generate indices that describe all volumes - all_index = [] - for n in range(self.dim): - all_index.append(np.arange(n_boundaries[n]+1)) - self.combo = np.array(np.meshgrid(*tuple(all_index))).T.reshape(-1, self.dim) - - # Generate coordinate shifts for each volume - # List of list (1st dim is spatial dimension, 2nd is volume splits in a given spatial dimension) - shifts = [] - for n in range(self.dim): - if self.boundaries[n] is None: - shifts.append([0.]) - continue - dim_shifts = [] - for i in range(len(self.boundaries[n])): - dim_shifts.append(self.boundaries[n][i-1] if i > 0 else 0.) - dim_shifts.append(self.boundaries[n][-1]) - shifts.append(dim_shifts) - self.shifts = shifts - - def num_volumes(self): - """ - Returns - ======= - int - """ - return len(self.combo) - - def virtual_batch_ids(self, entry=0): - """ - Parameters - ========== - entry: int, optional - Which entry of the dataset you are trying to access. - - Returns - ======= - list - List of virtual batch ids that correspond to this entry. - """ - return np.arange(len(self.combo)) + entry * self.num_volumes() - - def translate(self, voxels, volume): - """ - Meant to reverse what the split method does: for voxels coordinates initially in the range of volume 0, - translate to the range of a specific volume given in argument. - - Parameters - ========== - voxels: np.ndarray - Expected shape is (D_0, ..., D_N, self.dim) with N >=0. In other words, voxels can be a list of - coordinate or a single coordinate with shape (d,). - volume: int - - Returns - ======= - np.ndarray - Translated voxels array, using internally computed shifts. - """ - assert volume >= 0 and volume < self.num_volumes() - assert voxels.shape[-1] == self.dim - - new_voxels = voxels.copy() - for n in range(self.dim): - new_voxels[..., n] += int(self.shifts[n][self.combo[volume][n]]) - return new_voxels - - def untranslate(self, voxels, volume): - """ - Meant to reverse what the translate method does: for voxels coordinates initially in the range of full detector, - translate to the range of 1 volume for a specific volume given in argument. - - Parameters - ========== - voxels: np.ndarray - Expected shape is (D_0, ..., D_N, self.dim) with N >=0. In other words, voxels can be a list of - coordinate or a single coordinate with shape (d,). - volume: int - - Returns - ======= - np.ndarray - Translated voxels array, using internally computed shifts. - """ - assert volume >= 0 and volume < self.num_volumes() - assert voxels.shape[-1] == self.dim - - new_voxels = voxels.copy() - for n in range(self.dim): - new_voxels[..., n] -= int(self.shifts[n][self.combo[volume][n]]) - return new_voxels - - def split(self, voxels): - """ - Parameters - ========== - voxels: np.array, shape (N, 4) - It should contain (batch id, x, y, z) coordinates in this order (as an example if you are working in 3D). - - Returns - ======= - new_voxels: np.array, shape (N, 4) - The array contains voxels with shifted coordinates + virtual batch ids. This array is not yet permuted - to obey the lexsort. - perm: np.array, shape (N,) - This is a permutation mask which can be used to apply the lexsort to both the new voxels and the features - or data tensor (which is not passed to this function). - """ - assert len(voxels.shape) == 2 - batch_ids = voxels[:, 0] - coords = voxels[:, 1:] - assert self.dim == coords.shape[1] - - # This will contain the list of boolean masks corresponding to each boundary - # in each spatial dimension (so, list of list) - all_boundaries = [] - for n in range(self.dim): - if self.boundaries[n] is None: - all_boundaries.append([np.ones((coords.shape[0],), dtype=bool)]) - continue - dim_boundaries = [] - for i in range(len(self.boundaries[n])): - dim_boundaries.append( coords[:, n] < self.boundaries[n][i] ) - dim_boundaries.append( coords[:, n] >= self.boundaries[n][-1] ) - all_boundaries.append(dim_boundaries) - - virtual_batch_ids = np.zeros((coords.shape[0],), dtype=np.int32) - new_coords = coords.copy() - for idx, c in enumerate(self.combo): # Looping over volumes - m = all_boundaries[0][c[0]] # Building a boolean mask for this volume - for n in range(1, self.dim): - m = np.logical_and(m, all_boundaries[n][c[n]]) - # Now defining virtual batch id - # We need to take into account original batch id - virtual_batch_ids[m] = idx + batch_ids[m] * self.num_volumes() - for n in range(self.dim): - new_coords[m, n] -= int(self.shifts[n][c[n]]) - - new_voxels = np.concatenate([virtual_batch_ids[:, None], new_coords], axis=1) - perm = np.lexsort(new_voxels.T[list(range(1, self.dim+1)) + [0], :]) - return new_voxels, perm +from mlreco.utils.volumes import VolumeBoundaries -def CollateSparse(batch, **kwargs): +def CollateSparse(batch, boundaries=None): ''' Collate sparse input. @@ -233,8 +35,8 @@ def CollateSparse(batch, **kwargs): ''' import MinkowskiEngine as ME - split_boundaries = 'boundaries' in kwargs - vb = VolumeBoundaries(kwargs['boundaries']) if split_boundaries else None + split_boundaries = boundaries is not None + vb = VolumeBoundaries(boundaries) if split_boundaries else None result = {} concat = np.concatenate diff --git a/mlreco/main_funcs.py b/mlreco/main_funcs.py index 6816f974..20bf973c 100644 --- a/mlreco/main_funcs.py +++ b/mlreco/main_funcs.py @@ -66,11 +66,11 @@ def process_config(cfg, verbose=True): os.environ['OMP_NUM_THREADS'] = '16' # default value # Set default concat_result - default_concat_result = ['input_edge_features', 'input_node_features','points', 'coordinates', + default_concat_result = ['input_edge_features', 'input_node_features', 'coordinates', 'particle_node_features', 'particle_edge_features', 'track_node_features', 'shower_node_features', 'input_node_points', 'shower_points', 'track_points', 'particle_points', - 'ppn_coords', 'mask_ppn', 'ppn_layers', 'classify_endpoints', + 'ppn_points', 'ppn_coords', 'ppn_masks', 'ppn_layers', 'ppn_classify_endpoints', 'vertex_layers', 'vertex_coords', 'primary_label_scales', 'segment_label_scales', 'seediness', 'margins', 'embeddings', 'fragments', 'fragments_seg', 'shower_fragments', 'shower_edge_index', @@ -86,7 +86,7 @@ def process_config(cfg, verbose=True): 'clust_fragments', 'clust_frag_seg', 'interactions', 'inter_cosmic_pred', 'node_pred_vtx', 'total_num_points', 'total_nonghost_points', 'spatial_embeddings', 'occupancy', 'hypergraph_features', 'logits', - 'features', 'feature_embeddings', 'covariance', 'clusts','edge_index','edge_pred','node_pred'] + 'features', 'feature_embeddings', 'covariance', 'clusts', 'edge_index', 'edge_pred', 'node_pred'] if 'concat_result' not in cfg['trainval']: cfg['trainval']['concat_result'] = default_concat_result @@ -392,10 +392,10 @@ def inference_loop(handlers): # Store output if requested if 'post_processing' in handlers.cfg: - for processor_name,processor_cfg in handlers.cfg['post_processing'].items(): + for processor_name, processor_cfg in handlers.cfg['post_processing'].items(): processor_name = processor_name.split('+')[0] - processor = getattr(post_processing,str(processor_name)) - processor(handlers.cfg,processor_cfg,data_blob,result_blob,handlers.cfg['trainval']['log_dir'],handlers.iteration) + processor = getattr(post_processing, str(processor_name)) + processor(handlers.cfg, processor_cfg, data_blob, result_blob, handlers.cfg['trainval']['log_dir'], handlers.iteration) handlers.watch.stop('iteration') tsum += handlers.watch.time('iteration') diff --git a/mlreco/models/full_chain.py b/mlreco/models/full_chain.py index 8fa390b9..bdabe09d 100644 --- a/mlreco/models/full_chain.py +++ b/mlreco/models/full_chain.py @@ -275,14 +275,12 @@ def full_chain_cnn(self, input): deghost_result.pop('ghost') deghost_result['segmentation'][0] = result['segmentation'][0][deghost] if self.enable_ppn and not self.enable_charge_rescaling: - deghost_result['points'] = [result['points'][0][deghost]] - if 'classify_endpoints' in deghost_result: - deghost_result['classify_endpoints'] = [result['classify_endpoints'][0][deghost]] - deghost_result['mask_ppn'][0][-1] = result['mask_ppn'][0][-1][deghost] - #print(len(result['ppn_score'])) - #deghost_result['ppn_score'][0][-1] = result['ppn_score'][0][-1][deghost] + deghost_result['ppn_points'] = [result['ppn_points'][0][deghost]] + deghost_result['ppn_masks'][0][-1] = result['ppn_masks'][0][-1][deghost] deghost_result['ppn_coords'][0][-1] = result['ppn_coords'][0][-1][deghost] deghost_result['ppn_layers'][0][-1] = result['ppn_layers'][0][-1][deghost] + if 'ppn_classify_endpoints' in deghost_result: + deghost_result['ppn_classify_endpoints'] = [result['ppn_classify_endpoints'][0][deghost]] cnn_result.update(deghost_result) cnn_result['ghost'] = result['ghost'] # cnn_result['segmentation'][0] = segmentation @@ -352,16 +350,12 @@ def full_chain_cnn(self, input): if self.enable_dbscan and self.process_fragments: # Get the fragment predictions from the DBSCAN fragmenter - # print('Input = ', input[0].shape) - # print('points = ', cnn_result['points'][0].shape) fragment_data = self.dbscan_fragment_manager(input[0], cnn_result) cluster_result['fragments'].extend(fragment_data[0]) cluster_result['frag_batch_ids'].extend(fragment_data[1]) cluster_result['frag_seg'].extend(fragment_data[2]) # Format Fragments - # for i, c in enumerate(cluster_result['fragments']): - # print('format' , torch.unique(input[0][c, self.batch_column_id], return_counts=True)) fragments_result = format_fragments(cluster_result['fragments'], cluster_result['frag_batch_ids'], cluster_result['frag_seg'], diff --git a/mlreco/models/layers/common/dbscan.py b/mlreco/models/layers/common/dbscan.py index bbb97262..8689a0f2 100644 --- a/mlreco/models/layers/common/dbscan.py +++ b/mlreco/models/layers/common/dbscan.py @@ -123,8 +123,8 @@ def forward(self, data, output=None, points=None): if points is None: from mlreco.utils.ppn import uresnet_ppn_type_point_selector numpy_output = {'segmentation': [output['segmentation'][0].detach().cpu().numpy()], - 'points' : [output['points'][0].detach().cpu().numpy()], - 'mask_ppn' : [x.detach().cpu().numpy() for x in output['mask_ppn'][0]], + 'ppn_points' : [output['ppn_points'][0].detach().cpu().numpy()], + 'ppn_masks' : [x.detach().cpu().numpy() for x in output['ppn_masks'][0]], 'ppn_coords' : [x.detach().cpu().numpy() for x in output['ppn_coords'][0]]} points = uresnet_ppn_type_point_selector(data, numpy_output, diff --git a/mlreco/models/layers/common/gnn_full_chain.py b/mlreco/models/layers/common/gnn_full_chain.py index 33d60f93..fa6a9930 100644 --- a/mlreco/models/layers/common/gnn_full_chain.py +++ b/mlreco/models/layers/common/gnn_full_chain.py @@ -205,13 +205,7 @@ def run_fragment_gnns(self, result, input): 'group_pred': 'shower_group_pred', 'input_node_points' : 'shower_points', 'input_node_features': 'shower_node_features'} - # shower_grappa_input = input - # if self.use_true_fragments and 'points' not in kwargs: - # # Add true particle coords to input - # print("adding true points to grappa shower input") - # shower_grappa_input += result['true_points'] - # result['shower_gnn_points'] = [kwargs['points']] - # result['shower_gnn_extra_feats'] = [kwargs['extra_feats']] + self.run_gnn(self.grappa_shower, input, result, @@ -604,7 +598,7 @@ class FullChainLoss(torch.nn.modules.loss._Loss): mlreco.models.full_chain.FullChainLoss, FullChainGNN """ # INPUT_SCHEMA = [ - # ["parse_sparse3d_scn", (int,), (3, 1)], + # ["parse_sparse3d", (int,), (3, 1)], # ["parse_particle_points", (int,), (3, 1)] # ] @@ -669,7 +663,7 @@ def forward(self, out, seg_label, ppn_label=None, cluster_label=None, kinematics loss += self.segmentation_weight*res_seg['loss'] #print('uresnet ', self.segmentation_weight, res_seg['loss'], loss) - if self.enable_ppn and 'ppn_output_coordinates' in out: + if self.enable_ppn and 'ppn_output_coords' in out: # Apply the PPN loss res_ppn = self.ppn_loss(out, seg_label, ppn_label) for key in res_ppn: @@ -901,7 +895,7 @@ def forward(self, out, seg_label, ppn_label=None, cluster_label=None, kinematics print('Deghosting Accuracy: {:.4f}'.format(res_deghost['accuracy'])) if self.enable_uresnet and 'segmentation' in out: print('Segmentation Accuracy: {:.4f}'.format(res_seg['accuracy'])) - if self.enable_ppn and 'ppn_output_coordinates' in out: + if self.enable_ppn and 'ppn_output_coords' in out: print('PPN Accuracy: {:.4f}'.format(res_ppn['accuracy'])) if self.enable_cnn_clust and ('graph' in out or 'embeddings' in out): if not self._enable_graph_spice: diff --git a/mlreco/models/layers/common/ppnplus.py b/mlreco/models/layers/common/ppnplus.py index 95794d5c..2e8e69a3 100644 --- a/mlreco/models/layers/common/ppnplus.py +++ b/mlreco/models/layers/common/ppnplus.py @@ -196,17 +196,17 @@ class PPN(torch.nn.Module): Output ------ - points: torch.Tensor + ppn_points: torch.Tensor Contains X, Y, Z predictions, semantic class prediction logits, and prob score - mask_ppn: list of torch.Tensor - Binary mask at various spatial scales of PPN predictions (voxel-wise score > some threshold) + ppn_masks: list of torch.Tensor + Binary masks at various spatial scales of PPN predictions (voxel-wise score > some threshold) ppn_coords: list of torch.Tensor List of XYZ coordinates at various spatial scales. ppn_layers: list of torch.Tensor List of score features at various spatial scales. - ppn_output_coordinates: torch.Tensor + ppn_output_coords: torch.Tensor XYZ coordinates tensor at the very last layer of PPN (initial spatial scale) - classify_endpoints: torch.Tensor + ppn_classify_endpoints: torch.Tensor Logits for end/start point classification. See Also @@ -313,7 +313,7 @@ def __init__(self, cfg, name='ppn'): def forward(self, final, decoderTensors, ghost=None, ghost_labels=None): ppn_layers, ppn_coords = [], [] tmp = [] - mask_ppn = [] + ppn_masks = [] device = final.device # We need to make labels on-the-fly to include true points in the @@ -370,7 +370,7 @@ def forward(self, final, decoderTensors, ghost=None, ghost_labels=None): s_expanded = self.expand_as(mask, x.F.shape, propagate_all=self.propagate_all, use_binary_mask=self.use_binary_mask_ppn) - mask_ppn.append((mask.F > self.ppn_score_threshold)) + ppn_masks.append((mask.F > self.ppn_score_threshold)) x = x * s_expanded.detach() # Note that we skipped ghost masking for the final sparse tensor, @@ -378,7 +378,7 @@ def forward(self, final, decoderTensors, ghost=None, ghost_labels=None): # This is done at the full chain cnn stage, for consistency with SCN device = x.F.device - ppn_output_coordinates = x.C + ppn_output_coords = x.C # print(x.tensor_stride, x.shape, "ppn_score_threshold = ", self.ppn_score_threshold) for p in tmp: a = p.to(dtype=torch.float32, device=device) @@ -392,17 +392,17 @@ def forward(self, final, decoderTensors, ghost=None, ghost_labels=None): ppn_endpoint = self.ppn_endpoint(x) # X, Y, Z, logits, and prob score - points = torch.cat([pixel_pred.F, ppn_type.F, ppn_final_score.F], dim=1) + ppn_points = torch.cat([pixel_pred.F, ppn_type.F, ppn_final_score.F], dim=1) res = { - 'points': [points], - 'mask_ppn': [mask_ppn], + 'ppn_points': [ppn_points], + 'ppn_masks': [ppn_masks], 'ppn_layers': [ppn_layers], 'ppn_coords': [ppn_coords], - 'ppn_output_coordinates': [ppn_output_coordinates], + 'ppn_output_coords': [ppn_output_coords], } if self._classify_endpoints: - res['classify_endpoints'] = [ppn_endpoint.F] + res['ppn_classify_endpoints'] = [ppn_endpoint.F] return res @@ -413,14 +413,20 @@ class PPNLonelyLoss(torch.nn.modules.loss._Loss): Output ------ - reg_loss: float + reg_loss : float Distance loss - mask_loss: float - Binary voxel-wise prediction (is there an object of interest or not) - type_loss: float - Semantic prediction loss. - classify_endpoints_loss: float - classify_endpoints_acc: float + mask_loss : float + Binary voxel-wise prediction loss (is there an object of interest or not) + classify_endpoints_loss : float + Endpoint classification loss + type_loss : float + Semantic prediction loss + output_mask_accuracy: float + Binary voxel-wise prediction accuracy in the last layer + type_accuracy : float + Semantic prediction accuracy + classify_endpoints_accuracy : float + Endpoint classification accuracy See Also -------- @@ -465,7 +471,7 @@ def forward(self, result, segment_label, particles_label): # TODO Add weighting assert len(particles_label) == len(segment_label) - ppn_output_coordinates = result['ppn_output_coordinates'] + ppn_output_coords = result['ppn_output_coords'] batch_ids = [result['ppn_coords'][0][-1][:, 0]] num_batches = len(batch_ids[0].unique()) num_layers = len(result['ppn_layers'][0]) @@ -478,11 +484,9 @@ def forward(self, result, segment_label, particles_label): 'mask_loss': 0., 'type_loss': 0., 'classify_endpoints_loss': 0., - 'classify_endpoints_accuracy': 0., + 'output_mask_accuracy': 0., 'type_accuracy': 0., - 'mask_accuracy': 0., - 'mask_final_accuracy': 0., - 'regression_accuracy': 0., + 'classify_endpoints_accuracy': 0., 'num_positives': 0., 'num_voxels': 0. } @@ -497,7 +501,7 @@ def forward(self, result, segment_label, particles_label): particles = particles[class_mask] ppn_layers = result['ppn_layers'][igpu] ppn_coords = result['ppn_coords'][igpu] - points = result['points'][igpu] + ppn_points = result['ppn_points'][igpu] loss_gpu, acc_gpu = 0.0, 0.0 for layer in range(len(ppn_layers)): # print("Layer = ", layer) @@ -541,9 +545,9 @@ def forward(self, result, segment_label, particles_label): # Get Final Layers anchors = coords_layer[batch_particle_index][:, 1:4].float().to(device) + 0.5 - pixel_score = points[batch_particle_index][:, -1] - pixel_logits = points[batch_particle_index][:, 3:8] - pixel_pred = points[batch_particle_index][:, :3] + anchors + pixel_score = ppn_points[batch_particle_index][:, -1] + pixel_logits = ppn_points[batch_particle_index][:, 3:8] + pixel_pred = ppn_points[batch_particle_index][:, :3] + anchors d = local_cdist(points_label, pixel_pred) positives = (d < self.resolution).any(dim=0) @@ -561,7 +565,7 @@ def forward(self, result, segment_label, particles_label): with torch.no_grad(): mask_final_acc = ((pixel_score > 0).long() == positives.long()).sum()\ / float(pixel_score.shape[0]) - res['mask_final_accuracy'] += float(mask_final_acc) / float(num_batches) + res['output_mask_accuracy'] += float(mask_final_acc) / float(num_batches) res['num_positives'] += float(torch.sum(positives)) / float(num_batches) res['num_voxels'] += float(pixel_pred.shape[0]) / float(num_batches) @@ -607,7 +611,7 @@ def forward(self, result, segment_label, particles_label): true = particles[particles[:, 0].int() == b][point_class_mask][point_class_index, -1] #pred = result['classify_endpoints'][i][batch_index][event_mask][positives] - pred = result['classify_endpoints'][igpu][batch_index_layer][point_class_positives] + pred = result['ppn_classify_endpoints'][igpu][batch_index_layer][point_class_positives] tracks = event_types_label[point_class_index] == self._track_label if tracks.sum().item(): loss_point_class += torch.mean(self.segloss(pred[tracks], true[tracks].long())) diff --git a/mlreco/models/layers/common/vertex_ppn.py b/mlreco/models/layers/common/vertex_ppn.py index 6753c3d8..98ae1ee4 100644 --- a/mlreco/models/layers/common/vertex_ppn.py +++ b/mlreco/models/layers/common/vertex_ppn.py @@ -168,14 +168,14 @@ class VertexPPNLoss(torch.nn.modules.loss._Loss): Output ------ - reg_loss: float + vertex_reg_loss : float Distance loss - mask_loss: float + vertex_mask_loss : float Binary voxel-wise prediction (is there an object of interest or not) - type_loss: float - Semantic prediction loss. - classify_endpoints_loss: float - classify_endpoints_acc: float + vertex_loss : float + Combined loss + vertex_accuracy : float + Combined accuracy See Also -------- @@ -345,5 +345,5 @@ def forward(self, result, kinematics_label): total_acc /= num_batches res['vertex_loss'] = total_loss - res['vertex_acc'] = float(total_acc) + res['vertex_accuracy'] = float(total_acc) return res diff --git a/mlreco/utils/cluster/fragmenter.py b/mlreco/utils/cluster/fragmenter.py index 69c5e9a5..a193a396 100644 --- a/mlreco/utils/cluster/fragmenter.py +++ b/mlreco/utils/cluster/fragmenter.py @@ -77,8 +77,8 @@ def forward(self, input, cnn_result, semantic_labels=None): - input (torch.Tensor): N x 6 (coords, edep, semantic_labels) - cnn_result: dict of List[torch.Tensor], containing: - segmentation - - points - - mask_ppn2 + - ppn_points + - ppn_masks Returns: - fragment_data @@ -109,8 +109,8 @@ def forward(self, input, cnn_result, semantic_labels=None): - input (torch.Tensor): N x 6 (coords, edep, semantic_labels) - cnn_result: dict of List[torch.Tensor], containing: - segmentation - - points - - mask_ppn2 + - ppn_points + - ppn_masks Returns: - fragments diff --git a/mlreco/utils/gnn/data.py b/mlreco/utils/gnn/data.py index 49339f09..9145cfff 100644 --- a/mlreco/utils/gnn/data.py +++ b/mlreco/utils/gnn/data.py @@ -217,7 +217,7 @@ def _get_extra_gnn_features(fragments, if use_ppn: ppn_points = torch.empty((0,6), device=input[0].device, dtype=torch.float) - points_tensor = result['points'][0].detach() + points_tensor = result['ppn_points'][0].detach() for i, f in enumerate(fragments[mask]): fragment_voxels = input[0][f][:,coords_col[0]:coords_col[1]] if frag_seg[mask][i] == 1: diff --git a/mlreco/utils/ppn.py b/mlreco/utils/ppn.py index 682d835f..7ebf9e15 100644 --- a/mlreco/utils/ppn.py +++ b/mlreco/utils/ppn.py @@ -299,24 +299,24 @@ def uresnet_ppn_type_point_selector(data, out, score_threshold=0.5, type_score_t 1 row per ppn-predicted points """ event_data = data#.cpu().detach().numpy() - points = out['points'][0]#[entry]#.cpu().detach().numpy() + points = out['ppn_points'][0]#[entry]#.cpu().detach().numpy() ppn_coords = out['ppn_coords'] - # If 'points' is specified in `concat_result`, + # If 'ppn_points' is specified in `concat_result`, # then it won't be unwrapped. if len(points) == len(ppn_coords[-1]): pass # print(entry, np.unique(ppn_coords[-1][:, 0], return_counts=True)) #points = points[ppn_coords[-1][:, 0] == entry, :] else: # in case it has been unwrapped (possible in no-ghost scenario) - points = out['points'][entry] + points = out['ppn_points'][entry] - enable_classify_endpoints = 'classify_endpoints' in out + enable_classify_endpoints = 'ppn_classify_endpoints' in out #print("ENABLE CLASSIFY ENDPOINTS = ", enable_classify_endpoints) if enable_classify_endpoints: - classify_endpoints = out['classify_endpoints'][0] + classify_endpoints = out['ppn_classify_endpoints'][0] #print(classify_endpoints) - mask_ppn = out['mask_ppn'][-1] + ppn_mask = out['ppn_masks'][-1] # predicted type labels # uresnet_predictions = torch.argmax(out['segmentation'][0], -1).cpu().detach().numpy() uresnet_predictions = np.argmax(out['segmentation'][entry], -1) @@ -327,7 +327,7 @@ def uresnet_ppn_type_point_selector(data, out, score_threshold=0.5, type_score_t #points = points[mask_ghost] #if enable_classify_endpoints: # classify_endpoints = classify_endpoints[mask_ghost] - #mask_ppn = mask_ppn[mask_ghost] + #ppn_mask = ppn_mask[mask_ghost] uresnet_predictions = uresnet_predictions[mask_ghost] #scores = scores[mask_ghost] @@ -352,8 +352,8 @@ def uresnet_ppn_type_point_selector(data, out, score_threshold=0.5, type_score_t final_endpoints = [] batch_index = batch_ids == b batch_index2 = ppn_coords[-1][:, 0] == b - # print(batch_index.shape, batch_index2.shape, mask_ppn.shape, scores.shape) - mask = ((~(mask_ppn[batch_index2] == 0)).any(axis=1)) & (scores[batch_index2][:, 1] > score_threshold) + # print(batch_index.shape, batch_index2.shape, ppn_mask.shape, scores.shape) + mask = ((~(ppn_mask[batch_index2] == 0)).any(axis=1)) & (scores[batch_index2][:, 1] > score_threshold) # If we want to restrict the postprocessing to specific voxels # (e.g. within a particle cluster, not the full event) # then use the argument `selection`. @@ -421,69 +421,6 @@ def uresnet_ppn_type_point_selector(data, out, score_threshold=0.5, type_score_t return result -def uresnet_ppn_point_selector(data, out, nms_score_threshold=0.8, entry=0, - window_size=4, score_threshold=0.9, **kwargs): - """ - Basic selection of PPN points. - - Parameters - ---------- - data - 5-types sparse tensor - out - ppn output - - Returns - ------- - [x,y,z,bid,label] of ppn-predicted points - """ - # analysis_keys: - # segmentation: 3 - # points: 0 - # mask: 5 - # ppn1: 1 - # ppn2: 2 - # FIXME assumes 3D for now - points = out['points'][entry]#.cpu().detach().numpy() - #ppn1 = out['ppn1'][entry]#.cpu().detach().numpy() - #ppn2 = out[2][0].cpu().detach().numpy() - mask = out['mask_ppn2'][entry]#.cpu().detach().numpy() - # predicted type labels - pred_labels = np.argmax(out['segmentation'][entry], axis=-1)#.cpu().detach().numpy() - - scores = scipy.special.softmax(points[:, 3:5], axis=1) - points = points[:,:3] - - - # PPN predictions after masking - mask = (~(mask == 0)).any(axis=1) - - scores = scores[mask] - maskinds = np.where(mask)[0] - keep = scores[:,1] > score_threshold - - # NMS filter - keep2 = nms_numpy(points[mask][keep], scores[keep,1], nms_score_threshold, window_size) - - maskinds = maskinds[keep][keep2] - points = points[maskinds] - labels = pred_labels[maskinds] - - data_in = data#.cpu().detach().numpy() - voxels = data_in[:,:3] - ppn_pts = voxels[maskinds] + 0.5 + points - batch = data_in[maskinds,3] - label = pred_labels[maskinds] - - # TODO: only return single point in voxel per batch per label - ppn_pts, batch, label = group_points(ppn_pts, batch, label) - - - # Output should be in [x,y,z,bid,label] format - pts_out = np.column_stack((ppn_pts, batch, label)) - - # return indices of points in input, offsets - return pts_out - - def get_track_endpoints_geo(data, f, points_tensor=None, use_numpy=False, use_proxy=True): """ Compute endpoints of a track-like cluster f @@ -496,7 +433,7 @@ def get_track_endpoints_geo(data, f, points_tensor=None, use_numpy=False, use_pr Input: - data is the input data tensor, which can be indexed by f. - - points_tensor is the output of PPN 'points' (optional) + - points_tensor is the output of PPN `ppn_points` (optional) - f is a list of voxel indices for voxels that belong to the track. Output: diff --git a/mlreco/utils/unwrap.py b/mlreco/utils/unwrap.py index 46c418db..ce271c1e 100644 --- a/mlreco/utils/unwrap.py +++ b/mlreco/utils/unwrap.py @@ -1,9 +1,31 @@ import numpy as np import torch + def list_concat(data_blob, outputs, avoid_keys=[]): + ''' + Concatenate the data_blob and outputs dictionary + + Need to account for: multi-gpu, minibatching, multiple outputs, batches. + + Parameters + ---------- + data_blob : dict + A dictionary of array of array of minibatch data [key][num_minibatch][num_device] + outputs : dict + Results dictionary, output of trainval.forward [key][num_minibatch*num_device] + avoid_keys: list + List of keys not to concatenate + + Returns + ------- + dict + Concatenated version of data_blob where array lenghts are num_minibatch*num_device*minibatch_size + dict + Concatenated version of outputs where array lenghts are num_minibatch*num_device*minibatch_size + ''' result_data = {} - for key,data in data_blob.items(): + for key, data in data_blob.items(): if key in avoid_keys: result_data[key]=data continue @@ -40,33 +62,40 @@ def list_concat(data_blob, outputs, avoid_keys=[]): return result_data, result_outputs -def unwrap(data_blob, outputs, batch_id_col=0, avoid_keys=[], input_key='input_data'): +def unwrap(data_blob, outputs, batch_id_col=0, avoid_keys=[], input_key='input_data', boundaries=None): ''' Break down the data_blob and outputs dictionary into events for sparseconvnet formatted tensors. Need to account for: multi-gpu, minibatching, multiple outputs, batches. - INPUTS: - data_blob: a dictionary of array of array of - minibatch data [key][num_minibatch][num_device] - outputs: results dictionary, output of trainval.forward, - [key][num_minibatch*num_device] - batch_id_col: 2 for 2D, 3 for 3D,,, and indicate - the location of "batch id". For MinkowskiEngine, batch indices - are always located at the 0th column of the N x C coordinate - array - OUTPUT: - two un-wrapped arrays of dictionaries where - array length = num_minibatch*num_device*minibatch_size - ASSUMES: - the shape of data_blob and outputs as explained above - ''' + Parameters + ---------- + data_blob : dict + A dictionary of array of array of minibatch data [key][num_minibatch][num_device] + outputs : dict + Results dictionary, output of trainval.forward [key][num_minibatch*num_device] + batch_id_col : int + Indicates the location of "batch id". For MinkowskiEngine, batch indices are always located at + the 0th column of the N x C coordinate array + + Returns + ------- + dict + Unwrapped version of data_blob where array lenghts are num_minibatch*num_device*minibatch_size + dict + Unwrapped version of outputs where array lenghts are num_minibatch*num_device*minibatch_size + + Info + ---- + Assumes the shape of data_blob and outputs as explained above + ''' batch_idx_max = 0 # Handle data result_data = {} unwrap_map = {} # dict of [#pts][batch_id] = where + # a-0) Find the target keys target_array_keys = [] target_list_keys = [] @@ -137,7 +166,6 @@ def unwrap(data_blob, outputs, batch_id_col=0, avoid_keys=[], input_key='input_d # b-0) Find the target keys target_array_keys = [] target_list_keys = [] - # print(len(result_outputs['points'])) for key, data in outputs.items(): if key in avoid_keys: if not isinstance(data, list): @@ -173,7 +201,7 @@ def unwrap(data_blob, outputs, batch_id_col=0, avoid_keys=[], input_key='input_d batch_id_loc = batch_id_col if d.shape[1] > batch_id_col else -1 batch_idx = np.unique(d[:,batch_id_loc]) # ensure these are integer values - # if target == 'points': + # if target == 'ppn_points': # print(target) # print(d) # print("--------------Batch IDX----------------") diff --git a/mlreco/utils/volumes.py b/mlreco/utils/volumes.py new file mode 100644 index 00000000..fff3fe3a --- /dev/null +++ b/mlreco/utils/volumes.py @@ -0,0 +1,202 @@ +import numpy as np + + +class VolumeBoundaries: + """ + VolumeBoundaries is a helper class to deal with multiple detector volumes. Assume you have N + volumes that you want to process independently, but your input data file does not separate + between them (maybe it is hard to make the separation at simulation level, e.g. in Supera). + You can specify in the configuration of the collate function where the volume boundaries are + and this helper class will take care of the following: + + 1. Relabel batch ids: this will introduce "virtual" batch ids to account for each volume in + each batch. + + 2. Shift coordinates: voxel coordinates are shifted such that the origin is always the bottom + left corner of a volume. In other words, it ensures the voxel coordinate phase space is the + same regardless of which volume we are processing. That way you can train on a single volume + (subpart of the detector, e.g. cryostat or TPC) and process later however many volumes make up + your detector. + + 3. Sort coordinates: there is no guarantee that concatenating coordinates of N volumes vs the + stored coordinates for label tensors which cover all volumes already by default will yield the + same ordering. Hence we do a np.lexsort on coordinates after 1. and 2. have happened. We sort + by: batch id, z, y, x in this order. + + An example of configuration would be : + + ```yaml + collate: + collate_fn: Collatesparse + boundaries: [[1376.3], None, None] + ``` + + `boundaries` is what defines the different volumes. It has a length equal to the spatial dimension. + For each spatial dimension, `None` means that there is no boundary along that axis. + A list of floating numbers specifies the volume boundaries along that axis in voxel units. + The list of volumes will be inferred from this list of boundaries ("meshgrid" style, taking + all possible combinations of the boundaries to generate all the volumes). + """ + def __init__(self, definitions): + """ + See explanation of `boundaries` above. + + Parameters + ---------- + definitions: list + """ + self.dim = len(definitions) + self.boundaries = definitions + + # Quick sanity check + for i in range(self.dim): + assert self.boundaries[i] == 'None' or self.boundaries[i] is None or (isinstance(self.boundaries[i], list) and len(self.boundaries[i]) > 0) + if self.boundaries[i] == 'None': + self.boundaries[i] = None + continue + if self.boundaries[i] is None: continue + self.boundaries[i].sort() # Ascending order + + n_boundaries = [len(self.boundaries[n]) if self.boundaries[n] is not None else 0 for n in range(self.dim)] + # Generate indices that describe all volumes + all_index = [] + for n in range(self.dim): + all_index.append(np.arange(n_boundaries[n]+1)) + self.combo = np.array(np.meshgrid(*tuple(all_index))).T.reshape(-1, self.dim) + + # Generate coordinate shifts for each volume + # List of list (1st dim is spatial dimension, 2nd is volume splits in a given spatial dimension) + shifts = [] + for n in range(self.dim): + if self.boundaries[n] is None: + shifts.append([0.]) + continue + dim_shifts = [] + for i in range(len(self.boundaries[n])): + dim_shifts.append(self.boundaries[n][i-1] if i > 0 else 0.) + dim_shifts.append(self.boundaries[n][-1]) + shifts.append(dim_shifts) + self.shifts = shifts + + def num_volumes(self): + """ + Returns + ------- + int + """ + return len(self.combo) + + def virtual_batch_ids(self, entry=0): + """ + Parameters + ---------- + entry: int, optional + Which entry of the dataset you are trying to access. + + Returns + ------- + list + List of virtual batch ids that correspond to this entry. + """ + return np.arange(len(self.combo)) + entry * self.num_volumes() + + def translate(self, voxels, volume): + """ + Meant to reverse what the split method does: for voxels coordinates initially in the range of volume 0, + translate to the range of a specific volume given in argument. + + Parameters + ---------- + voxels: np.ndarray + Expected shape is (D_0, ..., D_N, self.dim) with N >=0. In other words, voxels can be a list of + coordinate or a single coordinate with shape (d,). + volume: int + + Returns + ------- + np.ndarray + Translated voxels array, using internally computed shifts. + """ + assert volume >= 0 and volume < self.num_volumes() + assert voxels.shape[-1] == self.dim + + new_voxels = voxels.copy() + for n in range(self.dim): + new_voxels[..., n] += int(self.shifts[n][self.combo[volume][n]]) + return new_voxels + + def untranslate(self, voxels, volume): + """ + Meant to reverse what the translate method does: for voxels coordinates initially in the range of full detector, + translate to the range of 1 volume for a specific volume given in argument. + + Parameters + ---------- + voxels: np.ndarray + Expected shape is (D_0, ..., D_N, self.dim) with N >=0. In other words, voxels can be a list of + coordinate or a single coordinate with shape (d,). + volume: int + + Returns + ------- + np.ndarray + Translated voxels array, using internally computed shifts. + """ + assert volume >= 0 and volume < self.num_volumes() + assert voxels.shape[-1] == self.dim + + new_voxels = voxels.copy() + for n in range(self.dim): + new_voxels[..., n] -= int(self.shifts[n][self.combo[volume][n]]) + return new_voxels + + def split(self, voxels): + """ + Parameters + ---------- + voxels: np.array, shape (N, 4) + It should contain (batch id, x, y, z) coordinates in this order (as an example if you are working in 3D). + + Returns + ------- + new_voxels: np.array, shape (N, 4) + The array contains voxels with shifted coordinates + virtual batch ids. This array is not yet permuted + to obey the lexsort. + perm: np.array, shape (N,) + This is a permutation mask which can be used to apply the lexsort to both the new voxels and the features + or data tensor (which is not passed to this function). + """ + assert len(voxels.shape) == 2 + batch_ids = voxels[:, 0] + coords = voxels[:, 1:] + assert self.dim == coords.shape[1] + + # This will contain the list of boolean masks corresponding to each boundary + # in each spatial dimension (so, list of list) + all_boundaries = [] + for n in range(self.dim): + if self.boundaries[n] is None: + all_boundaries.append([np.ones((coords.shape[0],), dtype=bool)]) + continue + dim_boundaries = [] + for i in range(len(self.boundaries[n])): + dim_boundaries.append( coords[:, n] < self.boundaries[n][i] ) + dim_boundaries.append( coords[:, n] >= self.boundaries[n][-1] ) + all_boundaries.append(dim_boundaries) + + virtual_batch_ids = np.zeros((coords.shape[0],), dtype=np.int32) + new_coords = coords.copy() + for idx, c in enumerate(self.combo): # Looping over volumes + m = all_boundaries[0][c[0]] # Building a boolean mask for this volume + for n in range(1, self.dim): + m = np.logical_and(m, all_boundaries[n][c[n]]) + # Now defining virtual batch id + # We need to take into account original batch id + virtual_batch_ids[m] = idx + batch_ids[m] * self.num_volumes() + for n in range(self.dim): + new_coords[m, n] -= int(self.shifts[n][c[n]]) + + new_voxels = np.concatenate([virtual_batch_ids[:, None], new_coords], axis=1) + perm = np.lexsort(new_voxels.T[list(range(1, self.dim+1)) + [0], :]) + return new_voxels, perm + From 1e0a4e9ec83a42a37e9c9feb6fc4e92f097c6af3 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Thu, 9 Mar 2023 00:15:37 -0800 Subject: [PATCH 036/180] Bug fix --- mlreco/post_processing/metrics/ppn_metrics.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mlreco/post_processing/metrics/ppn_metrics.py b/mlreco/post_processing/metrics/ppn_metrics.py index ed84a610..42608362 100644 --- a/mlreco/post_processing/metrics/ppn_metrics.py +++ b/mlreco/post_processing/metrics/ppn_metrics.py @@ -4,7 +4,7 @@ from mlreco.post_processing import post_processing from mlreco.utils.dbscan import dbscan_points -from mlreco.utils.ppn import uresnet_ppn_point_selector, uresnet_ppn_type_point_selector +from mlreco.utils.ppn import uresnet_ppn_type_point_selector @post_processing(['ppn-metrics-gt', 'ppn-metrics-pred'], @@ -72,7 +72,6 @@ def ppn_metrics(cfg, module_cfg, data_blob, res, logdir, iteration, if mode == 'no_type': ppn = uresnet_ppn_type_point_selector(input_data[data_idx], res, entry=data_idx, score_threshold=0.5, window_size=3, type_threshold=2, enforce_type=False) else: - #ppn = uresnet_ppn_point_selector(input_data[data_idx], res, entry=data_idx, score_threshold=0.6, window_size=10, nms_score_threshold=0.99 ) ppn = uresnet_ppn_type_point_selector(input_data[data_idx], res, entry=data_idx, score_threshold=0.5, window_size=3, type_threshold=2) if ppn.shape[0] == 0: From ca9721d73ce11adb4fa8bbc1b5112bd22b5fcc0e Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Thu, 9 Mar 2023 12:07:49 -0800 Subject: [PATCH 037/180] Handle true particles with true nonghost but no predicted nonghost voxels properly --- analysis/algorithms/selections/template.py | 2 +- analysis/algorithms/utils.py | 11 +- analysis/classes/Interaction.py | 22 +-- analysis/classes/evaluator.py | 173 ++++++++++++++------- 4 files changed, 133 insertions(+), 75 deletions(-) diff --git a/analysis/algorithms/selections/template.py b/analysis/algorithms/selections/template.py index 5363866d..cbb677b5 100644 --- a/analysis/algorithms/selections/template.py +++ b/analysis/algorithms/selections/template.py @@ -207,7 +207,7 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): if true_p is not None: pred_particle_dict['pred_particle_has_match'] = True - if test_containment: + if test_containment and true_p.size > 0: true_particle_dict['true_particle_is_contained'] = predictor.is_contained(true_p.points) true_particle_dict['true_particle_interaction_id'] = true_p.interaction_id if 'particles_asis' in data_blob: diff --git a/analysis/algorithms/utils.py b/analysis/algorithms/utils.py index 786c339e..890df7ff 100644 --- a/analysis/algorithms/utils.py +++ b/analysis/algorithms/utils.py @@ -339,16 +339,17 @@ def get_particle_properties(particle: Particle, update_dict['particle_num_ppn_candidates'] = len(particle.ppn_candidates) if isinstance(particle, TruthParticle): - dists = np.linalg.norm(particle.points - particle.startpoint.reshape(1, -1), axis=1) - min_dist = np.min(dists) - if min_dist > 5.0: - update_dict['particle_startpoint_is_touching'] = False + if particle.size > 0: + dists = np.linalg.norm(particle.points - particle.startpoint.reshape(1, -1), axis=1) + min_dist = np.min(dists) + if min_dist > 5.0: + update_dict['particle_startpoint_is_touching'] = False creation_process = particle.particle_asis.creation_process() update_dict['particle_creation_process'] = creation_process update_dict['particle_px'] = float(particle.particle_asis.px()) update_dict['particle_py'] = float(particle.particle_asis.py()) update_dict['particle_pz'] = float(particle.particle_asis.pz()) - if compute_energy: + if compute_energy and particle.size > 0: update_dict['particle_sum_edep'] = particle.sum_edep direction = get_particle_direction(particle, optimize=True) assert len(direction) == 3 diff --git a/analysis/classes/Interaction.py b/analysis/classes/Interaction.py index 40d9861c..c3720291 100644 --- a/analysis/classes/Interaction.py +++ b/analysis/classes/Interaction.py @@ -44,15 +44,19 @@ def __init__(self, interaction_id: int, particles : OrderedDict, vertex=None, nu self.points = [] self.depositions = [] for p in self.particles: - self.voxel_indices.append(p.voxel_indices) - self.points.append(p.points) - self.depositions.append(p.depositions) - assert p.interaction_id == interaction_id - self.voxel_indices = np.hstack(self.voxel_indices) - self.points = np.concatenate(self.points, axis=0) - self.depositions = np.hstack(self.depositions) - - self.size = self.voxel_indices.shape[0] + if p.points.shape[0] > 0: + self.voxel_indices.append(p.voxel_indices) + self.points.append(p.points) + self.depositions.append(p.depositions) + assert p.interaction_id == interaction_id + if len(self.voxel_indices) > 0: + self.voxel_indices = np.hstack(self.voxel_indices) + if len(self.points) > 0: + self.points = np.concatenate(self.points, axis=0) + if len(self.depositions) > 0: + self.depositions = np.hstack(self.depositions) + + self.size = len(self.voxel_indices) self.num_particles = len(self.particles) self.get_particles_summary() diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index 7b678f5e..c5cef9be 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -28,6 +28,87 @@ from analysis.classes.predictor import FullChainPredictor +def get_true_particle_labels(labels, mask, pid=-1, verbose=False): + semantic_type, sem_counts = np.unique(labels[mask][:, -1].astype(int), + return_counts=True) + if semantic_type.shape[0] > 1: + if verbose: + print("Semantic Type of Particle {} is not "\ + "unique: {}, {}".format(pid, + str(semantic_type), + str(sem_counts))) + perm = sem_counts.argmax() + semantic_type = semantic_type[perm] + else: + semantic_type = semantic_type[0] + + interaction_id, int_counts = np.unique(labels[mask][:, 7].astype(int), + return_counts=True) + if interaction_id.shape[0] > 1: + if verbose: + print("Interaction ID of Particle {} is not "\ + "unique: {}".format(pid, str(interaction_id))) + perm = int_counts.argmax() + interaction_id = interaction_id[perm] + else: + interaction_id = interaction_id[0] + + nu_id, nu_counts = np.unique(labels[mask][:, 8].astype(int), + return_counts=True) + if nu_id.shape[0] > 1: + if verbose: + print("Neutrino ID of Particle {} is not "\ + "unique: {}".format(pid, str(nu_id))) + perm = nu_counts.argmax() + nu_id = nu_id[perm] + else: + nu_id = nu_id[0] + + return semantic_type, interaction_id, nu_id + + +def handle_empty_true_particles(labels_noghost, mask_noghost, p, entry, num_volumes, + verbose=False): + pid = int(p.id()) + pdg = TYPE_LABELS.get(p.pdg_code(), -1) + is_primary = p.group_id() == p.parent_id() + + semantic_type, interaction_id, nu_id = -1, -1, -1 + coords, depositions, voxel_indices = np.array([]), np.array([]), np.array([]) + coords_noghost, depositions_noghost = np.array([]), np.array([]) + if np.count_nonzero(mask_noghost) > 0: + coords_noghost = labels_noghost[mask_noghost][:, 1:4] + depositions_noghost = labels_noghost[mask_noghost][:, 4].squeeze() + semantic_type, interaction_id, nu_id = get_true_particle_labels(labels_noghost, + mask_noghost, + pid=pid, + verbose=verbose) + particle = TruthParticle(coords, + pid, semantic_type, interaction_id, pdg, + entry, particle_asis=p, + depositions=depositions, + is_primary=is_primary, + coords_noghost=coords_noghost, + depositions_noghost=depositions_noghost, + depositions_MeV=depositions, + volume=entry % num_volumes) + particle.p = np.array([p.px(), p.py(), p.pz()]) + particle.fragments = [] + particle.particle_asis = p + particle.nu_id = nu_id + particle.voxel_indices = voxel_indices + + particle.startpoint = np.array([p.first_step().x(), + p.first_step().y(), + p.first_step().z()]) + + if semantic_type == 1: + particle.endpoint = np.array([p.last_step().x(), + p.last_step().y(), + p.last_step().z()]) + return particle + + class FullChainEvaluator(FullChainPredictor): ''' Helper class for full chain prediction and evaluation. @@ -309,29 +390,21 @@ def get_true_particles(self, entry, only_primaries=True, for idx, p in enumerate(self.data_blob['particles_asis'][global_entry]): pid = int(p.id()) + pdg = TYPE_LABELS.get(p.pdg_code(), -1) + is_primary = p.group_id() == p.parent_id() + if self.deghosting: + mask_noghost = labels_noghost[:, 6].astype(int) == pid + if np.count_nonzero(mask_noghost) <= 0: + continue # 1. Check if current pid is one of the existing group ids if pid not in particle_ids: - # print("PID {} not in particle_ids".format(pid)) - continue - is_primary = p.group_id() == p.parent_id() - if p.pdg_code() not in TYPE_LABELS: - # print("PID {} not in TYPE LABELS".format(pid)) + particle = handle_empty_true_particles(labels_noghost, mask_noghost, p, entry, + self._num_volumes, verbose=verbose) + particles.append(particle) continue - # For deghosting inputs, perform voxel cut with true nonghost coords. - if self.deghosting: - exclude_ids = self._apply_true_voxel_cut(global_entry) - if pid in exclude_ids: - # Skip this particle if its below the voxel minimum requirement - # print("PID {} was excluded from the list of particles due"\ - # " to true nonghost voxel cut. Exclude IDS = {}".format( - # p.id(), str(exclude_ids) - # )) - continue - pdg = TYPE_LABELS[p.pdg_code()] + # 1. Process voxels mask = labels[:, 6].astype(int) == pid - if self.deghosting: - mask_noghost = labels_noghost[:, 6].astype(int) == pid # If particle is Michel electron, we have the option to # only consider the primary ionization. # Semantic labels only label the primary ionization as Michel. @@ -341,47 +414,8 @@ def get_true_particles(self, entry, only_primaries=True, if self.deghosting: mask_noghost = mask_noghost & (labels_noghost[:, -1].astype(int) == 2) - # Check semantics - semantic_type, sem_counts = np.unique( - labels[mask][:, -1].astype(int), return_counts=True) - - if semantic_type.shape[0] > 1: - if verbose: - print("Semantic Type of Particle {} is not "\ - "unique: {}, {}".format(pid, - str(semantic_type), - str(sem_counts))) - perm = sem_counts.argmax() - semantic_type = semantic_type[perm] - else: - semantic_type = semantic_type[0] - - - coords = self.data_blob['input_data'][entry][mask][:, 1:4] - - interaction_id, int_counts = np.unique(labels[mask][:, 7].astype(int), - return_counts=True) - if interaction_id.shape[0] > 1: - if verbose: - print("Interaction ID of Particle {} is not "\ - "unique: {}".format(pid, str(interaction_id))) - perm = int_counts.argmax() - interaction_id = interaction_id[perm] - else: - interaction_id = interaction_id[0] - - nu_id, nu_counts = np.unique(labels[mask][:, 8].astype(int), - return_counts=True) - if nu_id.shape[0] > 1: - if verbose: - print("Neutrino ID of Particle {} is not "\ - "unique: {}".format(pid, str(nu_id))) - perm = nu_counts.argmax() - nu_id = nu_id[perm] - else: - nu_id = nu_id[0] - + voxel_indices = np.where(mask)[0] fragments = np.unique(labels[mask][:, 5].astype(int)) depositions_MeV = labels[mask][:, 4] depositions = rescaled_input_charge[mask] # Will be in ADC @@ -390,6 +424,23 @@ def get_true_particles(self, entry, only_primaries=True, coords_noghost = labels_noghost[mask_noghost][:, 1:4] depositions_noghost = labels_noghost[mask_noghost][:, 4].squeeze() + # 2. Process particle-level labels + if p.pdg_code() not in TYPE_LABELS: + # print("PID {} not in TYPE LABELS".format(pid)) + continue + # For deghosting inputs, perform voxel cut with true nonghost coords. + if self.deghosting: + exclude_ids = self._apply_true_voxel_cut(global_entry) + if pid in exclude_ids: + # Skip this particle if its below the voxel minimum requirement + # print("PID {} was excluded from the list of particles due"\ + # " to true nonghost voxel cut. Exclude IDS = {}".format( + # p.id(), str(exclude_ids) + # )) + continue + + semantic_type, interaction_id, nu_id = get_true_particle_labels(labels, mask, pid=pid, verbose=verbose) + particle = TruthParticle(self._translate(coords, volume), pid, semantic_type, interaction_id, pdg, entry, @@ -405,7 +456,7 @@ def get_true_particles(self, entry, only_primaries=True, particle.fragments = fragments particle.particle_asis = p particle.nu_id = nu_id - particle.voxel_indices = np.where(mask)[0] + particle.voxel_indices = voxel_indices particle.startpoint = np.array([p.first_step().x(), p.first_step().y(), @@ -445,7 +496,7 @@ def get_true_interactions(self, entry, drop_nonprimary_particles=True, if compute_vertex: vertices = self.get_true_vertices(entry, volume=volume) for ia in out: - if compute_vertex: + if compute_vertex and ia.id in vertices: ia.vertex = vertices[ia.id] ia.volume = volume out_interactions_list.extend(out) @@ -615,6 +666,8 @@ def match_interactions(self, entry, mode='pred_to_true', if codomain is not None: codomain_particles = codomain.particles # continue + domain_particles = [p for p in domain_particles if p.points.shape[0] > 0] + codomain_particles = [p for p in codomain_particles if p.points.shape[0] > 0] if matching_mode == 'one_way': matched_particles, _ = match_particles_fn(domain_particles, codomain_particles, min_overlap=self.min_overlap_count, From bb905047fe27dc1cf9e435c16bc6be76c192277f Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Thu, 9 Mar 2023 14:32:54 -0800 Subject: [PATCH 038/180] Crash hotfi --- analysis/classes/Interaction.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/analysis/classes/Interaction.py b/analysis/classes/Interaction.py index c3720291..08e0b5b1 100644 --- a/analysis/classes/Interaction.py +++ b/analysis/classes/Interaction.py @@ -33,7 +33,8 @@ def __init__(self, interaction_id: int, particles : OrderedDict, vertex=None, nu 1: 'Electron', 2: 'Muon', 3: 'Pion', - 4: 'Proton' + 4: 'Proton', + -1: 'Other' } self.particles = particles self.match = [] @@ -80,10 +81,10 @@ def check_particle_input(self, x): def update_info(self): self.particle_ids = list(self._particles.keys()) - self.particle_counts = Counter({ self.pid_keys[i] : 0 for i in range(len(self.pid_keys))}) + self.particle_counts = Counter({ self.pid_keys[i] : 0 for i in list(self.pid_keys.keys())}) self.particle_counts.update([self.pid_keys[p.pid] for p in self._particles.values()]) - self.primary_particle_counts = Counter({ self.pid_keys[i] : 0 for i in range(len(self.pid_keys))}) + self.primary_particle_counts = Counter({ self.pid_keys[i] : 0 for i in list(self.pid_keys.keys())}) self.primary_particle_counts.update([self.pid_keys[p.pid] for p in self._particles.values() if p.is_primary]) if sum(self.primary_particle_counts.values()) > 0: self.is_valid = True From 558c0fd332c5e4197494893203978cd61cc50531 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Thu, 9 Mar 2023 15:24:42 -0800 Subject: [PATCH 039/180] Bug fix in PPN post-processing --- mlreco/utils/ppn.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/mlreco/utils/ppn.py b/mlreco/utils/ppn.py index 693c5b71..18a663af 100644 --- a/mlreco/utils/ppn.py +++ b/mlreco/utils/ppn.py @@ -374,13 +374,13 @@ def uresnet_ppn_type_point_selector(data, out, score_threshold=0.5, type_score_t ppn_points = ppn_type_softmax[:, c] > type_score_threshold #ppn_type_predictions == c if np.count_nonzero(ppn_points) > 0 and np.count_nonzero(uresnet_points) > 0: d = scipy.spatial.distance.cdist(points[batch_index2][mask][ppn_points][:, :3] + event_data[batch_index][mask][ppn_points][:, coords_col[0]:coords_col[1]] + 0.5, event_data[batch_index][mask][uresnet_points][:, coords_col[0]:coords_col[1]]) - ppn_mask = (d < type_threshold).any(axis=1) - final_points.append(points[batch_index2][mask][ppn_points][ppn_mask][:, :3] + 0.5 + event_data[batch_index][mask][ppn_points][ppn_mask][:, coords_col[0]:coords_col[1]]) - final_scores.append(scores[batch_index2][mask][ppn_points][ppn_mask]) - final_types.append(ppn_type_predictions[ppn_points][ppn_mask]) - final_softmax.append(ppn_type_softmax[ppn_points][ppn_mask]) + ppn_mask2 = (d < type_threshold).any(axis=1) + final_points.append(points[batch_index2][mask][ppn_points][ppn_mask2][:, :3] + 0.5 + event_data[batch_index][mask][ppn_points][ppn_mask2][:, coords_col[0]:coords_col[1]]) + final_scores.append(scores[batch_index2][mask][ppn_points][ppn_mask2]) + final_types.append(ppn_type_predictions[ppn_points][ppn_mask2]) + final_softmax.append(ppn_type_softmax[ppn_points][ppn_mask2]) if enable_classify_endpoints: - final_endpoints.append(ppn_classify_endpoints[ppn_points][ppn_mask]) + final_endpoints.append(ppn_classify_endpoints[ppn_points][ppn_mask2]) else: final_points = [points[batch_index2][mask][:, :3] + 0.5 + event_data[batch_index][mask][:, coords_col[0]:coords_col[1]]] final_scores = [scores[batch_index2][mask]] From 3ec416812b6a103da6c1dc6518e0f6acf875e82a Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Thu, 9 Mar 2023 16:20:47 -0800 Subject: [PATCH 040/180] True nonghost - predicted nonghost matching with chamfer distance, visualization crash fix --- analysis/classes/particle.py | 58 +++++++++++++++++++++++++- mlreco/visualization/plotly_layouts.py | 5 ++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/analysis/classes/particle.py b/analysis/classes/particle.py index cf6e59c0..3e8fe0eb 100644 --- a/analysis/classes/particle.py +++ b/analysis/classes/particle.py @@ -7,6 +7,7 @@ import re from scipy.optimize import linear_sum_assignment +from scipy.spatial.distance import cdist from pprint import pprint @@ -64,6 +65,53 @@ def matrix_iou(particles_x, particles_y): return overlap_matrix +def matrix_chamfer(particles_x, particles_y, mode='default'): + """Function for computing the M x N overlap matrix by the Chamfer distance. + + Parameters + ---------- + particles_x: List[Particle] + List of N particles to match with + particles_y: List[Particle] + List of M particles to match with + + Note the correspondence particles_x -> N and particles_y -> M. + + This function can match two arbitrary points clouds, hence + there is no need for the two particle lists to share the same + voxels. + + In particular, this could be used to match TruthParticle with Particles + using true nonghost coordinates. In this case, must be the + list of TruthParticles and the list of Particles. + + Returns + ------- + overlap_matrix: (M, N) np.float array, with range [0, 1] + """ + overlap_matrix = np.zeros((len(particles_y), len(particles_x)), dtype=np.float32) + for i, py in enumerate(particles_y): + for j, px in enumerate(particles_x): + if mode == 'default': + dist = cdist(px.points, py.points) + elif mode == 'true_nonghost': + if type(px) == TruthParticle and type(py) == Particle: + dist = cdist(px.coords_noghost, py.points) + elif type(px) == Particle and type(py) == TruthParticle: + dist = cdist(px.points, py.coords_noghost) + elif type(px) == Particle and type(py) == Particle: + dist = cdist(px.points, py.points) + else: + dist = cdist(px.coords_noghost, py.coords_noghost) + else: + raise ValueError('Particle overlap computation mode {} is not implemented!'.format(mode)) + loss_x = np.min(dist, axis=0) + loss_y = np.min(dist, axis=1) + loss = loss_x.sum() / loss_x.shape[0] + loss_y.sum() / loss_y.shape[0] + overlap_matrix[i, j] = loss + return overlap_matrix + + def match_particles_fn(particles_from : Union[List[Particle], List[TruthParticle]], particles_to : Union[List[Particle], List[TruthParticle]], min_overlap=0, num_classes=5, verbose=False, overlap_mode='iou'): @@ -170,7 +218,11 @@ def match_particles_fn(particles_from : Union[List[Particle], List[TruthParticle def match_particles_optimal(particles_from : Union[List[Particle], List[TruthParticle]], particles_to : Union[List[Particle], List[TruthParticle]], - min_overlap=0, num_classes=5, verbose=False, overlap_mode='iou'): + min_overlap=0, + num_classes=5, + verbose=False, + overlap_mode='iou', + use_true_nonghost_voxels=False): ''' Match particles so that the final resulting sum of the overlap matrix is optimal. @@ -197,6 +249,8 @@ def match_particles_optimal(particles_from : Union[List[Particle], List[TruthPar overlap_matrix = matrix_counts(particles_y, particles_x) elif overlap_mode == 'iou': overlap_matrix = matrix_iou(particles_y, particles_x) + elif overlap_mode == 'chamfer': + overlap_matrix = -matrix_chamfer(particles_y, particles_x) else: raise ValueError("Overlap matrix mode {} is not supported.".format(overlap_mode)) @@ -295,6 +349,8 @@ def match_interactions_optimal(ints_from : List[Interaction], overlap_matrix = matrix_counts(ints_y, ints_x) elif overlap_mode == 'iou': overlap_matrix = matrix_iou(ints_y, ints_x) + elif overlap_mode == 'chamfer': + overlap_matrix = -matrix_iou(ints_y, ints_x) else: raise ValueError("Overlap matrix mode {} is not supported.".format(overlap_mode)) diff --git a/mlreco/visualization/plotly_layouts.py b/mlreco/visualization/plotly_layouts.py index 81ba61eb..815fb843 100644 --- a/mlreco/visualization/plotly_layouts.py +++ b/mlreco/visualization/plotly_layouts.py @@ -105,6 +105,8 @@ def trace_particles(particles, color='id', size=1, cmin, cmax = int(colors.min()), int(colors.max()) opacity = 1 for p in particles: + if p.points.shape[0] <= 0: + continue c = int(getattr(p, color)) * np.ones(p.points.shape[0]) if highlight_primaries: if p.is_primary: @@ -194,7 +196,8 @@ def trace_interactions(interactions, color='id', colorscale="rainbow", prefix='' voxels = [] # Merge all particles' voxels into one tensor for p in particles: - voxels.append(p.points) + if p.points.shape[0] > 0: + voxels.append(p.points) voxels = np.vstack(voxels) plot = go.Scatter3d(x=voxels[:,0], y=voxels[:,1], From 10fa7aed57980f021d39362fb2248a8b84e55d60 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Fri, 10 Mar 2023 00:54:24 -0800 Subject: [PATCH 041/180] [WIP] Complete reformatting of unwrapper (works up to UResNet-PPN) --- mlreco/models/layers/common/ppnplus.py | 22 ++ mlreco/models/uresnet.py | 45 ++- mlreco/models/uresnet_ppn_chain.py | 11 + mlreco/trainval.py | 65 ++-- mlreco/utils/__init__.py | 1 - mlreco/utils/globals.py | 2 + mlreco/utils/unwrap.py | 459 ++++++++++--------------- 7 files changed, 272 insertions(+), 333 deletions(-) create mode 100644 mlreco/utils/globals.py diff --git a/mlreco/models/layers/common/ppnplus.py b/mlreco/models/layers/common/ppnplus.py index 2e8e69a3..cf54b428 100644 --- a/mlreco/models/layers/common/ppnplus.py +++ b/mlreco/models/layers/common/ppnplus.py @@ -213,6 +213,16 @@ class PPN(torch.nn.Module): -------- PPNLonelyLoss, mlreco.models.uresnet_ppn_chain ''' + + RETURNS = { + 'ppn_points': ('tensor', 'ppn_output_coords'), + 'ppn_masks': ('tensor_list', 'ppn_coords'), + 'ppn_layers': ('tensor_list', 'ppn_coords'), + 'ppn_coords': ('tensor_list',), + 'ppn_output_coords': ('tensor',), + 'ppn_classify_endpoints': ('tensor', 'ppn_output_coords') + } + def __init__(self, cfg, name='ppn'): super(PPN, self).__init__() setup_cnn_configuration(self, cfg, name) @@ -433,6 +443,18 @@ class PPNLonelyLoss(torch.nn.modules.loss._Loss): PPN, mlreco.models.uresnet_ppn_chain """ + RETURNS = { + 'reg_loss': ('scalar',), + 'mask_loss': ('scalar',), + 'type_loss': ('scalar',), + 'classify_endpoints_loss': ('scalar',), + 'output_mask_accuracy': ('scalar',), + 'type_accuracy': ('scalar',), + 'classify_endpoints_accuracy': ('scalar',), + 'num_positives': ('scalar',), + 'num_voxels': ('scalar',) + } + def __init__(self, cfg, name='ppn'): super(PPNLonelyLoss, self).__init__() self.loss_config = cfg.get(name, {}) diff --git a/mlreco/models/uresnet.py b/mlreco/models/uresnet.py index 036762ec..6293b1e6 100644 --- a/mlreco/models/uresnet.py +++ b/mlreco/models/uresnet.py @@ -53,14 +53,14 @@ class UResNet_Chain(nn.Module): beta: float, default 1.0 Weight for ghost/non-ghost segmentation loss. - Output + Returns ------ - segmentation: torch.Tensor - finalTensor: torch.Tensor - encoderTensors: list of torch.Tensor - decoderTensors: list of torch.Tensor - ghost: torch.Tensor - ghost_sptensor: torch.Tensor + segmentation : torch.Tensor + finalTensor : torch.Tensor + encoderTensors : list of torch.Tensor + decoderTensors : list of torch.Tensor + ghost : torch.Tensor + ghost_sptensor : torch.Tensor See Also -------- @@ -68,11 +68,20 @@ class UResNet_Chain(nn.Module): """ INPUT_SCHEMA = [ - ["parse_sparse3d_scn", (float,), (3, 1)] + ['parse_sparse3d', (float,), (3, 1)] ] MODULES = ['uresnet_lonely'] + RETURNS = { + 'segmentation': ('tensor', 'input_data'), # Suboptimal, depends on input + 'finalTensor': ('tensor',), + 'encoderTensors': ('tensor_list',), + 'decoderTensors': ('tensor_list',), + 'ghost': ('tensor', 'ghost_sptensor',), + 'ghost_sptensor': ('tensor',) + } + def __init__(self, cfg, name='uresnet_lonely'): super(UResNet_Chain, self).__init__() self.model_config = cfg.get(name, {}) @@ -140,9 +149,20 @@ class SegmentationLoss(torch.nn.modules.loss._Loss): UResNet_Chain """ INPUT_SCHEMA = [ - ["parse_sparse3d_scn", (int,), (3, 1)] + ['parse_sparse3d', (int,), (3, 1)] ] + RETURNS = { + 'accuracy': ('scalar',), + 'loss': ('scalar', ), + 'ghost_mask_accuracy': ('scalar',), + 'ghost_mask_loss': ('scalar',), + 'uresnet_accuracy': ('scalar',), + 'uresnet_loss': ('scalar',), + 'ghost2ghost_accuracy': ('scalar',), + 'nonghost2nonghost_accuracy' : ('scalar',) + } + def __init__(self, cfg, reduction='sum', batch_col=0): super(SegmentationLoss, self).__init__(reduction=reduction) self._cfg = cfg.get('uresnet_lonely', {}) @@ -155,6 +175,9 @@ def __init__(self, cfg, reduction='sum', batch_col=0): self.cross_entropy = torch.nn.CrossEntropyLoss(reduction='none') self._batch_col = batch_col + for c in range(self._num_classes): + self.RETURNS[f'accuracy_class_{c}'] = ('scalar',) + def forward(self, result, label, weights=None): """ result[0], label and weight are lists of size #gpus = batch_size. @@ -288,8 +311,8 @@ def forward(self, result, label, weights=None): 'ghost_mask_loss': self._beta * mask_loss / count if count else self._beta * mask_loss, 'uresnet_accuracy': uresnet_acc / count if count else 1., 'uresnet_loss': self._alpha * uresnet_loss / count if count else self._alpha * uresnet_loss, - 'ghost2ghost': ghost2ghost / count if count else 1., - 'nonghost2nonghost': nonghost2nonghost / count if count else 1. + 'ghost2ghost_accuracy': ghost2ghost / count if count else 1., + 'nonghost2nonghost_accuracy': nonghost2nonghost / count if count else 1. } else: results = { diff --git a/mlreco/models/uresnet_ppn_chain.py b/mlreco/models/uresnet_ppn_chain.py index 7c4d7398..45208e4a 100644 --- a/mlreco/models/uresnet_ppn_chain.py +++ b/mlreco/models/uresnet_ppn_chain.py @@ -67,6 +67,8 @@ class UResNetPPN(nn.Module): """ MODULES = ['mink_uresnet', 'mink_uresnet_ppn_chain', 'mink_ppn'] + RETURNS = dict(UResNet_Chain.RETURNS, **PPN.RETURNS) + def __init__(self, cfg): super(UResNetPPN, self).__init__() self.model_config = cfg @@ -118,11 +120,20 @@ class UResNetPPNLoss(nn.Module): -------- mlreco.models.uresnet.SegmentationLoss, mlreco.models.layers.common.ppnplus.PPNLonelyLoss """ + + RETURNS = { + 'loss': ('scalar',), + 'accuracy': ('scalar',) + } + def __init__(self, cfg): super(UResNetPPNLoss, self).__init__() self.ppn_loss = PPNLonelyLoss(cfg) self.segmentation_loss = SegmentationLoss(cfg) + self.RETURNS.update({'segmentation_'+k:v for k, v in self.segmentation_loss.RETURNS.items()}) + self.RETURNS.update({'ppn_'+k:v for k, v in self.ppn_loss.RETURNS.items()}) + def forward(self, outputs, segment_label, particles_label, weights=None): res_segmentation = self.segmentation_loss( diff --git a/mlreco/trainval.py b/mlreco/trainval.py index df6717a7..4e4d469b 100644 --- a/mlreco/trainval.py +++ b/mlreco/trainval.py @@ -1,14 +1,15 @@ import os, re, warnings import torch +from collections import defaultdict from .iotools.data_parallel import DataParallel from .models import construct from .models.experimental.bayes.calibration import calibrator_construct, calibrator_loss_construct -import mlreco.utils as utils -from .utils.utils import to_numpy +from .utils import to_numpy, stopwatch from .utils.adabound import AdaBound, AdaBoundW +from .utils.unwrap import Unwrapper class trainval(object): @@ -16,7 +17,7 @@ class trainval(object): Groups all relevant functions for forward/backward of a network. """ def __init__(self, cfg): - self._watch = utils.stopwatch() + self._watch = stopwatch() self.tspent_sum = {} self._model_config = cfg['model'] self._trainval_config = cfg['trainval'] @@ -26,6 +27,7 @@ def __init__(self, cfg): self._gpus = self._trainval_config.get('gpus', []) self._batch_size = self._iotool_config.get('batch_size', 1) self._minibatch_size = self._iotool_config.get('minibatch_size') + self._boundaries = self._iotool_config.get('collate', {}).get('boundaries', None) self._input_keys = self._model_config.get('network_input', []) self._output_keys = self._model_config.get('keep_output',[]) self._ignore_keys = self._model_config.get('ignore_keys', []) @@ -193,61 +195,48 @@ def train_step(self, data_iter, iteration=None, log_time=True): def forward(self, data_iter, iteration=None): """ - Run forward for - flags.BATCH_SIZE / (flags.MINIBATCH_SIZE * len(flags.GPUS)) times + Run forward flags.BATCH_SIZE / (flags.MINIBATCH_SIZE * len(flags.GPUS)) times """ + # Start the clock for the training/forward set self._watch.start('train') self._watch.start('forward') - res_combined = {} - data_combined = {} - num_forward = int(self._batch_size / (self._minibatch_size * max(1,len(self._gpus)))) + # Initialize unwrapper (TODO: Move to __init__) + unwrap = self._trainval_config.get('unwrap', False) or bool(self._trainval_config.get('unwrapper', None)) + if unwrap: + rules = self._net.module.RETURNS if hasattr(self._net.module, 'RETURNS') else {} + rules = dict(rules, **self._criterion.RETURNS) if hasattr(self._criterion, 'RETURNS') else rules + unwrapper = Unwrapper(max(1, len(self._gpus)), self._batch_size, rules, self._boundaries, remove_batch_col=False) # TODO: make True + + # If batch_size > mini_batch_size * n_gpus, run forward more than once per iteration + data_combined, res_combined = defaultdict(list), defaultdict(list) + num_forward = int(self._batch_size / (self._minibatch_size * max(1,len(self._gpus)))) for idx in range(num_forward): + # Get the batched data self._watch.start('io') input_data = self.get_data_minibatched(data_iter) input_train, input_loss = self.make_input_forward(input_data) - self._watch.stop('io') self.tspent_sum['io'] += self._watch.time('io') + # Run forward res = self._forward(input_train, input_loss, iteration=iteration) - # Here, contruct the unwrapped input and output - # First, handle the case of a simple list concat - concat_keys = self._trainval_config.get('concat_result', []) - if len(concat_keys): - avoid_keys = [k for k,v in input_data.items() if not k in concat_keys] - avoid_keys += [k for k,v in res.items() if not k in concat_keys] - input_data,res = utils.list_concat(input_data,res,avoid_keys=avoid_keys) - - # Below for more sophisticated unwrapping functions - # should call a single function that returns a list which can be "extended" in res_combined and data_combined. - # inside the unwrapper function, find all unique batch ids. - # unwrap the outcome - unwrapper = self._trainval_config.get('unwrapper', None) - if unwrapper: - try: - unwrapper = getattr(utils.unwrap, unwrapper) - except ImportError: - msg = 'model.output specifies an unwrapper "%s" which is not available under mlreco.utils' - print(msg % self._trainval_config['unwrapper']) - raise ImportError - # print(input_data['index']) - input_data, res = unwrapper(input_data, res, avoid_keys=concat_keys) + + # Unwrap output, if requested + if unwrap: + input_data, res = unwrapper(input_data, res) else: if 'index' in input_data: input_data['index'] = input_data['index'][0] - for key in res.keys(): - if key not in res_combined: - res_combined[key] = [] - res_combined[key].extend(res[key]) + # Append results to the existing list for key in input_data.keys(): - if key not in data_combined: - data_combined[key] = [] data_combined[key].extend(input_data[key]) + for key in res.keys(): + res_combined[key].extend(res[key]) self._watch.stop('forward') - return data_combined, res_combined + return dict(data_combined), dict(res_combined) def _forward(self, train_blob, loss_blob, iteration=None): diff --git a/mlreco/utils/__init__.py b/mlreco/utils/__init__.py index 4151c5dd..16281fe0 100644 --- a/mlreco/utils/__init__.py +++ b/mlreco/utils/__init__.py @@ -1,2 +1 @@ -from .unwrap import list_concat from .utils import * diff --git a/mlreco/utils/globals.py b/mlreco/utils/globals.py new file mode 100644 index 00000000..0c85ed26 --- /dev/null +++ b/mlreco/utils/globals.py @@ -0,0 +1,2 @@ +# Column which specifies the batch ID in a tensor +BATCH_COL = 0 diff --git a/mlreco/utils/unwrap.py b/mlreco/utils/unwrap.py index ce271c1e..3c87b041 100644 --- a/mlreco/utils/unwrap.py +++ b/mlreco/utils/unwrap.py @@ -1,295 +1,188 @@ import numpy as np import torch +from dataclasses import dataclass - -def list_concat(data_blob, outputs, avoid_keys=[]): - ''' - Concatenate the data_blob and outputs dictionary - - Need to account for: multi-gpu, minibatching, multiple outputs, batches. - - Parameters - ---------- - data_blob : dict - A dictionary of array of array of minibatch data [key][num_minibatch][num_device] - outputs : dict - Results dictionary, output of trainval.forward [key][num_minibatch*num_device] - avoid_keys: list - List of keys not to concatenate - - Returns - ------- - dict - Concatenated version of data_blob where array lenghts are num_minibatch*num_device*minibatch_size - dict - Concatenated version of outputs where array lenghts are num_minibatch*num_device*minibatch_size - ''' - result_data = {} - for key, data in data_blob.items(): - if key in avoid_keys: - result_data[key]=data - continue - if isinstance(data[0],list): - result_data[key] = [] - for d in data: result_data[key] += d - elif isinstance(data[0],np.ndarray): - result_data[key] = np.concatenate(data) - else: - print('Unexpected data type',type(data)) - raise TypeError - - result_outputs = {} - for key,data in outputs.items(): - if key in avoid_keys: - result_outputs[key]=data - continue - if len(data) == 1: - result_outputs[key]=data[0] - continue - # remove the outer-list - if isinstance(data[0],list): - result_outputs[key] = [] - for d in data: - result_outputs[key] += d - elif isinstance(data[0],np.ndarray): - result_outputs[key] = np.concatenate(data) - elif isinstance(data[0],torch.Tensor): - result_outputs[key] = torch.concatenate(data,axis=0) - else: - print('Unexpected data type',type(data)) - raise TypeError - - return result_data, result_outputs +from .globals import * +from .volumes import VolumeBoundaries -def unwrap(data_blob, outputs, batch_id_col=0, avoid_keys=[], input_key='input_data', boundaries=None): +class Unwrapper: ''' - Break down the data_blob and outputs dictionary into events - for sparseconvnet formatted tensors. + Break down the input and output dictionaries into individual events. Need to account for: multi-gpu, minibatching, multiple outputs, batches. - - Parameters - ---------- - data_blob : dict - A dictionary of array of array of minibatch data [key][num_minibatch][num_device] - outputs : dict - Results dictionary, output of trainval.forward [key][num_minibatch*num_device] - batch_id_col : int - Indicates the location of "batch id". For MinkowskiEngine, batch indices are always located at - the 0th column of the N x C coordinate array - - Returns - ------- - dict - Unwrapped version of data_blob where array lenghts are num_minibatch*num_device*minibatch_size - dict - Unwrapped version of outputs where array lenghts are num_minibatch*num_device*minibatch_size - - Info - ---- - Assumes the shape of data_blob and outputs as explained above ''' - batch_idx_max = 0 - - # Handle data - result_data = {} - unwrap_map = {} # dict of [#pts][batch_id] = where - - # a-0) Find the target keys - target_array_keys = [] - target_list_keys = [] - for key,data in data_blob.items(): - if key in avoid_keys: - result_data[key]=data - continue - if not key in result_data: result_data[key]=[] - if isinstance(data[0],np.ndarray) and len(data[0].shape) == 2: - target_array_keys.append(key) - elif isinstance(data[0],torch.Tensor) and len(data[0].shape) == 2: - target_array_keys.append(key) - elif isinstance(data[0],list) and \ - isinstance(data[0][0],np.ndarray) and \ - len(data[0][0].shape) == 2: - target_list_keys.append(key) - elif isinstance(data[0],list): - for d in data: result_data[key].extend(d) + def __init__(self, num_gpus, batch_size, rules={}, boundaries=None, remove_batch_col=False): + ''' + Translate rule arrays and boundaries into instructions. + + Parameters + ---------- + batch_size : int + Number of events in the batch + rules : dict + Dictionary which contains a set of unwrapping rules for each + output key of the reconstruction chain. If there is no rule + associated with a key, the list is concatenated. + boundaries : list + List of detector volume boundaries + remove_batch_col : bool + Remove column which specifies batch ID from the unwrapped tensors + ''' + self.num_gpus = num_gpus + self.batch_size = batch_size + self.remove_batch_col = remove_batch_col + self.merger = VolumeBoundaries(boundaries) if boundaries else None + self.rules = self._parse_rules(rules) + self.masks = {} + + def __call__(self, data_blob, result_blob): + ''' + Main unwrapping function. Loops over the data and result keys + and applies the unwrapping rules. Returns the unwrapped versions + of the two dictionaries + + Parameters + ---------- + data_blob : dict + Dictionary of array of array of minibatch data [key][num_minibatch][num_device] + result_blob : dict + Results dictionary, output of trainval.forward [key][num_minibatch*num_device] + ''' + self._build_batch_masks(data_blob, result_blob) + data_unwrapped, result_unwrapped = {}, {} + for k, v in data_blob.items(): + data_unwrapped[k] = self._unwrap(k, v) + for k, v in result_blob.items(): + result_unwrapped[k] = self._unwrap(k, v) + + return data_unwrapped, result_unwrapped + + @dataclass + class Rule: + method : str = None + ref_key : str = None + + def _parse_rules(self, rules): + ''' + Translate rule arrays into Rule objects. Do the + necessary checks to ensure rule sanity. + + Parameters + ---------- + rules : dict + Dictionary which contains a set of unwrapping rules for each + output key of the reconstruction chain. If there is no rule + associated with a key, the list is concatenated. + ''' + parsed_rules = {} + for key, rule in rules.items(): + parsed_rules[key] = self.Rule(*rule) + if not parsed_rules[key].ref_key: + parsed_rules[key].ref_key = key + + assert parsed_rules[key].method in ['scalar', 'tensor', 'tensor_list'] + + return parsed_rules + + + def _build_batch_masks(self, data_blob, result_blob): + ''' + For all the returned data objects that require a batch mask: + build it and store it. + + Parameters + ---------- + data_blob : dict + Dictionary of array of array of minibatch data [key][num_minibatch][num_device] + result_blob : dict + Results dictionary, output of trainval.forward [key][num_minibatch*num_device] + ''' + self.masks = {} + for key, value in data_blob.items(): + if isinstance(value[0], np.ndarray): + self.masks[key] = [self._batch_masks(value[g]) for g in range(self.num_gpus)] + if key not in self.rules: + self.rules[key] = self.Rule('tensor', key) + for key in result_blob.keys(): + if key in self.rules and self.rules[key].method != 'scalar': + ref_key = self.rules[key].ref_key + assert ref_key in self.masks or ref_key in result_blob, 'Must provide the reference tensor to unwrap' + assert self.rules[key].method == self.rules[ref_key].method, 'Reference must be of same type' + if ref_key not in self.masks: + if self.rules[key].method == 'tensor': + self.masks[ref_key] = [self._batch_masks(result_blob[ref_key][g]) for g in range(self.num_gpus)] + elif self.rules[key].method == 'tensor_list': + self.masks[ref_key] = [[self._batch_masks(v) for v in result_blob[ref_key][g]] for g in range(self.num_gpus)] + + def _batch_masks(self, tensor): + ''' + Makes a list of masks for each batch for a specific tensor. + + Parameters + ---------- + tensor : np.ndarray + Tensor with a batch ID column + ''' + # Identify how many volumes we are dealing with + num_volumes = self.merger.num_volumes() if self.merger else 1 + + # Create batch masks + batch_masks = [] + for b in range(self.batch_size): + batch_mask = [] + for v in range(num_volumes): + batch_mask.extend(np.where(tensor[:, BATCH_COL] == b*num_volumes+v)[0]) + batch_masks.append(batch_mask) + + return batch_masks + + def _unwrap(self, key, data): + ''' + Routes set of data to the appropriate unwrapping scheme + + Parameters + ---------- + key : str + Name of the data product to unwrap + data : list + Data product + ''' + if key not in self.rules or self.rules[key].method in [None, 'scalar']: + return self._concatenate(data) else: - print('Un-interpretable input data...') - print('key:',key) - print('data:',data) - raise TypeError - # a-1) Handle the list of ndarrays - - for target in target_array_keys: - data = data_blob[target] - for d in data: - # print(target, d, d.shape) - # check if batch map is available, and create if not - if not d.shape[0] in unwrap_map: - batch_map = {} - batch_id_loc = batch_id_col if d.shape[1] > batch_id_col else -1 - batch_idx = np.unique(d[:,batch_id_loc]) - if len(batch_idx): - batch_idx_max = max(batch_idx_max, int(batch_idx.max())) - for b in batch_idx: - batch_map[b] = d[:,batch_id_loc] == b - unwrap_map[d.shape[0]]=batch_map - - batch_map = unwrap_map[d.shape[0]] - for where in batch_map.values(): - result_data[target].append(d[where]) - - # a-2) Handle the list of list of ndarrays - for target in target_list_keys: - data = data_blob[target] - for dlist in data: - # construct a list of batch ids - batch_ids = [] - batch_id_loc = batch_id_col if d.shape[1] > batch_id_col else -1 - for d in dlist: - batch_ids.extend([n for n in np.unique(d[:,batch_id_loc]) if not n in batch_ids]) - batch_ids.sort() - for b in batch_ids: - result_data[target].append([ d[d[:,batch_id_loc] == b] for d in dlist ]) - - # Handle output - result_outputs = {} - - # Fix unwrap map - output_unwrap_map = {} - for key in unwrap_map: - if (np.array([d.shape[0] for d in data_blob[input_key]]) == key).any(): - output_unwrap_map[key] = unwrap_map[key] - unwrap_map = output_unwrap_map - - # b-0) Find the target keys - target_array_keys = [] - target_list_keys = [] - for key, data in outputs.items(): - if key in avoid_keys: - if not isinstance(data, list): - result_outputs[key] = [data] # Temporary Fix + ref_key = self.rules[key].ref_key + if self.rules[key].method == 'tensor': + return [data[g][mask] for g in range(self.num_gpus) for mask in self.masks[ref_key][g]] + elif self.rules[key].method == 'tensor_list': + return [[d[self.masks[ref_key][g][i][b]] for i, d in enumerate(data[g])] for g in range(self.num_gpus) for b in range(self.batch_size)] + #return [[d[mask] for mask in self.masks[ref_key][g][i]] for g in range(self.num_gpus) for i, d in enumerate(data[g])] + + def _concatenate(self, data): + ''' + Simply concatenates the lists coming from each GPU + + Parameters + ---------- + key : str + Name of the data product to unwrap + data : list + Data product + ''' + if isinstance(data[0], (int, float)): + if len(data) == 1: + return [data[0] for i in range(self.batch_size)] + elif len(data) == self.batch_count: + return data else: - result_outputs[key] = data - continue - if not key in result_outputs: result_outputs[key]=[] - if not isinstance(data,list): result_outputs[key].append(data) - elif isinstance(data[0],np.ndarray) and len(data[0].shape)==2: - target_array_keys.append(key) - elif isinstance(data[0],list) and isinstance(data[0][0],np.ndarray) and len(data[0][0].shape)==2: - target_list_keys.append(key) - elif isinstance(data[0],list): - for d in data: result_outputs[key].extend(d) + raise ValueError('Only accept scalar arrays of size 1 or batch_size: '+\ + f'{len(data)} != {self.batch_size}') + if isinstance(data[0], list): + concat_data = [] + for d in data: + concat_data += d + return concat_data + elif isinstance(data[0], np.ndarray): + return np.concatenate(data) else: - result_outputs[key].extend(data) - #print('Un-interpretable output data...') - #print('key:',key) - #print('data:',data) - #raise TypeError - - # b-1) Handle the list of ndarrays - if target_array_keys is not None: - target_array_keys.sort(reverse=True) - - for target in target_array_keys: - data = outputs[target] - for d in data: - # check if batch map is available, and create if not - if not d.shape[0] in unwrap_map: - batch_map = {} - batch_id_loc = batch_id_col if d.shape[1] > batch_id_col else -1 - batch_idx = np.unique(d[:,batch_id_loc]) - # ensure these are integer values - # if target == 'ppn_points': - # print(target) - # print(d) - # print("--------------Batch IDX----------------") - # print(batch_idx) - # assert False - # print(target, len(batch_idx), len(np.unique(batch_idx.astype(np.int32)))) - assert(len(batch_idx) == len(np.unique(batch_idx.astype(np.int32)))) - if len(batch_idx): - batch_idx_max = max(batch_idx_max, int(batch_idx.max())) - # We are going to assume **consecutive numbering of batch idx** starting from 0 - # b/c problems arise if one of the targets is missing an entry (eg all voxels predicted ghost, - # which means a batch idx is missing from batch_idx for target = input_rescaled) - # then alignment across targets is lost (output[target][entry] may not correspond to batch id `entry`) - batch_idx = np.arange(0, int(batch_idx_max)+1, 1) - # print(target, batch_idx, [np.count_nonzero(d[:,batch_id_loc] == b) for b in batch_idx]) - for b in batch_idx: - batch_map[b] = d[:,batch_id_loc] == b - unwrap_map[d.shape[0]]=batch_map - - batch_map = unwrap_map[d.shape[0]] - for where in batch_map.values(): - result_outputs[target].append(d[where]) - - # b-2) Handle the list of list of ndarrays - #for target in target_list_keys: - # data = outputs[target] - # num_elements = len(data[0]) - # for list_idx in range(num_elements): - # combined_list = [] - # for d in data: - # target_data = d[list_idx] - # if not target_data.shape[0] in unwrap_map: - # batch_map = {} - # batch_idx = np.unique(target_data[:,data_dim]) - # for b in batch_idx: - # batch_map[b] = target_data[:,data_dim] == b - # unwrap_map[target_data.shape[0]]=batch_map - - # batch_map = unwrap_map[target_data.shape[0]] - # combined_list.extend([ target_data[where] for where in batch_map.values() ]) - # #combined_list.extend([ target_data[target_data[:,data_dim] == b] for b in batch_idx]) - # result_outputs[target].append(combined_list) - - # b-2) Handle the list of list of ndarrays - - # ensure outputs[key] length is same for all key in target_list_keys - # for target in target_list_keys: - # print(target,len(outputs[target])) - num_elements = np.unique([len(outputs[target]) for target in target_list_keys]) - assert len(num_elements)<1 or len(num_elements) == 1 - num_elements = 0 if len(num_elements) < 1 else int(num_elements[0]) - # construct unwrap mapping - list_unwrap_map = [] - list_batch_ctrs = [] - for data_index in range(num_elements): - element_map = {} - batch_ctrs = [] - for target in target_list_keys: - dlist = outputs[target][data_index] - for d in dlist: - # print(d) - if not d.shape[0] in element_map: - if len(d.shape) < 2: - print(target, d.shape) - batch_id_loc = batch_id_col if d.shape[1] > batch_id_col else -1 - batch_idx = np.unique(d[:,batch_id_loc]) - if len(batch_idx): - batch_idx_max = max(batch_idx_max, int(batch_idx.max())) - batch_ctrs.append(int(batch_idx_max+1)) - try: - assert(len(batch_idx) == len(np.unique(batch_idx.astype(np.int32)))) - except AssertionError: - raise AssertionError("Result key {} is not included in concat_result".format(target)) - where = [d[:,batch_id_loc] == b for b in range(batch_ctrs[-1])] - element_map[d.shape[0]] = where - # print(batch_ctrs) - # if len(np.unique(batch_ctrs)) != 1: - # print(element_map) - # for i, d in enumerate(dlist): - # print(i, d, np.unique(d[:, batch_id_loc].astype(int))) - # assert len(np.unique(batch_ctrs)) == 1 - list_unwrap_map.append(element_map) - list_batch_ctrs.append(min(batch_ctrs)) - for target in target_list_keys: - data = outputs[target] - for data_index, dlist in enumerate(data): - batch_ctrs = list_batch_ctrs[data_index] - element_map = list_unwrap_map[data_index] - for b in range(batch_ctrs): - result_outputs[target].append([ d[element_map[d.shape[0]][b]] for d in dlist]) - return result_data, result_outputs + raise TypeError('Unexpected data type', type(data[0])) From 1fc6aa7d232a90ad203bfe8a108ca7897a6483b4 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Fri, 10 Mar 2023 23:23:25 -0800 Subject: [PATCH 042/180] [WIP] Graph-SPICE unwrapping mostly complete, need to debug postprocessing --- mlreco/models/factories.py | 14 +++-- mlreco/models/full_chain.py | 4 +- mlreco/models/graph_spice.py | 52 +++++++++++-------- mlreco/models/layers/cluster_cnn/factories.py | 4 +- .../cluster_cnn/graph_spice_embedder.py | 11 +++- .../cluster_cnn/losses/gs_embeddings.py | 30 ++++++++--- mlreco/models/layers/common/gnn_full_chain.py | 4 +- mlreco/models/spice.py | 10 ++-- .../cluster/cluster_graph_constructor.py | 44 ++++++++++------ mlreco/utils/cluster/graph_batch.py | 27 ++++++++-- mlreco/utils/unwrap.py | 20 +++++-- 11 files changed, 146 insertions(+), 74 deletions(-) diff --git a/mlreco/models/factories.py b/mlreco/models/factories.py index f815c075..a63218a3 100644 --- a/mlreco/models/factories.py +++ b/mlreco/models/factories.py @@ -8,24 +8,22 @@ def model_dict(): ------- dict """ - from . import grappa + from . import full_chain from . import uresnet from . import uresnet_ppn_chain - from . import spice from . import singlep + from . import spice from . import graph_spice + from . import grappa from . import bayes_uresnet - from . import full_chain from . import vertex # Make some models available (not all of them, e.g. PPN is not standalone) models = { # Full reconstruction chain, including an option for deghosting "full_chain": (full_chain.FullChain, full_chain.FullChainLoss), - - # --------------------MinkowskiEngine Backend---------------------- # UresNet "uresnet": (uresnet.UResNet_Chain, uresnet.SegmentationLoss), # UResNet + PPN @@ -35,11 +33,11 @@ def model_dict(): # Multi Particle Classifier "multip": (singlep.MultiParticleImageClassifier, singlep.MultiParticleTypeLoss), # SPICE - "spice": (spice.MinkSPICE, spice.SPICELoss), + "spice": (spice.SPICE, spice.SPICELoss), + # Graph SPICE + "graph_spice": (graph_spice.GraphSPICE, graph_spice.GraphSPICELoss), # Graph neural network Particle Aggregation (GrapPA) "grappa": (grappa.GNN, grappa.GNNLoss), - # Graph SPICE - "graph_spice": (graph_spice.MinkGraphSPICE, graph_spice.GraphSPICELoss), # Bayesian Classifier "bayes_singlep": (singlep.BayesianParticleClassifier, singlep.ParticleTypeLoss), # Bayesian UResNet diff --git a/mlreco/models/full_chain.py b/mlreco/models/full_chain.py index bdabe09d..3f945635 100644 --- a/mlreco/models/full_chain.py +++ b/mlreco/models/full_chain.py @@ -5,7 +5,7 @@ from mlreco.models.layers.common.gnn_full_chain import FullChainGNN, FullChainLoss from mlreco.models.layers.common.ppnplus import PPN, PPNLonelyLoss from mlreco.models.uresnet import UResNet_Chain, SegmentationLoss -from mlreco.models.graph_spice import MinkGraphSPICE, GraphSPICELoss +from mlreco.models.graph_spice import GraphSPICE, GraphSPICELoss from mlreco.utils.cluster.cluster_graph_constructor import ClusterGraphConstructor from mlreco.utils.deghosting import adapt_labels_knn as adapt_labels @@ -95,7 +95,7 @@ def __init__(self, cfg): self.cluster_classes = [] if self.enable_cnn_clust: self._enable_graph_spice = 'graph_spice' in cfg - self.graph_spice = MinkGraphSPICE(cfg) + self.graph_spice = GraphSPICE(cfg) self.gs_manager = ClusterGraphConstructor(cfg.get('graph_spice', {}).get('constructor_cfg', {}), batch_col=self.batch_col, training=False) # for downstream, need to run prediction in inference mode diff --git a/mlreco/models/graph_spice.py b/mlreco/models/graph_spice.py index 928df4e4..87467fd2 100644 --- a/mlreco/models/graph_spice.py +++ b/mlreco/models/graph_spice.py @@ -11,7 +11,7 @@ from mlreco.utils.cluster.cluster_graph_constructor import ClusterGraphConstructor -class MinkGraphSPICE(nn.Module): +class GraphSPICE(nn.Module): ''' Neighbor-graph embedding based particle clustering. @@ -118,7 +118,6 @@ class MinkGraphSPICE(nn.Module): graph: graph_info: coordinates: - batch_indices: hypergraph_features: See Also @@ -128,8 +127,16 @@ class MinkGraphSPICE(nn.Module): MODULES = ['constructor_cfg', 'embedder_cfg', 'kernel_cfg', 'gspice_fragment_manager'] + RETURNS = { + 'coordinates': ['tensor'], + 'edge_index': ['edge_tensor', ('edge_index', 'coordinates')], + 'edge_score': ['edge_tensor', ('edge_index', 'coordinates')], + 'edge_truth': ['edge_tensor', ('edge_index', 'coordinates')], + 'graph_info': ['tensor'] + } + def __init__(self, cfg, name='graph_spice'): - super(MinkGraphSPICE, self).__init__() + super(GraphSPICE, self).__init__() self.model_config = cfg.get(name, {}) self.skip_classes = self.model_config.get('skip_classes', [2, 3, 4]) self.dimension = self.model_config.get('dimension', 3) @@ -148,7 +155,9 @@ def __init__(self, cfg, name='graph_spice'): # `training` needs to be set at forward time. # Before that, self.training is always True. self.gs_manager = ClusterGraphConstructor(constructor_cfg, - batch_col=0) + batch_col=0) + + self.RETURNS.update(self.embedder.RETURNS) def weight_initialization(self): @@ -174,24 +183,25 @@ def forward(self, input): ''' ''' + # Pass input through the model self.gs_manager.training = self.training point_cloud, labels = self.filter_class(input) res = self.embedder([point_cloud]) - coordinates = point_cloud[:, 1:4] - batch_indices = point_cloud[:, 0].int() - - res['coordinates'] = [coordinates] - res['batch_indices'] = [batch_indices] - + res['coordinates'] = [point_cloud[:, :4]] if self.use_raw_features: res['hypergraph_features'] = res['features'] + # Build the graph graph = self.gs_manager(res, self.kernel_fn, labels) - res['graph'] = [graph] - res['graph_info'] = [self.gs_manager.info] + + res['edge_index'] = [graph.edge_index.T] + res['edge_score'] = [graph.edge_attr] + res['edge_truth'] = [graph.edge_truth] + res['graph_info'] = [self.gs_manager.info.to_numpy()] + return res @@ -230,8 +240,11 @@ class GraphSPICELoss(nn.Module): See Also -------- - MinkGraphSPICE + GraphSPICE """ + + RETURNS = {} + def __init__(self, cfg, name='graph_spice_loss'): super(GraphSPICELoss, self).__init__() self.model_config = cfg.get('graph_spice', {}) @@ -245,6 +258,8 @@ def __init__(self, cfg, name='graph_spice_loss'): # self.eval_mode = self.loss_config.get('eval', False) self.loss_fn = spice_loss_construct(self.loss_name)(self.loss_config) + self.RETURNS.update(self.loss_fn.RETURNS) + constructor_cfg = self.model_config.get('constructor_cfg', {}) self.gs_manager = ClusterGraphConstructor(constructor_cfg, batch_col=0) @@ -266,15 +281,7 @@ def forward(self, result, segment_label, cluster_label): ''' ''' - slabel, clabel = self.filter_class(segment_label, cluster_label) - - graph = result['graph'][0] - graph_info = result['graph_info'][0] - self.gs_manager.replace_state(graph, graph_info) - result['edge_score'] = [graph.edge_attr] - result['edge_index'] = [graph.edge_index] - if self.gs_manager.use_cluster_labels: - result['edge_truth'] = [graph.edge_truth] + #self.gs_manager.replace_state(result) # if self.invert: # pred_labels = result['edge_score'][0] < 0.0 @@ -290,5 +297,6 @@ def forward(self, result, segment_label, cluster_label): # edge_diff.shape[0])) + slabel, clabel = self.filter_class(segment_label, cluster_label) res = self.loss_fn(result, slabel, clabel) return res diff --git a/mlreco/models/layers/cluster_cnn/factories.py b/mlreco/models/layers/cluster_cnn/factories.py index e7e44974..2f4be557 100644 --- a/mlreco/models/layers/cluster_cnn/factories.py +++ b/mlreco/models/layers/cluster_cnn/factories.py @@ -43,10 +43,10 @@ def cluster_model_dict(): ''' # from mlreco.models.scn.cluster_cnn import spatial_embeddings # from mlreco.models.scn.cluster_cnn import graph_spice - from mlreco.models.layers.cluster_cnn.embeddings import SPICE as MinkSPICE + from mlreco.models.layers.cluster_cnn.embeddings import SPICE models = { # "spice_cnn": spatial_embeddings.SpatialEmbeddings, - "spice_cnn_me": MinkSPICE, + "spice_cnn_me": SPICE, # "graph_spice_embedder": graph_spice.GraphSPICEEmbedder, # "graph_spice_geo_embedder": graph_spice.GraphSPICEGeoEmbedder # "graphgnn_spice": graphgnn_spice.SparseOccuSegGNN diff --git a/mlreco/models/layers/cluster_cnn/graph_spice_embedder.py b/mlreco/models/layers/cluster_cnn/graph_spice_embedder.py index 22d405b0..ede34055 100644 --- a/mlreco/models/layers/cluster_cnn/graph_spice_embedder.py +++ b/mlreco/models/layers/cluster_cnn/graph_spice_embedder.py @@ -12,6 +12,16 @@ class GraphSPICEEmbedder(UResNet): MODULES = ['network_base', 'uresnet', 'graph_spice_embedder'] + RETURNS = { + 'spatial_embeddings': ['tensor', 'coordinates'], + 'covariance': ['tensor', 'coordinates'], + 'feature_embeddings': ['tensor', 'coordinates'], + 'occupancy': ['tensor', 'coordinates'], + 'features': ['tensor', 'coordinates'], + 'hypergraph_features': ['tensor', 'coordinates'], + 'segmentation': ['tensor', 'coordinates'] + } + def __init__(self, cfg, name='graph_spice_embedder'): super(GraphSPICEEmbedder, self).__init__(cfg) self.model_config = cfg.get(name, {}) @@ -130,7 +140,6 @@ def get_embeddings(self, input): "occupancy": [occupancy], "features": [output_features], "hypergraph_features": [hypergraph_features], - # "segmentation": [segmentation] } if self.segmentationLayer: res["segmentation"] = [segmentation] diff --git a/mlreco/models/layers/cluster_cnn/losses/gs_embeddings.py b/mlreco/models/layers/cluster_cnn/losses/gs_embeddings.py index dca8d0b8..fba88a78 100644 --- a/mlreco/models/layers/cluster_cnn/losses/gs_embeddings.py +++ b/mlreco/models/layers/cluster_cnn/losses/gs_embeddings.py @@ -88,6 +88,17 @@ class GraphSPICEEmbeddingLoss(nn.Module): Loss function for Sparse Spatial Embeddings Model, with fixed centroids and symmetric gaussian kernels. ''' + + RETURNS = { + 'loss' : ['scalar'], + 'accuracy': ['scalar'], + 'ft_inter_loss': ['scalar'], + 'ft_intra_loss': ['scalar'], + 'ft_reg_loss': ['scalar'], + 'sp_inter_loss': ['scalar'], + 'sp_intra_loss': ['scalar'], + } + def __init__(self, cfg, name='graph_spice_loss'): super(GraphSPICEEmbeddingLoss, self).__init__() self.loss_config = cfg #[name] @@ -259,17 +270,17 @@ def combine_multiclass(self, sp_embeddings, ft_embeddings, covariance, sp_centroids, ft_centroids, eps=self.eps) occ_loss = self.occupancy_loss(occ, groups_unique) # TODO: Combine loss with weighting, keep track for logging - loss['ft_intra'].append(ft_out['intracluster_loss']) - loss['ft_inter'].append(ft_out['intercluster_loss']) - loss['ft_reg'].append(ft_out['regularization_loss']) - loss['sp_intra'].append(sp_out['intracluster_loss']) - loss['sp_inter'].append(sp_out['intercluster_loss']) + loss['ft_intra_loss'].append(ft_out['intracluster_loss']) + loss['ft_inter_loss'].append(ft_out['intercluster_loss']) + loss['ft_reg_loss'].append(ft_out['regularization_loss']) + loss['sp_intra_loss'].append(sp_out['intracluster_loss']) + loss['sp_inter_loss'].append(sp_out['intercluster_loss']) loss['cov_loss'].append(float(cov_loss)) loss['occ_loss'].append(float(occ_loss)) loss['loss'].append( ft_out['loss'] + sp_out['loss'] + cov_loss + occ_loss) # TODO: Implement train-time accuracy estimation - accuracy['acc_{}'.format(int(sc))] = acc + accuracy['accuracy_{}'.format(int(sc))] = acc accuracy['accuracy'] += acc counts += 1 @@ -365,6 +376,11 @@ class NodeEdgeHybridLoss(torch.nn.modules.loss._Loss): ''' Combined Node + Edge Loss ''' + + RETURNS = { + 'edge_accuracy' : ['scalar'] + } + def __init__(self, cfg, name='graph_spice_loss'): super(NodeEdgeHybridLoss, self).__init__() # print("CFG + ", cfg) @@ -377,6 +393,8 @@ def __init__(self, cfg, name='graph_spice_loss'): self.acc_fn = IoUScore() self.use_cluster_labels = cfg.get('use_cluster_labels', True) + self.RETURNS.update(self.loss_fn.RETURNS) + def forward(self, result, segment_label, cluster_label): group_label = [cluster_label[0][:, [0, 1, 2, 3, 5]]] diff --git a/mlreco/models/layers/common/gnn_full_chain.py b/mlreco/models/layers/common/gnn_full_chain.py index fa6a9930..96c538e2 100644 --- a/mlreco/models/layers/common/gnn_full_chain.py +++ b/mlreco/models/layers/common/gnn_full_chain.py @@ -720,9 +720,7 @@ def forward(self, out, seg_label, ppn_label=None, cluster_label=None, kinematics 'hypergraph_features': out['hypergraph_features'], 'features': out['features'], 'occupancy': out['occupancy'], - 'coordinates': out['coordinates'], - 'batch_indices': out['batch_indices'], - #'segmentation': [out['segmentation'][0][deghost]] if self.enable_ghost else [out['segmentation'][0]] + 'coordinates': out['coordinates'] } segmentation_pred = out['segmentation'][0] diff --git a/mlreco/models/spice.py b/mlreco/models/spice.py index d0c98352..bfebc0d8 100644 --- a/mlreco/models/spice.py +++ b/mlreco/models/spice.py @@ -1,19 +1,15 @@ import torch import torch.nn as nn -from mlreco.models.layers.cluster_cnn.embeddings import SPICE +from mlreco.models.layers.cluster_cnn.embeddings import SPICE as SPICE_base # TODO why does this live out of this module? from mlreco.models.layers.cluster_cnn import spice_loss_construct -class MinkSPICE(SPICE): +class SPICE(SPICE_base): MODULES = ['network_base', 'uresnet_encoder', 'embedding_decoder', 'seediness_decoder'] def __init__(self, cfg): - super(MinkSPICE, self).__init__(cfg) - - #print('Total Number of Trainable Parameters = {}'.format( - # sum(p.numel() for p in self.parameters() if p.requires_grad))) - #print(self) + super(SPICE, self).__init__(cfg) class SPICELoss(nn.Module): diff --git a/mlreco/utils/cluster/cluster_graph_constructor.py b/mlreco/utils/cluster/cluster_graph_constructor.py index 20c91fd1..c2f80ed5 100644 --- a/mlreco/utils/cluster/cluster_graph_constructor.py +++ b/mlreco/utils/cluster/cluster_graph_constructor.py @@ -233,8 +233,8 @@ def _initialize_graph_unwrapped(self, res: dict, (see initialize_graph for functionality) ''' features = res['hypergraph_features'] - batch_indices = res['batch_indices'] - coordinates = res['coordinates'] + batch_indices = res['coordinates'][:,0].int() + coordinates = res['coordinates'][:,1:4] assert len(features) != len(labels) assert len(features) != torch.unique(batch_indices).shpae[0] data_list = [] @@ -295,12 +295,10 @@ def initialize_graph(self, res : dict, return self._initialize_graph_unwrapped(res, labels) features = res['hypergraph_features'][0] - batch_indices = res['batch_indices'][0] - coordinates = res['coordinates'][0] + batch_indices = res['coordinates'][0][:,0].int() + coordinates = res['coordinates'][0][:,1:4] data_list = [] - # print(labels) - graph_id = 0 index = 0 @@ -321,8 +319,8 @@ def initialize_graph(self, res : dict, data = GraphData(x=features_class, pos=coords_class, edge_index=edge_indices) - graph_id_key = dict(Index=index, - BatchID=int(bidx), + graph_id_key = dict(BatchID=int(bidx), + Index=index, SemanticID=int(c), GraphID=graph_id) graph_id += 1 @@ -342,12 +340,20 @@ def initialize_graph(self, res : dict, self._num_total_edges = self._graph_batch.edge_index.shape[1] - def replace_state(self, graph_batch, info): - self._graph_batch = graph_batch + def replace_state(self, result, prefix=''): + + concat = torch.cat if isinstance(result[prefix+'features'][0], torch.Tensor) else np.concatenate + graph = GraphBatch(x = concat(result[prefix+'features']), + batch = concat(result[prefix+'coordinates'])[:,0], + pos = concat(result[prefix+'coordinates'])[:,1:4], + edge_index = concat(result[prefix+'edge_index']).T, + edge_attr = concat(result[prefix+'edge_score']), + edge_truth = concat(result[prefix+'edge_truth'])) + self._graph_batch = graph self._num_total_nodes = self._graph_batch.x.shape[0] self._node_dim = self._graph_batch.x.shape[1] self._num_total_edges = self._graph_batch.edge_index.shape[1] - self._info = info + self._info = result[prefix+'graph_info'] def _set_edge_attributes(self, kernel_fn : Callable): @@ -436,8 +442,11 @@ def fit_predict_one(self, entry, G.add_nodes_from(np.arange(num_nodes)) # Drop edges with low predicted probability score - edges = subgraph.edge_index.T.cpu().numpy() - edge_logits = subgraph.edge_attr.detach().cpu().numpy() + edges = subgraph.edge_index.T + edge_logits = subgraph.edge_attr + if isinstance(edges, torch.Tensor): + edges = edges.detach().cpu().numpy + edge_logits = edge_logits.detach().cpu().numpy edge_probs = expit(edge_logits) if invert: pos_edges = edges[edge_probs < self.ths] @@ -456,7 +465,9 @@ def fit_predict_one(self, entry, orphan_mask[x] = True # Assign orphans - G.pos = subgraph.pos.cpu().numpy() + G.pos = subgraph.pos + if isinstance(G.pos, torch.Tensor): + G.pos = G.pos.detach().cpu().numpy() if not orphan_mask.all(): n_orphans = 0 while orphan_mask.any() and (n_orphans != np.sum(orphan_mask)): @@ -495,7 +506,10 @@ def fit_predict(self, skip=[], **kwargs): for entry in entry_list: pred, G, subgraph = self.fit_predict_one(entry, **kwargs) - batch_index = (self._graph_batch.batch.cpu().numpy() == entry) + batch = self._graph_batch.batch + if isinstance(batch, torch.Tensor): + batch = batch.cpu().numpy() + batch_index = batch == entry pred_data_list.append(GraphData(x=torch.Tensor(pred).long(), pos=torch.Tensor(G.pos))) # node_pred[batch_index] = pred diff --git a/mlreco/utils/cluster/graph_batch.py b/mlreco/utils/cluster/graph_batch.py index 245d4e8f..32cbd7d0 100644 --- a/mlreco/utils/cluster/graph_batch.py +++ b/mlreco/utils/cluster/graph_batch.py @@ -1,12 +1,12 @@ from typing import List, AnyStr +import numpy as np import torch from torch import Tensor from torch_sparse import SparseTensor, cat import torch_geometric -from torch_geometric.data import Data -from torch_geometric.data import Batch +from torch_geometric.data import Data, Batch class GraphBatch(Batch): ''' @@ -147,7 +147,7 @@ def from_data_list(cls, data_list, follow_batch=[], exclude_keys=[]): return batch.contiguous() - def get_example(self, idx: int) -> Data: + def get_example_old(self, idx: int) -> Data: r"""Reconstructs the :class:`torch_geometric.data.Data` object at index :obj:`idx` from the batch object. The batch object must have been created via :meth:`from_data_list` in @@ -205,6 +205,27 @@ def get_example(self, idx: int) -> Data: return data + def get_example(self, idx: int) -> Data: + r"""Reconstructs the :class:`torch_geometric.data.Data` object at index + :obj:`idx` from the batch object. + The batch object must have been created via :meth:`from_data_list` in + order to be able to reconstruct the initial objects.""" + + if isinstance(self.x, torch.Tensor): + x_mask = torch.nonzero(self.batch == idx).flatten() + e_mask = torch.nonzeros(self.batch[self.edge_index[0]] == idx).flatten() + else: + x_mask = np.where(self.batch == idx)[0] + e_mask = np.where(self.batch[self.edge_index[0]] == idx)[0] + + data = Data() + data.x = self.x[x_mask] + data.pos = self.pos[x_mask] + data.edge_index = self.edge_index[:,e_mask] + data.edge_attr = self.edge_attr[e_mask] + data.edge_truth = self.edge_truth[e_mask] + + return data def add_node_features(self, node_feats, name : AnyStr, dtype=None): if hasattr(self, name): diff --git a/mlreco/utils/unwrap.py b/mlreco/utils/unwrap.py index 3c87b041..ce40728b 100644 --- a/mlreco/utils/unwrap.py +++ b/mlreco/utils/unwrap.py @@ -34,7 +34,7 @@ def __init__(self, num_gpus, batch_size, rules={}, boundaries=None, remove_batch self.remove_batch_col = remove_batch_col self.merger = VolumeBoundaries(boundaries) if boundaries else None self.rules = self._parse_rules(rules) - self.masks = {} + self.masks, self.offsets = {}, {} def __call__(self, data_blob, result_blob): ''' @@ -81,7 +81,7 @@ def _parse_rules(self, rules): if not parsed_rules[key].ref_key: parsed_rules[key].ref_key = key - assert parsed_rules[key].method in ['scalar', 'tensor', 'tensor_list'] + assert parsed_rules[key].method in ['scalar', 'tensor', 'tensor_list', 'edge_tensor'] return parsed_rules @@ -98,14 +98,14 @@ def _build_batch_masks(self, data_blob, result_blob): result_blob : dict Results dictionary, output of trainval.forward [key][num_minibatch*num_device] ''' - self.masks = {} + self.masks, self.offsets = {}, {} for key, value in data_blob.items(): if isinstance(value[0], np.ndarray): self.masks[key] = [self._batch_masks(value[g]) for g in range(self.num_gpus)] if key not in self.rules: self.rules[key] = self.Rule('tensor', key) for key in result_blob.keys(): - if key in self.rules and self.rules[key].method != 'scalar': + if key in self.rules and self.rules[key].method in ['tensor', 'tensor_list']: ref_key = self.rules[key].ref_key assert ref_key in self.masks or ref_key in result_blob, 'Must provide the reference tensor to unwrap' assert self.rules[key].method == self.rules[ref_key].method, 'Reference must be of same type' @@ -114,6 +114,15 @@ def _build_batch_masks(self, data_blob, result_blob): self.masks[ref_key] = [self._batch_masks(result_blob[ref_key][g]) for g in range(self.num_gpus)] elif self.rules[key].method == 'tensor_list': self.masks[ref_key] = [[self._batch_masks(v) for v in result_blob[ref_key][g]] for g in range(self.num_gpus)] + elif key in self.rules and self.rules[key].method == 'edge_tensor': + assert len(self.rules[key].ref_key) == 2, 'Must provide a reference to the edge_index and the node batch ids' + for ref_key in self.rules[key].ref_key: + assert ref_key in result_blob, 'Must provide reference tensor to unwrap' + ref_edge, ref_node = self.rules[key].ref_key + if ref_edge not in self.masks: + edge_index, batch_ids = result_blob[ref_edge], result_blob[ref_node] + self.masks[ref_edge] = [self._batch_masks(batch_ids[g][edge_index[g][:,0]]) for g in range(self.num_gpus)] + self.offsets[ref_edge] = [np.cumsum([np.sum(batch_ids[g][:,BATCH_COL] == b-1) for b in range(self.batch_size)]) for g in range(self.num_gpus)] def _batch_masks(self, tensor): ''' @@ -156,7 +165,8 @@ def _unwrap(self, key, data): return [data[g][mask] for g in range(self.num_gpus) for mask in self.masks[ref_key][g]] elif self.rules[key].method == 'tensor_list': return [[d[self.masks[ref_key][g][i][b]] for i, d in enumerate(data[g])] for g in range(self.num_gpus) for b in range(self.batch_size)] - #return [[d[mask] for mask in self.masks[ref_key][g][i]] for g in range(self.num_gpus) for i, d in enumerate(data[g])] + elif self.rules[key].method == 'edge_tensor': + return [data[g][mask]-(key==ref_key[0])*self.offsets[ref_key[0]][g][i] for g in range(self.num_gpus) for i, mask in enumerate(self.masks[ref_key[0]][g])] def _concatenate(self, data): ''' From b2f65e1f886a2722ec4be6f289bdd3a6a3831b56 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Mon, 13 Mar 2023 00:28:41 -0700 Subject: [PATCH 043/180] [WIP] Added unwrapping rules to full chain, fixed PPN postprocessing --- mlreco/main_funcs.py | 13 ++++--- mlreco/models/.full_chain.py.swp | Bin 0 -> 32768 bytes mlreco/models/full_chain.py | 33 ++++++++++++++++-- mlreco/models/graph_spice.py | 8 ++--- mlreco/models/layers/common/gnn_full_chain.py | 17 +++------ mlreco/models/layers/common/ppnplus.py | 30 ++++++++-------- mlreco/models/uresnet.py | 12 +++---- mlreco/models/uresnet_ppn_chain.py | 4 +-- .../cluster/cluster_graph_constructor.py | 4 +-- mlreco/utils/cluster/graph_batch.py | 6 ++-- mlreco/utils/ppn.py | 20 +++-------- mlreco/utils/unwrap.py | 4 +-- 12 files changed, 82 insertions(+), 69 deletions(-) create mode 100644 mlreco/models/.full_chain.py.swp diff --git a/mlreco/main_funcs.py b/mlreco/main_funcs.py index f793139a..e64e69c7 100644 --- a/mlreco/main_funcs.py +++ b/mlreco/main_funcs.py @@ -375,12 +375,15 @@ def inference_loop(handlers): # Metrics for each event # global_metrics = {} weights = glob.glob(handlers.cfg['trainval']['model_path']) - # if len(weights) > 0: - print("Looping over weights: ", len(weights)) - for w in weights: print(' -',w) + if not len(weights): + weights = [None] + if len(weights) > 1: + print("Looping over weights: ", len(weights)) + for w in weights: print(' -',w) for weight in weights: - print('Setting weights',weight) - handlers.cfg['trainval']['model_path'] = weight + if weight is not None and len(weights) > 1: + print('Setting weights', weight) + handlers.cfg['trainval']['model_path'] = weight loaded_iteration = handlers.trainer.initialize() make_directories(handlers.cfg,loaded_iteration,handlers) handlers.iteration = 0 diff --git a/mlreco/models/.full_chain.py.swp b/mlreco/models/.full_chain.py.swp new file mode 100644 index 0000000000000000000000000000000000000000..ff3ac9837b43f0e2940fe982bdd381a0a59c20bf GIT binary patch literal 32768 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 1: + if isinstance(v[1], str): + if 'graph_spice' in v[1]: continue + gspice_returns[k][1] = 'graph_spice_'+v[1] + else: + for i in range(len(v[1])): + if 'graph_spice' in v[1][i]: continue + gspice_returns[k][1][i] = 'graph_spice_'+v[1][i] + self.RETURNS.update(gspice_returns) + #self.RETURNS.update({f'graph_spice_{k}':v for k, v in self.graph_spice.RETURNS.items()}) + + if self.enable_dbscan: self.frag_cfg = cfg.get('dbscan', {}).get('dbscan_fragment_manager', {}) self.dbscan_fragment_manager = DBSCANFragmentManager(self.frag_cfg, @@ -326,12 +352,13 @@ def full_chain_cnn(self, input): cnn_result['graph_spice_label'] = [graph_spice_label] spatial_embeddings_output = self.graph_spice((input[0][:,:5], graph_spice_label)) - cnn_result.update(spatial_embeddings_output) + cnn_result.update({f'graph_spice_{k}':v for k, v in spatial_embeddings_output.items()}) if self.process_fragments: - self.gs_manager.replace_state(spatial_embeddings_output['graph'][0], - spatial_embeddings_output['graph_info'][0]) + #self.gs_manager.replace_state(spatial_embeddings_output['graph'][0], + # spatial_embeddings_output['graph_info'][0]) + self.gs_manager.replace_state(spatial_embeddings_output) self.gs_manager.fit_predict(invert=self._gspice_invert, min_points=self._gspice_min_points) cluster_predictions = self.gs_manager._node_pred.x diff --git a/mlreco/models/graph_spice.py b/mlreco/models/graph_spice.py index 87467fd2..e2f691f0 100644 --- a/mlreco/models/graph_spice.py +++ b/mlreco/models/graph_spice.py @@ -129,9 +129,9 @@ class GraphSPICE(nn.Module): RETURNS = { 'coordinates': ['tensor'], - 'edge_index': ['edge_tensor', ('edge_index', 'coordinates')], - 'edge_score': ['edge_tensor', ('edge_index', 'coordinates')], - 'edge_truth': ['edge_tensor', ('edge_index', 'coordinates')], + 'edge_index': ['edge_tensor', ['edge_index', 'coordinates']], + 'edge_score': ['edge_tensor', ['edge_index', 'coordinates']], + 'edge_truth': ['edge_tensor', ['edge_index', 'coordinates']], 'graph_info': ['tensor'] } @@ -281,7 +281,7 @@ def forward(self, result, segment_label, cluster_label): ''' ''' - #self.gs_manager.replace_state(result) + self.gs_manager.replace_state(result) # if self.invert: # pred_labels = result['edge_score'][0] < 0.0 diff --git a/mlreco/models/layers/common/gnn_full_chain.py b/mlreco/models/layers/common/gnn_full_chain.py index 96c538e2..8ae6c6d2 100644 --- a/mlreco/models/layers/common/gnn_full_chain.py +++ b/mlreco/models/layers/common/gnn_full_chain.py @@ -710,18 +710,8 @@ def forward(self, out, seg_label, ppn_label=None, cluster_label=None, kinematics if self.enable_cnn_clust: # If there is no track voxel, maybe GraphSpice didn't run - if self._enable_graph_spice and 'graph' in out: - graph_spice_out = { - 'graph': out['graph'], - 'graph_info': out['graph_info'], - 'spatial_embeddings': out['spatial_embeddings'], - 'feature_embeddings': out['feature_embeddings'], - 'covariance': out['covariance'], - 'hypergraph_features': out['hypergraph_features'], - 'features': out['features'], - 'occupancy': out['occupancy'], - 'coordinates': out['coordinates'] - } + if self._enable_graph_spice and 'graph_spice_graph_info' in out: + graph_spice_out = {k.split('graph_spice_')[-1]:v for k, v in out.items() if 'graph_spice_' in k} segmentation_pred = out['segmentation'][0] @@ -895,7 +885,7 @@ def forward(self, out, seg_label, ppn_label=None, cluster_label=None, kinematics print('Segmentation Accuracy: {:.4f}'.format(res_seg['accuracy'])) if self.enable_ppn and 'ppn_output_coords' in out: print('PPN Accuracy: {:.4f}'.format(res_ppn['accuracy'])) - if self.enable_cnn_clust and ('graph' in out or 'embeddings' in out): + if self.enable_cnn_clust and ('graph_spice_graph_info' in out or 'embeddings' in out): if not self._enable_graph_spice: print('Clustering Embedding Accuracy: {:.4f}'.format(res_cnn_clust['accuracy'])) else: @@ -932,6 +922,7 @@ def forward(self, out, seg_label, ppn_label=None, cluster_label=None, kinematics print('Primary particle score accuracy: {:.4f}'.format(res['grappa_kinematics_vtx_score_accuracy'])) if self.enable_cosmic: print('Cosmic discrimination accuracy: {:.4f}'.format(res_cosmic['accuracy'])) + return res diff --git a/mlreco/models/layers/common/ppnplus.py b/mlreco/models/layers/common/ppnplus.py index cf54b428..f9b1db66 100644 --- a/mlreco/models/layers/common/ppnplus.py +++ b/mlreco/models/layers/common/ppnplus.py @@ -215,12 +215,12 @@ class PPN(torch.nn.Module): ''' RETURNS = { - 'ppn_points': ('tensor', 'ppn_output_coords'), - 'ppn_masks': ('tensor_list', 'ppn_coords'), - 'ppn_layers': ('tensor_list', 'ppn_coords'), - 'ppn_coords': ('tensor_list',), - 'ppn_output_coords': ('tensor',), - 'ppn_classify_endpoints': ('tensor', 'ppn_output_coords') + 'ppn_points': ['tensor', 'ppn_output_coords'], + 'ppn_masks': ['tensor_list', 'ppn_coords'], + 'ppn_layers': ['tensor_list', 'ppn_coords'], + 'ppn_coords': ['tensor_list'], + 'ppn_output_coords': ['tensor'], + 'ppn_classify_endpoints': ['tensor', 'ppn_output_coords'] } def __init__(self, cfg, name='ppn'): @@ -444,15 +444,15 @@ class PPNLonelyLoss(torch.nn.modules.loss._Loss): """ RETURNS = { - 'reg_loss': ('scalar',), - 'mask_loss': ('scalar',), - 'type_loss': ('scalar',), - 'classify_endpoints_loss': ('scalar',), - 'output_mask_accuracy': ('scalar',), - 'type_accuracy': ('scalar',), - 'classify_endpoints_accuracy': ('scalar',), - 'num_positives': ('scalar',), - 'num_voxels': ('scalar',) + 'reg_loss': ['scalar'], + 'mask_loss': ['scalar'], + 'type_loss': ['scalar'], + 'classify_endpoints_loss': ['scalar'], + 'output_mask_accuracy': ['scalar'], + 'type_accuracy': ['scalar'], + 'classify_endpoints_accuracy': ['scalar'], + 'num_positives': ['scalar'], + 'num_voxels': ['scalar'] } def __init__(self, cfg, name='ppn'): diff --git a/mlreco/models/uresnet.py b/mlreco/models/uresnet.py index 6293b1e6..4ee338e5 100644 --- a/mlreco/models/uresnet.py +++ b/mlreco/models/uresnet.py @@ -74,12 +74,12 @@ class UResNet_Chain(nn.Module): MODULES = ['uresnet_lonely'] RETURNS = { - 'segmentation': ('tensor', 'input_data'), # Suboptimal, depends on input - 'finalTensor': ('tensor',), - 'encoderTensors': ('tensor_list',), - 'decoderTensors': ('tensor_list',), - 'ghost': ('tensor', 'ghost_sptensor',), - 'ghost_sptensor': ('tensor',) + 'segmentation': ['tensor', 'input_data'], # Suboptimal, depends on input + 'finalTensor': ['tensor'], + 'encoderTensors': ['tensor_list'], + 'decoderTensors': ['tensor_list'], + 'ghost': ['tensor', 'ghost_sptensor'], + 'ghost_sptensor': ['tensor'] } def __init__(self, cfg, name='uresnet_lonely'): diff --git a/mlreco/models/uresnet_ppn_chain.py b/mlreco/models/uresnet_ppn_chain.py index 45208e4a..0e602513 100644 --- a/mlreco/models/uresnet_ppn_chain.py +++ b/mlreco/models/uresnet_ppn_chain.py @@ -122,8 +122,8 @@ class UResNetPPNLoss(nn.Module): """ RETURNS = { - 'loss': ('scalar',), - 'accuracy': ('scalar',) + 'loss': ['scalar'], + 'accuracy': ['scalar'] } def __init__(self, cfg): diff --git a/mlreco/utils/cluster/cluster_graph_constructor.py b/mlreco/utils/cluster/cluster_graph_constructor.py index c2f80ed5..ccf10e5a 100644 --- a/mlreco/utils/cluster/cluster_graph_constructor.py +++ b/mlreco/utils/cluster/cluster_graph_constructor.py @@ -445,8 +445,8 @@ def fit_predict_one(self, entry, edges = subgraph.edge_index.T edge_logits = subgraph.edge_attr if isinstance(edges, torch.Tensor): - edges = edges.detach().cpu().numpy - edge_logits = edge_logits.detach().cpu().numpy + edges = edges.detach().cpu().numpy() + edge_logits = edge_logits.detach().cpu().numpy() edge_probs = expit(edge_logits) if invert: pos_edges = edges[edge_probs < self.ths] diff --git a/mlreco/utils/cluster/graph_batch.py b/mlreco/utils/cluster/graph_batch.py index 32cbd7d0..98e5bcdd 100644 --- a/mlreco/utils/cluster/graph_batch.py +++ b/mlreco/utils/cluster/graph_batch.py @@ -213,15 +213,17 @@ def get_example(self, idx: int) -> Data: if isinstance(self.x, torch.Tensor): x_mask = torch.nonzero(self.batch == idx).flatten() - e_mask = torch.nonzeros(self.batch[self.edge_index[0]] == idx).flatten() + x_offset = x_mask[0] + e_mask = torch.nonzero(self.batch[self.edge_index[0]] == idx).flatten() else: x_mask = np.where(self.batch == idx)[0] + x_offset = 0 e_mask = np.where(self.batch[self.edge_index[0]] == idx)[0] data = Data() data.x = self.x[x_mask] data.pos = self.pos[x_mask] - data.edge_index = self.edge_index[:,e_mask] + data.edge_index = self.edge_index[:,e_mask] - x_offset data.edge_attr = self.edge_attr[e_mask] data.edge_truth = self.edge_truth[e_mask] diff --git a/mlreco/utils/ppn.py b/mlreco/utils/ppn.py index 18a663af..456f2df9 100644 --- a/mlreco/utils/ppn.py +++ b/mlreco/utils/ppn.py @@ -298,25 +298,15 @@ def uresnet_ppn_type_point_selector(data, out, score_threshold=0.5, type_score_t (optional) endpoint type] 1 row per ppn-predicted points """ + unwrapped = len(out['ppn_points']) == len(out['ppn_coords']) event_data = data#.cpu().detach().numpy() - points = out['ppn_points'][0]#[entry]#.cpu().detach().numpy() - ppn_coords = out['ppn_coords'] - # If 'ppn_points' is specified in `concat_result`, - # then it won't be unwrapped. - if len(points) == len(ppn_coords[-1]): - pass - # print(entry, np.unique(ppn_coords[-1][:, 0], return_counts=True)) - #points = points[ppn_coords[-1][:, 0] == entry, :] - else: # in case it has been unwrapped (possible in no-ghost scenario) - points = out['ppn_points'][entry] - + points = out['ppn_points'][entry] + ppn_coords = out['ppn_coords'][entry] if unwrapped else out['ppn_coords'] enable_classify_endpoints = 'ppn_classify_endpoints' in out if enable_classify_endpoints: - classify_endpoints = out['ppn_classify_endpoints'][0] + classify_endpoints = out['ppn_classify_endpoints'][entry] - ppn_mask = out['ppn_masks'][-1] - # predicted type labels - # uresnet_predictions = torch.argmax(out['segmentation'][0], -1).cpu().detach().numpy() + ppn_mask = out['ppn_masks'][entry][-1] if unwrapped else out['ppn_masks'][-1] uresnet_predictions = np.argmax(out['segmentation'][entry], -1) if 'ghost' in out and apply_deghosting: diff --git a/mlreco/utils/unwrap.py b/mlreco/utils/unwrap.py index ce40728b..a795a9af 100644 --- a/mlreco/utils/unwrap.py +++ b/mlreco/utils/unwrap.py @@ -81,7 +81,7 @@ def _parse_rules(self, rules): if not parsed_rules[key].ref_key: parsed_rules[key].ref_key = key - assert parsed_rules[key].method in ['scalar', 'tensor', 'tensor_list', 'edge_tensor'] + assert parsed_rules[key].method in ['done', 'scalar', 'tensor', 'tensor_list', 'edge_tensor'] return parsed_rules @@ -157,7 +157,7 @@ def _unwrap(self, key, data): data : list Data product ''' - if key not in self.rules or self.rules[key].method in [None, 'scalar']: + if key not in self.rules or self.rules[key].method in [None, 'done', 'scalar']: return self._concatenate(data) else: ref_key = self.rules[key].ref_key From bd948e6878136e22b400e5ce4f8c17c95cd36ec5 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Mon, 13 Mar 2023 16:23:51 -0700 Subject: [PATCH 044/180] Graph Spice graph manager unwrapper fix --- .../cluster/cluster_graph_constructor.py | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/mlreco/utils/cluster/cluster_graph_constructor.py b/mlreco/utils/cluster/cluster_graph_constructor.py index ccf10e5a..0f9b0088 100644 --- a/mlreco/utils/cluster/cluster_graph_constructor.py +++ b/mlreco/utils/cluster/cluster_graph_constructor.py @@ -17,6 +17,7 @@ from mlreco.utils.metrics import * from mlreco.utils.cluster.graph_batch import GraphBatch from torch_geometric.data import Data as GraphData +# from torch_geometric.data import Batch as GraphBatch from sklearn.neighbors import KNeighborsClassifier, RadiusNeighborsClassifier, kneighbors_graph from scipy.special import expit @@ -340,15 +341,33 @@ def initialize_graph(self, res : dict, self._num_total_edges = self._graph_batch.edge_index.shape[1] - def replace_state(self, result, prefix=''): - + def replace_state(self, result, prefix='', unwrapped=False): concat = torch.cat if isinstance(result[prefix+'features'][0], torch.Tensor) else np.concatenate - graph = GraphBatch(x = concat(result[prefix+'features']), - batch = concat(result[prefix+'coordinates'])[:,0], - pos = concat(result[prefix+'coordinates'])[:,1:4], - edge_index = concat(result[prefix+'edge_index']).T, - edge_attr = concat(result[prefix+'edge_score']), - edge_truth = concat(result[prefix+'edge_truth'])) + if unwrapped: + batch_size = len(result[prefix+'features']) + data_list = [] + for i in range(batch_size): + data = GraphData(x = torch.Tensor(result[prefix+'features'][i]).float(), + # batch = result[prefix+'coordinates'][:,0], + pos = torch.Tensor(result[prefix+'coordinates'][i][:,1:4]).float(), + edge_index = torch.Tensor(result[prefix+'edge_index'][i].T).long(), + edge_attr = torch.Tensor(result[prefix+'edge_score'][i]).float(), + edge_truth = torch.Tensor(result[prefix+'edge_truth'][i]).long()) + data_list.append(data) + graph = GraphBatch.from_data_list(data_list) + if not isinstance(result[prefix+'features'][0], torch.Tensor): + graph.x = graph.x.numpy() + graph.pos = graph.pos.numpy() + graph.edge_index = graph.edge_index.numpy() + graph.edge_attr = graph.edge_attr.numpy() + graph.edge_truth = graph.edge_truth.numpy() + else: + graph = GraphBatch(x = concat(result[prefix+'features']), + batch = concat(result[prefix+'coordinates'])[:,0], + pos = concat(result[prefix+'coordinates'])[:,1:4], + edge_index = concat(result[prefix+'edge_index']).T, + edge_attr = concat(result[prefix+'edge_score']), + edge_truth = concat(result[prefix+'edge_truth'])) self._graph_batch = graph self._num_total_nodes = self._graph_batch.x.shape[0] self._node_dim = self._graph_batch.x.shape[1] From f09e78456ba13bf77fa8b8289d086b8bfe3a2abf Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Tue, 14 Mar 2023 16:34:02 -0700 Subject: [PATCH 045/180] Add track endpoint corrector using predicted direction --- analysis/algorithms/utils.py | 67 +++++++++++++++++------------------ analysis/algorithms/vertex.py | 4 +-- 2 files changed, 34 insertions(+), 37 deletions(-) diff --git a/analysis/algorithms/utils.py b/analysis/algorithms/utils.py index 890df7ff..071938bd 100644 --- a/analysis/algorithms/utils.py +++ b/analysis/algorithms/utils.py @@ -166,6 +166,18 @@ def correct_track_endpoints_linfit(p, bin_size=17): p.endpoint = p1 +def correct_track_endpoints_direction(p): + assert p.semantic_type == 1 + vec = p.endpoint - p.startpoint + vec = vec / np.linalg.norm(vec) + direction = get_particle_direction(p, optimize=True) + direction = direction / np.linalg.norm(direction) + if np.sum(vec * direction) < 0: + p1, p2 = p.startpoint, p.endpoint + p.startpoint = p2 + p.endpoint = p1 + + def get_track_points(p, correction_mode='ppn', brute_force=False): if brute_force: pts = np.vstack(get_track_endpoints_max_dist(p)) @@ -177,49 +189,28 @@ def get_track_points(p, correction_mode='ppn', brute_force=False): correct_track_endpoints_local_density(p) elif correction_mode == 'linfit': correct_track_endpoints_linfit(p) + elif correction_mode == 'direction': + correct_track_endpoints_direction(p) else: raise ValueError("Track extrema correction mode {} not defined!".format(correction_mode)) -def load_range_reco(particle_type='muon', kinetic_energy=True): - """ - Return a function maps the residual range of a track to the kinetic - energy of the track. The mapping is based on the Bethe-Bloch formula - and stored per particle type in TGraph objects. The TGraph::Eval - function is used to perform the interpolation. - - Parameters - ---------- - particle_type: A string with the particle name. - kinetic_energy: If true (false), return the kinetic energy (momentum) - - Returns - ------- - The kinetic energy or momentum according to Bethe-Bloch. - """ - output_var = ('_RRtoT' if kinetic_energy else '_RRtodEdx') - if particle_type in ['muon', 'pion', 'kaon', 'proton']: - input_file = ROOT.TFile.Open('RRInput.root', 'read') - graph = input_file.Get(f'{particle_type}{output_var}') - return np.vectorize(graph.Eval) - else: - print(f'Range-based reconstruction for particle "{particle_type}" not available.') - - def get_interaction_properties(interaction: Interaction, spatial_size, prefix=None): update_dict = OrderedDict({ 'interaction_id': -1, 'interaction_size': -1, - 'count_primary_leptons': -1, - 'count_primary_electrons': -1, 'count_primary_particles': -1, 'vertex_x': -1, 'vertex_y': -1, 'vertex_z': -1, 'has_vertex': False, 'vertex_valid': 'Default Invalid', - 'count_primary_protons': -1, + 'count_primary_photons': -1, + 'count_primary_electrons': -1, + 'count_primary_muons': -1, + 'count_primary_pions': -1, + 'count_primary_protons': -1 # 'nu_reco_energy': -1 }) @@ -227,24 +218,32 @@ def get_interaction_properties(interaction: Interaction, spatial_size, prefix=No out = attach_prefix(update_dict, prefix) return out else: - count_primary_leptons = {} + count_primary_muons = {} count_primary_particles = {} count_primary_protons = {} count_primary_electrons = {} + count_primary_photons = {} + count_primary_pions = {} for p in interaction.particles: if p.is_primary: count_primary_particles[p.id] = True + if p.pid == 0: + count_primary_photons[p.id] = True if p.pid == 1: count_primary_electrons[p.id] = True - if (p.pid == 1 or p.pid == 2): - count_primary_leptons[p.id] = True - elif p.pid == 4: - count_primary_protons[p.id] = True + if p.pid == 2: + count_primary_muons[p.id] = True + if p.pid == 3: + count_primary_pions[p.id] = True + if p.pid == 4: + count_primary_protons[p.id] = True update_dict['interaction_id'] = interaction.id update_dict['interaction_size'] = interaction.size - update_dict['count_primary_leptons'] = sum(count_primary_leptons.values()) + update_dict['count_primary_muons'] = sum(count_primary_muons.values()) + update_dict['count_primary_photons'] = sum(count_primary_photons.values()) + update_dict['count_primary_pions'] = sum(count_primary_pions.values()) update_dict['count_primary_particles'] = sum(count_primary_particles.values()) update_dict['count_primary_protons'] = sum(count_primary_protons.values()) update_dict['count_primary_electrons'] = sum(count_primary_electrons.values()) diff --git a/analysis/algorithms/vertex.py b/analysis/algorithms/vertex.py index f91a105f..a6873f5f 100644 --- a/analysis/algorithms/vertex.py +++ b/analysis/algorithms/vertex.py @@ -90,9 +90,7 @@ def get_track_shower_poca(particles, return_annot=False, start_segment_radius=10 def compute_vertex_matrix_inversion(particles, dim=3, - use_primaries=True, - weight=False, - var_sigma=0.05): + use_primaries=True): """ Given a set of particles, compute the vertex by the following method: From 28c62bcdcbf4e52fa32a20784cdc5c91bf0482a7 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Thu, 16 Mar 2023 12:49:59 -0700 Subject: [PATCH 046/180] Finished overhauling the unwrapping, now to fix the analysis tools --- mlreco/iotools/parsers/particles.py | 7 +- mlreco/iotools/parsers/unwrap_rules.py | 51 ++++ mlreco/main_funcs.py | 25 -- mlreco/models/.full_chain.py.swp | Bin 32768 -> 0 bytes mlreco/models/full_chain.py | 78 +++-- mlreco/models/graph_spice.py | 3 +- mlreco/models/grappa.py | 38 ++- mlreco/models/layers/common/gnn_full_chain.py | 184 ++++++------ mlreco/models/layers/common/ppnplus.py | 5 +- .../models/layers/gnn/losses/edge_channel.py | 7 + .../layers/gnn/losses/node_kinematics.py | 24 +- .../models/layers/gnn/losses/node_primary.py | 7 + mlreco/models/layers/gnn/losses/node_type.py | 7 + mlreco/models/uresnet.py | 4 +- mlreco/trainval.py | 6 +- .../cluster/cluster_graph_constructor.py | 3 +- mlreco/utils/cluster/fragmenter.py | 15 +- mlreco/utils/cluster/graph_batch.py | 3 +- mlreco/utils/globals.py | 5 +- mlreco/utils/unwrap.py | 269 +++++++++++++++--- 20 files changed, 501 insertions(+), 240 deletions(-) create mode 100644 mlreco/iotools/parsers/unwrap_rules.py delete mode 100644 mlreco/models/.full_chain.py.swp diff --git a/mlreco/iotools/parsers/particles.py b/mlreco/iotools/parsers/particles.py index 71dad8cf..60592eb9 100644 --- a/mlreco/iotools/parsers/particles.py +++ b/mlreco/iotools/parsers/particles.py @@ -331,8 +331,11 @@ def parse_particle_singlep_einit(particle_event): np.ndarray List of true initial energy for each particle in TTree. """ + einits = [] + einit = -1 for p in particle_event.as_vector(): is_primary = p.track_id() == p.parent_track_id() if not p.track_id() == 1: continue - return p.energy_init() - return -1 + return np.asarray([p.energy_init()]) + + return np.asarray([einit]) diff --git a/mlreco/iotools/parsers/unwrap_rules.py b/mlreco/iotools/parsers/unwrap_rules.py new file mode 100644 index 00000000..9f120183 --- /dev/null +++ b/mlreco/iotools/parsers/unwrap_rules.py @@ -0,0 +1,51 @@ +from mlreco.utils.globals import COORD_COLS + +RULES = { + 'parse_sparse2d': ['tensor', None], + 'parse_sparse3d': ['tensor', None, False, COORD_COLS], + 'parse_sparse3d_ghost': ['tensor', None, False, COORD_COLS], + 'parse_sparse3d_charge_rescaled': ['tensor', None, False, COORD_COLS], + + 'parse_cluster2d': ['tensor', None], + 'parse_cluster3d': ['tensor', None, False, COORD_COLS], + 'parse_cluster3d_charge_rescaled': ['tensor', None, False, COORD_COLS], + + 'parse_particles': ['list'], + 'parse_neutrinos': ['list'], + 'parse_particle_points': ['tensor', None, False, COORD_COLS], + 'parse_particle_coords': ['tensor', None, False, COORD_COLS], + 'parse_particle_graph': ['tensor', None], + 'parse_particle_singlep_pdg': ['tensor', None], + 'parse_particle_singlep_einit': ['tensor', None], + + 'parse_meta2d': ['list'], + 'parse_meta3d': ['list'], + 'parse_run_info': ['list'], + 'parse_opflash': ['list'], + 'parse_crthits': ['list'] +} + +def input_unwrap_rules(schemas): + ''' + Translates parser schemas into unwrap rules. + + Parameters + ---------- + schemas : dict + Dictionary of parser schemas + + Returns + ------- + dict + Dictionary of unwrapping rules + ''' + rules = {} + for name, schema in schemas.items(): + parser = schema['parser'] + assert parser in RULES, f'Unable to unwrap data from {parser}' + rules[name] = RULES[parser] + if rules[name][0] == 'tensor': + rules[name][1] = name + + return rules + diff --git a/mlreco/main_funcs.py b/mlreco/main_funcs.py index e64e69c7..91a30309 100644 --- a/mlreco/main_funcs.py +++ b/mlreco/main_funcs.py @@ -65,31 +65,6 @@ def process_config(cfg, verbose=True): # Set MinkowskiEngine number of threads os.environ['OMP_NUM_THREADS'] = '16' # default value - # Set default concat_result - default_concat_result = ['input_edge_features', 'input_node_features', 'coordinates', - 'particle_node_features', 'particle_edge_features', - 'track_node_features', 'shower_node_features', - 'input_node_points', 'shower_points', 'track_points', 'particle_points', - 'ppn_points', 'ppn_coords', 'ppn_masks', 'ppn_layers', 'ppn_classify_endpoints', - 'vertex_layers', 'vertex_coords', 'primary_label_scales', 'segment_label_scales', - 'seediness', 'margins', 'embeddings', 'fragments', - 'fragments_seg', 'shower_fragments', 'shower_edge_index', - 'shower_edge_pred','shower_node_pred','shower_group_pred','track_fragments', - 'track_edge_index', 'track_node_pred', 'track_edge_pred', 'track_group_pred', - 'particle_fragments', 'particle_edge_index', 'particle_node_pred', - 'particle_edge_pred', 'particle_group_pred', 'particles', - 'inter_edge_index', 'inter_node_pred', 'inter_edge_pred', 'inter_group_pred', - 'inter_particles', 'node_pred_p', 'node_pred_type', - 'vtx_labels', 'vtx_anchors', 'grappa_inter_vtx_labels', 'grappa_inter_vtx_anchors', - 'kinematics_node_pred_p', 'kinematics_node_pred_type', - 'flow_edge_pred', 'kinematics_particles', 'kinematics_edge_index', - 'clust_fragments', 'clust_frag_seg', 'interactions', 'inter_cosmic_pred', - 'node_pred_vtx', 'total_num_points', 'total_nonghost_points', - 'spatial_embeddings', 'occupancy', 'hypergraph_features', 'logits', - 'features', 'feature_embeddings', 'covariance', 'clusts', 'edge_index', 'edge_pred', 'node_pred'] - if 'concat_result' not in cfg['trainval']: - cfg['trainval']['concat_result'] = default_concat_result - if 'iotool' in cfg: # Update IO seed diff --git a/mlreco/models/.full_chain.py.swp b/mlreco/models/.full_chain.py.swp deleted file mode 100644 index ff3ac9837b43f0e2940fe982bdd381a0a59c20bf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 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 1: - if isinstance(v[1], str): - if 'graph_spice' in v[1]: continue - gspice_returns[k][1] = 'graph_spice_'+v[1] - else: - for i in range(len(v[1])): - if 'graph_spice' in v[1][i]: continue - gspice_returns[k][1][i] = 'graph_spice_'+v[1][i] - self.RETURNS.update(gspice_returns) - #self.RETURNS.update({f'graph_spice_{k}':v for k, v in self.graph_spice.RETURNS.items()}) + self.RETURNS.update(prefix_unwrapper_rules(self.graph_spice.RETURNS, 'graph_spice')) + self.RETURNS['graph_spice_label'] = ['tensor', 'graph_spice_label', False, True] if self.enable_dbscan: @@ -150,7 +147,7 @@ def __init__(self, cfg): @staticmethod def get_extra_gnn_features(fragments, - frag_seg, + fragments_seg, classes, input, result, @@ -166,7 +163,7 @@ def get_extra_gnn_features(fragments, Parameters ========== fragments: np.ndarray - frag_seg: np.ndarray + fragments_seg: np.ndarray classes: list input: list result: dictionary @@ -183,7 +180,7 @@ def get_extra_gnn_features(fragments, and `extra_feats` (if `use_supp` is True). """ return _get_extra_gnn_features(fragments, - frag_seg, + fragments_seg, classes, input, result, @@ -318,14 +315,14 @@ def full_chain_cnn(self, input): # --- # 1. Clustering w/ CNN or DBSCAN will produce # - fragments (list of list of integer indexing the input data) - # - frag_batch_ids (list of batch ids for each fragment) - # - frag_seg (list of integers, semantic label for each fragment) + # - fragments_batch_ids (list of batch ids for each fragment) + # - fragments_seg (list of integers, semantic label for each fragment) # --- cluster_result = { - 'fragments': [], - 'frag_batch_ids': [], - 'frag_seg': [] + 'fragment_clusts': [], + 'fragment_batch_ids': [], + 'fragment_seg': [] } if self._gspice_use_true_labels: semantic_labels = label_seg[0][:, -1] @@ -371,35 +368,36 @@ def full_chain_cnn(self, input): # print('filtered input', filtered_input.shape, filtered_input[:, 0].sum(), filtered_input[:, 1].sum(), filtered_input[:, 2].sum(), filtered_input[:, 3].sum(), filtered_input[:, 4].sum(), filtered_input[:, 5].sum()) # print(torch.unique( filtered_input[:, 5], return_counts=True)) fragment_data = self._gspice_fragment_manager(filtered_input, input[0], filtered_semantic) - cluster_result['fragments'].extend(fragment_data[0]) - cluster_result['frag_batch_ids'].extend(fragment_data[1]) - cluster_result['frag_seg'].extend(fragment_data[2]) + cluster_result['fragment_clusts'].extend(fragment_data[0]) + cluster_result['fragment_batch_ids'].extend(fragment_data[1]) + cluster_result['fragment_seg'].extend(fragment_data[2]) if self.enable_dbscan and self.process_fragments: # Get the fragment predictions from the DBSCAN fragmenter fragment_data = self.dbscan_fragment_manager(input[0], cnn_result) - cluster_result['fragments'].extend(fragment_data[0]) - cluster_result['frag_batch_ids'].extend(fragment_data[1]) - cluster_result['frag_seg'].extend(fragment_data[2]) + cluster_result['fragment_clusts'].extend(fragment_data[0]) + cluster_result['fragment_batch_ids'].extend(fragment_data[1]) + cluster_result['fragment_seg'].extend(fragment_data[2]) # Format Fragments - fragments_result = format_fragments(cluster_result['fragments'], - cluster_result['frag_batch_ids'], - cluster_result['frag_seg'], + fragments_result = format_fragments(cluster_result['fragment_clusts'], + cluster_result['fragment_batch_ids'], + cluster_result['fragment_seg'], input[0][:, self.batch_col], batch_size=self.batch_size) cnn_result.update({'frag_dict':fragments_result}) cnn_result.update({ - 'fragments': fragments_result['fragments'], - 'fragments_seg': fragments_result['fragments_seg'] + 'fragment_clusts': fragments_result['fragment_clusts'], + 'fragment_seg': fragments_result['fragment_seg'], + 'fragment_batch_ids': fragments_result['fragment_batch_ids'] }) if self.enable_cnn_clust or self.enable_dbscan: - cnn_result.update({ 'semantic_labels': [semantic_labels] }) + cnn_result.update({'segment_label_adapted': [semantic_labels] }) if label_clustering is not None: - cnn_result.update({ 'label_clustering': label_clustering }) + cnn_result.update({'cluster_label_adapted': label_clustering }) # if self.use_true_fragments and coords is not None: # print('adding true points info') diff --git a/mlreco/models/graph_spice.py b/mlreco/models/graph_spice.py index e2f691f0..18faf2f0 100644 --- a/mlreco/models/graph_spice.py +++ b/mlreco/models/graph_spice.py @@ -199,7 +199,8 @@ def forward(self, input): res['edge_index'] = [graph.edge_index.T] res['edge_score'] = [graph.edge_attr] - res['edge_truth'] = [graph.edge_truth] + if hasattr(graph, 'edge_truth'): + res['edge_truth'] = [graph.edge_truth] res['graph_info'] = [self.gs_manager.info.to_numpy()] return res diff --git a/mlreco/models/grappa.py b/mlreco/models/grappa.py index e6ebca13..26900f4d 100644 --- a/mlreco/models/grappa.py +++ b/mlreco/models/grappa.py @@ -7,6 +7,7 @@ from mlreco.models.experimental.transformers.transformer import TransformerEncoderLayer from mlreco.models.layers.gnn import gnn_model_construct, node_encoder_construct, edge_encoder_construct, node_loss_construct, edge_loss_construct +from mlreco.utils.globals import * from mlreco.utils.gnn.data import merge_batch, split_clusts, split_edge_index from mlreco.utils.gnn.cluster import form_clusters, get_cluster_batch, get_cluster_label, get_cluster_primary_label, get_cluster_points_label, get_cluster_directions, get_cluster_dedxs from mlreco.utils.gnn.network import complete_graph, delaunay_graph, mst_graph, bipartite_graph, inter_cluster_distance, knn_graph, restrict_graph @@ -105,8 +106,8 @@ class GNN(torch.nn.Module): Outputs ------- - input_node_features: - input_edge_features: + node_features: + edge_features: clusts: edge_index: node_pred: @@ -122,6 +123,20 @@ class GNN(torch.nn.Module): MODULES = [('grappa', ['base', 'dbscan', 'node_encoder', 'edge_encoder', 'gnn_model']), 'grappa_loss'] + RETURNS = { + 'batch_ids': ['tensor'], + 'clusts' : ['index_list', ['input_data', 'batch_ids'], True], + 'node_features': ['tensor', 'batch_ids', True], + 'node_pred': ['tensor', 'batch_ids', True], + 'node_pred_type': ['tensor', 'batch_ids', True], + 'node_pred_vtx': ['tensor', 'batch_ids', True], + 'node_pred_p': ['tensor', 'batch_ids', True], + 'group_pred': ['index_tensor', 'batch_ids', True], + 'edge_features': ['edge_tensor', ['edge_index', 'batch_ids'], True], + 'edge_index': ['edge_tensor', ['edge_index', 'batch_ids'], True], + 'edge_pred': ['edge_tensor', ['edge_index', 'batch_ids'], True] + } + def __init__(self, cfg, name='grappa', batch_col=0, coords_col=(1, 4)): super(GNN, self).__init__() @@ -331,6 +346,7 @@ def forward(self, data, clusts=None, groups=None, points=None, extra_feats=None, batch_ids = get_cluster_batch(cluster_data, clusts, batch_index=self.batch_index) clusts_split, cbids = split_clusts(clusts, batch_ids, batches, bcounts) + result['batch_ids'] = [batch_ids] result['clusts'] = [clusts_split] if self.edge_max_count > -1: _, cnts = np.unique(batch_ids, return_counts=True) @@ -417,10 +433,8 @@ def forward(self, data, clusts=None, groups=None, points=None, extra_feats=None, index = torch.tensor(edge_index, device=cluster_data.device, dtype=torch.long) xbatch = torch.tensor(batch_ids, device=cluster_data.device) - result['input_node_features'] = [[x[b] for b in cbids]] - result['input_edge_features'] = [[e[b] for b in ebids]] - if points is not None: - result['input_node_points'] = [[points[b] for b in cbids]] + result['node_features'] = [[x[b] for b in cbids]] + result['edge_features'] = [[e[b] for b in ebids]] # Pass through the model, update results out = self.gnn_model(x, index, e, xbatch) @@ -477,6 +491,16 @@ class GNNLoss(torch.nn.modules.loss._Loss): name: """ + + RETURNS = { + 'loss': ['scalar'], + 'node_loss': ['scalar'], + 'edge_loss': ['scalar'], + 'accuracy': ['scalar'], + 'node_accuracy': ['scalar'], + 'edge_accuracy': ['scalar'] + } + def __init__(self, cfg, name='grappa_loss', batch_col=0, coords_col=(1, 4)): super(GNNLoss, self).__init__() @@ -488,9 +512,11 @@ def __init__(self, cfg, name='grappa_loss', batch_col=0, coords_col=(1, 4)): if 'node_loss' in cfg[name]: self.apply_node_loss = True self.node_loss = node_loss_construct(cfg[name], batch_col=batch_col, coords_col=coords_col) + self.RETURNS.update(self.node_loss.RETURNS) if 'edge_loss' in cfg[name]: self.apply_edge_loss = True self.edge_loss = edge_loss_construct(cfg[name], batch_col=batch_col, coords_col=coords_col) + self.RETURNS.update(self.edge_loss.RETURNS) def forward(self, result, clust_label, graph=None, node_label=None, iteration=None): diff --git a/mlreco/models/layers/common/gnn_full_chain.py b/mlreco/models/layers/common/gnn_full_chain.py index 8ae6c6d2..9f722fb3 100644 --- a/mlreco/models/layers/common/gnn_full_chain.py +++ b/mlreco/models/layers/common/gnn_full_chain.py @@ -2,6 +2,7 @@ import numpy as np from mlreco.models.grappa import GNN, GNNLoss +from mlreco.utils.unwrap import prefix_unwrapper_rules from mlreco.utils.deghosting import adapt_labels_knn as adapt_labels from mlreco.utils.gnn.evaluation import (node_assignment_score, primary_assignment) @@ -36,6 +37,8 @@ def __init__(self, cfg): self._shower_ids = grappa_shower_cfg.get('base', {}).get('node_type', 0) self._shower_use_true_particles = grappa_shower_cfg.get('use_true_particles', False) if not isinstance(self._shower_ids, list): self._shower_ids = [self._shower_ids] + self.RETURNS.update(prefix_unwrapper_rules(self.grappa_shower.RETURNS, 'shower_fragment')) + self.RETURNS['shower_fragment_clusts'][1][0] = 'input_data' if not self.enable_ghost else 'input_rescaled' if self.enable_gnn_track: self.grappa_track = GNN(cfg, name='grappa_track', batch_col=self.batch_col, coords_col=self.coords_col) @@ -43,12 +46,16 @@ def __init__(self, cfg): self._track_ids = grappa_track_cfg.get('base', {}).get('node_type', 1) self._track_use_true_particles = grappa_track_cfg.get('use_true_particles', False) if not isinstance(self._track_ids, list): self._track_ids = [self._track_ids] + self.RETURNS.update(prefix_unwrapper_rules(self.grappa_track.RETURNS, 'track_fragment')) + self.RETURNS['track_fragment_clusts'][1][0] = 'input_data' if not self.enable_ghost else 'input_rescaled' if self.enable_gnn_particle: self.grappa_particle = GNN(cfg, name='grappa_particle', batch_col=self.batch_col, coords_col=self.coords_col) grappa_particle_cfg = cfg.get('grappa_particle', {}) self._particle_ids = grappa_particle_cfg.get('base', {}).get('node_type', [0,1,2,3]) self._particle_use_true_particles = grappa_particle_cfg.get('use_true_particles', False) + self.RETURNS.update(prefix_unwrapper_rules(self.grappa_particle.RETURNS, 'particle_fragment')) + self.RETURNS['particle_fragment_clusts'][1][0] = 'input_data' if not self.enable_ghost else 'input_rescaled' if self.enable_gnn_inter: self.grappa_inter = GNN(cfg, name='grappa_inter', batch_col=self.batch_col, coords_col=self.coords_col) @@ -60,12 +67,16 @@ def __init__(self, cfg): self._inter_enforce_semantics = grappa_inter_cfg.get('enforce_semantics', True) self._inter_enforce_semantics_shape = grappa_inter_cfg.get('enforce_semantics_shape', (4,5)) self._inter_enforce_semantics_map = grappa_inter_cfg.get('enforce_semantics_map', [[0,0,1,1,1,2,3],[0,1,2,3,4,1,1]]) + self.RETURNS.update(prefix_unwrapper_rules(self.grappa_inter.RETURNS, 'particle')) + self.RETURNS['particle_clusts'][1][0] = 'input_data' if not self.enable_ghost else 'input_rescaled' if self.enable_gnn_kinematics: self.grappa_kinematics = GNN(cfg, name='grappa_kinematics', batch_col=self.batch_col, coords_col=self.coords_col) self._kinematics_use_true_particles = cfg.get('grappa_kinematics', {}).get('use_true_particles', False) + self.RETURNS.update(prefix_unwrapper_rules(self.grappa_kinematics.RETURNS, 'kinematics')) + self.RETURNS['kinematics_clusts'][1][0] = 'input_data' if not self.enable_ghost else 'input_rescaled' - def run_gnn(self, grappa, input, result, clusts, labels, kwargs={}): + def run_gnn(self, grappa, input, result, clusts, prefix, kwargs={}): """ Generic function to group in one place the common code to run a GNN model. @@ -75,13 +86,17 @@ def run_gnn(self, grappa, input, result, clusts, labels, kwargs={}): - input: input data - result: dictionary - clusts: list of list of indices (indexing input data) - - labels: dictionary of strings to label the final result + - prefix: prefix to append at the front of the output - kwargs: extra arguments to pass to the gnn Returns ======= None (modifies the result dict in place) """ + # Figure out the expected output keys + labels = {k:f'{prefix}_{k}' for k in grappa.RETURNS.keys()} + labels['group_pred'] = f'{prefix}_group_pred' + # Pass data through the GrapPA model gnn_output = grappa(input, clusts, batch_size=self.batch_size, **kwargs) @@ -140,7 +155,7 @@ def get_all_fragments(self, result, input): """ if self.use_true_fragments: - label_clustering = result['label_clustering'][0] + label_clustering = result['cluster_label_adapted'][0] fragments = form_clusters(label_clustering[0].int().cpu().numpy(), column=5, batch_index=self.batch_col) @@ -157,13 +172,13 @@ def get_all_fragments(self, result, input): fragments = result['frag_dict']['frags'][0] frag_seg = result['frag_dict']['frag_seg'][0] frag_batch_ids = result['frag_dict']['frag_batch_ids'][0] - semantic_labels = result['semantic_labels'][0] + semantic_labels = result['segment_label_adapted'][0] frag_dict = { 'frags': fragments, 'frag_seg': frag_seg, 'frag_batch_ids': frag_batch_ids, - 'semantic_labels': semantic_labels + 'segment_label_adapted': semantic_labels } # Since and depend on the batch column of the input @@ -198,19 +213,12 @@ def run_fragment_gnns(self, result, input): use_ppn=self.use_ppn_in_gnn, use_supp=self.use_supp_in_gnn) - output_keys = {'clusts' : 'shower_fragments', - 'node_pred' : 'shower_node_pred', - 'edge_pred' : 'shower_edge_pred', - 'edge_index': 'shower_edge_index', - 'group_pred': 'shower_group_pred', - 'input_node_points' : 'shower_points', - 'input_node_features': 'shower_node_features'} self.run_gnn(self.grappa_shower, input, result, fragments[em_mask], - output_keys, + 'shower_fragment', kwargs) if self.enable_gnn_track: @@ -224,19 +232,11 @@ def run_fragment_gnns(self, result, input): use_ppn=self.use_ppn_in_gnn, use_supp=self.use_supp_in_gnn) - output_keys = {'clusts' : 'track_fragments', - 'node_pred' : 'track_node_pred', - 'edge_pred' : 'track_edge_pred', - 'edge_index': 'track_edge_index', - 'group_pred': 'track_group_pred', - 'input_node_points' : 'track_points', - 'input_node_features': 'track_node_features'} - self.run_gnn(self.grappa_track, input, result, fragments[track_mask], - output_keys, + 'track_fragment', kwargs) if self.enable_gnn_particle: @@ -252,17 +252,11 @@ def run_fragment_gnns(self, result, input): kwargs['groups'] = frag_seg[mask] - output_keys = {'clusts' : 'particle_fragments', - 'node_pred' : 'particle_node_pred', - 'edge_pred' : 'particle_edge_pred', - 'edge_index': 'particle_edge_index', - 'group_pred': 'particle_group_pred'} - self.run_gnn(self.grappa_particle, input, result, fragments[mask], - output_keys, + 'particle_fragment', kwargs) return frag_dict @@ -273,7 +267,7 @@ def get_all_particles(self, frag_result, result, input): fragments = frag_result['frags'] frag_seg = frag_result['frag_seg'] frag_batch_ids = frag_result['frag_batch_ids'] - semantic_labels = frag_result['semantic_labels'] + semantic_labels = frag_result['segment_label_adapted'] # for i, c in enumerate(fragments): # print('format' , torch.unique(input[0][c, self.batch_col], return_counts=True)) @@ -292,12 +286,11 @@ def get_all_particles(self, frag_result, result, input): # To use true group predictions, change use_group_pred to True # in each grappa config. if self.enable_gnn_particle: - self.select_particle_in_group(result, counts, b, particles, part_primary_ids, - 'particle_node_pred', - 'particle_group_pred', - 'particle_fragments') + 'particle_fragment_node_pred', + 'particle_fragment_group_pred', + 'particle_fragment_clusts') for c in self._particle_ids: mask &= (frag_seg != c) @@ -305,19 +298,19 @@ def get_all_particles(self, frag_result, result, input): if self.enable_gnn_shower: self.select_particle_in_group(result, counts, b, particles, part_primary_ids, - 'shower_node_pred', - 'shower_group_pred', - 'shower_fragments') + 'shower_fragment_node_pred', + 'shower_fragment_group_pred', + 'shower_fragment_clusts') for c in self._shower_ids: mask &= (frag_seg != c) - # Append one particle per track group + # Append one particle 'particle' track group if self.enable_gnn_track: self.select_particle_in_group(result, counts, b, particles, part_primary_ids, - 'track_node_pred', - 'track_group_pred', - 'track_fragments') + 'track_fragment_node_pred', + 'track_fragment_group_pred', + 'track_fragment_clusts') for c in self._track_ids: mask &= (frag_seg != c) @@ -354,8 +347,9 @@ def get_all_particles(self, frag_result, result, input): parts_seg = [part_seg[b] for idx, b in enumerate(bcids)] result.update({ - 'particles': [parts], - 'particles_seg': [parts_seg] + 'particle_clusts': [parts], + 'particle_seg': [parts_seg], + 'particle_batch_ids': [part_batch_ids], }) part_result = { @@ -379,7 +373,7 @@ def run_particle_gnns(self, result, input, frag_result): part_primary_ids = part_result['part_primary_ids'] counts = part_result['counts'] - label_clustering = result['label_clustering'][0] if 'label_clustering' in result else None + label_clustering = result['cluster_label_adapted'][0] if 'cluster_label_adapted' in result else None if label_clustering is None and (self.use_true_fragments or (self.enable_cosmic and self._cosmic_use_true_interactions)): raise Exception('Need clustering labels to use true fragments or true interactions.') @@ -400,16 +394,16 @@ def run_particle_gnns(self, result, input, frag_result): if part_seg[i] == 0 and not self._inter_use_true_particles and self._inter_use_shower_primary: voxel_inds = counts[:part_batch_ids[i]].sum().item() + \ np.arange(counts[part_batch_ids[i]].item()) - if len(voxel_inds) and len(result['shower_fragments'][0][part_batch_ids[i]]) > 0: + if len(voxel_inds) and len(result['shower_fragment_clusts'][0][part_batch_ids[i]]) > 0: try: - p = voxel_inds[result['shower_fragments'][0]\ + p = voxel_inds[result['shower_fragment_clusts'][0]\ [part_batch_ids[i]][part_primary_ids[i]]] except IndexError as e: - print(len(result['shower_fragments'][0])) + print(len(result['shower_fragment_clusts'][0])) print([part_batch_ids[i]]) print(part_primary_ids[i]) print(len(voxel_inds)) - print(result['shower_fragments'][0][part_batch_ids[i]][part_primary_ids[i]]) + print(result['shower_fragment_clusts'][0][part_batch_ids[i]][part_primary_ids[i]]) raise e extra_feats_particles.append(p) @@ -431,35 +425,28 @@ def run_particle_gnns(self, result, input, frag_result): use_ppn=self.use_ppn_in_gnn, use_supp=True) - output_keys = {'clusts': 'inter_particles', - 'edge_pred': 'inter_edge_pred', - 'edge_index': 'inter_edge_index', - 'group_pred': 'inter_group_pred', - 'node_pred': 'inter_node_pred', - 'node_pred_type': 'node_pred_type', - 'node_pred_p': 'node_pred_p', - 'node_pred_vtx': 'node_pred_vtx', - 'input_node_points' : 'particle_points', - 'input_node_features': 'particle_node_features', - 'input_edge_features': 'particle_edge_features'} - self.run_gnn(self.grappa_inter, input, result, particles[inter_mask], - output_keys, + 'particle', kwargs) + # Store particle level quantities for ease of access + if 'points' in kwargs: + result['particle_start_points'] = [np.hstack([result['particle_batch_ids'][0][:,None], kwargs['points'][:,:3].cpu().numpy()])] + result['particle_end_points'] = [np.hstack([result['particle_batch_ids'][0][:,None], kwargs['points'][:,3:].cpu().numpy()])] + # If requested, enforce that particle PID predictions are compatible with semantics, # i.e. set logits to -inf if they belong to incompatible PIDs - if self._inter_enforce_semantics and 'node_pred_type' in result: + if self._inter_enforce_semantics and 'particle_node_pred_type' in result: sem_pid_logic = -float('inf')*torch.ones(self._inter_enforce_semantics_shape, dtype=input[0].dtype, device=input[0].device) sem_pid_logic[self._inter_enforce_semantics_map] = 0. - pid_logits = result['node_pred_type'] + pid_logits = result['particle_node_pred_type'] for i in range(len(pid_logits)): for b in range(len(pid_logits[i])): pid_logits[i][b] += sem_pid_logic[part_seg[part_batch_ids==b]] - result['node_pred_type'] = pid_logits + result['particle_node_pred_type'] = pid_logits # --- # 4. GNN for particle flow & kinematics @@ -468,17 +455,12 @@ def run_particle_gnns(self, result, input, frag_result): if self.enable_gnn_kinematics: if not self.enable_gnn_inter: raise Exception("Need interaction clustering before kinematic GNN.") - output_keys = {'clusts': 'kinematics_particles', - 'edge_index': 'kinematics_edge_index', - 'node_pred_p': 'kinematics_node_pred_p', - 'node_pred_type': 'kinematics_node_pred_type', - 'edge_pred': 'flow_edge_pred'} self.run_gnn(self.grappa_kinematics, input, result, particles[inter_mask], - output_keys) + 'kinematics') # --- # 5. CNN for interaction classification @@ -501,7 +483,7 @@ def run_particle_gnns(self, result, input, frag_result): for b in range(len(counts)): self.select_particle_in_group(result, counts, b, interactions, inter_primary_ids, - None, 'inter_group_pred', 'particles') + None, 'particle_group_pred', 'particle_clusts') same_length = np.all([len(inter) == len(interactions[0]) for inter in interactions]) interactions = [inter.astype(np.int64) for inter in interactions] @@ -759,12 +741,12 @@ def forward(self, out, seg_label, ppn_label=None, cluster_label=None, kinematics if self.enable_gnn_shower: # Apply the GNN shower clustering loss gnn_out = {} - if 'shower_edge_pred' in out: + if 'shower_fragment_edge_pred' in out: gnn_out = { - 'clusts':out['shower_fragments'], - 'node_pred':out['shower_node_pred'], - 'edge_pred':out['shower_edge_pred'], - 'edge_index':out['shower_edge_index'] + 'clusts':out['shower_fragment_clusts'], + 'node_pred':out['shower_fragment_node_pred'], + 'edge_pred':out['shower_fragment_edge_pred'], + 'edge_index':out['shower_fragment_edge_index'] } res_gnn_shower = self.shower_gnn_loss(gnn_out, cluster_label) for key in res_gnn_shower: @@ -776,11 +758,11 @@ def forward(self, out, seg_label, ppn_label=None, cluster_label=None, kinematics if self.enable_gnn_track: # Apply the GNN track clustering loss gnn_out = {} - if 'track_edge_pred' in out: + if 'track_fragment_edge_pred' in out: gnn_out = { - 'clusts':out['track_fragments'], - 'edge_pred':out['track_edge_pred'], - 'edge_index':out['track_edge_index'] + 'clusts':out['track_fragment_clusts'], + 'edge_pred':out['track_fragment_edge_pred'], + 'edge_index':out['track_fragment_edge_index'] } res_gnn_track = self.track_gnn_loss(gnn_out, cluster_label) for key in res_gnn_track: @@ -791,12 +773,12 @@ def forward(self, out, seg_label, ppn_label=None, cluster_label=None, kinematics if self.enable_gnn_particle: # Apply the GNN particle clustering loss gnn_out = {} - if 'particle_edge_pred' in out: + if 'particle_fragment_edge_pred' in out: gnn_out = { - 'clusts':out['particle_fragments'], - 'node_pred':out['particle_node_pred'], - 'edge_pred':out['particle_edge_pred'], - 'edge_index':out['particle_edge_index'] + 'clusts':out['particle_fragment_clusts'], + 'node_pred':out['particle_fragment_node_pred'], + 'edge_pred':out['particle_fragment_edge_pred'], + 'edge_index':out['particle_fragment_edge_index'] } res_gnn_part = self.particle_gnn_loss(gnn_out, cluster_label) for key in res_gnn_particle: @@ -808,18 +790,18 @@ def forward(self, out, seg_label, ppn_label=None, cluster_label=None, kinematics if self.enable_gnn_inter: # Apply the GNN interaction grouping loss gnn_out = {} - if 'inter_edge_pred' in out: + if 'particle_edge_pred' in out: gnn_out = { - 'clusts':out['inter_particles'], - 'edge_pred':out['inter_edge_pred'], - 'edge_index':out['inter_edge_index'] + 'clusts':out['particle_clusts'], + 'edge_pred':out['particle_edge_pred'], + 'edge_index':out['particle_edge_index'] } - if 'inter_node_pred' in out: gnn_out.update({ 'node_pred': out['inter_node_pred'] }) - if 'node_pred_type' in out: gnn_out.update({ 'node_pred_type': out['node_pred_type'] }) - if 'node_pred_p' in out: gnn_out.update({ 'node_pred_p': out['node_pred_p'] }) - if 'node_pred_vtx' in out: gnn_out.update({ 'node_pred_vtx': out['node_pred_vtx'] }) - if 'particle_node_features' in out: gnn_out.update({ 'input_node_features': out['particle_node_features'] }) - if 'particle_edge_features' in out: gnn_out.update({ 'input_edge_features': out['particle_edge_features'] }) + if 'particle_node_pred' in out: gnn_out.update({ 'node_pred': out['particle_node_pred'] }) + if 'particle_node_pred_type' in out: gnn_out.update({ 'node_pred_type': out['particle_node_pred_type'] }) + if 'particle_node_pred_p' in out: gnn_out.update({ 'node_pred_p': out['particle_node_pred_p'] }) + if 'particle_node_pred_vtx' in out: gnn_out.update({ 'node_pred_vtx': out['particle_node_pred_vtx'] }) + if 'particle_node_features' in out: gnn_out.update({ 'node_features': out['particle_node_features'] }) + if 'particle_edge_features' in out: gnn_out.update({ 'edge_features': out['particle_edge_features'] }) res_gnn_inter = self.inter_gnn_loss(gnn_out, cluster_label, node_label=kinematics_label, graph=particle_graph, iteration=iteration) for key in res_gnn_inter: @@ -831,16 +813,16 @@ def forward(self, out, seg_label, ppn_label=None, cluster_label=None, kinematics if self.enable_gnn_kinematics: # Loss on node predictions (type & momentum) gnn_out = {} - if 'flow_edge_pred' in out: + if 'kinematics_edge_pred' in out: gnn_out = { 'clusts': out['kinematics_particles'], - 'edge_pred': out['flow_edge_pred'], + 'edge_pred': out['kinematics_edge_pred'], 'edge_index': out['kinematics_edge_index'] } - if 'node_pred_type' in out: - gnn_out.update({ 'node_pred_type': out['node_pred_type'] }) - if 'node_pred_p' in out: - gnn_out.update({ 'node_pred_p': out['node_pred_p'] }) + if 'kinematics_node_pred_type' in out: + gnn_out.update({ 'node_pred_type': out['kinematics_node_pred_type'] }) + if 'kinematics_node_pred_p' in out: + gnn_out.update({ 'node_pred_p': out['kinematics_node_pred_p'] }) res_kinematics = self.kinematics_loss(gnn_out, kinematics_label, graph=particle_graph) for key in res_kinematics: res['grappa_kinematics_' + key] = res_kinematics[key] diff --git a/mlreco/models/layers/common/ppnplus.py b/mlreco/models/layers/common/ppnplus.py index f9b1db66..f195c69b 100644 --- a/mlreco/models/layers/common/ppnplus.py +++ b/mlreco/models/layers/common/ppnplus.py @@ -6,6 +6,7 @@ import MinkowskiFunctional as MF from mlreco.utils import local_cdist +from mlreco.utils.globals import * from mlreco.models.layers.common.blocks import ResNetBlock, SPP, ASPP from mlreco.models.layers.common.activation_normalization_factories import activations_construct from mlreco.models.layers.common.configuration import setup_cnn_configuration @@ -218,8 +219,8 @@ class PPN(torch.nn.Module): 'ppn_points': ['tensor', 'ppn_output_coords'], 'ppn_masks': ['tensor_list', 'ppn_coords'], 'ppn_layers': ['tensor_list', 'ppn_coords'], - 'ppn_coords': ['tensor_list'], - 'ppn_output_coords': ['tensor'], + 'ppn_coords': ['tensor_list', 'ppn_coords', False, True], + 'ppn_output_coords': ['tensor', 'ppn_output_coords', False, True], 'ppn_classify_endpoints': ['tensor', 'ppn_output_coords'] } diff --git a/mlreco/models/layers/gnn/losses/edge_channel.py b/mlreco/models/layers/gnn/losses/edge_channel.py index 7cb4ea08..88959289 100644 --- a/mlreco/models/layers/gnn/losses/edge_channel.py +++ b/mlreco/models/layers/gnn/losses/edge_channel.py @@ -27,6 +27,13 @@ class EdgeChannelLoss(torch.nn.Module): target : high_purity : """ + + RETURNS = { + 'loss': ['scalar'], + 'accuracy': ['scalar'], + 'n_edges': ['scalar'] + } + def __init__(self, loss_config, batch_col=0, coords_col=(1, 4)): super(EdgeChannelLoss, self).__init__() diff --git a/mlreco/models/layers/gnn/losses/node_kinematics.py b/mlreco/models/layers/gnn/losses/node_kinematics.py index fb0ee586..9e128488 100644 --- a/mlreco/models/layers/gnn/losses/node_kinematics.py +++ b/mlreco/models/layers/gnn/losses/node_kinematics.py @@ -63,6 +63,26 @@ class NodeKinematicsLoss(torch.nn.Module): reduction : balance_classes : """ + + RETURNS = { + 'loss': ['scalar'], + 'type_loss': ['scalar'], + 'p_loss': ['scalar'], + 'vtx_score_loss': ['scalar'], + 'vtx_position_loss': ['scalar'], + 'accuracy': ['scalar'], + 'type_accuracy': ['scalar'], + 'p_accuracy': ['scalar'], + 'vtx_score_accuracy': ['scalar'], + 'vtx_position_accuracy': ['scalar'], + 'n_clusts_momentum': ['scalar'], + 'n_clusts_type': ['scalar'], + 'n_clusts_vtx': ['scalar'], + 'n_clusts_vtx_positives': ['scalar'], + 'vtx_labels': ['tensor', None, True], + 'vtx_labels': ['tensor', None, True] + } + def __init__(self, loss_config, batch_col=0, coords_col=(1, 4)): super(NodeKinematicsLoss, self).__init__() @@ -237,7 +257,7 @@ def forward(self, out, types): if compute_vtx and out['node_pred_vtx'][i][j].shape[0]: # Get the vertex predictions, node features and true vertices from the specified columns node_pred_vtx = out['node_pred_vtx'][i][j] - input_node_features = out['input_node_features'][i][j] + node_features = out['node_features'][i][j] node_assn_vtx = np.stack([get_cluster_label(labels, clusts, column=c) for c in range(self.vtx_col, self.vtx_col+3)], axis=1) node_assn_vtx_pos = get_cluster_label(labels, clusts, column=self.vtx_positives_col) compute_vtx_pos = node_pred_vtx.shape[-1] == 5 @@ -274,7 +294,7 @@ def forward(self, out, types): vtx_pred = node_pred_vtx[pos_mask_vtx,:3] if self.use_anchor_points: # If requested, predict positions with respect to anchor points (end points of particles) - end_points = input_node_features[valid_mask_vtx,19:25][pos_mask_vtx].view(-1, 2, 3) + end_points = node_features[valid_mask_vtx,19:25][pos_mask_vtx].view(-1, 2, 3) dist_to_anchor = torch.norm(vtx_pred.view(-1, 1, 3) - end_points, dim=2).view(-1, 2) min_dist = torch.argmin(dist_to_anchor, dim=1) range_index = torch.arange(end_points.shape[0]).to(device=end_points.device).long() diff --git a/mlreco/models/layers/gnn/losses/node_primary.py b/mlreco/models/layers/gnn/losses/node_primary.py index 7e616788..815b5477 100644 --- a/mlreco/models/layers/gnn/losses/node_primary.py +++ b/mlreco/models/layers/gnn/losses/node_primary.py @@ -24,6 +24,13 @@ class NodePrimaryLoss(torch.nn.Module): use_group_pred : group_pred_alg : """ + + RETURNS = { + 'loss': ['scalar'], + 'accuracy': ['scalar'], + 'n_clusts': ['scalar'] + } + def __init__(self, loss_config, batch_col=0, coords_col=(1, 4)): super(NodePrimaryLoss, self).__init__() diff --git a/mlreco/models/layers/gnn/losses/node_type.py b/mlreco/models/layers/gnn/losses/node_type.py index 8d29c79c..74ef4638 100644 --- a/mlreco/models/layers/gnn/losses/node_type.py +++ b/mlreco/models/layers/gnn/losses/node_type.py @@ -22,6 +22,13 @@ class NodeTypeLoss(torch.nn.Module): reduction : balance_classes : """ + + RETURNS = { + 'loss': ['scalar'], + 'accuracy': ['scalar'], + 'n_clusts': ['scalar'] + } + def __init__(self, loss_config, batch_col=0, coords_col=(1, 4)): super(NodeTypeLoss, self).__init__() diff --git a/mlreco/models/uresnet.py b/mlreco/models/uresnet.py index 4ee338e5..d0fee2ea 100644 --- a/mlreco/models/uresnet.py +++ b/mlreco/models/uresnet.py @@ -74,11 +74,11 @@ class UResNet_Chain(nn.Module): MODULES = ['uresnet_lonely'] RETURNS = { - 'segmentation': ['tensor', 'input_data'], # Suboptimal, depends on input + 'segmentation': ['tensor', 'input_data'], 'finalTensor': ['tensor'], 'encoderTensors': ['tensor_list'], 'decoderTensors': ['tensor_list'], - 'ghost': ['tensor', 'ghost_sptensor'], + 'ghost': ['tensor', 'input_data'], 'ghost_sptensor': ['tensor'] } diff --git a/mlreco/trainval.py b/mlreco/trainval.py index 4e4d469b..826eb56f 100644 --- a/mlreco/trainval.py +++ b/mlreco/trainval.py @@ -3,6 +3,7 @@ from collections import defaultdict from .iotools.data_parallel import DataParallel +from .iotools.parsers.unwrap_rules import input_unwrap_rules from .models import construct from .models.experimental.bayes.calibration import calibrator_construct, calibrator_loss_construct @@ -204,8 +205,9 @@ def forward(self, data_iter, iteration=None): # Initialize unwrapper (TODO: Move to __init__) unwrap = self._trainval_config.get('unwrap', False) or bool(self._trainval_config.get('unwrapper', None)) if unwrap: - rules = self._net.module.RETURNS if hasattr(self._net.module, 'RETURNS') else {} - rules = dict(rules, **self._criterion.RETURNS) if hasattr(self._criterion, 'RETURNS') else rules + rules = input_unwrap_rules(self._iotool_config['dataset']['schema']) + if hasattr(self._net.module, 'RETURNS'): rules.update(self._net.module.RETURNS) + if hasattr(self._criterion, 'RETURNS'): rules.update(self._criterion.RETURNS) unwrapper = Unwrapper(max(1, len(self._gpus)), self._batch_size, rules, self._boundaries, remove_batch_col=False) # TODO: make True # If batch_size > mini_batch_size * n_gpus, run forward more than once per iteration diff --git a/mlreco/utils/cluster/cluster_graph_constructor.py b/mlreco/utils/cluster/cluster_graph_constructor.py index ccf10e5a..7d2201af 100644 --- a/mlreco/utils/cluster/cluster_graph_constructor.py +++ b/mlreco/utils/cluster/cluster_graph_constructor.py @@ -343,12 +343,13 @@ def initialize_graph(self, res : dict, def replace_state(self, result, prefix=''): concat = torch.cat if isinstance(result[prefix+'features'][0], torch.Tensor) else np.concatenate + has_truth = prefix+'edge_truth' in result graph = GraphBatch(x = concat(result[prefix+'features']), batch = concat(result[prefix+'coordinates'])[:,0], pos = concat(result[prefix+'coordinates'])[:,1:4], edge_index = concat(result[prefix+'edge_index']).T, edge_attr = concat(result[prefix+'edge_score']), - edge_truth = concat(result[prefix+'edge_truth'])) + edge_truth = concat(result[prefix+'edge_truth']) if has_truth else None) self._graph_batch = graph self._num_total_nodes = self._graph_batch.x.shape[0] self._node_dim = self._graph_batch.x.shape[1] diff --git a/mlreco/utils/cluster/fragmenter.py b/mlreco/utils/cluster/fragmenter.py index a193a396..d9b938ab 100644 --- a/mlreco/utils/cluster/fragmenter.py +++ b/mlreco/utils/cluster/fragmenter.py @@ -44,13 +44,14 @@ def format_fragments(fragments, frag_batch_ids, frag_seg, batch_column, batch_si frags_seg = [frag_seg_np[b].astype(np.int32) for idx, b in enumerate(bcids)] out = { - 'frags' : [fragments_np], - 'frag_seg' : [frag_seg_np], - 'fragments' : [frags], - 'fragments_seg' : [frags_seg], - 'frag_batch_ids': [frag_batch_ids_np], - 'vids' : [vids], - 'counts' : [counts] + 'frags' : [fragments_np], + 'frag_seg' : [frag_seg_np], + 'frag_batch_ids' : [frag_batch_ids_np], + 'fragment_clusts' : [frags], + 'fragment_seg' : [frags_seg], + 'fragment_batch_ids': [frag_batch_ids_np], + 'vids' : [vids], + 'counts' : [counts] } return out diff --git a/mlreco/utils/cluster/graph_batch.py b/mlreco/utils/cluster/graph_batch.py index 98e5bcdd..2f7391f4 100644 --- a/mlreco/utils/cluster/graph_batch.py +++ b/mlreco/utils/cluster/graph_batch.py @@ -225,7 +225,8 @@ def get_example(self, idx: int) -> Data: data.pos = self.pos[x_mask] data.edge_index = self.edge_index[:,e_mask] - x_offset data.edge_attr = self.edge_attr[e_mask] - data.edge_truth = self.edge_truth[e_mask] + if hasattr(self, 'edge_truth') and self.edge_truth is not None: + data.edge_truth = self.edge_truth[e_mask] return data diff --git a/mlreco/utils/globals.py b/mlreco/utils/globals.py index 0c85ed26..7bb726fa 100644 --- a/mlreco/utils/globals.py +++ b/mlreco/utils/globals.py @@ -1,2 +1,5 @@ # Column which specifies the batch ID in a tensor -BATCH_COL = 0 +BATCH_COL = 0 + +# Columns which specify the voxel coordinates in a tensor +COORD_COLS = (1,2,3) diff --git a/mlreco/utils/unwrap.py b/mlreco/utils/unwrap.py index a795a9af..2e9b5b1f 100644 --- a/mlreco/utils/unwrap.py +++ b/mlreco/utils/unwrap.py @@ -1,6 +1,6 @@ import numpy as np -import torch from dataclasses import dataclass +from copy import deepcopy from .globals import * from .volumes import VolumeBoundaries @@ -8,10 +8,9 @@ class Unwrapper: ''' - Break down the input and output dictionaries into individual events. - - Need to account for: multi-gpu, minibatching, multiple outputs, batches. + Tools to break down the input and output dictionaries into individual events. ''' + def __init__(self, num_gpus, batch_size, rules={}, boundaries=None, remove_batch_col=False): ''' Translate rule arrays and boundaries into instructions. @@ -33,8 +32,8 @@ def __init__(self, num_gpus, batch_size, rules={}, boundaries=None, remove_batch self.batch_size = batch_size self.remove_batch_col = remove_batch_col self.merger = VolumeBoundaries(boundaries) if boundaries else None + self.num_volumes = self.merger.num_volumes() if self.merger else 1 self.rules = self._parse_rules(rules) - self.masks, self.offsets = {}, {} def __call__(self, data_blob, result_blob): ''' @@ -45,23 +44,40 @@ def __call__(self, data_blob, result_blob): Parameters ---------- data_blob : dict - Dictionary of array of array of minibatch data [key][num_minibatch][num_device] + Dictionary of array of array of minibatch data [key][num_gpus][batch_size] result_blob : dict - Results dictionary, output of trainval.forward [key][num_minibatch*num_device] + Results dictionary, output of trainval.forward [key][num_gpus][batch_size] ''' self._build_batch_masks(data_blob, result_blob) data_unwrapped, result_unwrapped = {}, {} - for k, v in data_blob.items(): - data_unwrapped[k] = self._unwrap(k, v) - for k, v in result_blob.items(): - result_unwrapped[k] = self._unwrap(k, v) + for key, value in data_blob.items(): + data_unwrapped[key] = self._unwrap(key, value) + for key, value in result_blob.items(): + result_unwrapped[key] = self._unwrap(key, value) return data_unwrapped, result_unwrapped @dataclass class Rule: - method : str = None - ref_key : str = None + ''' + Simple dataclass which stores the relevant + rule attributes with human-readable names. + + Attributes + ---------- + method : str + Unwrapping scheme + ref_key : str + Key of the data product that supplies the batch mapping + done : bool + True if the unwrapping is done by the model internally + translate : tuple + List of column indices that correspond to coordinates to correct + ''' + method : str = None + ref_key : str = None + done : bool = False + translate : bool = False def _parse_rules(self, rules): ''' @@ -75,37 +91,43 @@ def _parse_rules(self, rules): output key of the reconstruction chain. If there is no rule associated with a key, the list is concatenated. ''' + valid_methods = [None, 'scalar', 'list', 'tensor', 'tensor_list', 'edge_tensor', 'index_tensor', 'index_list'] parsed_rules = {} for key, rule in rules.items(): parsed_rules[key] = self.Rule(*rule) if not parsed_rules[key].ref_key: parsed_rules[key].ref_key = key - assert parsed_rules[key].method in ['done', 'scalar', 'tensor', 'tensor_list', 'edge_tensor'] + assert parsed_rules[key].method in valid_methods return parsed_rules - def _build_batch_masks(self, data_blob, result_blob): ''' For all the returned data objects that require a batch mask: - build it and store it. + build it and store it. Also store the index offsets within that + batch, wherever necessary to unwrap. Parameters ---------- data_blob : dict - Dictionary of array of array of minibatch data [key][num_minibatch][num_device] + Dictionary of array of array of minibatch data [key][num_gpus][batch_size] result_blob : dict - Results dictionary, output of trainval.forward [key][num_minibatch*num_device] + Results dictionary, output of trainval.forward [key][num_gpus][batch_size] ''' self.masks, self.offsets = {}, {} for key, value in data_blob.items(): - if isinstance(value[0], np.ndarray): + # Inputs are all either tensors or lists, only build mask for tensors + if key in self.rules and self.rules[key].method == 'tensor': self.masks[key] = [self._batch_masks(value[g]) for g in range(self.num_gpus)] - if key not in self.rules: - self.rules[key] = self.Rule('tensor', key) + for key in result_blob.keys(): - if key in self.rules and self.rules[key].method in ['tensor', 'tensor_list']: + # Skip outputs with no rule + if key not in self.rules: + continue + + # For tensors and lists of tensors, build one mask per reference tensor + if not self.rules[key].done and self.rules[key].method in ['tensor', 'tensor_list']: ref_key = self.rules[key].ref_key assert ref_key in self.masks or ref_key in result_blob, 'Must provide the reference tensor to unwrap' assert self.rules[key].method == self.rules[ref_key].method, 'Reference must be of same type' @@ -114,37 +136,87 @@ def _build_batch_masks(self, data_blob, result_blob): self.masks[ref_key] = [self._batch_masks(result_blob[ref_key][g]) for g in range(self.num_gpus)] elif self.rules[key].method == 'tensor_list': self.masks[ref_key] = [[self._batch_masks(v) for v in result_blob[ref_key][g]] for g in range(self.num_gpus)] - elif key in self.rules and self.rules[key].method == 'edge_tensor': + + # For edge tensors, build one mask from each tensor (must figure out batch IDs of edges) + elif self.rules[key].method == 'edge_tensor': assert len(self.rules[key].ref_key) == 2, 'Must provide a reference to the edge_index and the node batch ids' for ref_key in self.rules[key].ref_key: assert ref_key in result_blob, 'Must provide reference tensor to unwrap' ref_edge, ref_node = self.rules[key].ref_key - if ref_edge not in self.masks: - edge_index, batch_ids = result_blob[ref_edge], result_blob[ref_node] + edge_index, batch_ids = result_blob[ref_edge], result_blob[ref_node] + if not self.rules[key].done and ref_edge not in self.masks: self.masks[ref_edge] = [self._batch_masks(batch_ids[g][edge_index[g][:,0]]) for g in range(self.num_gpus)] - self.offsets[ref_edge] = [np.cumsum([np.sum(batch_ids[g][:,BATCH_COL] == b-1) for b in range(self.batch_size)]) for g in range(self.num_gpus)] + if ref_node not in self.offsets: + self.offsets[ref_node] = [self._batch_offsets(batch_ids[g]) for g in range(self.num_gpus)] + + # For an index tensor, only need to record the batch offsets within the wrapped tensor + elif self.rules[key].method == 'index_tensor': + ref_key = self.rules[key].ref_key + assert ref_key in result_blob, 'Must provide reference tensor to unwrap' + if not self.rules[key].done and ref_key not in self.masks: + self.masks[ref_key] = [self._batch_masks(result_blob[ref_key][g]) for g in range(self.num_gpus)] + if ref_key not in self.offsets: + self.offsets[ref_key] = [self._batch_offsets(result_blob[ref_key][g]) for g in range(self.num_gpus)] + + # For lists of tensor indices, only need to record the offsets within the wrapped tensor + elif self.rules[key].method == 'index_list': + assert len(self.rules[key].ref_key) == 2, 'Must provide a reference to indexed tensor and the index batch ids' + for ref_key in self.rules[key].ref_key: + assert ref_key in result_blob, 'Must provide reference tensor to unwrap' + ref_tensor, ref_index = self.rules[key].ref_key + if not self.rules[key].done and ref_index not in self.masks: + self.masks[ref_index] = [self._batch_masks(result_blob[ref_index][g]) for g in range(self.num_gpus)] + if ref_tensor not in self.offsets: + self.offsets[ref_tensor] = [self._batch_offsets(result_blob[ref_tensor][g]) for g in range(self.num_gpus)] def _batch_masks(self, tensor): ''' - Makes a list of masks for each batch for a specific tensor. + Makes a list of masks for each batch entry, for a specific tensor. Parameters ---------- tensor : np.ndarray - Tensor with a batch ID column - ''' - # Identify how many volumes we are dealing with - num_volumes = self.merger.num_volumes() if self.merger else 1 + Tensor with a batch ID column + Returns + ------- + list + List of batch masks + ''' # Create batch masks - batch_masks = [] - for b in range(self.batch_size): - batch_mask = [] - for v in range(num_volumes): - batch_mask.extend(np.where(tensor[:, BATCH_COL] == b*num_volumes+v)[0]) - batch_masks.append(batch_mask) + masks = [] + for b in range(self.batch_size*self.num_volumes): + if len(tensor.shape) == 1: + masks.append(np.where(tensor == b)[0]) + else: + masks.append(np.where(tensor[:, BATCH_COL] == b)[0]) + + return masks + + def _batch_offsets(self, tensor): + ''' + Computes the index of the first element in a tensor + for each entry in the batch. - return batch_masks + Parameters + ---------- + tensor : np.ndarray + Tensor with a batch ID column + + Returns + ------- + np.ndarray + Array of batch offsets + ''' + # Compute batch offsets + offsets = np.zeros(self.batch_size*self.num_volumes, np.int64) + for b in range(1, self.batch_size*self.num_volumes): + if len(tensor.shape) == 1: + offsets[b] = offsets[b-1] + np.sum(tensor == b-1) + else: + offsets[b] = offsets[b-1] + np.sum(tensor[:, BATCH_COL] == b-1) + + return offsets def _unwrap(self, key, data): ''' @@ -157,16 +229,89 @@ def _unwrap(self, key, data): data : list Data product ''' - if key not in self.rules or self.rules[key].method in [None, 'done', 'scalar']: - return self._concatenate(data) + # Scalars and lists are trivial to unwrap + if key not in self.rules or self.rules[key].method in [None, 'scalar', 'list']: + unwrapped = self._concatenate(data) else: ref_key = self.rules[key].ref_key - if self.rules[key].method == 'tensor': - return [data[g][mask] for g in range(self.num_gpus) for mask in self.masks[ref_key][g]] - elif self.rules[key].method == 'tensor_list': - return [[d[self.masks[ref_key][g][i][b]] for i, d in enumerate(data[g])] for g in range(self.num_gpus) for b in range(self.batch_size)] - elif self.rules[key].method == 'edge_tensor': - return [data[g][mask]-(key==ref_key[0])*self.offsets[ref_key[0]][g][i] for g in range(self.num_gpus) for i, mask in enumerate(self.masks[ref_key[0]][g])] + unwrapped = [] + for g in range(self.num_gpus): + for b in range(self.batch_size): + # Tensor unwrapping + if self.rules[key].method == 'tensor': + tensors = [] + for v in range(self.num_volumes): + if not self.rules[key].done: + tensor = data[g][self.masks[ref_key][g][b*self.num_volumes+v]] + if v > 0 and self.rules[key].translate: + tensor[:, BATCH_COL] = b + tensor[:, COORD_COLS] = self.merger.translate(tensor[:,COORD_COLS], v) + tensors.append(tensor) + else: + tensors.append(data[g][b*self.num_volumes+v]) + unwrapped.append(np.concatenate(tensors)) + + # Tensor list unwrapping + elif self.rules[key].method == 'tensor_list': + tensors = [] + for i, d in enumerate(data[g]): + subtensors = [] + for v in range(self.num_volumes): + subtensor = d[self.masks[ref_key][g][i][b*self.num_volumes+v]] + if v > 0 and self.rules[key].translate: + subtensor[:, BATCH_COL] = b + subtensor[:, COORD_COLS] = self.merger.translate(subtensor[:,COORD_COLS], v) + subtensors.append(subtensor) + tensors.append(np.concatenate(subtensors)) + unwrapped.append(tensors) + + # Edge tensor unwrapping + elif self.rules[key].method == 'edge_tensor': + ref_edge, ref_node = ref_key + tensors = [] + for v in range(self.num_volumes): + if not self.rules[key].done: + tensor = data[g][self.masks[ref_edge][g][b*self.num_volumes+v]] + offset = (key == ref_edge) * self.offsets[ref_node][g][b*self.num_volumes] + else: + tensor = data[g][b*self.num_volumes+v] + offset = (key == ref_edge) *\ + (self.offsets[ref_node][g][b*self.num_volumes+v]-self.offsets[ref_node][g][b*self.num_volumes]) + tensors.append(tensor + offset) + unwrapped.append(np.concatenate(tensors)) + + # Index tensor unwrapping + elif self.rules[key].method == 'index_tensor': + tensors = [] + for v in range(self.num_volumes): + if not self.rules[key].done: + offset = self.offsets[ref_key][g][b*self.num_volumes] + tensors.append(data[self.masks[ref_key][g][b*self.num_volumes+v]] - offset) + else: + offset = self.offsets[ref_key][g][b*self.num_volumes+v]-self.offsets[ref_key][g][b*self.num_volumes] + tensors.append(data[g][b*self.num_volumes+v] + offset) + + unwrapped.append(np.concatenate(tensors)) + + # Index list unwrapping + elif self.rules[key].method == 'index_list': + ref_tensor, ref_index = ref_key + index_list = [] + for v in range(self.num_volumes): + if not self.rules[key].done: + offset = self.offsets[ref_tensor][g][b*self.num_volumes] + for i in self.masks[ref_index][g][b*self.num_volumes+v]: + index_list.append(data[g][i] - offset) + else: + offset = self.offsets[ref_tensor][g][b*self.num_volumes+v]-self.offsets[ref_tensor][g][b*self.num_volumes] + for index in data[g][b*self.num_volumes+v]: + index_list.append(index + offset) + + same_length = np.all([len(c) == len(index_list[0]) for c in index_list]) + index_list = np.array(index_list, dtype=object if not same_length else np.int64) + unwrapped.append(index_list) + + return unwrapped def _concatenate(self, data): ''' @@ -181,7 +326,7 @@ def _concatenate(self, data): ''' if isinstance(data[0], (int, float)): if len(data) == 1: - return [data[0] for i in range(self.batch_size)] + return [data[g] for g in range(self.num_gpus) for i in range(self.batch_size)] elif len(data) == self.batch_count: return data else: @@ -196,3 +341,33 @@ def _concatenate(self, data): return np.concatenate(data) else: raise TypeError('Unexpected data type', type(data[0])) + +def prefix_unwrapper_rules(rules, prefix): + ''' + Modifies the default rules of a module to account for + a prefix being added to its standard set of outputs + + Parameters + ---------- + rules : dict + Dictionary which contains a set of unwrapping rules for each + output key of the reconstruction chain. If there is no rule + associated with a key, the list is concatenated. + + Returns + ------- + dict + Dictionary of rules containing the appropriate names + ''' + prules = {} + for key, value in rules.items(): + pkey = f'{prefix}_{key}' + prules[pkey] = deepcopy(rules[key]) + if len(value) > 1: + if isinstance(value[1], str): + prules[pkey][1] = f'{prefix}_{value[1]}' + else: + for i in range(len(value[1])): + prules[pkey][1][i] = f'{prefix}_{value[1][i]}' + + return prules From 3576020b67bef1f1774ef473b969e5e061463c5a Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Thu, 16 Mar 2023 17:53:32 -0700 Subject: [PATCH 047/180] Bug fix in batch column in the unwrapper --- mlreco/utils/unwrap.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/mlreco/utils/unwrap.py b/mlreco/utils/unwrap.py index 2e9b5b1f..f64babb7 100644 --- a/mlreco/utils/unwrap.py +++ b/mlreco/utils/unwrap.py @@ -243,9 +243,10 @@ def _unwrap(self, key, data): for v in range(self.num_volumes): if not self.rules[key].done: tensor = data[g][self.masks[ref_key][g][b*self.num_volumes+v]] - if v > 0 and self.rules[key].translate: + if self.rules[key].translate: tensor[:, BATCH_COL] = b - tensor[:, COORD_COLS] = self.merger.translate(tensor[:,COORD_COLS], v) + if v > 0: + tensor[:, COORD_COLS] = self.merger.translate(tensor[:,COORD_COLS], v) tensors.append(tensor) else: tensors.append(data[g][b*self.num_volumes+v]) @@ -258,9 +259,10 @@ def _unwrap(self, key, data): subtensors = [] for v in range(self.num_volumes): subtensor = d[self.masks[ref_key][g][i][b*self.num_volumes+v]] - if v > 0 and self.rules[key].translate: + if self.rules[key].translate: subtensor[:, BATCH_COL] = b - subtensor[:, COORD_COLS] = self.merger.translate(subtensor[:,COORD_COLS], v) + if v > 0: + subtensor[:, COORD_COLS] = self.merger.translate(subtensor[:,COORD_COLS], v) subtensors.append(subtensor) tensors.append(np.concatenate(subtensors)) unwrapped.append(tensors) From 9a622d228bd6afbf9d4800e46b0b874ca31812c0 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Thu, 16 Mar 2023 23:23:32 -0700 Subject: [PATCH 048/180] Batch column swapped out with volume ID in unwrapper --- mlreco/utils/unwrap.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/mlreco/utils/unwrap.py b/mlreco/utils/unwrap.py index f64babb7..90e3d523 100644 --- a/mlreco/utils/unwrap.py +++ b/mlreco/utils/unwrap.py @@ -243,8 +243,11 @@ def _unwrap(self, key, data): for v in range(self.num_volumes): if not self.rules[key].done: tensor = data[g][self.masks[ref_key][g][b*self.num_volumes+v]] + if len(tensor.shape) == 2: + tensor[:, BATCH_COL] = v + else: + tensor[:] = v if self.rules[key].translate: - tensor[:, BATCH_COL] = b if v > 0: tensor[:, COORD_COLS] = self.merger.translate(tensor[:,COORD_COLS], v) tensors.append(tensor) @@ -259,8 +262,11 @@ def _unwrap(self, key, data): subtensors = [] for v in range(self.num_volumes): subtensor = d[self.masks[ref_key][g][i][b*self.num_volumes+v]] + if len(subtensor.shape) == 2: + subtensor[:, BATCH_COL] = v + else: + subtensor[:] = v if self.rules[key].translate: - subtensor[:, BATCH_COL] = b if v > 0: subtensor[:, COORD_COLS] = self.merger.translate(subtensor[:,COORD_COLS], v) subtensors.append(subtensor) From d54f4cf475dcf0a2ed226bb87986332f2debe91f Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Thu, 16 Mar 2023 23:50:25 -0700 Subject: [PATCH 049/180] Bug fix --- mlreco/utils/unwrap.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/mlreco/utils/unwrap.py b/mlreco/utils/unwrap.py index 90e3d523..57b5fb08 100644 --- a/mlreco/utils/unwrap.py +++ b/mlreco/utils/unwrap.py @@ -243,10 +243,11 @@ def _unwrap(self, key, data): for v in range(self.num_volumes): if not self.rules[key].done: tensor = data[g][self.masks[ref_key][g][b*self.num_volumes+v]] - if len(tensor.shape) == 2: - tensor[:, BATCH_COL] = v - else: - tensor[:] = v + if key == ref_key: + if len(tensor.shape) == 2: + tensor[:, BATCH_COL] = v + else: + tensor[:] = v if self.rules[key].translate: if v > 0: tensor[:, COORD_COLS] = self.merger.translate(tensor[:,COORD_COLS], v) @@ -262,10 +263,11 @@ def _unwrap(self, key, data): subtensors = [] for v in range(self.num_volumes): subtensor = d[self.masks[ref_key][g][i][b*self.num_volumes+v]] - if len(subtensor.shape) == 2: - subtensor[:, BATCH_COL] = v - else: - subtensor[:] = v + if key == ref_key: + if len(subtensor.shape) == 2: + subtensor[:, BATCH_COL] = v + else: + subtensor[:] = v if self.rules[key].translate: if v > 0: subtensor[:, COORD_COLS] = self.merger.translate(subtensor[:,COORD_COLS], v) From 406d7a516216cca85626d144091596fb4999189e Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Mon, 20 Mar 2023 23:14:44 -0700 Subject: [PATCH 050/180] Minor fixes --- mlreco/utils/cluster/graph_batch.py | 60 +---------------------------- mlreco/utils/globals.py | 16 +++++++- mlreco/utils/gnn/cluster.py | 2 +- 3 files changed, 16 insertions(+), 62 deletions(-) diff --git a/mlreco/utils/cluster/graph_batch.py b/mlreco/utils/cluster/graph_batch.py index 2f7391f4..bc4b9457 100644 --- a/mlreco/utils/cluster/graph_batch.py +++ b/mlreco/utils/cluster/graph_batch.py @@ -147,64 +147,6 @@ def from_data_list(cls, data_list, follow_batch=[], exclude_keys=[]): return batch.contiguous() - def get_example_old(self, idx: int) -> Data: - r"""Reconstructs the :class:`torch_geometric.data.Data` object at index - :obj:`idx` from the batch object. - The batch object must have been created via :meth:`from_data_list` in - order to be able to reconstruct the initial objects.""" - - if self.__slices__ is None: - raise RuntimeError( - ('Cannot reconstruct data list from batch because the batch ' - 'object was not created using `Batch.from_data_list()`.')) - - data = self.__data_class__() - - for key in self.__slices__.keys(): - item = self[key] - if self.__cat_dims__[key] is None: - # The item was concatenated along a new batch dimension, - # so just index in that dimension: - item = item[idx] - else: - # Narrow the item based on the values in `__slices__`. - if isinstance(item, Tensor): - dim = self.__cat_dims__[key] - start = self.__slices__[key][idx] - end = self.__slices__[key][idx + 1] - item = item.narrow(dim, start, end - start) - elif isinstance(item, SparseTensor): - for j, dim in enumerate(self.__cat_dims__[key]): - start = self.__slices__[key][idx][j].item() - end = self.__slices__[key][idx + 1][j].item() - item = item.narrow(dim, start, end - start) - else: - start = self.__slices__[key][idx] - end = self.__slices__[key][idx + 1] - item = item[start:end] - item = item[0] if len(item) == 1 else item - - # Decrease its value by `cumsum` value: - cum = self.__cumsum__[key][idx] - if isinstance(item, Tensor): - if not isinstance(cum, int) or cum != 0: - item = item - cum - elif isinstance(item, SparseTensor): - value = item.storage.value() - if value is not None and value.dtype != torch.bool: - if not isinstance(cum, int) or cum != 0: - value = value - cum - item = item.set_value(value, layout='coo') - elif isinstance(item, (int, float)): - item = item - cum - - data[key] = item - - if self.__num_nodes_list__[idx] is not None: - data.num_nodes = self.__num_nodes_list__[idx] - - return data - def get_example(self, idx: int) -> Data: r"""Reconstructs the :class:`torch_geometric.data.Data` object at index :obj:`idx` from the batch object. @@ -213,7 +155,7 @@ def get_example(self, idx: int) -> Data: if isinstance(self.x, torch.Tensor): x_mask = torch.nonzero(self.batch == idx).flatten() - x_offset = x_mask[0] + x_offset = x_mask[0] if len(x_mask) else 0 e_mask = torch.nonzero(self.batch[self.edge_index[0]] == idx).flatten() else: x_mask = np.where(self.batch == idx)[0] diff --git a/mlreco/utils/globals.py b/mlreco/utils/globals.py index 7bb726fa..38420d71 100644 --- a/mlreco/utils/globals.py +++ b/mlreco/utils/globals.py @@ -1,5 +1,17 @@ -# Column which specifies the batch ID in a tensor +# Column which specifies the batch ID in a sparse tensor BATCH_COL = 0 -# Columns which specify the voxel coordinates in a tensor +# Columns which specify the voxel coordinates in a sparse tensor COORD_COLS = (1,2,3) + +# Colum which specifies the value of a voxel in a sparse tensor +VALUE_COL = 4 + +# Colum which specifies the cluster ID of a voxel in a sparse tensor +CLUST_COL = 5 + +# Colum which specifies the cluster group ID of a voxel in a sparse tensor +GROUP_COL = 6 + +# Colum which specifies the shape ID of a voxel in a sparse tensor +SHAPE_COL = -1 diff --git a/mlreco/utils/gnn/cluster.py b/mlreco/utils/gnn/cluster.py index ec6a60dc..2627dc73 100644 --- a/mlreco/utils/gnn/cluster.py +++ b/mlreco/utils/gnn/cluster.py @@ -673,7 +673,7 @@ def principal_axis(voxels:nb.float64[:,:]) -> nb.float64[:]: return v[:,2] -@nb.njit +@nb.njit(cache=True) def cluster_dedx(voxels: nb.float64[:,:], values: nb.float64[:], start: nb.float64[:], From 222d52a965c3d0f2ca27f057a251445dc1f6a207 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Tue, 21 Mar 2023 19:46:26 -0700 Subject: [PATCH 051/180] Analysis tools namespace fix --- analysis/algorithms/vertex.py | 3 +- analysis/classes/evaluator.py | 555 ++++++++++++++++------------------ analysis/classes/predictor.py | 464 ++++++++++++---------------- 3 files changed, 457 insertions(+), 565 deletions(-) diff --git a/analysis/algorithms/vertex.py b/analysis/algorithms/vertex.py index a6873f5f..bcf4bd48 100644 --- a/analysis/algorithms/vertex.py +++ b/analysis/algorithms/vertex.py @@ -175,8 +175,7 @@ def compute_vertex_candidates(particles, # 3. Select POCA of all primary tracks and showers pseudovtx = compute_vertex_matrix_inversion(valid_particles, dim=3, - use_primaries=True, - weight=True) + use_primaries=True) # if not (pseudovtx < 0).all(): # candidates.append(pseudovtx) diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index c5cef9be..e88e5e59 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -258,96 +258,88 @@ def get_true_fragments(self, entry, verbose=False, volume=None) -> List[TruthPar ''' Get list of instances for given batch id. ''' - self._check_volume(volume) - entries = self._get_entries(entry, volume) + fragments = [] - out_fragments_list = [] - for entry in entries: - volume = entry % self._num_volumes - - # Both are "adapted" labels - labels = self.data_blob['cluster_label'][entry] - segment_label = self.data_blob['segment_label'][entry][:, -1] - rescaled_input_charge = self.result['input_rescaled'][entry][:, 4] - - fragment_ids = set(list(np.unique(labels[:, 5]).astype(int))) - fragments = [] - - for fid in fragment_ids: - mask = labels[:, 5] == fid - - semantic_type, counts = np.unique(labels[:, -1][mask], return_counts=True) - if semantic_type.shape[0] > 1: - if verbose: - print("Semantic Type of Fragment {} is not "\ - "unique: {}, {}".format(fid, - str(semantic_type), - str(counts))) - perm = counts.argmax() - semantic_type = semantic_type[perm] - else: - semantic_type = semantic_type[0] - - points = labels[mask][:, 1:4] - size = points.shape[0] - depositions = rescaled_input_charge[mask] - depositions_MeV = labels[mask][:, 4] - voxel_indices = np.where(mask)[0] - - group_id, counts = np.unique(labels[:, 6][mask].astype(int), return_counts=True) - if group_id.shape[0] > 1: - if verbose: - print("Group ID of Fragment {} is not "\ - "unique: {}, {}".format(fid, - str(group_id), - str(counts))) - perm = counts.argmax() - group_id = group_id[perm] - else: - group_id = group_id[0] - - interaction_id, counts = np.unique(labels[:, 7][mask].astype(int), return_counts=True) - if interaction_id.shape[0] > 1: - if verbose: - print("Interaction ID of Fragment {} is not "\ - "unique: {}, {}".format(fid, - str(interaction_id), - str(counts))) - perm = counts.argmax() - interaction_id = interaction_id[perm] - else: - interaction_id = interaction_id[0] - - - is_primary, counts = np.unique(labels[:, -2][mask].astype(bool), return_counts=True) - if is_primary.shape[0] > 1: - if verbose: - print("Primary label of Fragment {} is not "\ - "unique: {}, {}".format(fid, - str(is_primary), - str(counts))) - perm = counts.argmax() - is_primary = is_primary[perm] - else: - is_primary = is_primary[0] + # Both are "adapted" labels + labels = self.data_blob['cluster_label'][entry] + segment_label = self.data_blob['segment_label'][entry][:, -1] + rescaled_input_charge = self.result['input_rescaled'][entry][:, 4] - part = TruthParticleFragment(self._translate(points, volume), - fid, semantic_type, - interaction_id=interaction_id, - group_id=group_id, - image_id=entry, - voxel_indices=voxel_indices, - depositions=depositions, - depositions_MeV=depositions_MeV, - is_primary=is_primary, - alias='Fragment', - volume=volume) + fragment_ids = set(list(np.unique(labels[:, 5]).astype(int))) - fragments.append(part) - out_fragments_list.extend(fragments) + for fid in fragment_ids: + mask = labels[:, 5] == fid - return out_fragments_list + semantic_type, counts = np.unique(labels[:, -1][mask], return_counts=True) + if semantic_type.shape[0] > 1: + if verbose: + print("Semantic Type of Fragment {} is not "\ + "unique: {}, {}".format(fid, + str(semantic_type), + str(counts))) + perm = counts.argmax() + semantic_type = semantic_type[perm] + else: + semantic_type = semantic_type[0] + + points = labels[mask][:, 1:4] + size = points.shape[0] + depositions = rescaled_input_charge[mask] + depositions_MeV = labels[mask][:, 4] + voxel_indices = np.where(mask)[0] + + group_id, counts = np.unique(labels[:, 6][mask].astype(int), return_counts=True) + if group_id.shape[0] > 1: + if verbose: + print("Group ID of Fragment {} is not "\ + "unique: {}, {}".format(fid, + str(group_id), + str(counts))) + perm = counts.argmax() + group_id = group_id[perm] + else: + group_id = group_id[0] + + interaction_id, counts = np.unique(labels[:, 7][mask].astype(int), return_counts=True) + if interaction_id.shape[0] > 1: + if verbose: + print("Interaction ID of Fragment {} is not "\ + "unique: {}, {}".format(fid, + str(interaction_id), + str(counts))) + perm = counts.argmax() + interaction_id = interaction_id[perm] + else: + interaction_id = interaction_id[0] + + + is_primary, counts = np.unique(labels[:, -2][mask].astype(bool), return_counts=True) + if is_primary.shape[0] > 1: + if verbose: + print("Primary label of Fragment {} is not "\ + "unique: {}, {}".format(fid, + str(is_primary), + str(counts))) + perm = counts.argmax() + is_primary = is_primary[perm] + else: + is_primary = is_primary[0] + + part = TruthParticleFragment(self._translate(points, volume), + fid, semantic_type, + interaction_id=interaction_id, + group_id=group_id, + image_id=entry, + voxel_indices=voxel_indices, + depositions=depositions, + depositions_MeV=depositions_MeV, + is_primary=is_primary, + alias='Fragment') + + fragments.append(part) + + return fragments def get_true_particles(self, entry, only_primaries=True, @@ -369,108 +361,101 @@ def get_true_particles(self, entry, only_primaries=True, id number p: true momentum vector ''' - self._check_volume(volume) + out_particles_list = [] - entries = self._get_entries(entry, volume) + labels = self.data_blob['cluster_label'][entry] + if self.deghosting: + labels_noghost = self.data_blob['cluster_label_true_nonghost'][entry] + segment_label = self.data_blob['segment_label'][entry][:, -1] + particle_ids = set(list(np.unique(labels[:, 6]).astype(int))) + rescaled_input_charge = self.result['input_rescaled'][entry][:, 4] - out_particles_list = [] - global_entry = entry - for entry in entries: - volume = entry % self._num_volumes + particles = [] + exclude_ids = set([]) - labels = self.data_blob['cluster_label'][entry] + for idx, p in enumerate(self.data_blob['particles_asis'][entry]): + pid = int(p.id()) + pdg = TYPE_LABELS.get(p.pdg_code(), -1) + is_primary = p.group_id() == p.parent_id() if self.deghosting: - labels_noghost = self.data_blob['cluster_label_true_nonghost'][entry] - segment_label = self.data_blob['segment_label'][entry][:, -1] - particle_ids = set(list(np.unique(labels[:, 6]).astype(int))) - rescaled_input_charge = self.result['input_rescaled'][entry][:, 4] - - particles = [] - exclude_ids = set([]) - - for idx, p in enumerate(self.data_blob['particles_asis'][global_entry]): - pid = int(p.id()) - pdg = TYPE_LABELS.get(p.pdg_code(), -1) - is_primary = p.group_id() == p.parent_id() - if self.deghosting: - mask_noghost = labels_noghost[:, 6].astype(int) == pid - if np.count_nonzero(mask_noghost) <= 0: - continue - # 1. Check if current pid is one of the existing group ids - if pid not in particle_ids: - particle = handle_empty_true_particles(labels_noghost, mask_noghost, p, entry, - self._num_volumes, verbose=verbose) - particles.append(particle) - continue + mask_noghost = labels_noghost[:, 6].astype(int) == pid + if np.count_nonzero(mask_noghost) <= 0: + continue + # 1. Check if current pid is one of the existing group ids + if pid not in particle_ids: + particle = handle_empty_true_particles(labels_noghost, mask_noghost, p, entry, + self._num_volumes, verbose=verbose) + particles.append(particle) + continue - # 1. Process voxels - mask = labels[:, 6].astype(int) == pid - # If particle is Michel electron, we have the option to - # only consider the primary ionization. - # Semantic labels only label the primary ionization as Michel. - # Cluster labels will have the entire Michel together. - if self.michel_primary_ionization_only and 2 in labels[mask][:, -1].astype(int): - mask = mask & (labels[:, -1].astype(int) == 2) - if self.deghosting: - mask_noghost = mask_noghost & (labels_noghost[:, -1].astype(int) == 2) - - coords = self.data_blob['input_data'][entry][mask][:, 1:4] - voxel_indices = np.where(mask)[0] - fragments = np.unique(labels[mask][:, 5].astype(int)) - depositions_MeV = labels[mask][:, 4] - depositions = rescaled_input_charge[mask] # Will be in ADC - coords_noghost, depositions_noghost = None, None + # 1. Process voxels + mask = labels[:, 6].astype(int) == pid + # If particle is Michel electron, we have the option to + # only consider the primary ionization. + # Semantic labels only label the primary ionization as Michel. + # Cluster labels will have the entire Michel together. + if self.michel_primary_ionization_only and 2 in labels[mask][:, -1].astype(int): + mask = mask & (labels[:, -1].astype(int) == 2) if self.deghosting: - coords_noghost = labels_noghost[mask_noghost][:, 1:4] - depositions_noghost = labels_noghost[mask_noghost][:, 4].squeeze() + mask_noghost = mask_noghost & (labels_noghost[:, -1].astype(int) == 2) + + coords = self.data_blob['input_data'][entry][mask][:, 1:4] + voxel_indices = np.where(mask)[0] + fragments = np.unique(labels[mask][:, 5].astype(int)) + depositions_MeV = labels[mask][:, 4] + depositions = rescaled_input_charge[mask] # Will be in ADC + coords_noghost, depositions_noghost = None, None + if self.deghosting: + coords_noghost = labels_noghost[mask_noghost][:, 1:4] + depositions_noghost = labels_noghost[mask_noghost][:, 4].squeeze() - # 2. Process particle-level labels - if p.pdg_code() not in TYPE_LABELS: - # print("PID {} not in TYPE LABELS".format(pid)) + # 2. Process particle-level labels + if p.pdg_code() not in TYPE_LABELS: + # print("PID {} not in TYPE LABELS".format(pid)) + continue + # For deghosting inputs, perform voxel cut with true nonghost coords. + if self.deghosting: + exclude_ids = self._apply_true_voxel_cut(entry) + if pid in exclude_ids: + # Skip this particle if its below the voxel minimum requirement + # print("PID {} was excluded from the list of particles due"\ + # " to true nonghost voxel cut. Exclude IDS = {}".format( + # p.id(), str(exclude_ids) + # )) continue - # For deghosting inputs, perform voxel cut with true nonghost coords. - if self.deghosting: - exclude_ids = self._apply_true_voxel_cut(global_entry) - if pid in exclude_ids: - # Skip this particle if its below the voxel minimum requirement - # print("PID {} was excluded from the list of particles due"\ - # " to true nonghost voxel cut. Exclude IDS = {}".format( - # p.id(), str(exclude_ids) - # )) - continue - - semantic_type, interaction_id, nu_id = get_true_particle_labels(labels, mask, pid=pid, verbose=verbose) - - particle = TruthParticle(self._translate(coords, volume), - pid, - semantic_type, interaction_id, pdg, entry, - particle_asis=p, - depositions=depositions, - is_primary=is_primary, - coords_noghost=coords_noghost, - depositions_noghost=depositions_noghost, - depositions_MeV=depositions_MeV, - volume=entry % self._num_volumes) - - particle.p = np.array([p.px(), p.py(), p.pz()]) - particle.fragments = fragments - particle.particle_asis = p - particle.nu_id = nu_id - particle.voxel_indices = voxel_indices - - particle.startpoint = np.array([p.first_step().x(), - p.first_step().y(), - p.first_step().z()]) - - if semantic_type == 1: - particle.endpoint = np.array([p.last_step().x(), - p.last_step().y(), - p.last_step().z()]) - - if particle.voxel_indices.shape[0] >= self.min_particle_voxel_count: - particles.append(particle) - - out_particles_list.extend(particles) + + semantic_type, interaction_id, nu_id = get_true_particle_labels(labels, mask, pid=pid, verbose=verbose) + + particle = TruthParticle(self._translate(coords, volume), + pid, + semantic_type, interaction_id, pdg, entry, + particle_asis=p, + depositions=depositions, + is_primary=is_primary, + coords_noghost=coords_noghost, + depositions_noghost=depositions_noghost, + depositions_MeV=depositions_MeV, + volume=entry % self._num_volumes) + + particle.p = np.array([p.px(), p.py(), p.pz()]) + particle.fragments = fragments + particle.particle_asis = p + particle.nu_id = nu_id + particle.voxel_indices = voxel_indices + + particle.startpoint = np.array([p.first_step().x(), + p.first_step().y(), + p.first_step().z()]) + + if semantic_type == 1: + particle.endpoint = np.array([p.last_step().x(), + p.last_step().y(), + p.last_step().z()]) + + if particle.voxel_indices.shape[0] >= self.min_particle_voxel_count: + particles.append(particle) + + out_particles_list.extend(particles) if only_primaries: out_particles_list = [p for p in out_particles_list if p.is_primary] @@ -482,26 +467,21 @@ def get_true_interactions(self, entry, drop_nonprimary_particles=True, min_particle_voxel_count=-1, volume=None, compute_vertex=True) -> List[Interaction]: - self._check_volume(volume) if min_particle_voxel_count < 0: min_particle_voxel_count = self.min_particle_voxel_count - entries = self._get_entries(entry, volume) - out_interactions_list = [] - for e in entries: - volume = e % self._num_volumes if self.vb is not None else volume - true_particles = self.get_true_particles(entry, only_primaries=drop_nonprimary_particles, volume=volume) - out = group_particles_to_interactions_fn(true_particles, - get_nu_id=True, mode='truth') - if compute_vertex: - vertices = self.get_true_vertices(entry, volume=volume) - for ia in out: - if compute_vertex and ia.id in vertices: - ia.vertex = vertices[ia.id] - ia.volume = volume - out_interactions_list.extend(out) - - return out_interactions_list + out = [] + true_particles = self.get_true_particles(entry, only_primaries=drop_nonprimary_particles, volume=volume) + out = group_particles_to_interactions_fn(true_particles, + get_nu_id=True, mode='truth') + if compute_vertex: + vertices = self.get_true_vertices(entry, volume=volume) + for ia in out: + if compute_vertex and ia.id in vertices: + ia.vertex = vertices[ia.id] + ia.volume = volume + + return out def get_true_vertices(self, entry, volume=None): @@ -517,22 +497,17 @@ def get_true_vertices(self, entry, volume=None): Keys are true interactions ids, values are np.array of shape (N, 3) with true vertices coordinates. """ - self._check_volume(volume) - - entries = self._get_entries(entry, volume) out = {} - for entry in entries: - volume = entry % self._num_volumes if self.vb is not None else volume - inter_idxs = np.unique( - self.data_blob['cluster_label'][entry][:, 7].astype(int)) - for inter_idx in inter_idxs: - if inter_idx < 0: - continue - vtx = get_vertex(self.data_blob['kinematics_label'], - self.data_blob['cluster_label'], - data_idx=entry, - inter_idx=inter_idx) - out[inter_idx] = self._translate(vtx, volume) + inter_idxs = np.unique( + self.data_blob['cluster_label'][entry][:, 7].astype(int)) + for inter_idx in inter_idxs: + if inter_idx < 0: + continue + vtx = get_vertex(self.data_blob['kinematics_label'], + self.data_blob['cluster_label'], + data_idx=entry, + inter_idx=inter_idx) + out[inter_idx] = self._translate(vtx, volume) return out @@ -557,38 +532,34 @@ def match_particles(self, entry, ''' self._check_volume(volume) - entries = self._get_entries(entry, volume) all_matches = [] all_counts = [] - for e in entries: - volume = e % self._num_volumes if self.vb is not None else volume - # print('matching', entries, volume) - if mode == 'pred_to_true': - # Match each pred to one in true - particles_from = self.get_particles(entry, only_primaries=only_primaries, volume=volume) - particles_to = self.get_true_particles(entry, only_primaries=only_primaries, volume=volume) - elif mode == 'true_to_pred': - # Match each true to one in pred - particles_to = self.get_particles(entry, only_primaries=only_primaries, volume=volume) - particles_from = self.get_true_particles(entry, only_primaries=only_primaries, volume=volume) - else: - raise ValueError("Mode {} is not valid. For matching each"\ - " prediction to truth, use 'pred_to_true' (and vice versa).".format(mode)) - all_kwargs = {"min_overlap": self.min_overlap_count, "overlap_mode": self.overlap_mode, **kwargs} - if matching_mode == 'one_way': - matched_pairs, counts = match_particles_fn(particles_from, particles_to, + volume = e % self._num_volumes if self.vb is not None else volume + # print('matching', entries, volume) + if mode == 'pred_to_true': + # Match each pred to one in true + particles_from = self.get_particles(entry, only_primaries=only_primaries, volume=volume) + particles_to = self.get_true_particles(entry, only_primaries=only_primaries, volume=volume) + elif mode == 'true_to_pred': + # Match each true to one in pred + particles_to = self.get_particles(entry, only_primaries=only_primaries, volume=volume) + particles_from = self.get_true_particles(entry, only_primaries=only_primaries, volume=volume) + else: + raise ValueError("Mode {} is not valid. For matching each"\ + " prediction to truth, use 'pred_to_true' (and vice versa).".format(mode)) + all_kwargs = {"min_overlap": self.min_overlap_count, "overlap_mode": self.overlap_mode, **kwargs} + if matching_mode == 'one_way': + matched_pairs, counts = match_particles_fn(particles_from, particles_to, + **all_kwargs) + elif matching_mode == 'optimal': + matched_pairs, counts = match_particles_optimal(particles_from, particles_to, **all_kwargs) - elif matching_mode == 'optimal': - matched_pairs, counts = match_particles_optimal(particles_from, particles_to, - **all_kwargs) - else: - raise ValueError - all_matches.extend(matched_pairs) - all_counts.extend(list(counts)) + else: + raise ValueError if return_counts: - return all_matches, all_counts + return matched_pairs, counts else: - return all_matches + return matched_pairs def match_interactions(self, entry, mode='pred_to_true', @@ -618,70 +589,66 @@ def match_interactions(self, entry, mode='pred_to_true', """ self._check_volume(volume) - entries = self._get_entries(entry, volume) all_matches, all_counts = [], [] - for e in entries: - volume = e % self._num_volumes if self.vb is not None else volume - if mode == 'pred_to_true': - ints_from = self.get_interactions(entry, - drop_nonprimary_particles=drop_nonprimary_particles, - volume=volume, - compute_vertex=compute_vertex, - vertex_mode=vertex_mode) - ints_to = self.get_true_interactions(entry, - drop_nonprimary_particles=drop_nonprimary_particles, - volume=volume, - compute_vertex=compute_vertex) - elif mode == 'true_to_pred': - ints_to = self.get_interactions(entry, + if mode == 'pred_to_true': + ints_from = self.get_interactions(entry, drop_nonprimary_particles=drop_nonprimary_particles, volume=volume, compute_vertex=compute_vertex, vertex_mode=vertex_mode) - ints_from = self.get_true_interactions(entry, - drop_nonprimary_particles=drop_nonprimary_particles, - volume=volume, - compute_vertex=compute_vertex) - else: - raise ValueError("Mode {} is not valid. For matching each"\ - " prediction to truth, use 'pred_to_true' (and vice versa).".format(mode)) - - all_kwargs = {"min_overlap": self.min_overlap_count, "overlap_mode": self.overlap_mode, **kwargs} - if matching_mode == 'one_way': - matched_interactions, counts = match_interactions_fn(ints_from, ints_to, + ints_to = self.get_true_interactions(entry, + drop_nonprimary_particles=drop_nonprimary_particles, + volume=volume, + compute_vertex=compute_vertex) + elif mode == 'true_to_pred': + ints_to = self.get_interactions(entry, + drop_nonprimary_particles=drop_nonprimary_particles, + volume=volume, + compute_vertex=compute_vertex, + vertex_mode=vertex_mode) + ints_from = self.get_true_interactions(entry, + drop_nonprimary_particles=drop_nonprimary_particles, + volume=volume, + compute_vertex=compute_vertex) + else: + raise ValueError("Mode {} is not valid. For matching each"\ + " prediction to truth, use 'pred_to_true' (and vice versa).".format(mode)) + + all_kwargs = {"min_overlap": self.min_overlap_count, "overlap_mode": self.overlap_mode, **kwargs} + + if matching_mode == 'one_way': + matched_interactions, counts = match_interactions_fn(ints_from, ints_to, + **all_kwargs) + elif matching_mode == 'optimal': + matched_interactions, counts = match_interactions_optimal(ints_from, ints_to, **all_kwargs) - elif matching_mode == 'optimal': - matched_interactions, counts = match_interactions_optimal(ints_from, ints_to, - **all_kwargs) - else: - raise ValueError - if len(matched_interactions) == 0: - continue - if match_particles: - for interactions in matched_interactions: - domain, codomain = interactions - domain_particles, codomain_particles = [], [] - if domain is not None: - domain_particles = domain.particles - if codomain is not None: - codomain_particles = codomain.particles - # continue - domain_particles = [p for p in domain_particles if p.points.shape[0] > 0] - codomain_particles = [p for p in codomain_particles if p.points.shape[0] > 0] - if matching_mode == 'one_way': - matched_particles, _ = match_particles_fn(domain_particles, codomain_particles, + else: + raise ValueError + if len(matched_interactions) == 0: + return [], [] + if match_particles: + for interactions in matched_interactions: + domain, codomain = interactions + domain_particles, codomain_particles = [], [] + if domain is not None: + domain_particles = domain.particles + if codomain is not None: + codomain_particles = codomain.particles + # continue + domain_particles = [p for p in domain_particles if p.points.shape[0] > 0] + codomain_particles = [p for p in codomain_particles if p.points.shape[0] > 0] + if matching_mode == 'one_way': + matched_particles, _ = match_particles_fn(domain_particles, codomain_particles, + min_overlap=self.min_overlap_count, + overlap_mode=self.overlap_mode) + elif matching_mode == 'optimal': + matched_particles, _ = match_particles_optimal(domain_particles, codomain_particles, min_overlap=self.min_overlap_count, overlap_mode=self.overlap_mode) - elif matching_mode == 'optimal': - matched_particles, _ = match_particles_optimal(domain_particles, codomain_particles, - min_overlap=self.min_overlap_count, - overlap_mode=self.overlap_mode) - else: - raise ValueError - all_matches.extend(matched_interactions) - all_counts.extend(counts) + else: + raise ValueError if return_counts: - return all_matches, all_counts + return matched_interactions, counts else: - return all_matches \ No newline at end of file + return matched_interactions \ No newline at end of file diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index 5eb7efc6..b910ed9c 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -448,7 +448,7 @@ def _fit_predict_fragments(self, entry): Returns: - new_labels: 1D numpy integer array of predicted fragment labels. ''' - fragments = self.result['fragments'][entry] + fragments = self.result['fragment_clusts'][entry] num_voxels = self.data_blob['input_data'][entry].shape[0] pred_frag_labels = -np.ones(num_voxels).astype(int) @@ -501,8 +501,8 @@ def _fit_predict_interaction_labels(self, entry): Returns: - new_labels: 1D numpy integer array of predicted interaction labels. ''' - inter_group_pred = self.result['inter_group_pred'][entry] - particles = self.result['particles'][entry] + inter_group_pred = self.result['particle_group_pred'][entry] + particles = self.result['particle_clusts'][entry] num_voxels = self.data_blob['input_data'][entry].shape[0] pred_inter_labels = -np.ones(num_voxels).astype(int) @@ -529,8 +529,8 @@ def _fit_predict_pids(self, entry): Returns: - labels: 1D numpy integer array of predicted particle type labels. ''' - particles = self.result['particles'][entry] - type_logits = self.result['node_pred_type'][entry] + particles = self.result['particle_clusts'][entry] + type_logits = self.result['particle_node_pred_type'][entry] pids = np.argmax(type_logits, axis=1) num_voxels = self.data_blob['input_data'][entry].shape[0] @@ -541,61 +541,6 @@ def _fit_predict_pids(self, entry): return pred_pids - - # def _fit_predict_vertex_info(self, entry, inter_idx): - # ''' - # Method for obtaining interaction vertex information given - # entry number and interaction ID number. - - # Inputs: - # - entry: Batch number to retrieve example. - - # - inter_idx: Interaction ID number. - - # If the interaction specified by does not exist - # in the sample numbered by , function will raise a - # ValueError. - - # Returns: - # - vertex_info: (x,y,z) coordinate of predicted vertex - # ''' - # # Currently deprecated due to speed issues. - # # vertex_info = predict_vertex(inter_idx, entry, - # # self.data_blob['input_data'], - # # self.result, - # # attaching_threshold=self.attaching_threshold, - # # inter_threshold=self.inter_threshold, - # # apply_deghosting=False) - # vertex_info = compute_vertex_matrix_inversion() - - # return vertex_info - - - def _get_entries(self, entry, volume): - """ - Make a list of actual entries in the batch ids. This accounts for potential - virtual batch ids in case we used volume boundaries to process several volumes - separately. - - Parameters - ========== - entry: int - Which entry of the original dataset you want to access. - volume: int or None - Which volume you want to access. None means all of them. - - Returns - ======= - list - List of integers = actual batch ids in the tensors (potentially virtual batch ids). - """ - entries = [entry] # default behavior - if self.vb is not None: # in case we defined virtual batch ids (volume boundaries) - entries = self.vb.virtual_batch_ids(entry) # these are ALL the virtual batch ids corresponding to this entry - if volume is not None: # maybe we wanted to select a specific volume - entries = [entries[volume]] - return entries - def _check_volume(self, volume): """ Basic sanity check that the volume given makes sense given the config. @@ -692,107 +637,103 @@ def get_fragments(self, entry, only_primaries=False, if min_particle_voxel_count < 0: min_particle_voxel_count = self.min_particle_voxel_count - entries = self._get_entries(entry, volume) - out_fragment_list = [] - for entry in entries: - volume = entry % self._num_volumes - point_cloud = self.data_blob['input_data'][entry][:, 1:4] - depositions = self.result['input_rescaled'][entry][:, 4] - fragments = self.result['fragments'][entry] - fragments_seg = self.result['fragments_seg'][entry] + point_cloud = self.data_blob['input_data'][entry][:, 1:4] + depositions = self.result['input_rescaled'][entry][:, 4] + fragments = self.result['fragment_clusts'][entry] + fragments_seg = self.result['fragment_seg'][entry] - shower_mask = np.isin(fragments_seg, self.module_config['grappa_shower']['base']['node_type']) - shower_frag_primary = np.argmax(self.result['shower_node_pred'][entry], axis=1) + shower_mask = np.isin(fragments_seg, self.module_config['grappa_shower']['base']['node_type']) + shower_frag_primary = np.argmax(self.result['shower_fragment_node_pred'][entry], axis=1) - if 'shower_node_features' in self.result: - shower_node_features = self.result['shower_node_features'][entry] - if 'track_node_features' in self.result: - track_node_features = self.result['track_node_features'][entry] + if 'shower_node_features' in self.result: + shower_node_features = self.result['shower_fragment_node_features'][entry] + if 'track_node_features' in self.result: + track_node_features = self.result['track_fragment_node_features'][entry] - assert len(fragments_seg) == len(fragments) + assert len(fragments_seg) == len(fragments) - temp = [] + temp = [] - if ('inter_group_pred' in self.result) and ('particles' in self.result) and len(fragments) > 0: + if ('particle_group_pred' in self.result) and ('particle_clusts' in self.result) and len(fragments) > 0: - group_labels = self._fit_predict_groups(entry) - inter_labels = self._fit_predict_interaction_labels(entry) - group_ids = get_cluster_label(group_labels.reshape(-1, 1), fragments, column=0) - inter_ids = get_cluster_label(inter_labels.reshape(-1, 1), fragments, column=0) - - else: - group_ids = np.ones(len(fragments)).astype(int) * -1 - inter_ids = np.ones(len(fragments)).astype(int) * -1 + group_labels = self._fit_predict_groups(entry) + inter_labels = self._fit_predict_interaction_labels(entry) + group_ids = get_cluster_label(group_labels.reshape(-1, 1), fragments, column=0) + inter_ids = get_cluster_label(inter_labels.reshape(-1, 1), fragments, column=0) + else: + group_ids = np.ones(len(fragments)).astype(int) * -1 + inter_ids = np.ones(len(fragments)).astype(int) * -1 + + if true_id: + true_fragment_labels = self.data_blob['cluster_label'][entry][:, 5] + + + for i, p in enumerate(fragments): + voxels = point_cloud[p] + seg_label = fragments_seg[i] + part = ParticleFragment(self._translate(voxels, volume), + i, seg_label, + interaction_id=inter_ids[i], + group_id=group_ids[i], + image_id=entry, + voxel_indices=p, + depositions=depositions[p], + is_primary=False, + pid_conf=-1, + alias='Fragment', + volume=volume) + temp.append(part) if true_id: - true_fragment_labels = self.data_blob['cluster_label'][entry][:, 5] - - - for i, p in enumerate(fragments): - voxels = point_cloud[p] - seg_label = fragments_seg[i] - part = ParticleFragment(self._translate(voxels, volume), - i, seg_label, - interaction_id=inter_ids[i], - group_id=group_ids[i], - image_id=entry, - voxel_indices=p, - depositions=depositions[p], - is_primary=False, - pid_conf=-1, - alias='Fragment', - volume=volume) - temp.append(part) - if true_id: - fid = true_fragment_labels[p] - fids, counts = np.unique(fid.astype(int), return_counts=True) - part.true_ids = fids - part.true_counts = counts - - # Label shower fragments as primaries and attach startpoint - shower_counter = 0 - for p in np.array(temp)[shower_mask]: - is_primary = shower_frag_primary[shower_counter] - p.is_primary = bool(is_primary) - p.startpoint = shower_node_features[shower_counter][19:22] - # p.group_id = int(shower_group_pred[shower_counter]) - shower_counter += 1 - assert shower_counter == shower_frag_primary.shape[0] - - # Attach endpoint to track fragments - track_counter = 0 - for p in temp: - if p.semantic_type == 1: - # p.group_id = int(track_group_pred[track_counter]) - p.startpoint = track_node_features[track_counter][19:22] - p.endpoint = track_node_features[track_counter][22:25] - track_counter += 1 - # assert track_counter == track_group_pred.shape[0] - - # Apply fragment voxel cut - out = [] - for p in temp: - if p.points.shape[0] < min_particle_voxel_count: - continue - out.append(p) - - # Check primaries and assign ppn points - if only_primaries: - out = [p for p in out if p.is_primary] - - if semantic_type is not None: - out = [p for p in out if p.semantic_type == semantic_type] - - if len(out) == 0: - return out - - ppn_results = self._fit_predict_ppn(entry) - match_points_to_particles(ppn_results, out, - ppn_distance_threshold=attaching_threshold) - - out_fragment_list.extend(out) + fid = true_fragment_labels[p] + fids, counts = np.unique(fid.astype(int), return_counts=True) + part.true_ids = fids + part.true_counts = counts + + # Label shower fragments as primaries and attach startpoint + shower_counter = 0 + for p in np.array(temp)[shower_mask]: + is_primary = shower_frag_primary[shower_counter] + p.is_primary = bool(is_primary) + p.startpoint = shower_node_features[shower_counter][19:22] + # p.group_id = int(shower_group_pred[shower_counter]) + shower_counter += 1 + assert shower_counter == shower_frag_primary.shape[0] + + # Attach endpoint to track fragments + track_counter = 0 + for p in temp: + if p.semantic_type == 1: + # p.group_id = int(track_group_pred[track_counter]) + p.startpoint = track_node_features[track_counter][19:22] + p.endpoint = track_node_features[track_counter][22:25] + track_counter += 1 + # assert track_counter == track_group_pred.shape[0] + + # Apply fragment voxel cut + out = [] + for p in temp: + if p.points.shape[0] < min_particle_voxel_count: + continue + out.append(p) + + # Check primaries and assign ppn points + if only_primaries: + out = [p for p in out if p.is_primary] + + if semantic_type is not None: + out = [p for p in out if p.semantic_type == semantic_type] + + if len(out) == 0: + return out + + ppn_results = self._fit_predict_ppn(entry) + match_points_to_particles(ppn_results, out, + ppn_distance_threshold=attaching_threshold) + + out_fragment_list.extend(out) return out_fragment_list @@ -846,113 +787,104 @@ def get_particles(self, entry, only_primaries=True, if min_particle_voxel_count < 0: min_particle_voxel_count = self.min_particle_voxel_count - entries = self._get_entries(entry, volume) - - out_particle_list = [] - # Loop over images - for entry in entries: - volume = entry % self._num_volumes - - point_cloud = self.data_blob['input_data'][entry][:, 1:4] - depositions = self.result['input_rescaled'][entry][:, 4] - particles = self.result['particles'][entry] - # inter_group_pred = self.result['inter_group_pred'][entry] - #print(point_cloud.shape, depositions.shape, len(particles)) - particles_seg = self.result['particles_seg'][entry] - type_logits = self.result['node_pred_type'][entry] - particle_points = self.result['particle_points'][entry] - pids = np.argmax(type_logits, axis=1) + point_cloud = self.data_blob['input_data'][entry][:, 1:4] + depositions = self.result['input_rescaled'][entry][:, 4] + particles = self.result['particle_clusts'][entry] + # inter_group_pred = self.result['inter_group_pred'][entry] + #print(point_cloud.shape, depositions.shape, len(particles)) + particle_seg = self.result['particle_seg'][entry] - out = [] - if point_cloud.shape[0] == 0: - return out - assert len(particles_seg) == len(particles) - assert len(pids) == len(particles) - assert len(particle_points) == len(particles) - assert point_cloud.shape[0] == depositions.shape[0] + type_logits = self.result['particle_node_pred_type'][entry] + particle_start_points = self.result['particle_start_points'][entry] + particle_end_points = self.result['particle_end_points'][entry] + pids = np.argmax(type_logits, axis=1) - node_pred_vtx = self.result['node_pred_vtx'][entry] + out = [] + if point_cloud.shape[0] == 0: + return out + assert len(particle_seg) == len(particles) + assert len(pids) == len(particles) + assert len(particle_end_points) == len(particles) + assert len(particle_start_points) == len(particles) + assert point_cloud.shape[0] == depositions.shape[0] - assert node_pred_vtx.shape[0] == len(particles) + node_pred_vtx = self.result['particle_node_pred_vtx'][entry] - if ('inter_group_pred' in self.result) and ('particles' in self.result) and len(particles) > 0: + assert node_pred_vtx.shape[0] == len(particles) - assert len(self.result['inter_group_pred'][entry]) == len(particles) - inter_labels = self._fit_predict_interaction_labels(entry) - inter_ids = get_cluster_label(inter_labels.reshape(-1, 1), particles, column=0) + if ('particle_group_pred' in self.result) and ('particle_clusts' in self.result) and len(particles) > 0: + assert len(self.result['particle_group_pred'][entry]) == len(particles) + inter_labels = self._fit_predict_interaction_labels(entry) + inter_ids = get_cluster_label(inter_labels.reshape(-1, 1), particles, column=0) + else: + inter_ids = np.ones(len(particles)).astype(int) * -1 + + for i, p in enumerate(particles): + voxels = point_cloud[p] + if voxels.shape[0] < min_particle_voxel_count: + continue + seg_label = particle_seg[i] + pid = pids[i] + if seg_label == 2 or seg_label == 3: + pid = 1 + interaction_id = inter_ids[i] + if self.pred_vtx_positions: + is_primary = bool(np.argmax(node_pred_vtx[i][3:])) else: - inter_ids = np.ones(len(particles)).astype(int) * -1 - - for i, p in enumerate(particles): - voxels = point_cloud[p] - if voxels.shape[0] < min_particle_voxel_count: - continue - seg_label = particles_seg[i] - pid = pids[i] - if seg_label == 2 or seg_label == 3: - pid = 1 - interaction_id = inter_ids[i] - if self.pred_vtx_positions: - is_primary = bool(np.argmax(node_pred_vtx[i][3:])) - else: - is_primary = bool(np.argmax(node_pred_vtx[i])) - part = Particle(self._translate(voxels, volume), - i, - seg_label, interaction_id, - pid, - entry, - voxel_indices=p, - depositions=depositions[p], - is_primary=is_primary, - pid_conf=softmax(type_logits[i])[pids[i]], - volume=volume) - - part.startpoint = particle_points[i][:3] - part.endpoint = particle_points[i][3:] - - out.append(part) - - if only_primaries: - out = [p for p in out if p.is_primary] - - if len(out) == 0: - return out - - ppn_results = self._fit_predict_ppn(entry) - - # Get ppn candidates for particle - match_points_to_particles(ppn_results, out, - ppn_distance_threshold=attaching_threshold) - - # Attach startpoint and endpoint - # as done in full chain geometric encoder - for p in out: - if p.size < min_particle_voxel_count: - continue - if p.semantic_type == 0: - # Check startpoint is replicated - assert(np.sum( - np.abs(p.startpoint - p.endpoint)) < 1e-12) - p.endpoint = None - elif p.semantic_type == 1: - if self.track_endpoints_mode == 'node_features': - get_track_points(p, - correction_mode=self.track_point_corrector) - elif self.track_endpoints_mode == 'brute_force': - get_track_points(p, - correction_mode=self.track_point_corrector, - brute_force=True) - else: - raise ValueError("Track endpoint attachment mode {}\ - not supported!".format(self.track_endpoints_mode)) + is_primary = bool(np.argmax(node_pred_vtx[i])) + part = Particle(voxels, + i, + seg_label, interaction_id, + pid, + entry, + voxel_indices=p, + depositions=depositions[p], + is_primary=is_primary, + pid_conf=softmax(type_logits[i])[pids[i]]) + + part.startpoint = particle_start_points[i][1:4] + part.endpoint = particle_end_points[i][1:4] + + out.append(part) + + if only_primaries: + out = [p for p in out if p.is_primary] + + if len(out) == 0: + return out + + ppn_results = self._fit_predict_ppn(entry) + + # Get ppn candidates for particle + match_points_to_particles(ppn_results, out, + ppn_distance_threshold=attaching_threshold) + + # Attach startpoint and endpoint + # as done in full chain geometric encoder + for p in out: + if p.size < min_particle_voxel_count: + continue + if p.semantic_type == 0: + # Check startpoint is replicated + assert(np.sum( + np.abs(p.startpoint - p.endpoint)) < 1e-12) + p.endpoint = None + elif p.semantic_type == 1: + if self.track_endpoints_mode == 'node_features': + get_track_points(p, correction_mode=self.track_point_corrector) + elif self.track_endpoints_mode == 'brute_force': + get_track_points(p, correction_mode=self.track_point_corrector, + brute_force=True) else: - continue - out_particle_list.extend(out) + raise ValueError("Track endpoint attachment mode {}\ + not supported!".format(self.track_endpoints_mode)) + else: + continue - return out_particle_list + return out def get_interactions(self, entry, @@ -986,32 +918,26 @@ def get_interactions(self, entry, Returns: - out: List of instances (see particle.Interaction). ''' - self._check_volume(volume) - - entries = self._get_entries(entry, volume) if vertex_mode == None: vertex_mode = self.vertex_mode - out_interaction_list = [] - for e in entries: - volume = e % self._num_volumes if self.vb is not None else volume - particles = self.get_particles(entry, - only_primaries=drop_nonprimary_particles, - volume=volume) - out = group_particles_to_interactions_fn(particles) - for ia in out: - if compute_vertex: - ia.vertex, ia.vertex_candidate_count = estimate_vertex( - ia.particles, - use_primaries=use_primaries_for_vertex, - mode=vertex_mode, - prune_candidates=self.prune_vertex, - return_candidate_count=True) - ia.volume = volume - out_interaction_list.extend(out) - - return out_interaction_list + out = [] + particles = self.get_particles(entry, + only_primaries=drop_nonprimary_particles, + volume=volume) + out = group_particles_to_interactions_fn(particles) + for ia in out: + if compute_vertex: + ia.vertex, ia.vertex_candidate_count = estimate_vertex( + ia.particles, + use_primaries=use_primaries_for_vertex, + mode=vertex_mode, + prune_candidates=self.prune_vertex, + return_candidate_count=True) + ia.volume = volume + + return out def fit_predict_labels(self, entry, volume=None): From 0cf9890f9759f876df955e211931550a0ab06d61 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Tue, 21 Mar 2023 22:17:22 -0700 Subject: [PATCH 052/180] Bug fix when unwrapping more than one data product sharing a parser --- mlreco/iotools/parsers/unwrap_rules.py | 3 +- mlreco/models/layers/common/gnn_full_chain.py | 6 ++-- mlreco/utils/unwrap.py | 32 ++++++++----------- 3 files changed, 19 insertions(+), 22 deletions(-) diff --git a/mlreco/iotools/parsers/unwrap_rules.py b/mlreco/iotools/parsers/unwrap_rules.py index 9f120183..c21591e1 100644 --- a/mlreco/iotools/parsers/unwrap_rules.py +++ b/mlreco/iotools/parsers/unwrap_rules.py @@ -1,3 +1,4 @@ +from copy import deepcopy from mlreco.utils.globals import COORD_COLS RULES = { @@ -43,7 +44,7 @@ def input_unwrap_rules(schemas): for name, schema in schemas.items(): parser = schema['parser'] assert parser in RULES, f'Unable to unwrap data from {parser}' - rules[name] = RULES[parser] + rules[name] = deepcopy(RULES[parser]) if rules[name][0] == 'tensor': rules[name][1] = name diff --git a/mlreco/models/layers/common/gnn_full_chain.py b/mlreco/models/layers/common/gnn_full_chain.py index 9f722fb3..fd852c56 100644 --- a/mlreco/models/layers/common/gnn_full_chain.py +++ b/mlreco/models/layers/common/gnn_full_chain.py @@ -887,17 +887,17 @@ def forward(self, out, seg_label, ppn_label=None, cluster_label=None, kinematics print('Interaction grouping accuracy: {:.4f}'.format(res_gnn_inter['edge_accuracy'])) if self.enable_gnn_kinematics: print('Flow accuracy: {:.4f}'.format(res_kinematics['edge_accuracy'])) - if 'node_pred_type' in out: + if 'particle_node_pred_type' in out: if 'grappa_inter_type_accuracy' in res: print('Particle ID accuracy: {:.4f}'.format(res['grappa_inter_type_accuracy'])) elif 'grappa_kinematics_type_accuracy' in res: print('Particle ID accuracy: {:.4f}'.format(res['grappa_kinematics_type_accuracy'])) - if 'node_pred_p' in out: + if 'particle_node_pred_p' in out: if 'grappa_inter_p_accuracy' in res: print('Momentum accuracy: {:.4f}'.format(res['grappa_inter_p_accuracy'])) elif 'grappa_kinematics_p_accuracy' in res: print('Momentum accuracy: {:.4f}'.format(res['grappa_kinematics_p_accuracy'])) - if 'node_pred_vtx' in out: + if 'particle_node_pred_vtx' in out: if 'grappa_inter_vtx_score_accuracy' in res: print('Primary particle score accuracy: {:.4f}'.format(res['grappa_inter_vtx_score_accuracy'])) elif 'grappa_kinematics_vtx_score_accuracy' in res: diff --git a/mlreco/utils/unwrap.py b/mlreco/utils/unwrap.py index 57b5fb08..77ffe683 100644 --- a/mlreco/utils/unwrap.py +++ b/mlreco/utils/unwrap.py @@ -115,13 +115,9 @@ def _build_batch_masks(self, data_blob, result_blob): result_blob : dict Results dictionary, output of trainval.forward [key][num_gpus][batch_size] ''' + comb_blob = dict(data_blob, **result_blob) self.masks, self.offsets = {}, {} - for key, value in data_blob.items(): - # Inputs are all either tensors or lists, only build mask for tensors - if key in self.rules and self.rules[key].method == 'tensor': - self.masks[key] = [self._batch_masks(value[g]) for g in range(self.num_gpus)] - - for key in result_blob.keys(): + for key in comb_blob.keys(): # Skip outputs with no rule if key not in self.rules: continue @@ -129,21 +125,21 @@ def _build_batch_masks(self, data_blob, result_blob): # For tensors and lists of tensors, build one mask per reference tensor if not self.rules[key].done and self.rules[key].method in ['tensor', 'tensor_list']: ref_key = self.rules[key].ref_key - assert ref_key in self.masks or ref_key in result_blob, 'Must provide the reference tensor to unwrap' - assert self.rules[key].method == self.rules[ref_key].method, 'Reference must be of same type' if ref_key not in self.masks: + assert ref_key in comb_blob, f'Must provide reference tensor ({ref_key}) to unwrap {key}' + assert self.rules[key].method == self.rules[ref_key].method, f'Reference ({ref_key}) must be of same type as {key}' if self.rules[key].method == 'tensor': - self.masks[ref_key] = [self._batch_masks(result_blob[ref_key][g]) for g in range(self.num_gpus)] + self.masks[ref_key] = [self._batch_masks(comb_blob[ref_key][g]) for g in range(self.num_gpus)] elif self.rules[key].method == 'tensor_list': - self.masks[ref_key] = [[self._batch_masks(v) for v in result_blob[ref_key][g]] for g in range(self.num_gpus)] + self.masks[ref_key] = [[self._batch_masks(v) for v in comb_blob[ref_key][g]] for g in range(self.num_gpus)] # For edge tensors, build one mask from each tensor (must figure out batch IDs of edges) elif self.rules[key].method == 'edge_tensor': assert len(self.rules[key].ref_key) == 2, 'Must provide a reference to the edge_index and the node batch ids' for ref_key in self.rules[key].ref_key: - assert ref_key in result_blob, 'Must provide reference tensor to unwrap' + assert ref_key in comb_blob, f'Must provide reference tensor ({ref_key}) to unwrap {key}' ref_edge, ref_node = self.rules[key].ref_key - edge_index, batch_ids = result_blob[ref_edge], result_blob[ref_node] + edge_index, batch_ids = comb_blob[ref_edge], comb_blob[ref_node] if not self.rules[key].done and ref_edge not in self.masks: self.masks[ref_edge] = [self._batch_masks(batch_ids[g][edge_index[g][:,0]]) for g in range(self.num_gpus)] if ref_node not in self.offsets: @@ -152,22 +148,22 @@ def _build_batch_masks(self, data_blob, result_blob): # For an index tensor, only need to record the batch offsets within the wrapped tensor elif self.rules[key].method == 'index_tensor': ref_key = self.rules[key].ref_key - assert ref_key in result_blob, 'Must provide reference tensor to unwrap' + assert ref_key in comb_blob, f'Must provide reference tensor ({ref_key}) to unwrap {key}' if not self.rules[key].done and ref_key not in self.masks: - self.masks[ref_key] = [self._batch_masks(result_blob[ref_key][g]) for g in range(self.num_gpus)] + self.masks[ref_key] = [self._batch_masks(comb_blob[ref_key][g]) for g in range(self.num_gpus)] if ref_key not in self.offsets: - self.offsets[ref_key] = [self._batch_offsets(result_blob[ref_key][g]) for g in range(self.num_gpus)] + self.offsets[ref_key] = [self._batch_offsets(comb_blob[ref_key][g]) for g in range(self.num_gpus)] # For lists of tensor indices, only need to record the offsets within the wrapped tensor elif self.rules[key].method == 'index_list': assert len(self.rules[key].ref_key) == 2, 'Must provide a reference to indexed tensor and the index batch ids' for ref_key in self.rules[key].ref_key: - assert ref_key in result_blob, 'Must provide reference tensor to unwrap' + assert ref_key in comb_blob, f'Must provide reference tensor ({ref_key}) to unwrap {key}' ref_tensor, ref_index = self.rules[key].ref_key if not self.rules[key].done and ref_index not in self.masks: - self.masks[ref_index] = [self._batch_masks(result_blob[ref_index][g]) for g in range(self.num_gpus)] + self.masks[ref_index] = [self._batch_masks(comb_blob[ref_index][g]) for g in range(self.num_gpus)] if ref_tensor not in self.offsets: - self.offsets[ref_tensor] = [self._batch_offsets(result_blob[ref_tensor][g]) for g in range(self.num_gpus)] + self.offsets[ref_tensor] = [self._batch_offsets(comb_blob[ref_tensor][g]) for g in range(self.num_gpus)] def _batch_masks(self, tensor): ''' From 5d1ad4e2dda3c4d566a98ce6905f4ae67faaa633 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Tue, 21 Mar 2023 22:18:57 -0700 Subject: [PATCH 053/180] Merge conflict --- mlreco/utils/deghosting.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mlreco/utils/deghosting.py b/mlreco/utils/deghosting.py index 066eb038..f447b9f4 100644 --- a/mlreco/utils/deghosting.py +++ b/mlreco/utils/deghosting.py @@ -303,7 +303,6 @@ def deghost_labels_and_predictions(data_blob, result): data_blob['input_data'] = [data_blob['input_data'][i][mask] \ for i, mask in enumerate(result['ghost_mask'])] - if 'cluster_label' in data_blob \ and data_blob['cluster_label'] is not None: # Save the clust_data before deghosting From 6eb89ae802ac83c3053a6cd81ea0910848f92bd9 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 22 Mar 2023 01:17:40 -0700 Subject: [PATCH 054/180] Remove volume handling in analysis tools, other minor fixes to make it work --- analysis/classes/Particle.py | 6 +- analysis/classes/evaluator.py | 60 +++++------- analysis/classes/predictor.py | 168 ++++++++++++++++------------------ 3 files changed, 106 insertions(+), 128 deletions(-) diff --git a/analysis/classes/Particle.py b/analysis/classes/Particle.py index d3cb1711..47683f76 100644 --- a/analysis/classes/Particle.py +++ b/analysis/classes/Particle.py @@ -45,7 +45,7 @@ class Particle: (1, 3) array of particle's endpoint, if it could be assigned ''' def __init__(self, coords, group_id, semantic_type, interaction_id, - pid, image_id, nu_id=-1, voxel_indices=None, depositions=None, volume=0, **kwargs): + pid, image_id, nu_id=-1, voxel_indices=None, depositions=None, volume=-1, **kwargs): self.id = group_id self.points = coords self.size = coords.shape[0] @@ -89,14 +89,14 @@ def __repr__(self): def __str__(self): fmt = "Particle( Image ID={:<3} | Particle ID={:<3} | Semantic_type: {:<15}"\ - " | PID: {:<8} | Primary: {:<2} | Score = {:.2f}% | Interaction ID: {:<2} | Size: {:<5} | Volume: {:<2} )" + " | PID: {:<8} | Primary: {:<2} | Interaction ID: {:<2} | Size: {:<5} | Score = {:.2f}% | Volume: {:<2} )" msg = fmt.format(self.image_id, self.id, self.semantic_keys[self.semantic_type] if self.semantic_type in self.semantic_keys else "None", self.pid_keys[self.pid] if self.pid in self.pid_keys else "None", self.is_primary, - self.pid_conf * 100, self.interaction_id, self.points.shape[0], + self.pid_conf * 100, self.volume) return msg diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index e88e5e59..97460b13 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -67,7 +67,7 @@ def get_true_particle_labels(labels, mask, pid=-1, verbose=False): return semantic_type, interaction_id, nu_id -def handle_empty_true_particles(labels_noghost, mask_noghost, p, entry, num_volumes, +def handle_empty_true_particles(labels_noghost, mask_noghost, p, entry, verbose=False): pid = int(p.id()) pdg = TYPE_LABELS.get(p.pdg_code(), -1) @@ -90,8 +90,7 @@ def handle_empty_true_particles(labels_noghost, mask_noghost, p, entry, num_vol is_primary=is_primary, coords_noghost=coords_noghost, depositions_noghost=depositions_noghost, - depositions_MeV=depositions, - volume=entry % num_volumes) + depositions_MeV=depositions) particle.p = np.array([p.px(), p.py(), p.pz()]) particle.fragments = [] particle.particle_asis = p @@ -179,7 +178,7 @@ def __init__(self, data_blob, result, cfg, processor_cfg={}, **kwargs): super(FullChainEvaluator, self).__init__(data_blob, result, cfg, processor_cfg, **kwargs) self.michel_primary_ionization_only = processor_cfg.get('michel_primary_ionization_only', False) - def get_true_label(self, entry, name, schema='cluster_label', volume=None): + def get_true_label(self, entry, name, schema='cluster_label'): """ Retrieve tensor in data blob, labelled with `schema`. @@ -203,16 +202,11 @@ def get_true_label(self, entry, name, schema='cluster_label', volume=None): name, str(list(self.LABEL_TO_COLUMN.keys())))) column_idx = self.LABEL_TO_COLUMN[name] - self._check_volume(volume) - - entries = self._get_entries(entry, volume) - out = [] - for entry in entries: - out.append(self.data_blob[schema][entry][:, column_idx]) + out = self.data_blob[schema][entry][:, column_idx] return np.concatenate(out, axis=0) - def get_predicted_label(self, entry, name, volume=None): + def get_predicted_label(self, entry, name): """ Returns predicted quantities to label a plot. @@ -228,7 +222,7 @@ def get_predicted_label(self, entry, name, volume=None): ======= np.array """ - pred = self.fit_predict_labels(entry, volume=volume) + pred = self.fit_predict_labels(entry) return pred[name] @@ -254,7 +248,7 @@ def _apply_true_voxel_cut(self, entry): return set(particles_exclude) - def get_true_fragments(self, entry, verbose=False, volume=None) -> List[TruthParticleFragment]: + def get_true_fragments(self, entry, verbose=False) -> List[TruthParticleFragment]: ''' Get list of instances for given batch id. ''' @@ -343,7 +337,7 @@ def get_true_fragments(self, entry, verbose=False, volume=None) -> List[TruthPar def get_true_particles(self, entry, only_primaries=True, - verbose=False, volume=None) -> List[TruthParticle]: + verbose=False) -> List[TruthParticle]: ''' Get list of instances for given batch id. @@ -384,7 +378,7 @@ def get_true_particles(self, entry, only_primaries=True, # 1. Check if current pid is one of the existing group ids if pid not in particle_ids: particle = handle_empty_true_particles(labels_noghost, mask_noghost, p, entry, - self._num_volumes, verbose=verbose) + verbose=verbose) particles.append(particle) continue @@ -400,6 +394,9 @@ def get_true_particles(self, entry, only_primaries=True, mask_noghost = mask_noghost & (labels_noghost[:, -1].astype(int) == 2) coords = self.data_blob['input_data'][entry][mask][:, 1:4] + volume_labels = self.data_blob['input_data'][entry][mask][:, 0] + volume_id, cts = np.unique(volume_labels, return_counts=True) + volume_id = int(volume_id[cts.argmax()]) voxel_indices = np.where(mask)[0] fragments = np.unique(labels[mask][:, 5].astype(int)) depositions_MeV = labels[mask][:, 4] @@ -426,7 +423,7 @@ def get_true_particles(self, entry, only_primaries=True, semantic_type, interaction_id, nu_id = get_true_particle_labels(labels, mask, pid=pid, verbose=verbose) - particle = TruthParticle(self._translate(coords, volume), + particle = TruthParticle(coords, pid, semantic_type, interaction_id, pdg, entry, particle_asis=p, @@ -435,7 +432,7 @@ def get_true_particles(self, entry, only_primaries=True, coords_noghost=coords_noghost, depositions_noghost=depositions_noghost, depositions_MeV=depositions_MeV, - volume=entry % self._num_volumes) + volume=volume_id) particle.p = np.array([p.px(), p.py(), p.pz()]) particle.fragments = fragments @@ -465,26 +462,25 @@ def get_true_particles(self, entry, only_primaries=True, def get_true_interactions(self, entry, drop_nonprimary_particles=True, min_particle_voxel_count=-1, - volume=None, compute_vertex=True) -> List[Interaction]: if min_particle_voxel_count < 0: min_particle_voxel_count = self.min_particle_voxel_count out = [] - true_particles = self.get_true_particles(entry, only_primaries=drop_nonprimary_particles, volume=volume) + true_particles = self.get_true_particles(entry, only_primaries=drop_nonprimary_particles) out = group_particles_to_interactions_fn(true_particles, get_nu_id=True, mode='truth') if compute_vertex: - vertices = self.get_true_vertices(entry, volume=volume) + vertices = self.get_true_vertices(entry) for ia in out: if compute_vertex and ia.id in vertices: ia.vertex = vertices[ia.id] - ia.volume = volume + # ia.volume = volume return out - def get_true_vertices(self, entry, volume=None): + def get_true_vertices(self, entry): """ Parameters ========== @@ -507,7 +503,7 @@ def get_true_vertices(self, entry, volume=None): self.data_blob['cluster_label'], data_idx=entry, inter_idx=inter_idx) - out[inter_idx] = self._translate(vtx, volume) + out[inter_idx] = vtx return out @@ -515,7 +511,6 @@ def get_true_vertices(self, entry, volume=None): def match_particles(self, entry, only_primaries=False, mode='pred_to_true', - volume=None, matching_mode='one_way', return_counts=False, **kwargs): @@ -530,20 +525,17 @@ def match_particles(self, entry, Must be either 'pred_to_true' or 'true_to_pred' volume: int, default None ''' - self._check_volume(volume) - all_matches = [] all_counts = [] - volume = e % self._num_volumes if self.vb is not None else volume # print('matching', entries, volume) if mode == 'pred_to_true': # Match each pred to one in true - particles_from = self.get_particles(entry, only_primaries=only_primaries, volume=volume) - particles_to = self.get_true_particles(entry, only_primaries=only_primaries, volume=volume) + particles_from = self.get_particles(entry, only_primaries=only_primaries) + particles_to = self.get_true_particles(entry, only_primaries=only_primaries) elif mode == 'true_to_pred': # Match each true to one in pred - particles_to = self.get_particles(entry, only_primaries=only_primaries, volume=volume) - particles_from = self.get_true_particles(entry, only_primaries=only_primaries, volume=volume) + particles_to = self.get_particles(entry, only_primaries=only_primaries) + particles_from = self.get_true_particles(entry, only_primaries=only_primaries) else: raise ValueError("Mode {} is not valid. For matching each"\ " prediction to truth, use 'pred_to_true' (and vice versa).".format(mode)) @@ -566,7 +558,6 @@ def match_interactions(self, entry, mode='pred_to_true', drop_nonprimary_particles=True, match_particles=True, return_counts=False, - volume=None, compute_vertex=True, vertex_mode='all', matching_mode='one_way', @@ -587,28 +578,23 @@ def match_interactions(self, entry, mode='pred_to_true', List[Tuple[Interaction, Interaction]] List of tuples, indicating the matched interactions. """ - self._check_volume(volume) all_matches, all_counts = [], [] if mode == 'pred_to_true': ints_from = self.get_interactions(entry, drop_nonprimary_particles=drop_nonprimary_particles, - volume=volume, compute_vertex=compute_vertex, vertex_mode=vertex_mode) ints_to = self.get_true_interactions(entry, drop_nonprimary_particles=drop_nonprimary_particles, - volume=volume, compute_vertex=compute_vertex) elif mode == 'true_to_pred': ints_to = self.get_interactions(entry, drop_nonprimary_particles=drop_nonprimary_particles, - volume=volume, compute_vertex=compute_vertex, vertex_mode=vertex_mode) ints_from = self.get_true_interactions(entry, drop_nonprimary_particles=drop_nonprimary_particles, - volume=volume, compute_vertex=compute_vertex) else: raise ValueError("Mode {} is not valid. For matching each"\ diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index b910ed9c..823a8ddf 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -20,7 +20,7 @@ from mlreco.utils.deghosting import deghost_labels_and_predictions from mlreco.utils.gnn.cluster import get_cluster_label -from mlreco.utils.volumes import VolumeBoundaries +# from mlreco.utils.volumes import VolumeBoundaries class FullChainPredictor: @@ -128,13 +128,13 @@ def __init__(self, data_blob, result, cfg, predictor_cfg={}, deghosting=False, # split over "virtual" batch ids # Note this is different from "self.volume_boundaries" above # FIXME rename one or the other to be clearer - boundaries = cfg['iotool'].get('collate', {}).get('boundaries', None) - if boundaries is not None: - self.vb = VolumeBoundaries(boundaries) - self._num_volumes = self.vb.num_volumes() - else: - self.vb = None - self._num_volumes = 1 + # boundaries = cfg['iotool'].get('collate', {}).get('boundaries', None) + # if boundaries is not None: + # self.vb = VolumeBoundaries(boundaries) + # self._num_volumes = self.vb.num_volumes() + # else: + # self.vb = None + # self._num_volumes = 1 # Prepare flash matching if requested self.enable_flash_matching = enable_flash_matching @@ -475,7 +475,7 @@ def _fit_predict_groups(self, entry): Returns: - labels: 1D numpy integer array of predicted group labels. ''' - particles = self.result['particles'][entry] + particles = self.result['particle_clusts'][entry] num_voxels = self.data_blob['input_data'][entry].shape[0] pred_group_labels = -np.ones(num_voxels).astype(int) @@ -558,45 +558,45 @@ def _check_volume(self, volume): if volume is not None: assert isinstance(volume, (int, np.int64, np.int32)) and volume >= 0 - def _translate(self, voxels, volume): - """ - Go from 1-volume-only back to full volume coordinates - - Parameters - ========== - voxels: np.ndarray - Shape (N, 3) - volume: int - - Returns - ======= - np.ndarray - Shape (N, 3) - """ - if self.vb is None or volume is None: - return voxels - else: - return self.vb.translate(voxels, volume) - - def _untranslate(self, voxels, volume): - """ - Go from full volume to 1-volume-only coordinates - - Parameters - ========== - voxels: np.ndarray - Shape (N, 3) - volume: int - - Returns - ======= - np.ndarray - Shape (N, 3) - """ - if self.vb is None or volume is None: - return voxels - else: - return self.vb.untranslate(voxels, volume) + # def _translate(self, voxels, volume): + # """ + # Go from 1-volume-only back to full volume coordinates + + # Parameters + # ========== + # voxels: np.ndarray + # Shape (N, 3) + # volume: int + + # Returns + # ======= + # np.ndarray + # Shape (N, 3) + # """ + # if self.vb is None or volume is None: + # return voxels + # else: + # return self.vb.translate(voxels, volume) + + # def _untranslate(self, voxels, volume): + # """ + # Go from full volume to 1-volume-only coordinates + + # Parameters + # ========== + # voxels: np.ndarray + # Shape (N, 3) + # volume: int + + # Returns + # ======= + # np.ndarray + # Shape (N, 3) + # """ + # if self.vb is None or volume is None: + # return voxels + # else: + # return self.vb.untranslate(voxels, volume) def get_fragments(self, entry, only_primaries=False, min_particle_voxel_count=-1, @@ -674,7 +674,7 @@ def get_fragments(self, entry, only_primaries=False, for i, p in enumerate(fragments): voxels = point_cloud[p] seg_label = fragments_seg[i] - part = ParticleFragment(self._translate(voxels, volume), + part = ParticleFragment(voxels, i, seg_label, interaction_id=inter_ids[i], group_id=group_ids[i], @@ -738,7 +738,7 @@ def get_fragments(self, entry, only_primaries=False, return out_fragment_list - def get_particles(self, entry, only_primaries=True, + def get_particles(self, entry, only_primaries=False, min_particle_voxel_count=-1, attaching_threshold=2, volume=None, @@ -789,6 +789,7 @@ def get_particles(self, entry, only_primaries=True, # Loop over images + volume_labels = self.data_blob['input_data'][entry][:, 0] point_cloud = self.data_blob['input_data'][entry][:, 1:4] depositions = self.result['input_rescaled'][entry][:, 4] particles = self.result['particle_clusts'][entry] @@ -813,6 +814,14 @@ def get_particles(self, entry, only_primaries=True, node_pred_vtx = self.result['particle_node_pred_vtx'][entry] assert node_pred_vtx.shape[0] == len(particles) + primary_labels = -np.ones(len(node_pred_vtx)).astype(int) + if self.pred_vtx_positions: + assert node_pred_vtx.shape[1] == 5 + primary_labels = np.argmax(node_pred_vtx[:, 3:], axis=1) + else: + assert node_pred_vtx.shape[1] == 2 + primary_labels = np.argmax(node_pred_vtx, axis=1) + assert primary_labels.shape[0] == len(particles) if ('particle_group_pred' in self.result) and ('particle_clusts' in self.result) and len(particles) > 0: @@ -824,6 +833,8 @@ def get_particles(self, entry, only_primaries=True, for i, p in enumerate(particles): voxels = point_cloud[p] + volume_id, cts = np.unique(volume_labels[p], return_counts=True) + volume_id = int(volume_id[cts.argmax()]) if voxels.shape[0] < min_particle_voxel_count: continue seg_label = particle_seg[i] @@ -831,10 +842,6 @@ def get_particles(self, entry, only_primaries=True, if seg_label == 2 or seg_label == 3: pid = 1 interaction_id = inter_ids[i] - if self.pred_vtx_positions: - is_primary = bool(np.argmax(node_pred_vtx[i][3:])) - else: - is_primary = bool(np.argmax(node_pred_vtx[i])) part = Particle(voxels, i, seg_label, interaction_id, @@ -842,8 +849,9 @@ def get_particles(self, entry, only_primaries=True, entry, voxel_indices=p, depositions=depositions[p], - is_primary=is_primary, - pid_conf=softmax(type_logits[i])[pids[i]]) + is_primary=primary_labels[i], + pid_conf=softmax(type_logits[i])[pids[i]], + volume=volume_id) part.startpoint = particle_start_points[i][1:4] part.endpoint = particle_end_points[i][1:4] @@ -946,40 +954,24 @@ def fit_predict_labels(self, entry, volume=None): We define to be 1d tensors that annotate voxels. ''' - self._check_volume(volume) - entries = self._get_entries(entry, volume) - - all_pred = { - 'segment': [], - 'fragment': [], - 'group': [], - 'interaction': [], - 'pdg': [] + + pred_seg = self._fit_predict_semantics(entry) + pred_fragments = self._fit_predict_fragments(entry) + pred_groups = self._fit_predict_groups(entry) + pred_interaction_labels = self._fit_predict_interaction_labels(entry) + pred_pids = self._fit_predict_pids(entry) + + pred = { + 'segment': pred_seg, + 'fragment': pred_fragments, + 'group': pred_groups, + 'interaction': pred_interaction_labels, + 'pdg': pred_pids } - for entry in entries: - pred_seg = self._fit_predict_semantics(entry) - pred_fragments = self._fit_predict_fragments(entry) - pred_groups = self._fit_predict_groups(entry) - pred_interaction_labels = self._fit_predict_interaction_labels(entry) - pred_pids = self._fit_predict_pids(entry) - - pred = { - 'segment': pred_seg, - 'fragment': pred_fragments, - 'group': pred_groups, - 'interaction': pred_interaction_labels, - 'pdg': pred_pids - } - - for key in pred: - if len(all_pred[key]) == 0: - all_pred[key] = pred[key] - else: - all_pred[key] = np.concatenate([all_pred[key], pred[key]], axis=0) - self._pred = all_pred + self._pred = pred - return all_pred + return pred def fit_predict(self, **kwargs): @@ -999,7 +991,7 @@ def fit_predict(self, **kwargs): labels = [] list_particles, list_interactions = [], [] - for entry in range(int(self.num_images / self._num_volumes)): + for entry in range(self.num_images): pred_dict = self.fit_predict_labels(entry) labels.append(pred_dict) From 7e0a458b5b61317c751b8ef7246c85ffcca2c819 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Wed, 22 Mar 2023 15:54:36 -0700 Subject: [PATCH 055/180] Added option to run analysis tools directly from HDF5 files --- analysis/decorator.py | 50 +++++++++++++-------- analysis/run.py | 11 ++--- mlreco/iotools/readers.py | 91 ++++++++++++++++++++++++++++++++------- 3 files changed, 114 insertions(+), 38 deletions(-) diff --git a/analysis/decorator.py b/analysis/decorator.py index 9f3b6d40..c4381f7c 100644 --- a/analysis/decorator.py +++ b/analysis/decorator.py @@ -10,6 +10,7 @@ from mlreco.main_funcs import cycle from mlreco.trainval import trainval from mlreco.iotools.factories import loader_factory +from mlreco.iotools.readers import HDF5Reader from mlreco.utils.utils import ChunkCSVData @@ -24,25 +25,37 @@ def evaluate(filenames, mode='per_image'): def decorate(func): @wraps(func) - def process_dataset(cfg, analysis_config, profile=True): + def process_dataset(analysis_config, cfg=None, profile=True): - io_cfg = cfg['iotool'] + assert cfg is not None or 'reader' in analysis_config + max_iteration = analysis_config['analysis']['iteration'] + if cfg is not None: + io_cfg = cfg['iotool'] - module_config = cfg['model']['modules'] - event_list = cfg['iotool']['dataset'].get('event_list', None) - if event_list is not None: - event_list = eval(event_list) - if isinstance(event_list, tuple): - assert event_list[0] < event_list[1] - event_list = list(range(event_list[0], event_list[1])) + module_config = cfg['model']['modules'] + event_list = cfg['iotool']['dataset'].get('event_list', None) + if event_list is not None: + event_list = eval(event_list) + if isinstance(event_list, tuple): + assert event_list[0] < event_list[1] + event_list = list(range(event_list[0], event_list[1])) - loader = loader_factory(cfg, event_list=event_list) - dataset = iter(cycle(loader)) - Trainer = trainval(cfg) - loaded_iteration = Trainer.initialize() - max_iteration = analysis_config['analysis']['iteration'] - if max_iteration == -1: - max_iteration = len(loader.dataset) + loader = loader_factory(cfg, event_list=event_list) + dataset = iter(cycle(loader)) + Trainer = trainval(cfg) + loaded_iteration = Trainer.initialize() + + if max_iteration == -1: + max_iteration = len(loader.dataset) + assert max_iteration <= len(loader.dataset) + else: + file_path = analysis_config['reader']['file_paths'] + entry_list = analysis_config['reader']['entry_list'] + skip_entry_list = analysis_config['reader']['skip_entry_list'] + Reader = HDF5Reader(file_paths, entry_list, skip_entry_list, True) + if max_iteration == -1: + max_iteration = len(Reader) + assert max_iteration <= len(Reader) iteration = 0 @@ -61,7 +74,10 @@ def process_dataset(cfg, analysis_config, profile=True): while iteration < max_iteration: if profile: start = time.time() - data_blob, res = Trainer.forward(dataset) + if cfg is not None: + data_blob, res = Trainer.forward(dataset) + else: + data_blob, res = Reader.get(iteration, nested=True) if profile: print("Forward took %d s" % (time.time() - start)) img_indices = data_blob['index'] diff --git a/analysis/run.py b/analysis/run.py index eeab8ceb..f17f385b 100644 --- a/analysis/run.py +++ b/analysis/run.py @@ -25,10 +25,11 @@ def main(analysis_cfg_path, model_cfg_path): - analysis_config = yaml.load(open(analysis_cfg_path, 'r'), - Loader=yaml.Loader) - config = yaml.load(open(model_cfg_path, 'r'), Loader=yaml.Loader) - process_config(config, verbose=False) + analysis_config = yaml.safe_load(open(analysis_cfg_path, 'r')) + config = None + if model_cfg_path is not None: + config = yaml.safe_load(open(model_cfg_path, 'r')) + process_config(config, verbose=False) pprint(analysis_config) if 'analysis' not in analysis_config: @@ -65,7 +66,7 @@ def process_func(data_blob, res, data_idx, analysis, model_cfg): raise Exception('You need to specify either `name` or `scripts` under `analysis` section.') # Run Algorithm - process_func(config, analysis_config) + process_func(analysis_config, config) if __name__=="__main__": diff --git a/mlreco/iotools/readers.py b/mlreco/iotools/readers.py index fd13e837..e0d61ef4 100644 --- a/mlreco/iotools/readers.py +++ b/mlreco/iotools/readers.py @@ -9,26 +9,39 @@ class HDF5Reader: More documentation to come. ''' - def __init__(self, file_path, entry_list=[], skip_entry_list=[], larcv_particles=False): + def __init__(self, file_paths, entry_list=[], skip_entry_list=[], larcv_particles=False): ''' Load up the HDF5 file. Parameters ---------- - file_path : str - Path to the HDF5 file to be read + file_paths : list + List of paths to the HDF5 files to be read entry_list: list(int) Entry IDs to be accessed skip_entry_list: list(int) Entry IDs to be skipped ''' - # Store attributes - self.file_path = file_path - with h5py.File(self.file_path, 'r') as file: - assert 'events' in file, 'File does not contain an event tree' - self.n_entries = len(file['events']) - + # Make sure the file path(s) is(are) provided in the form of a list + if isinstance(file_paths, str): + file_paths = [file_paths] + + # Loop over the input files, build a map from index to file ID + self.file_paths = file_paths + self.file_index = [] + self.num_entries = 0 + for i, path in enumerate(file_paths): + with h5py.File(path, 'r') as file: + assert 'events' in file, 'File does not contain an event tree' + self.num_entries += len(file['events']) + self.file_index.append(i*np.ones(len(file['events']), dtype=np.int32)) + self.file_index = np.concatenate(self.file_index) + + # Build an entry list to access self.entry_list = self.get_entry_list(entry_list, skip_entry_list) + self.file_index = self.file_index[self.entry_list] + + # Set whether or not to load true particle objects as LArCV particles self.larcv_particles = larcv_particles def __len__(self): @@ -40,12 +53,37 @@ def __len__(self): int Number of entries in the file ''' - return self.n_entries + return self.num_entries def __getitem__(self, idx): ''' Returns a specific entry in the file + Parameters + ---------- + idx : int + Integer entry ID to access + + Returns + ------- + data_blob : dict + Ditionary of input data products corresponding to one event + result_blob : dict + Ditionary of result data products corresponding to one event + ''' + return self.get(idx) + + def get(self, idx, nested=False): + ''' + Returns a specific entry in the file + + Parameters + ---------- + idx : int + Integer entry ID to access + nested : bool + If true, nest the output in an array of length 1 (for analysis tools) + Returns ------- data_blob : dict @@ -54,33 +92,49 @@ def __getitem__(self, idx): Ditionary of result data products corresponding to one event ''' # Get the appropriate entry index + assert idx < len(self.entry_list) entry_idx = self.entry_list[idx] + file_idx = self.file_index[idx] # Use the events tree to find out what needs to be loaded data_blob, result_blob = {}, {} - with h5py.File(self.file_path, 'r') as file: + with h5py.File(self.file_paths[file_idx], 'r') as file: event = file['events'][entry_idx] for key in event.dtype.names: - self.load_key(file, event, data_blob, result_blob, key) + self.load_key(file, event, data_blob, result_blob, key, nested) return data_blob, result_blob def get_entry_list(self, entry_list, skip_entry_list): ''' - Create a list of events that can be accessed by `__getitem__` + Create a list of events that can be accessed by `self.get` + + Parameters + ---------- + entry_list : list + List of integer entry IDs to add to the index + skip_entry_list : list + List of integer entry IDs to skip from the index + + Returns + ------- + list + List of integer entry IDs in the index ''' if not entry_list: - entry_list = np.arange(self.n_entries, dtype=int) + entry_list = np.arange(self.num_entries, dtype=int) if skip_entry_list: + assert np.all(np.asarray(entry_list) < self.num_entries) entry_list = set(entry_list) for s in skip_entry_list: - entry_list.pop(s) + if s in entry_list: + entry_list.pop(s) entry_list = list(entry_list) assert len(entry_list), 'Must at least have one entry to load' return entry_list - def load_key(self, file, event, data_blob, result_blob, key): + def load_key(self, file, event, data_blob, result_blob, key, nested): ''' Fetch a specific key for a specific event. @@ -96,6 +150,8 @@ def load_key(self, file, event, data_blob, result_blob, key): Dictionary used to store the loaded result data key: str Name of the dataset in the event + nested : bool + If true, nest the output in an array of length 1 (for analysis tools) ''' # The event-level information is a region reference: fetch it region_ref = event[key] @@ -125,6 +181,9 @@ def load_key(self, file, event, data_blob, result_blob, key): ret = [group[key][f'element_{i}'][r] for i, r in enumerate(el_refs)] blob[key] = ret + if nested: + blob[key] = [blob[key]] + @staticmethod def make_larcv_particles(array, names): ''' From 11d1bac04424f3d29511c17fe9b42ca6806e6a29 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Wed, 22 Mar 2023 17:12:02 -0700 Subject: [PATCH 056/180] Put training and validation curve under the same legend group in plotly --- mlreco/visualization/training.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mlreco/visualization/training.py b/mlreco/visualization/training.py index 205dabde..fceaf04e 100644 --- a/mlreco/visualization/training.py +++ b/mlreco/visualization/training.py @@ -205,7 +205,7 @@ def draw_training_curves(log_dir, models, metrics, layout = go.Layout(template='plotly_white', width=1000, height=500, margin=dict(r=20, l=20, b=20, t=20), xaxis=dict(title=dict(text='Epochs', font=dict(size=20)), tickfont=dict(size=20), linecolor='black', mirror=True), yaxis=dict(title=dict(text='Metric', font=dict(size=20)), tickfont=dict(size=20), linecolor='black', mirror=True), - legend=dict(font=dict(size=20))) + legend=dict(font=dict(size=20), tracegroupgap=1)) if len(models) == 1 and same_plot: layout['legend']['title'] = model_names[models[0]] if models[0] in model_names else models[0] @@ -289,11 +289,12 @@ def draw_training_curves(log_dir, models, metrics, if draw_val: axis.errorbar(epoch_val, metricm_val, yerr=metrice_val, fmt='.', color=color, linewidth=linewidth, markersize=markersize) else: - graphs += [go.Scatter(x=epoch_train, y=metric_train, name=label, line=dict(color=color), showlegend=(same_plot | (not same_plot and not i)))] + legendgroup = f'group{i*len(models)+j}' + graphs += [go.Scatter(x=epoch_train, y=metric_train, name=label, line=dict(color=color), legendgroup=legendgroup, showlegend=(same_plot | (not same_plot and not i)))] if draw_val: hovertext = [f'(Iteration: {iter_val[i]:d})' for i in range(len(iter_val))] # hovertext = [f'(Iteration: {iter_val[i]:d}, Epoch: {epoch_val[i]:0.3f}, Metric: {metricm_val[i]:0.3f})' for i in range(len(iter_val))] - graphs += [go.Scatter(x=epoch_val, y=metricm_val, error_y_array=metrice_val, mode='markers', hovertext=hovertext, marker=dict(color=color), showlegend=False)] + graphs += [go.Scatter(x=epoch_val, y=metricm_val, error_y_array=metrice_val, mode='markers', hovertext=hovertext, marker=dict(color=color), legendgroup=legendgroup, showlegend=False)] if not interactive: if not same_plot: From 008cf90fa78f86512811e9711f3ed6b5eb807ae7 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Wed, 22 Mar 2023 21:40:13 -0700 Subject: [PATCH 057/180] Weird purity metric bug fix --- mlreco/utils/metrics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mlreco/utils/metrics.py b/mlreco/utils/metrics.py index 70313028..798840f0 100644 --- a/mlreco/utils/metrics.py +++ b/mlreco/utils/metrics.py @@ -121,7 +121,7 @@ def purity(pred, truth, bid=None): pred, pcts = unique_with_batch(pred, bid) truth, tcts = unique_with_batch(truth, bid) else: - pred, pcts = (pred) + pred, pcts = unique_label(pred) truth, tcts = unique_label(truth) table = contingency_table(pred, truth, len(pcts), len(tcts)) purities = table.max(axis=1) / pcts From 96bc2858a1bc917020cdb29612599260e01250c3 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Thu, 23 Mar 2023 16:51:52 -0700 Subject: [PATCH 058/180] Made segmentation the size of input_rescaled --- mlreco/models/full_chain.py | 34 ++++++++----------- mlreco/models/layers/common/gnn_full_chain.py | 23 +++++-------- mlreco/utils/deghosting.py | 21 ++++++------ 3 files changed, 33 insertions(+), 45 deletions(-) diff --git a/mlreco/models/full_chain.py b/mlreco/models/full_chain.py index 04b52e71..72ed455a 100644 --- a/mlreco/models/full_chain.py +++ b/mlreco/models/full_chain.py @@ -79,8 +79,9 @@ class FullChain(FullChainGNN): 'particle_seg': ['tensor', 'particle_batch_ids', True], 'particle_start_points': ['tensor', 'particle_start_points', False, True], 'particle_end_points': ['tensor', 'particle_end_points', False, True], - 'segment_label_adapted': ['tensor', 'input_data'], - 'cluster_label_adapted': ['tensor', 'cluster_label_adapted', False, True] + 'segment_label_tmp': ['tensor', 'input_data'], # Will get rid of this + 'cluster_label_adapted': ['tensor', 'cluster_label_adapted', False, True], + 'kinematics_label_adapted': ['tensor', 'kinematics_label_adapted', False, True] } def __init__(self, cfg): @@ -93,7 +94,8 @@ def __init__(self, cfg): self.deghost_input_features = self.uresnet_deghost.net.num_input self.RETURNS.update(self.uresnet_deghost.RETURNS) self.RETURNS['input_rescaled'] = ['tensor', 'input_rescaled', False, True] - self.RETURNS['segment_label_adapted'][1] = 'input_rescaled' + self.RETURNS['segmentation'][1] = 'input_rescaled' + self.RETURNS['segment_label_tmp'][1] = 'input_rescaled' self.RETURNS['fragment_clusts'][1][0] = 'input_rescaled' # Initialize the UResNet+PPN modules @@ -241,15 +243,13 @@ def full_chain_cnn(self, input): if not self.enable_charge_rescaling: result.update(self.uresnet_lonely([input[0][:, :4+self.input_features]])) else: - full_seg = torch.zeros((input[0][:,:5].shape[0], 5), device=input[0].device, dtype=input[0].dtype) if torch.sum(deghost): result.update(self.uresnet_lonely([input[0][deghost, :4+self.input_features]])) - seg = result['segmentation'][0] - full_seg[deghost] = seg - result['segmentation'][0] = full_seg else: - result['segmentation'] = [full_seg] - return result, input, lambda x: x + # TODO: move empty case handling elsewhere + seg = torch.zeros((input[0][deghost,:5].shape[0], 5), device=input[0].device, dtype=input[0].dtype) # DUMB + result['segmentation'] = [seg] + return result, input if self.enable_ppn: ppn_input = {} @@ -291,12 +291,9 @@ def full_chain_cnn(self, input): batch_column=0, coords_column_range=(1,4)) - segmentation = result['segmentation'][0].clone() - deghost_result = {} deghost_result.update(result) deghost_result.pop('ghost') - deghost_result['segmentation'][0] = result['segmentation'][0][deghost] if self.enable_ppn and not self.enable_charge_rescaling: deghost_result['ppn_points'] = [result['ppn_points'][0][deghost]] deghost_result['ppn_masks'][0][-1] = result['ppn_masks'][0][-1][deghost] @@ -306,7 +303,6 @@ def full_chain_cnn(self, input): deghost_result['ppn_classify_endpoints'] = [result['ppn_classify_endpoints'][0][deghost]] cnn_result.update(deghost_result) cnn_result['ghost'] = result['ghost'] - # cnn_result['segmentation'][0] = segmentation else: cnn_result.update(result) @@ -328,6 +324,9 @@ def full_chain_cnn(self, input): semantic_labels = label_seg[0][:, -1] else: semantic_labels = torch.argmax(cnn_result['segmentation'][0], dim=1).flatten() + if not self.charge_rescaling and 'ghost' in cnn_result: + deghost = result['ghost'][0].argmax(dim=1) == 0 + semantic_labels = semantic_labels[deghost] if self.enable_cnn_clust: if label_clustering is None and self.training: @@ -395,7 +394,7 @@ def full_chain_cnn(self, input): }) if self.enable_cnn_clust or self.enable_dbscan: - cnn_result.update({'segment_label_adapted': [semantic_labels] }) + cnn_result.update({'segment_label_tmp': [semantic_labels] }) if label_clustering is not None: cnn_result.update({'cluster_label_adapted': label_clustering }) @@ -403,12 +402,7 @@ def full_chain_cnn(self, input): # print('adding true points info') # cnn_result['true_points'] = coords - def return_to_original(result): - if self.enable_ghost: - result['segmentation'][0] = segmentation - return result - - return cnn_result, input, return_to_original + return cnn_result, input class FullChainLoss(FullChainLoss): diff --git a/mlreco/models/layers/common/gnn_full_chain.py b/mlreco/models/layers/common/gnn_full_chain.py index fd852c56..36055c78 100644 --- a/mlreco/models/layers/common/gnn_full_chain.py +++ b/mlreco/models/layers/common/gnn_full_chain.py @@ -172,13 +172,13 @@ def get_all_fragments(self, result, input): fragments = result['frag_dict']['frags'][0] frag_seg = result['frag_dict']['frag_seg'][0] frag_batch_ids = result['frag_dict']['frag_batch_ids'][0] - semantic_labels = result['segment_label_adapted'][0] + semantic_labels = result['segment_label_tmp'][0] frag_dict = { 'frags': fragments, 'frag_seg': frag_seg, 'frag_batch_ids': frag_batch_ids, - 'segment_label_adapted': semantic_labels + 'segment_label_tmp': semantic_labels } # Since and depend on the batch column of the input @@ -267,7 +267,7 @@ def get_all_particles(self, frag_result, result, input): fragments = frag_result['frags'] frag_seg = frag_result['frag_seg'] frag_batch_ids = frag_result['frag_batch_ids'] - semantic_labels = frag_result['segment_label_adapted'] + semantic_labels = frag_result['segment_label_tmp'] # for i, c in enumerate(fragments): # print('format' , torch.unique(input[0][c, self.batch_col], return_counts=True)) @@ -561,13 +561,12 @@ def forward(self, input): input: list of np.ndarray """ - result, input, revert_func = self.full_chain_cnn(input) + result, input = self.full_chain_cnn(input) if len(input[0]) and 'frag_dict' in result and self.process_fragments and (self.enable_gnn_track or self.enable_gnn_shower or self.enable_gnn_inter or self.enable_gnn_particle): result = self.full_chain_gnn(result, input) if 'frag_dict' in result: del result['frag_dict'] - result = revert_func(result) return result @@ -632,13 +631,14 @@ def forward(self, out, seg_label, ppn_label=None, cluster_label=None, kinematics res['deghost_' + key] = res_deghost[key] accuracy += res_deghost['accuracy'] loss += self.deghost_weight*res_deghost['loss'] - deghost = (out['ghost'][0][:,0] > out['ghost'][0][:,1]) & (seg_label[0][:,-1] < 5) # Only apply loss to reco/true non-ghosts + deghost = out['ghost'][0][:,0] > out['ghost'][0][:,1] if self.enable_uresnet and 'segmentation' in out: if not self.enable_charge_rescaling: res_seg = self.uresnet_loss(out, seg_label) else: - res_seg = self.uresnet_loss({'segmentation':[out['segmentation'][0][deghost]]}, [seg_label[0][deghost]]) + true_deghost = seg_label[0][deghost,-1] < 5 # Do not apply loss on true ghosts classified as non-ghosts + res_seg = self.uresnet_loss({'segmentation':[out['segmentation'][0][true_deghost]]}, [seg_label[0][deghost][true_deghost]]) for key in res_seg: res['segmentation_' + key] = res_seg[key] accuracy += res_seg['accuracy'] @@ -671,11 +671,7 @@ def forward(self, out, seg_label, ppn_label=None, cluster_label=None, kinematics # Adapt to ghost points if cluster_label is not None: - cluster_label = adapt_labels(out, - seg_label, - cluster_label, - batch_column=self.batch_col, - true_mask=true_mask) + cluster_label = out['cluster_label_adapted'] if kinematics_label is not None: kinematics_label = adapt_labels(out, @@ -683,6 +679,7 @@ def forward(self, out, seg_label, ppn_label=None, cluster_label=None, kinematics kinematics_label, batch_column=self.batch_col, true_mask=true_mask) + out['kinematics_label_adapted'] = kinematics_label # TODO: Merge cluster and kinematics labels segment_label = seg_label[0][deghost][:, -1] seg_label = seg_label[0][deghost] @@ -697,8 +694,6 @@ def forward(self, out, seg_label, ppn_label=None, cluster_label=None, kinematics segmentation_pred = out['segmentation'][0] - if self.enable_ghost: - segmentation_pred = segmentation_pred[deghost] if self._gspice_use_true_labels: gs_seg_label = torch.cat([cluster_label[0][:, :4], segment_label[:, None]], dim=1) else: diff --git a/mlreco/utils/deghosting.py b/mlreco/utils/deghosting.py index f447b9f4..37de8d73 100644 --- a/mlreco/utils/deghosting.py +++ b/mlreco/utils/deghosting.py @@ -106,6 +106,8 @@ def adapt_labels_knn(result, label_seg, label_clustering, compute_neighbor = lambda X_true, X_pred: cdist(X_pred[:, c1:c2], X_true[:, c1:c2]).argmin(axis=1) compute_distances = lambda X_true, X_pred: np.amax(np.abs(X_true[:, c1:c2] - X_pred[:, c1:c2]), axis=1) make_float = lambda x : x + make_long = lambda x: x.astype(np.int64) + to_device = lambda x, y: x get_shape = lambda x, y: (x.shape[0], y.shape[1]) else: unique = lambda x: x.int().unique() @@ -117,6 +119,8 @@ def adapt_labels_knn(result, label_seg, label_clustering, compute_neighbor = lambda X_true, X_pred: knn(X_true[:, c1:c2].float(), X_pred[:, c1:c2].float(), 1)[1] compute_distances = lambda X_true, X_pred: torch.amax(torch.abs(X_true[:, c1:c2] - X_pred[:, c1:c2]), dim=1) make_float = lambda x: x.float() + make_long = lambda x: x.long() + to_device = lambda x, y: x.to(y.device) get_shape = lambda x, y: (x.size(0), y.size(1)) if true_mask is not None: @@ -126,6 +130,9 @@ def adapt_labels_knn(result, label_seg, label_clustering, for i in range(len(label_seg)): coords = label_seg[i][:, :c3] label_c = [] + full_nonghost_mask = argmax(result['ghost'][i]) == 0 if true_mask is None else true_mask + full_semantic_pred = to_device(make_long(result['segmentation'][i].shape[1]*ones(len(coords))), coords) + full_semantic_pred[full_nonghost_mask] = argmax(result['segmentation'][i]) for batch_id in unique(coords[:, batch_column]): batch_mask = coords[:, batch_column] == batch_id batch_coords = coords[batch_mask] @@ -133,10 +140,7 @@ def adapt_labels_knn(result, label_seg, label_clustering, if len(batch_clustering) == 0: continue - if true_mask is None: - nonghost_mask = argmax(result['ghost'][i][batch_mask]) == 0 - else: - nonghost_mask = true_mask[batch_mask] + nonghost_mask = full_nonghost_mask[batch_mask] # Prepare new labels new_label_clustering = -1. * ones(get_shape(batch_coords, batch_clustering)) @@ -144,13 +148,8 @@ def adapt_labels_knn(result, label_seg, label_clustering, new_label_clustering = new_label_clustering.cuda() new_label_clustering[:, :c3] = batch_coords - # Loop over predicted semantics - # print(result['segmentation'][i].shape, batch_mask.shape, batch_mask.sum()) - if result['segmentation'][i].shape[0] == batch_mask.shape[0]: - semantic_pred = argmax(result['segmentation'][i][batch_mask]) - else: # adapt_labels was called from analysis tools (see below deghost_labels_and_predictions) - # the problem in this case is that `segmentation` has already been deghosted - semantic_pred = argmax(result['segmentation_true_nonghost'][i][batch_mask]) + # Segmentation is always pre-deghosted + semantic_pred = full_semantic_pred[batch_mask] # Include true nonghost voxels by default when they have the right semantic prediction true_pred = label_seg[i][batch_mask, -1] From eeeba99c2bce604a3604322566c9219b6a8e865c Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Thu, 23 Mar 2023 16:53:43 -0700 Subject: [PATCH 059/180] Typo fix --- mlreco/models/full_chain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mlreco/models/full_chain.py b/mlreco/models/full_chain.py index 72ed455a..27ba3ce5 100644 --- a/mlreco/models/full_chain.py +++ b/mlreco/models/full_chain.py @@ -324,7 +324,7 @@ def full_chain_cnn(self, input): semantic_labels = label_seg[0][:, -1] else: semantic_labels = torch.argmax(cnn_result['segmentation'][0], dim=1).flatten() - if not self.charge_rescaling and 'ghost' in cnn_result: + if not self.enable_charge_rescaling and 'ghost' in cnn_result: deghost = result['ghost'][0].argmax(dim=1) == 0 semantic_labels = semantic_labels[deghost] From fa01601687c874776f6425e4e91a9f765bb156c8 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Thu, 23 Mar 2023 17:50:38 -0700 Subject: [PATCH 060/180] Store start/end points for GNN input clusters --- mlreco/models/full_chain.py | 2 -- mlreco/models/grappa.py | 4 ++++ mlreco/models/layers/common/gnn_full_chain.py | 5 ----- mlreco/utils/unwrap.py | 3 ++- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/mlreco/models/full_chain.py b/mlreco/models/full_chain.py index 27ba3ce5..29dff97e 100644 --- a/mlreco/models/full_chain.py +++ b/mlreco/models/full_chain.py @@ -77,8 +77,6 @@ class FullChain(FullChainGNN): 'fragment_seg' : ['tensor', 'fragment_batch_ids', True], 'fragment_batch_ids' : ['tensor'], 'particle_seg': ['tensor', 'particle_batch_ids', True], - 'particle_start_points': ['tensor', 'particle_start_points', False, True], - 'particle_end_points': ['tensor', 'particle_end_points', False, True], 'segment_label_tmp': ['tensor', 'input_data'], # Will get rid of this 'cluster_label_adapted': ['tensor', 'cluster_label_adapted', False, True], 'kinematics_label_adapted': ['tensor', 'kinematics_label_adapted', False, True] diff --git a/mlreco/models/grappa.py b/mlreco/models/grappa.py index 26900f4d..64fc1085 100644 --- a/mlreco/models/grappa.py +++ b/mlreco/models/grappa.py @@ -131,6 +131,8 @@ class GNN(torch.nn.Module): 'node_pred_type': ['tensor', 'batch_ids', True], 'node_pred_vtx': ['tensor', 'batch_ids', True], 'node_pred_p': ['tensor', 'batch_ids', True], + 'start_points': ['tensor', 'batch_ids', False, True], + 'end_points': ['tensor', 'batch_ids', False, True], 'group_pred': ['index_tensor', 'batch_ids', True], 'edge_features': ['edge_tensor', ['edge_index', 'batch_ids'], True], 'edge_index': ['edge_tensor', ['edge_index', 'batch_ids'], True], @@ -414,6 +416,8 @@ def forward(self, data, clusts=None, groups=None, points=None, extra_feats=None, if points is None: points = get_cluster_points_label(cluster_data, particles, clusts, coords_index=self.coords_index) x = torch.cat([x, points.float()], dim=1) + result['start_points'] = [np.hstack([batch_ids[:,None], points[:,:3].detach().cpu().numpy()])] + result['end_points'] = [np.hstack([batch_ids[:,None], points[:,3:].detach().cpu().numpy()])] if self.add_local_dirs: dirs_start = get_cluster_directions(cluster_data[:, self.coords_index[0]:self.coords_index[1]], points[:,:3], clusts, self.dir_max_dist, self.opt_dir_max_dist) if self.add_local_dirs != 'start': diff --git a/mlreco/models/layers/common/gnn_full_chain.py b/mlreco/models/layers/common/gnn_full_chain.py index 36055c78..81304bfc 100644 --- a/mlreco/models/layers/common/gnn_full_chain.py +++ b/mlreco/models/layers/common/gnn_full_chain.py @@ -432,11 +432,6 @@ def run_particle_gnns(self, result, input, frag_result): 'particle', kwargs) - # Store particle level quantities for ease of access - if 'points' in kwargs: - result['particle_start_points'] = [np.hstack([result['particle_batch_ids'][0][:,None], kwargs['points'][:,:3].cpu().numpy()])] - result['particle_end_points'] = [np.hstack([result['particle_batch_ids'][0][:,None], kwargs['points'][:,3:].cpu().numpy()])] - # If requested, enforce that particle PID predictions are compatible with semantics, # i.e. set logits to -inf if they belong to incompatible PIDs if self._inter_enforce_semantics and 'particle_node_pred_type' in result: diff --git a/mlreco/utils/unwrap.py b/mlreco/utils/unwrap.py index 77ffe683..38a1862b 100644 --- a/mlreco/utils/unwrap.py +++ b/mlreco/utils/unwrap.py @@ -98,7 +98,8 @@ def _parse_rules(self, rules): if not parsed_rules[key].ref_key: parsed_rules[key].ref_key = key - assert parsed_rules[key].method in valid_methods + assert parsed_rules[key].method in valid_methods,\ + f'Unwrapping method {parsed_rules[key].method} for {key} not valid' return parsed_rules From 1f01c29a6fa7c4becaac91e09238ae0f76e0a3fb Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Thu, 23 Mar 2023 18:38:11 -0700 Subject: [PATCH 061/180] Repalced cdist with its proxy for faster execution --- mlreco/visualization/gnn.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/mlreco/visualization/gnn.py b/mlreco/visualization/gnn.py index fd4e2d80..f6992027 100644 --- a/mlreco/visualization/gnn.py +++ b/mlreco/visualization/gnn.py @@ -1,5 +1,6 @@ import numpy as np import plotly.graph_objs as go +from mlreco.utils.numba_local import closest_pair def scatter_clusters(voxels, labels, clusters, markersize=5, colorscale='Viridis'): """ @@ -229,12 +230,10 @@ def network_topology(voxels, clusters, edge_index=[], clust_labels=[], edge_labe **kwargs) for i, c in enumerate(clusters)] # Define the edges closest pixel to closest pixel - import scipy as sp edge_vertices = [] for i, j in edge_index: vi, vj = voxels[clusters[i]], voxels[clusters[j]] - d12 = sp.spatial.distance.cdist(vi, vj, 'euclidean') - i1, i2 = np.unravel_index(np.argmin(d12), d12.shape) + i1, i2, _ = closest_pair(vi, vj, 'recursive') edge_vertices.append([vi[i1], vj[i2], [None, None, None]]) if draw_edges: @@ -270,12 +269,10 @@ def network_topology(voxels, clusters, edge_index=[], clust_labels=[], edge_labe # Define the edges closest pixel to closest pixel if draw_edges: - import scipy as sp edge_vertices = [] for i, j in edge_index: vi, vj = voxels[clusters[i]], voxels[clusters[j]] - d12 = sp.spatial.distance.cdist(vi, vj, 'euclidean') - i1, i2 = np.unravel_index(np.argmin(d12), d12.shape) + i1, i2, _ = closest_pair(vi, vj, 'recursive') edge_vertices.append([vi[i1], vj[i2], [None, None, None]]) edge_vertices = np.concatenate(edge_vertices) From ff5d68c8b35a355f1a3bbbf2f5f3a2306d113414 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Thu, 23 Mar 2023 19:39:55 -0700 Subject: [PATCH 062/180] Added simple pi0 tagging --- analysis/algorithms/utils.py | 28 +++++++++++++++-- analysis/classes/Interaction.py | 1 + analysis/classes/TruthInteraction.py | 1 + analysis/classes/evaluator.py | 26 +++++++++------ analysis/classes/particle.py | 47 +++++++++++++++++++++++++++- analysis/classes/predictor.py | 9 +++--- 6 files changed, 95 insertions(+), 17 deletions(-) diff --git a/analysis/algorithms/utils.py b/analysis/algorithms/utils.py index 071938bd..1ed3f0e7 100644 --- a/analysis/algorithms/utils.py +++ b/analysis/algorithms/utils.py @@ -210,7 +210,8 @@ def get_interaction_properties(interaction: Interaction, spatial_size, prefix=No 'count_primary_electrons': -1, 'count_primary_muons': -1, 'count_primary_pions': -1, - 'count_primary_protons': -1 + 'count_primary_protons': -1, + 'count_pi0': -1 # 'nu_reco_energy': -1 }) @@ -239,6 +240,8 @@ def get_interaction_properties(interaction: Interaction, spatial_size, prefix=No if p.pid == 4: count_primary_protons[p.id] = True + update_dict['count_pi0'] = len(interaction._pi0_tagged_photons) + update_dict['interaction_id'] = interaction.id update_dict['interaction_size'] = interaction.size update_dict['count_primary_muons'] = sum(count_primary_muons.values()) @@ -407,4 +410,25 @@ def get_mparticles_from_minteractions(int_matches): else: matched_particles.append((p, ia1[match_id])) match_counts.append(p._match_counts[match_id]) - return matched_particles, np.array(match_counts) \ No newline at end of file + return matched_particles, np.array(match_counts) + +def closest_distance_two_lines(a0, u0, a1, u1): + ''' + a0, u0: point (a0) and unit vector (u0) defining line 1 + a1, u1: point (a1) and unit vector (u1) defining line 2 + ''' + cross = np.cross(u0, u1) + # if the cross product is zero, the lines are parallel + if np.linalg.norm(cross) == 0: + # use any point on line A and project it onto line B + t = np.dot(a1 - a0, u1) + a = a1 + t * u1 # projected point + return np.linalg.norm(a0 - a) + else: + # use the formula from https://en.wikipedia.org/wiki/Skew_lines#Distance + t = np.dot(np.cross(a1 - a0, u1), cross) / np.linalg.norm(cross)**2 + # closest point on line A to line B + p = a0 + t * u0 + # closest point on line B to line A + q = p - cross * np.dot(p - a1, cross) / np.linalg.norm(cross)**2 + return np.linalg.norm(p - q) # distance between p and q \ No newline at end of file diff --git a/analysis/classes/Interaction.py b/analysis/classes/Interaction.py index 08e0b5b1..0262d319 100644 --- a/analysis/classes/Interaction.py +++ b/analysis/classes/Interaction.py @@ -69,6 +69,7 @@ def __init__(self, interaction_id: int, particles : OrderedDict, vertex=None, nu self.nu_id = nu_id self.volume = volume + self._pi0_tagged_photons = [] @property diff --git a/analysis/classes/TruthInteraction.py b/analysis/classes/TruthInteraction.py index e2c86b75..d881953f 100644 --- a/analysis/classes/TruthInteraction.py +++ b/analysis/classes/TruthInteraction.py @@ -18,6 +18,7 @@ def __init__(self, *args, **kwargs): self.depositions_MeV.append(p.depositions_MeV) if p.is_primary: self.num_primaries += 1 self.depositions_MeV = np.hstack(self.depositions_MeV) + self._pi0_tagged_photons = [] @property diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index 97460b13..b7aded75 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -257,7 +257,6 @@ def get_true_fragments(self, entry, verbose=False) -> List[TruthParticleFragment # Both are "adapted" labels labels = self.data_blob['cluster_label'][entry] - segment_label = self.data_blob['segment_label'][entry][:, -1] rescaled_input_charge = self.result['input_rescaled'][entry][:, 4] fragment_ids = set(list(np.unique(labels[:, 5]).astype(int))) @@ -360,7 +359,6 @@ def get_true_particles(self, entry, only_primaries=True, labels = self.data_blob['cluster_label'][entry] if self.deghosting: labels_noghost = self.data_blob['cluster_label_true_nonghost'][entry] - segment_label = self.data_blob['segment_label'][entry][:, -1] particle_ids = set(list(np.unique(labels[:, 6]).astype(int))) rescaled_input_charge = self.result['input_rescaled'][entry][:, 4] @@ -462,14 +460,17 @@ def get_true_particles(self, entry, only_primaries=True, def get_true_interactions(self, entry, drop_nonprimary_particles=True, min_particle_voxel_count=-1, - compute_vertex=True) -> List[Interaction]: + compute_vertex=True, + tag_pi0=False) -> List[Interaction]: if min_particle_voxel_count < 0: min_particle_voxel_count = self.min_particle_voxel_count out = [] true_particles = self.get_true_particles(entry, only_primaries=drop_nonprimary_particles) out = group_particles_to_interactions_fn(true_particles, - get_nu_id=True, mode='truth') + get_nu_id=True, + mode='truth', + tag_pi0=tag_pi0) if compute_vertex: vertices = self.get_true_vertices(entry) for ia in out: @@ -561,6 +562,7 @@ def match_interactions(self, entry, mode='pred_to_true', compute_vertex=True, vertex_mode='all', matching_mode='one_way', + tag_pi0=False, **kwargs): """ Parameters @@ -584,18 +586,22 @@ def match_interactions(self, entry, mode='pred_to_true', ints_from = self.get_interactions(entry, drop_nonprimary_particles=drop_nonprimary_particles, compute_vertex=compute_vertex, - vertex_mode=vertex_mode) + vertex_mode=vertex_mode, + tag_pi0=tag_pi0) ints_to = self.get_true_interactions(entry, - drop_nonprimary_particles=drop_nonprimary_particles, - compute_vertex=compute_vertex) + drop_nonprimary_particles=drop_nonprimary_particles, + compute_vertex=compute_vertex, + tag_pi0=tag_pi0) elif mode == 'true_to_pred': ints_to = self.get_interactions(entry, drop_nonprimary_particles=drop_nonprimary_particles, compute_vertex=compute_vertex, - vertex_mode=vertex_mode) + vertex_mode=vertex_mode, + tag_pi0=tag_pi0) ints_from = self.get_true_interactions(entry, - drop_nonprimary_particles=drop_nonprimary_particles, - compute_vertex=compute_vertex) + drop_nonprimary_particles=drop_nonprimary_particles, + compute_vertex=compute_vertex, + tag_pi0=tag_pi0) else: raise ValueError("Mode {} is not valid. For matching each"\ " prediction to truth, use 'pred_to_true' (and vice versa).".format(mode)) diff --git a/analysis/classes/particle.py b/analysis/classes/particle.py index 3e8fe0eb..80150024 100644 --- a/analysis/classes/particle.py +++ b/analysis/classes/particle.py @@ -4,6 +4,7 @@ from typing import Counter, List, Union from collections import defaultdict, OrderedDict from functools import partial +from itertools import combinations import re from scipy.optimize import linear_sum_assignment @@ -12,6 +13,8 @@ from pprint import pprint from . import Particle, TruthParticle, Interaction, TruthInteraction +from analysis.algorithms.utils import closest_distance_two_lines +from analysis.algorithms.calorimetry import get_particle_direction def matrix_counts(particles_x, particles_y): @@ -386,8 +389,47 @@ def match_interactions_optimal(ints_from : List[Interaction], return matches, intersections +def _tag_neutral_pions_true(particles): + out = [] + tagged = defaultdict(list) + for part in particles: + num_voxels_noghost = p.coords_noghost.shape[0] + p = part.asis + ancestor = p.ancestor_track_id() + if p.pdg_code() == 22 \ + and p.creation_process() == "Decay" \ + and p.parent_creation_process() == "primary" \ + and p.ancestor_pdg_code() == 111 \ + and num_voxels_noghost > 0: + tagged[ancestor].append(p.id()) + for photon_list in tagged.values(): + out.append(tuple(photon_list)) + return out + +def _tag_neutral_pions_reco(particles, threshold=5): + out = [] + photons = [p for p in particles if p.pid == 0] + for entry in combinations(photons, 2): + p1, p2 = entry + v1, v2 = get_particle_direction(p1), get_particle_direction(p2) + d = closest_distance_two_lines(p1.startpoint, v1, p2.startpoint, v2) + if d < threshold: + out.append((p1.id, p2.id)) + return out + + +def tag_neutral_pions(particles, mode): + if mode == 'truth': + return _tag_neutral_pions_true(particles) + elif mode == 'pred': + return _tag_neutral_pions_reco(particles) + else: + raise ValueError + + def group_particles_to_interactions_fn(particles : List[Particle], - get_nu_id=False, mode='pred'): + get_nu_id=False, mode='pred', + tag_pi0=False): """ Function for grouping particles to its parent interactions. @@ -426,5 +468,8 @@ def group_particles_to_interactions_fn(particles : List[Particle], interactions[int_id] = TruthInteraction(int_id, particles_dict, nu_id=nu_id) else: raise ValueError + if tag_pi0: + tagged = tag_neutral_pions(particles, mode=mode) + interactions[int_id]._pi0_tagged_photons = tagged return list(interactions.values()) diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index 823a8ddf..7431eb85 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -647,9 +647,9 @@ def get_fragments(self, entry, only_primaries=False, shower_mask = np.isin(fragments_seg, self.module_config['grappa_shower']['base']['node_type']) shower_frag_primary = np.argmax(self.result['shower_fragment_node_pred'][entry], axis=1) - if 'shower_node_features' in self.result: + if 'shower_fragment_node_features' in self.result: shower_node_features = self.result['shower_fragment_node_features'][entry] - if 'track_node_features' in self.result: + if 'track_fragment_node_features' in self.result: track_node_features = self.result['track_fragment_node_features'][entry] assert len(fragments_seg) == len(fragments) @@ -900,7 +900,8 @@ def get_interactions(self, entry, volume=None, compute_vertex=True, use_primaries_for_vertex=True, - vertex_mode=None) -> List[Interaction]: + vertex_mode=None, + tag_pi0=False) -> List[Interaction]: ''' Method for retriving interaction list for given batch index. @@ -934,7 +935,7 @@ def get_interactions(self, entry, particles = self.get_particles(entry, only_primaries=drop_nonprimary_particles, volume=volume) - out = group_particles_to_interactions_fn(particles) + out = group_particles_to_interactions_fn(particles, mode='pred', tag_pi0=tag_pi0) for ia in out: if compute_vertex: ia.vertex, ia.vertex_candidate_count = estimate_vertex( From 88b6870d96d27309884a23d20c95933502c2d7e0 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Thu, 23 Mar 2023 22:30:39 -0700 Subject: [PATCH 063/180] Further analysis tools changes --- analysis/classes/evaluator.py | 74 ++++++++++++----------------------- analysis/classes/particle.py | 2 +- analysis/classes/predictor.py | 46 ++++++---------------- 3 files changed, 39 insertions(+), 83 deletions(-) diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index b7aded75..801338ca 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -1,29 +1,16 @@ -from typing import Callable, Tuple, List +from typing import List import numpy as np -import os -import time - -from mlreco.utils.cluster.cluster_graph_constructor import ClusterGraphConstructor -from mlreco.utils.ppn import uresnet_ppn_type_point_selector -from mlreco.utils.metrics import unique_label -from collections import defaultdict - -from scipy.special import softmax -from analysis.classes import Particle, ParticleFragment, TruthParticleFragment, \ - TruthParticle, Interaction, TruthInteraction, FlashManager -from analysis.classes.particle import matrix_counts, matrix_iou, \ - match_particles_fn, match_interactions_fn, group_particles_to_interactions_fn, \ - match_interactions_optimal, match_particles_optimal + +from analysis.classes import TruthParticleFragment, TruthParticle, Interaction +from analysis.classes.particle import (match_particles_fn, + match_interactions_fn, + group_particles_to_interactions_fn, + match_interactions_optimal, + match_particles_optimal) from analysis.algorithms.point_matching import * from mlreco.utils.groups import type_labels as TYPE_LABELS from mlreco.utils.vertex import get_vertex -from analysis.algorithms.vertex import estimate_vertex -from analysis.algorithms.utils import correct_track_points -from mlreco.utils.deghosting import deghost_labels_and_predictions - -from mlreco.utils.gnn.cluster import get_cluster_label, form_clusters -from mlreco.iotools.collates import VolumeBoundaries from analysis.classes.predictor import FullChainPredictor @@ -178,7 +165,7 @@ def __init__(self, data_blob, result, cfg, processor_cfg={}, **kwargs): super(FullChainEvaluator, self).__init__(data_blob, result, cfg, processor_cfg, **kwargs) self.michel_primary_ionization_only = processor_cfg.get('michel_primary_ionization_only', False) - def get_true_label(self, entry, name, schema='cluster_label'): + def get_true_label(self, entry, name, schema='cluster_label_adapted'): """ Retrieve tensor in data blob, labelled with `schema`. @@ -202,7 +189,7 @@ def get_true_label(self, entry, name, schema='cluster_label'): name, str(list(self.LABEL_TO_COLUMN.keys())))) column_idx = self.LABEL_TO_COLUMN[name] - out = self.data_blob[schema][entry][:, column_idx] + out = self.result[schema][entry][:, column_idx] return np.concatenate(out, axis=0) @@ -228,7 +215,7 @@ def get_predicted_label(self, entry, name): def _apply_true_voxel_cut(self, entry): - labels = self.data_blob['cluster_label_true_nonghost'][entry] + labels = self.data_blob['cluster_label'][entry] particle_ids = set(list(np.unique(labels[:, 6]).astype(int))) particles_exclude = [] @@ -256,7 +243,7 @@ def get_true_fragments(self, entry, verbose=False) -> List[TruthParticleFragment fragments = [] # Both are "adapted" labels - labels = self.data_blob['cluster_label'][entry] + labels = self.result['cluster_label_adapted'][entry] rescaled_input_charge = self.result['input_rescaled'][entry][:, 4] fragment_ids = set(list(np.unique(labels[:, 5]).astype(int))) @@ -356,9 +343,8 @@ def get_true_particles(self, entry, only_primaries=True, ''' out_particles_list = [] - labels = self.data_blob['cluster_label'][entry] - if self.deghosting: - labels_noghost = self.data_blob['cluster_label_true_nonghost'][entry] + labels = self.result['cluster_label_adapted'][entry] + labels_noghost = self.data_blob['cluster_label'][entry] particle_ids = set(list(np.unique(labels[:, 6]).astype(int))) rescaled_input_charge = self.result['input_rescaled'][entry][:, 4] @@ -369,8 +355,7 @@ def get_true_particles(self, entry, only_primaries=True, pid = int(p.id()) pdg = TYPE_LABELS.get(p.pdg_code(), -1) is_primary = p.group_id() == p.parent_id() - if self.deghosting: - mask_noghost = labels_noghost[:, 6].astype(int) == pid + mask_noghost = labels_noghost[:, 6].astype(int) == pid if np.count_nonzero(mask_noghost) <= 0: continue # 1. Check if current pid is one of the existing group ids @@ -388,36 +373,27 @@ def get_true_particles(self, entry, only_primaries=True, # Cluster labels will have the entire Michel together. if self.michel_primary_ionization_only and 2 in labels[mask][:, -1].astype(int): mask = mask & (labels[:, -1].astype(int) == 2) - if self.deghosting: - mask_noghost = mask_noghost & (labels_noghost[:, -1].astype(int) == 2) + mask_noghost = mask_noghost & (labels_noghost[:, -1].astype(int) == 2) - coords = self.data_blob['input_data'][entry][mask][:, 1:4] - volume_labels = self.data_blob['input_data'][entry][mask][:, 0] + coords = self.result['input_rescaled'][entry][mask][:, 1:4] + volume_labels = self.result['input_rescaled'][entry][mask][:, 0] volume_id, cts = np.unique(volume_labels, return_counts=True) volume_id = int(volume_id[cts.argmax()]) voxel_indices = np.where(mask)[0] fragments = np.unique(labels[mask][:, 5].astype(int)) depositions_MeV = labels[mask][:, 4] depositions = rescaled_input_charge[mask] # Will be in ADC - coords_noghost, depositions_noghost = None, None - if self.deghosting: - coords_noghost = labels_noghost[mask_noghost][:, 1:4] - depositions_noghost = labels_noghost[mask_noghost][:, 4].squeeze() + coords_noghost = labels_noghost[mask_noghost][:, 1:4] + depositions_noghost = labels_noghost[mask_noghost][:, 4].squeeze() # 2. Process particle-level labels if p.pdg_code() not in TYPE_LABELS: # print("PID {} not in TYPE LABELS".format(pid)) continue - # For deghosting inputs, perform voxel cut with true nonghost coords. - if self.deghosting: - exclude_ids = self._apply_true_voxel_cut(entry) - if pid in exclude_ids: - # Skip this particle if its below the voxel minimum requirement - # print("PID {} was excluded from the list of particles due"\ - # " to true nonghost voxel cut. Exclude IDS = {}".format( - # p.id(), str(exclude_ids) - # )) - continue + exclude_ids = self._apply_true_voxel_cut(entry) + if pid in exclude_ids: + # Skip this particle if its below the voxel minimum requirement + continue semantic_type, interaction_id, nu_id = get_true_particle_labels(labels, mask, pid=pid, verbose=verbose) @@ -496,7 +472,7 @@ def get_true_vertices(self, entry): """ out = {} inter_idxs = np.unique( - self.data_blob['cluster_label'][entry][:, 7].astype(int)) + self.result['cluster_label_adapted'][entry][:, 7].astype(int)) for inter_idx in inter_idxs: if inter_idx < 0: continue diff --git a/analysis/classes/particle.py b/analysis/classes/particle.py index 80150024..acb83566 100644 --- a/analysis/classes/particle.py +++ b/analysis/classes/particle.py @@ -393,7 +393,7 @@ def _tag_neutral_pions_true(particles): out = [] tagged = defaultdict(list) for part in particles: - num_voxels_noghost = p.coords_noghost.shape[0] + num_voxels_noghost = part.coords_noghost.shape[0] p = part.asis ancestor = p.ancestor_track_id() if p.pdg_code() == 22 \ diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index 7431eb85..157d4b1e 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -6,18 +6,15 @@ from mlreco.utils.cluster.cluster_graph_constructor import ClusterGraphConstructor from mlreco.utils.ppn import uresnet_ppn_type_point_selector from mlreco.utils.metrics import unique_label -from collections import defaultdict from scipy.special import softmax -from analysis.classes import Particle, ParticleFragment, TruthParticleFragment, \ - TruthParticle, Interaction, TruthInteraction, FlashManager +from analysis.classes import Particle, ParticleFragment, Interaction, FlashManager from analysis.classes.particle import group_particles_to_interactions_fn from analysis.algorithms.point_matching import * from mlreco.utils.groups import type_labels as TYPE_LABELS from analysis.algorithms.vertex import estimate_vertex from analysis.algorithms.utils import get_track_points -from mlreco.utils.deghosting import deghost_labels_and_predictions from mlreco.utils.gnn.cluster import get_cluster_label # from mlreco.utils.volumes import VolumeBoundaries @@ -52,32 +49,17 @@ class FullChainPredictor: 3) Some outputs needs to be listed under trainval.concat_result. The predictor will run through a checklist to ensure this condition - - 4) Does not support deghosting at the moment. (TODO) ''' - def __init__(self, data_blob, result, cfg, predictor_cfg={}, deghosting=False, - enable_flash_matching=False, flash_matching_cfg="", opflash_keys=[]): + def __init__(self, data_blob, result, cfg, predictor_cfg={}, + enable_flash_matching=False, flash_matching_cfg="", opflash_keys=[]): self.module_config = cfg['model']['modules'] self.cfg = cfg - # Handle deghosting before anything and save deghosting specific - # quantities separately from data_blob and result - - self.deghosting = self.module_config['chain']['enable_ghost'] self.pred_vtx_positions = self.module_config['grappa_inter']['vertex_net'].get('pred_vtx_positions', None) self.data_blob = data_blob self.result = result - # Check data_blob lengths - # if len(self.data_blob['segment_label']) != len(self.data_blob['cluster_label']): - # for key in self.data_blob: - # print(key, len(self.data_blob[key])) - # raise AssertionError - - if self.deghosting: - deghost_labels_and_predictions(self.data_blob, self.result) - - self.num_images = len(data_blob['input_data']) + self.num_images = len(result['input_rescaled']) self.index = self.data_blob['index'] self.spatial_size = predictor_cfg['spatial_size'] @@ -98,8 +80,6 @@ def __init__(self, data_blob, result, cfg, predictor_cfg={}, deghosting=False, self.attaching_threshold = predictor_cfg.get('attaching_threshold', 2) self.inter_threshold = predictor_cfg.get('inter_threshold', 10) - self.batch_mask = self.data_blob['input_data'] - # Vertex estimation modes self.vertex_mode = predictor_cfg.get('vertex_mode', 'all') self.prune_vertex = predictor_cfg.get('prune_vertex', True) @@ -293,9 +273,9 @@ def _fit_predict_ppn(self, entry): x, y, z, coordinates, Score, Type, and sample index. ''' # Deghosting is already applied during initialization - ppn = uresnet_ppn_type_point_selector(self.data_blob['input_data'][entry], + ppn = uresnet_ppn_type_point_selector(self.result['input_rescaled'][entry], self.result, - entry=entry, apply_deghosting=not self.deghosting) + entry=entry, apply_deghosting=False) ppn_voxels = ppn[:, 1:4] ppn_score = ppn[:, 5] ppn_type = ppn[:, 12] @@ -450,7 +430,7 @@ def _fit_predict_fragments(self, entry): ''' fragments = self.result['fragment_clusts'][entry] - num_voxels = self.data_blob['input_data'][entry].shape[0] + num_voxels = self.result['input_rescaled'][entry].shape[0] pred_frag_labels = -np.ones(num_voxels).astype(int) for i, mask in enumerate(fragments): @@ -476,7 +456,7 @@ def _fit_predict_groups(self, entry): - labels: 1D numpy integer array of predicted group labels. ''' particles = self.result['particle_clusts'][entry] - num_voxels = self.data_blob['input_data'][entry].shape[0] + num_voxels = self.result['input_rescaled'][entry].shape[0] pred_group_labels = -np.ones(num_voxels).astype(int) for i, mask in enumerate(particles): @@ -503,7 +483,7 @@ def _fit_predict_interaction_labels(self, entry): ''' inter_group_pred = self.result['particle_group_pred'][entry] particles = self.result['particle_clusts'][entry] - num_voxels = self.data_blob['input_data'][entry].shape[0] + num_voxels = self.result['input_rescaled'][entry].shape[0] pred_inter_labels = -np.ones(num_voxels).astype(int) for i, mask in enumerate(particles): @@ -532,7 +512,7 @@ def _fit_predict_pids(self, entry): particles = self.result['particle_clusts'][entry] type_logits = self.result['particle_node_pred_type'][entry] pids = np.argmax(type_logits, axis=1) - num_voxels = self.data_blob['input_data'][entry].shape[0] + num_voxels = self.result['input_rescaled'][entry].shape[0] pred_pids = -np.ones(num_voxels).astype(int) @@ -639,7 +619,7 @@ def get_fragments(self, entry, only_primaries=False, out_fragment_list = [] - point_cloud = self.data_blob['input_data'][entry][:, 1:4] + point_cloud = self.result['input_rescaled'][entry][:, 1:4] depositions = self.result['input_rescaled'][entry][:, 4] fragments = self.result['fragment_clusts'][entry] fragments_seg = self.result['fragment_seg'][entry] @@ -789,8 +769,8 @@ def get_particles(self, entry, only_primaries=False, # Loop over images - volume_labels = self.data_blob['input_data'][entry][:, 0] - point_cloud = self.data_blob['input_data'][entry][:, 1:4] + volume_labels = self.result['input_rescaled'][entry][:, 0] + point_cloud = self.result['input_rescaled'][entry][:, 1:4] depositions = self.result['input_rescaled'][entry][:, 4] particles = self.result['particle_clusts'][entry] # inter_group_pred = self.result['inter_group_pred'][entry] From 23a02917f9d4a723cf918eca70285496046f0993 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Thu, 23 Mar 2023 22:45:12 -0700 Subject: [PATCH 064/180] Sped up geometric node encoding --- mlreco/utils/gnn/cluster.py | 9 +++++---- mlreco/utils/inference.py | 3 +++ mlreco/utils/numba_local.py | 17 ++++++++++++----- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/mlreco/utils/gnn/cluster.py b/mlreco/utils/gnn/cluster.py index 2627dc73..4a0b8f03 100644 --- a/mlreco/utils/gnn/cluster.py +++ b/mlreco/utils/gnn/cluster.py @@ -143,7 +143,7 @@ def _get_cluster_label(data: nb.float64[:,:], labels = np.empty(len(clusts), dtype=data.dtype) for i, c in enumerate(clusts): v, cts = nbl.unique(data[c, column]) - labels[i] = v[np.argmax(np.array(cts))] + labels[i] = v[np.argmax(cts)] return labels @@ -182,7 +182,7 @@ def _get_cluster_primary_label(data: nb.float64[:,:], v, cts = nbl.unique(data[clusts[i][primary_mask], column]) else: # If the primary is empty, use group v, cts = nbl.unique(data[clusts[i], column]) - labels[i] = v[np.argmax(np.array(cts))] + labels[i] = v[np.argmax(cts)] return labels @@ -298,7 +298,7 @@ def get_cluster_features(data: nb.float64[:,:], """ return _get_cluster_features(data, clusts, batch_col=batch_col, coords_col=coords_col) -@nb.njit(cache=True) +@nb.njit(parallel=True, cache=True) def _get_cluster_features(data: nb.float64[:,:], clusts: nb.types.List(nb.int64[:]), batch_col: nb.int64 = 0, @@ -367,6 +367,7 @@ def get_cluster_features_extended(data, clusts, batch_col=0, coords_col=(1, 4)): """ return _get_cluster_features_extended(data, clusts, batch_col=batch_col, coords_col=coords_col) +@nb.njit(parallel=True, cache=True) def _get_cluster_features_extended(data: nb.float64[:,:], clusts: nb.types.List(nb.int64[:]), batch_col: nb.int64 = 0, @@ -511,7 +512,7 @@ def get_cluster_dedxs(data, values, starts, clusts, max_dist=-1): """ return _get_cluster_dedxs(data, values, starts, clusts, max_dist) -@nb.njit(parallel=True) +@nb.njit(parallel=True, cache=True) def _get_cluster_dedxs(data: nb.float64[:,:], values: nb.float64[:], starts: nb.float64[:,:], diff --git a/mlreco/utils/inference.py b/mlreco/utils/inference.py index 1040fd05..31aa15c3 100644 --- a/mlreco/utils/inference.py +++ b/mlreco/utils/inference.py @@ -40,6 +40,9 @@ def get_inference_cfg(cfg_path, dataset_path=None, weights_path=None, batch_size # Turn train to False cfg['trainval']['train'] = False + # Turn on unwrapper + cfg['trainval']['unwrap'] = True + # Delete the random sampler if 'sampler' in cfg['iotool']: del cfg['iotool']['sampler'] diff --git a/mlreco/utils/numba_local.py b/mlreco/utils/numba_local.py index fe6f613e..6f057b2b 100644 --- a/mlreco/utils/numba_local.py +++ b/mlreco/utils/numba_local.py @@ -31,7 +31,7 @@ def submatrix(x: nb.float32[:,:], @nb.njit(cache=True) -def unique(x: nb.int32[:]) -> (nb.int32[:], nb.int32[:]): +def unique(x: nb.int32[:]) -> (nb.int32[:], nb.int64[:]): """ Numba implementation of `np.unique(x, return_counts=True)`. @@ -50,13 +50,20 @@ def unique(x: nb.int32[:]) -> (nb.int32[:], nb.int32[:]): b = np.sort(x.flatten()) unique = list(b[:1]) counts = [1 for _ in unique] - for x in b[1:]: - if x != unique[-1]: - unique.append(x) + for v in b[1:]: + if v != unique[-1]: + unique.append(v) counts.append(1) else: counts[-1] += 1 - return unique, counts + + unique_np = np.empty(len(unique), dtype=x.dtype) + counts_np = np.empty(len(counts), dtype=np.int32) + for i in range(len(unique)): + unique_np[i] = unique[i] + counts_np[i] = counts[i] + + return unique_np, counts_np @nb.njit(cache=True) From d3b99b374604e2cc94ea29fc0c67c57fd6a0346f Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Thu, 23 Mar 2023 23:12:25 -0700 Subject: [PATCH 065/180] Handle no-label data in graph-SPICE constructor --- mlreco/utils/cluster/cluster_graph_constructor.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/mlreco/utils/cluster/cluster_graph_constructor.py b/mlreco/utils/cluster/cluster_graph_constructor.py index 0f9b0088..79d72919 100644 --- a/mlreco/utils/cluster/cluster_graph_constructor.py +++ b/mlreco/utils/cluster/cluster_graph_constructor.py @@ -360,14 +360,16 @@ def replace_state(self, result, prefix='', unwrapped=False): graph.pos = graph.pos.numpy() graph.edge_index = graph.edge_index.numpy() graph.edge_attr = graph.edge_attr.numpy() - graph.edge_truth = graph.edge_truth.numpy() + if hasattr(graph, 'edge_truth'): + graph.edge_truth = graph.edge_truth.numpy() else: graph = GraphBatch(x = concat(result[prefix+'features']), batch = concat(result[prefix+'coordinates'])[:,0], pos = concat(result[prefix+'coordinates'])[:,1:4], edge_index = concat(result[prefix+'edge_index']).T, - edge_attr = concat(result[prefix+'edge_score']), - edge_truth = concat(result[prefix+'edge_truth'])) + edge_attr = concat(result[prefix+'edge_score'])) + if prefix+'edge_truth' in result: + graph.edge_truth = concat(result[prefix+'edge_truth']) self._graph_batch = graph self._num_total_nodes = self._graph_batch.x.shape[0] self._node_dim = self._graph_batch.x.shape[1] From d4af93712fa081737fdbb3fc17af41b3acdf63e5 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Thu, 23 Mar 2023 23:53:14 -0700 Subject: [PATCH 066/180] Wrap all glob.glob calls with sorted to ensure repeatability --- bin/run_chain.py | 2 +- bin/wrapper.py | 2 +- bin/wrapper_systematics.py | 2 +- mlreco/iotools/datasets.py | 2 +- mlreco/iotools/readers.py | 24 +++++++++++++++--------- mlreco/main_funcs.py | 2 +- 6 files changed, 20 insertions(+), 14 deletions(-) diff --git a/bin/run_chain.py b/bin/run_chain.py index c9374fb4..04e6ac6a 100644 --- a/bin/run_chain.py +++ b/bin/run_chain.py @@ -14,7 +14,7 @@ def load(filename, limit=None): import glob logs = [] - files = glob.glob(filename) + files = sorted(glob.glob(filename)) print(filename) for f in files: #print(f) diff --git a/bin/wrapper.py b/bin/wrapper.py index bcf5b08b..973d9b47 100644 --- a/bin/wrapper.py +++ b/bin/wrapper.py @@ -27,7 +27,7 @@ input_files = [os.path.join(sample_dir, "larcv*.root")] file_list = [] for f in input_files: - file_list.extend(glob.glob(f)) + file_list.extend(sorted(glob.glob(f))) file_list.sort() file_list = file_list[(task_id-1) * file_count_per_task:task_id * file_count_per_task] io_cfg['iotool']['dataset']['data_keys'] = file_list diff --git a/bin/wrapper_systematics.py b/bin/wrapper_systematics.py index 6983372e..f0cd2e74 100644 --- a/bin/wrapper_systematics.py +++ b/bin/wrapper_systematics.py @@ -37,7 +37,7 @@ input_files = [os.path.join(sample_dir, "larcv*.root")] file_list = [] for f in input_files: - file_list.extend(glob.glob(f)) + file_list.extend(sorted(glob.glob(f))) file_list.sort() #file_list = file_list[(task_id-1) * file_count_per_task:task_id * file_count_per_task] io_cfg['iotool']['dataset']['data_keys'] = file_list diff --git a/mlreco/iotools/datasets.py b/mlreco/iotools/datasets.py index 74bc89bb..e3cb1333 100644 --- a/mlreco/iotools/datasets.py +++ b/mlreco/iotools/datasets.py @@ -41,7 +41,7 @@ def __init__(self, data_schema, data_keys, limit_num_files=0, limit_num_samples= # Create file list self._files = [] for key in data_keys: - fs = glob.glob(key) + fs = sorted(glob.glob(key)) for f in fs: self._files.append(f) if len(self._files) >= limit_num_files: break diff --git a/mlreco/iotools/readers.py b/mlreco/iotools/readers.py index e0d61ef4..16bde6ec 100644 --- a/mlreco/iotools/readers.py +++ b/mlreco/iotools/readers.py @@ -1,6 +1,7 @@ -import numpy as np -import h5py import yaml +import h5py +import glob +import numpy as np class HDF5Reader: ''' @@ -9,7 +10,7 @@ class HDF5Reader: More documentation to come. ''' - def __init__(self, file_paths, entry_list=[], skip_entry_list=[], larcv_particles=False): + def __init__(self, file_keys, entry_list=[], skip_entry_list=[], larcv_particles=False): ''' Load up the HDF5 file. @@ -22,19 +23,24 @@ def __init__(self, file_paths, entry_list=[], skip_entry_list=[], larcv_particle skip_entry_list: list(int) Entry IDs to be skipped ''' - # Make sure the file path(s) is(are) provided in the form of a list - if isinstance(file_paths, str): - file_paths = [file_paths] + # Convert the file keys to a list of file paths with glob + self.file_paths = [] + if isinstance(file_keys, str): + file_keys = [file_keys] + for file_key in file_keys: + file_paths = glob.glob(file_key) + assert len(file_paths), f'File key {file_key} yielded no compatible path' + self.file_paths.extend(sorted(file_paths)) # Loop over the input files, build a map from index to file ID - self.file_paths = file_paths - self.file_index = [] self.num_entries = 0 - for i, path in enumerate(file_paths): + self.file_index = [] + for i, path in enumerate(self.file_paths): with h5py.File(path, 'r') as file: assert 'events' in file, 'File does not contain an event tree' self.num_entries += len(file['events']) self.file_index.append(i*np.ones(len(file['events']), dtype=np.int32)) + print('Registered', path) self.file_index = np.concatenate(self.file_index) # Build an entry list to access diff --git a/mlreco/main_funcs.py b/mlreco/main_funcs.py index 91a30309..08cffd9c 100644 --- a/mlreco/main_funcs.py +++ b/mlreco/main_funcs.py @@ -349,7 +349,7 @@ def inference_loop(handlers): tsum = 0. # Metrics for each event # global_metrics = {} - weights = glob.glob(handlers.cfg['trainval']['model_path']) + weights = sorted(glob.glob(handlers.cfg['trainval']['model_path'])) if not len(weights): weights = [None] if len(weights) > 1: From e40b5b270e8a5eb59e30747d312975b444716cb6 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Fri, 24 Mar 2023 01:02:26 -0700 Subject: [PATCH 067/180] Got rid of kinematics_label entirely --- mlreco/iotools/parsers/cluster.py | 51 ++++++++----------- mlreco/models/full_chain.py | 3 +- mlreco/models/layers/common/gnn_full_chain.py | 15 ++---- .../layers/gnn/losses/node_kinematics.py | 13 ++--- .../models/layers/gnn/losses/node_primary.py | 3 +- mlreco/models/layers/gnn/losses/node_type.py | 5 +- mlreco/utils/globals.py | 15 ++++-- 7 files changed, 50 insertions(+), 55 deletions(-) diff --git a/mlreco/iotools/parsers/cluster.py b/mlreco/iotools/parsers/cluster.py index 47e83d34..e14da666 100644 --- a/mlreco/iotools/parsers/cluster.py +++ b/mlreco/iotools/parsers/cluster.py @@ -60,7 +60,7 @@ def parse_cluster3d(cluster_event, particle_mpv_event = None, sparse_semantics_event = None, sparse_value_event = None, - add_particle_info = False, + add_particle_info = True, add_kinematics_info = False, clean_data = True, precedence = [1,2,0,3,4], @@ -84,7 +84,6 @@ def parse_cluster3d(cluster_event, sparse_semantics_event: sparse3d_semantics sparse_value_event: sparse3d_pcluster add_particle_info: true - add_kinematics_info: false clean_data: true precedence: [1,2,0,3,4] type_include_mpr: false @@ -100,7 +99,6 @@ def parse_cluster3d(cluster_event, sparse_semantics_event: larcv::EventSparseTensor3D sparse_value_event: larcv::EventSparseTensor3D add_particle_info: bool - add_kinematics_info: bool clean_data: bool precedence: list type_include_mpr: bool @@ -122,46 +120,41 @@ def parse_cluster3d(cluster_event, * interaction id, * nu id, * particle type, - * primary id - if add_kinematics_info is true, it also includes - * group id, - * particle type, - * momentum, + * shower primary id, + * primary group id, * vtx (x,y,z), - * primary group id - if either add_* is true, it includes last: + * momentum, * semantic type """ + # Temporary deprecation warning + if add_kinematics_info: + from warnings import warn + warn('add_kinematics_info is deprecated, simply use add_particle_info') + add_particle_info = True # Get the cluster-wise information meta = cluster_event.meta() num_clusters = cluster_event.as_vector().size() labels = OrderedDict() labels['cluster'] = np.arange(num_clusters) - if add_particle_info or add_kinematics_info: - assert particle_event is not None, "Must provide particle tree if particle/kinematics information is included" + if add_particle_info: + assert particle_event is not None, "Must provide particle tree if particle information is included" particles_v = particle_event.as_vector() particles_mpv_v = particle_mpv_event.as_vector() if particle_mpv_event is not None else None - inter_ids = get_interaction_id(particles_v) - nu_ids = get_nu_id(cluster_event, particles_v, inter_ids, particle_mpv=particles_mpv_v) + particles_voxel = parse_particles(particle_event, cluster_event) labels['cluster'] = np.array([p.id() for p in particles_v]) labels['group'] = np.array([p.group_id() for p in particles_v]) - if add_particle_info: - labels['inter'] = inter_ids - labels['nu'] = nu_ids - labels['type'] = get_particle_id(particles_v, nu_ids, type_include_mpr, type_include_secondary) - labels['primary_shower'] = get_shower_primary_id(cluster_event, particles_v) - if add_kinematics_info: - primary_ids = get_group_primary_id(particles_v, nu_ids, primary_include_mpr) - labels['type'] = get_particle_id(particles_v, nu_ids, type_include_mpr, type_include_secondary) - labels['p'] = np.array([p.p()/1e3 for p in particles_v]) # In GeV - particles_v = parse_particles(particle_event, cluster_event) - labels['vtx_x'] = np.array([p.ancestor_position().x() for p in particles_v]) - labels['vtx_y'] = np.array([p.ancestor_position().y() for p in particles_v]) - labels['vtx_z'] = np.array([p.ancestor_position().z() for p in particles_v]) - labels['primary_group'] = primary_ids - labels['sem'] = np.array([p.shape() for p in particles_v]) + labels['inter'] = get_interaction_id(particles_v) + labels['nu'] = get_nu_id(cluster_event, particles_v, labels['inter'], particles_mpv_v) + labels['type'] = get_particle_id(particles_v, labels['nu'], type_include_mpr, type_include_secondary) + labels['pshower'] = get_shower_primary_id(cluster_event, particles_v) + labels['pgroup'] = get_group_primary_id(particles_v, labels['nu'], primary_include_mpr) + labels['vtx_x'] = np.array([p.ancestor_position().x() for p in particles_v]) + labels['vtx_y'] = np.array([p.ancestor_position().y() for p in particles_v]) + labels['vtx_z'] = np.array([p.ancestor_position().z() for p in particles_v]) + labels['p'] = np.array([p.p()/1e3 for p in particles_v]) # In GeV + labels['shape'] = np.array([p.shape() for p in particles_v]) # Loop over clusters, store info clusters_voxels, clusters_features = [], [] diff --git a/mlreco/models/full_chain.py b/mlreco/models/full_chain.py index 29dff97e..5e895cd2 100644 --- a/mlreco/models/full_chain.py +++ b/mlreco/models/full_chain.py @@ -78,8 +78,7 @@ class FullChain(FullChainGNN): 'fragment_batch_ids' : ['tensor'], 'particle_seg': ['tensor', 'particle_batch_ids', True], 'segment_label_tmp': ['tensor', 'input_data'], # Will get rid of this - 'cluster_label_adapted': ['tensor', 'cluster_label_adapted', False, True], - 'kinematics_label_adapted': ['tensor', 'kinematics_label_adapted', False, True] + 'cluster_label_adapted': ['tensor', 'cluster_label_adapted', False, True] } def __init__(self, cfg): diff --git a/mlreco/models/layers/common/gnn_full_chain.py b/mlreco/models/layers/common/gnn_full_chain.py index 81304bfc..abb9ecf8 100644 --- a/mlreco/models/layers/common/gnn_full_chain.py +++ b/mlreco/models/layers/common/gnn_full_chain.py @@ -618,6 +618,9 @@ def forward(self, out, seg_label, ppn_label=None, cluster_label=None, kinematics particle_graph=None, iteration=None): res = {} accuracy, loss = 0., 0. + if kinematics_label is not None: + from warnings import warn + warn('kinematics_label is no longer needed, remove it from the config', DeprecationWarning, stacklevel=2) if self.enable_charge_rescaling: ghost_label = torch.cat((seg_label[0][:,:4], (seg_label[0][:,-1] == 5).type(seg_label[0].dtype).reshape(-1,1)), dim=-1) @@ -668,14 +671,6 @@ def forward(self, out, seg_label, ppn_label=None, cluster_label=None, kinematics if cluster_label is not None: cluster_label = out['cluster_label_adapted'] - if kinematics_label is not None: - kinematics_label = adapt_labels(out, - seg_label, - kinematics_label, - batch_column=self.batch_col, - true_mask=true_mask) - out['kinematics_label_adapted'] = kinematics_label # TODO: Merge cluster and kinematics labels - segment_label = seg_label[0][deghost][:, -1] seg_label = seg_label[0][deghost] else: @@ -793,7 +788,7 @@ def forward(self, out, seg_label, ppn_label=None, cluster_label=None, kinematics if 'particle_node_features' in out: gnn_out.update({ 'node_features': out['particle_node_features'] }) if 'particle_edge_features' in out: gnn_out.update({ 'edge_features': out['particle_edge_features'] }) - res_gnn_inter = self.inter_gnn_loss(gnn_out, cluster_label, node_label=kinematics_label, graph=particle_graph, iteration=iteration) + res_gnn_inter = self.inter_gnn_loss(gnn_out, cluster_label, node_label=cluster_label, graph=particle_graph, iteration=iteration) for key in res_gnn_inter: res['grappa_inter_' + key] = res_gnn_inter[key] @@ -813,7 +808,7 @@ def forward(self, out, seg_label, ppn_label=None, cluster_label=None, kinematics gnn_out.update({ 'node_pred_type': out['kinematics_node_pred_type'] }) if 'kinematics_node_pred_p' in out: gnn_out.update({ 'node_pred_p': out['kinematics_node_pred_p'] }) - res_kinematics = self.kinematics_loss(gnn_out, kinematics_label, graph=particle_graph) + res_kinematics = self.kinematics_loss(gnn_out, cluster_label, graph=particle_graph) for key in res_kinematics: res['grappa_kinematics_' + key] = res_kinematics[key] diff --git a/mlreco/models/layers/gnn/losses/node_kinematics.py b/mlreco/models/layers/gnn/losses/node_kinematics.py index 9e128488..c1d120e6 100644 --- a/mlreco/models/layers/gnn/losses/node_kinematics.py +++ b/mlreco/models/layers/gnn/losses/node_kinematics.py @@ -1,6 +1,7 @@ -from mlreco.utils.metrics import unique_label import torch import numpy as np +from mlreco.utils.globals import * +from mlreco.utils.metrics import unique_label from mlreco.utils.gnn.cluster import get_cluster_label, get_momenta_label from mlreco.models.experimental.bayes.evidential import EDLRegressionLoss, EVDLoss from torch_scatter import scatter @@ -90,11 +91,11 @@ def __init__(self, loss_config, batch_col=0, coords_col=(1, 4)): self.batch_col = batch_col self.coords_col = coords_col - self.group_col = loss_config.get('cluster_col', 6) - self.type_col = loss_config.get('type_col', 7) - self.momentum_col = loss_config.get('momentum_col', 8) - self.vtx_col = loss_config.get('vtx_col', 9) - self.vtx_positives_col = loss_config.get('vtx_positives_col', 12) + self.group_col = loss_config.get('cluster_col', GROUP_COL) + self.type_col = loss_config.get('type_col', TYPE_COL) + self.momentum_col = loss_config.get('momentum_col', MOM_COL) + self.vtx_col = loss_config.get('vtx_col', VTX_COLS[0]) + self.vtx_positives_col = loss_config.get('vtx_positives_col', PGRP_COL) # Set the losses self.type_loss = loss_config.get('type_loss', 'CE') diff --git a/mlreco/models/layers/gnn/losses/node_primary.py b/mlreco/models/layers/gnn/losses/node_primary.py index 815b5477..5671a8ad 100644 --- a/mlreco/models/layers/gnn/losses/node_primary.py +++ b/mlreco/models/layers/gnn/losses/node_primary.py @@ -1,5 +1,6 @@ import torch import numpy as np +from mlreco.utils.globals import * from mlreco.utils.gnn.cluster import get_cluster_label from mlreco.utils.gnn.evaluation import node_assignment, node_assignment_score, node_purity_mask @@ -37,7 +38,7 @@ def __init__(self, loss_config, batch_col=0, coords_col=(1, 4)): # Set the loss self.batch_col = batch_col self.coords_col = coords_col - self.primary_col = loss_config.get('primary_col', 10) + self.primary_col = loss_config.get('primary_col', PSHOW_COL) self.loss = loss_config.get('loss', 'CE') self.reduction = loss_config.get('reduction', 'sum') diff --git a/mlreco/models/layers/gnn/losses/node_type.py b/mlreco/models/layers/gnn/losses/node_type.py index 74ef4638..532ed12f 100644 --- a/mlreco/models/layers/gnn/losses/node_type.py +++ b/mlreco/models/layers/gnn/losses/node_type.py @@ -1,5 +1,6 @@ import torch import numpy as np +from mlreco.utils.globals import * from mlreco.utils.gnn.cluster import get_cluster_label from mlreco.models.experimental.bayes.evidential import EVDLoss @@ -36,8 +37,8 @@ def __init__(self, loss_config, batch_col=0, coords_col=(1, 4)): self.batch_col = batch_col self.coords_col = coords_col - self.group_col = loss_config.get('group_col', 6) - self.target_col = loss_config.get('target_col', 7) + self.group_col = loss_config.get('group_col', GROUP_COL) + self.target_col = loss_config.get('target_col', INTER_COL) # Set the loss self.loss = loss_config.get('loss', 'CE') diff --git a/mlreco/utils/globals.py b/mlreco/utils/globals.py index 38420d71..68567c0e 100644 --- a/mlreco/utils/globals.py +++ b/mlreco/utils/globals.py @@ -4,14 +4,19 @@ # Columns which specify the voxel coordinates in a sparse tensor COORD_COLS = (1,2,3) -# Colum which specifies the value of a voxel in a sparse tensor +# Colum which specifies the first value of a voxel in a sparse tensor VALUE_COL = 4 -# Colum which specifies the cluster ID of a voxel in a sparse tensor +# Columns that specify each attribute in a cluster label tensor CLUST_COL = 5 - -# Colum which specifies the cluster group ID of a voxel in a sparse tensor GROUP_COL = 6 +INTER_COL = 7 +NU_COL = 8 +TYPE_COL = 9 +PSHOW_COL = 10 +PGRP_COL = 11 +VTX_COLS = (12,13,14) +MOM_COL = 15 -# Colum which specifies the shape ID of a voxel in a sparse tensor +# Colum which specifies the shape ID of a voxel in a sparse tensor SHAPE_COL = -1 From 8aa16b96d332e529a93ce8be70505e24968766db Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Fri, 24 Mar 2023 09:30:14 -0700 Subject: [PATCH 068/180] Fixed OpenGLAS multi-threading issue in the get_cluster_features function --- mlreco/utils/gnn/cluster.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mlreco/utils/gnn/cluster.py b/mlreco/utils/gnn/cluster.py index 4a0b8f03..e295bba7 100644 --- a/mlreco/utils/gnn/cluster.py +++ b/mlreco/utils/gnn/cluster.py @@ -316,7 +316,7 @@ def _get_cluster_features(data: nb.float64[:,:], x = x - center # Get orientation matrix - A = x.T.dot(x) + A = np.dot(x.T, x) # Get eigenvectors, normalize orientation matrix and eigenvalues to largest # If points are superimposed, i.e. if the largest eigenvalue != 0, no need to keep going @@ -331,7 +331,7 @@ def _get_cluster_features(data: nb.float64[:,:], v0 = v[:,2] # Projection all points, x, along the principal axis - x0 = x.dot(v0) + x0 = np.dot(x, v0) # Evaluate the distance from the points to the principal axis xp0 = x - np.outer(x0, v0) @@ -667,7 +667,7 @@ def principal_axis(voxels:nb.float64[:,:]) -> nb.float64[:]: x = voxels - center # Get orientation matrix - A = x.T.dot(x) + A = np.dot(x.T, x) # Get eigenvectors, select the one which corresponds to the maximal spread _, v = np.linalg.eigh(A) From 16778c739395c24b3cf4f997a16324d9be92d412 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Fri, 24 Mar 2023 10:11:05 -0700 Subject: [PATCH 069/180] Minor fix --- analysis/algorithms/selections/template.py | 9 +- analysis/classes/predictor.py | 97 +++++++++++----------- 2 files changed, 54 insertions(+), 52 deletions(-) diff --git a/analysis/algorithms/selections/template.py b/analysis/algorithms/selections/template.py index cbb677b5..f40cb0a8 100644 --- a/analysis/algorithms/selections/template.py +++ b/analysis/algorithms/selections/template.py @@ -2,8 +2,7 @@ import os, copy, sys # Flash Matching -sys.path.append('/sdf/group/neutrino/ldomine/OpT0Finder/python') - +# sys.path.append('/sdf/group/neutrino/koh0207/OpT0Finder/python') from analysis.decorator import evaluate from analysis.classes.evaluator import FullChainEvaluator @@ -36,6 +35,7 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): vertex_mode = analysis_cfg['analysis']['vertex_mode'] matching_mode = analysis_cfg['analysis']['matching_mode'] compute_energy = analysis_cfg['analysis'].get('compute_energy', False) + flash_matching_cfg = analysis_cfg['analysis'].get('flash_matching_cfg', '') # FullChainEvaluator config processor_cfg = analysis_cfg['analysis'].get('processor_cfg', {}) @@ -61,12 +61,11 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): # Load data into evaluator if enable_flash_matching: predictor = FullChainEvaluator(data_blob, res, cfg, processor_cfg, - deghosting=deghosting, enable_flash_matching=enable_flash_matching, - flash_matching_cfg="/sdf/group/neutrino/koh0207/logs/nu_selection/flash_matching/config/flashmatch.cfg", + flash_matching_cfg=flash_matching_cfg, opflash_keys=['opflash_cryoE', 'opflash_cryoW']) else: - predictor = FullChainEvaluator(data_blob, res, cfg, processor_cfg, deghosting=deghosting) + predictor = FullChainEvaluator(data_blob, res, cfg, processor_cfg) image_idxs = data_blob['index'] spatial_size = predictor.spatial_size diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index 157d4b1e..c24a1521 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -17,7 +17,7 @@ from analysis.algorithms.utils import get_track_points from mlreco.utils.gnn.cluster import get_cluster_label -# from mlreco.utils.volumes import VolumeBoundaries +from mlreco.utils.volumes import VolumeBoundaries class FullChainPredictor: @@ -108,17 +108,20 @@ def __init__(self, data_blob, result, cfg, predictor_cfg={}, # split over "virtual" batch ids # Note this is different from "self.volume_boundaries" above # FIXME rename one or the other to be clearer - # boundaries = cfg['iotool'].get('collate', {}).get('boundaries', None) - # if boundaries is not None: - # self.vb = VolumeBoundaries(boundaries) - # self._num_volumes = self.vb.num_volumes() - # else: - # self.vb = None - # self._num_volumes = 1 + boundaries = cfg['iotool'].get('collate', {}).get('boundaries', None) + if boundaries is not None: + self.vb = VolumeBoundaries(boundaries) + self._num_volumes = self.vb.num_volumes() + else: + self.vb = None + self._num_volumes = 1 # Prepare flash matching if requested self.enable_flash_matching = enable_flash_matching self.fm = None + + self._num_volumes = len(np.unique(self.data_blob['cluster_label'][0][:, 0])) + if enable_flash_matching: reflash_merging_window = predictor_cfg.get('reflash_merging_window', None) @@ -538,45 +541,45 @@ def _check_volume(self, volume): if volume is not None: assert isinstance(volume, (int, np.int64, np.int32)) and volume >= 0 - # def _translate(self, voxels, volume): - # """ - # Go from 1-volume-only back to full volume coordinates - - # Parameters - # ========== - # voxels: np.ndarray - # Shape (N, 3) - # volume: int - - # Returns - # ======= - # np.ndarray - # Shape (N, 3) - # """ - # if self.vb is None or volume is None: - # return voxels - # else: - # return self.vb.translate(voxels, volume) - - # def _untranslate(self, voxels, volume): - # """ - # Go from full volume to 1-volume-only coordinates - - # Parameters - # ========== - # voxels: np.ndarray - # Shape (N, 3) - # volume: int - - # Returns - # ======= - # np.ndarray - # Shape (N, 3) - # """ - # if self.vb is None or volume is None: - # return voxels - # else: - # return self.vb.untranslate(voxels, volume) + def _translate(self, voxels, volume): + """ + Go from 1-volume-only back to full volume coordinates + + Parameters + ========== + voxels: np.ndarray + Shape (N, 3) + volume: int + + Returns + ======= + np.ndarray + Shape (N, 3) + """ + if self.vb is None or volume is None: + return voxels + else: + return self.vb.translate(voxels, volume) + + def _untranslate(self, voxels, volume): + """ + Go from full volume to 1-volume-only coordinates + + Parameters + ========== + voxels: np.ndarray + Shape (N, 3) + volume: int + + Returns + ======= + np.ndarray + Shape (N, 3) + """ + if self.vb is None or volume is None: + return voxels + else: + return self.vb.untranslate(voxels, volume) def get_fragments(self, entry, only_primaries=False, min_particle_voxel_count=-1, From ba414d3baddc1422ae63a3eac1f99dedfa7a5cc8 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Fri, 24 Mar 2023 14:12:02 -0700 Subject: [PATCH 070/180] Flash matching done --- analysis/algorithms/vertex.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/analysis/algorithms/vertex.py b/analysis/algorithms/vertex.py index bcf4bd48..a55e4a0b 100644 --- a/analysis/algorithms/vertex.py +++ b/analysis/algorithms/vertex.py @@ -8,7 +8,7 @@ @nb.njit(cache=True) def point_to_line_distance_(p1, p2, v2): - dist = np.linalg.norm(np.cross(v2, (p2 - p1))) + dist = np.sqrt(np.sum(np.cross(v2, (p2 - p1))**2)+1e-8) return dist From 99daa052659f99ab639fbe81ba321a0cbe90d777 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Fri, 24 Mar 2023 14:52:28 -0700 Subject: [PATCH 071/180] Analysis reader file name nomenclature --- analysis/decorator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/analysis/decorator.py b/analysis/decorator.py index c4381f7c..588162fe 100644 --- a/analysis/decorator.py +++ b/analysis/decorator.py @@ -49,7 +49,7 @@ def process_dataset(analysis_config, cfg=None, profile=True): max_iteration = len(loader.dataset) assert max_iteration <= len(loader.dataset) else: - file_path = analysis_config['reader']['file_paths'] + file_keys = analysis_config['reader']['file_keys'] entry_list = analysis_config['reader']['entry_list'] skip_entry_list = analysis_config['reader']['skip_entry_list'] Reader = HDF5Reader(file_paths, entry_list, skip_entry_list, True) From fce39bcbb3fb31be6e76192da5fec2ed53b86c3f Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Fri, 24 Mar 2023 16:09:53 -0700 Subject: [PATCH 072/180] HDF5Reader in analysis tools fix --- analysis/decorator.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/analysis/decorator.py b/analysis/decorator.py index 588162fe..b4c9860f 100644 --- a/analysis/decorator.py +++ b/analysis/decorator.py @@ -50,9 +50,9 @@ def process_dataset(analysis_config, cfg=None, profile=True): assert max_iteration <= len(loader.dataset) else: file_keys = analysis_config['reader']['file_keys'] - entry_list = analysis_config['reader']['entry_list'] - skip_entry_list = analysis_config['reader']['skip_entry_list'] - Reader = HDF5Reader(file_paths, entry_list, skip_entry_list, True) + entry_list = analysis_config['reader'].get('entry_list', []) + skip_entry_list = analysis_config['reader'].get('skip_entry_list', []) + Reader = HDF5Reader(file_keys, entry_list, skip_entry_list, True) if max_iteration == -1: max_iteration = len(Reader) assert max_iteration <= len(Reader) From 085ca604cf791d25f78533eab18fa928839ee43d Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Fri, 24 Mar 2023 16:42:55 -0700 Subject: [PATCH 073/180] hotfix --- analysis/algorithms/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/analysis/algorithms/utils.py b/analysis/algorithms/utils.py index 1ed3f0e7..485a4e11 100644 --- a/analysis/algorithms/utils.py +++ b/analysis/algorithms/utils.py @@ -184,7 +184,7 @@ def get_track_points(p, correction_mode='ppn', brute_force=False): else: pts = np.vstack([p.startpoint, p.endpoint]) if correction_mode == 'ppn': - correct_track_endpoints_ppn(p, pts=pts) + correct_track_endpoints_ppn(p) elif correction_mode == 'local_density': correct_track_endpoints_local_density(p) elif correction_mode == 'linfit': From 475c16b94f894a35b2f89294ba0a6cd7b93c2405 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Fri, 24 Mar 2023 22:29:04 -0700 Subject: [PATCH 074/180] Added support for larcv.Flash and larcv.CRTHit in HDF5 writer/reader --- analysis/decorator.py | 4 +- analysis/run.py | 5 +- mlreco/iotools/readers.py | 52 +++++++++++------- mlreco/iotools/writers.py | 108 ++++++++++++++++++++++++++------------ mlreco/utils/vertex.py | 1 - 5 files changed, 111 insertions(+), 59 deletions(-) diff --git a/analysis/decorator.py b/analysis/decorator.py index b4c9860f..eac272e0 100644 --- a/analysis/decorator.py +++ b/analysis/decorator.py @@ -29,7 +29,7 @@ def process_dataset(analysis_config, cfg=None, profile=True): assert cfg is not None or 'reader' in analysis_config max_iteration = analysis_config['analysis']['iteration'] - if cfg is not None: + if 'reader' not in analysis_config: io_cfg = cfg['iotool'] module_config = cfg['model']['modules'] @@ -74,7 +74,7 @@ def process_dataset(analysis_config, cfg=None, profile=True): while iteration < max_iteration: if profile: start = time.time() - if cfg is not None: + if 'reader' not in analysis_config: data_blob, res = Trainer.forward(dataset) else: data_blob, res = Reader.get(iteration, nested=True) diff --git a/analysis/run.py b/analysis/run.py index f17f385b..98657220 100644 --- a/analysis/run.py +++ b/analysis/run.py @@ -26,10 +26,7 @@ def main(analysis_cfg_path, model_cfg_path): analysis_config = yaml.safe_load(open(analysis_cfg_path, 'r')) - config = None - if model_cfg_path is not None: - config = yaml.safe_load(open(model_cfg_path, 'r')) - process_config(config, verbose=False) + config = yaml.safe_load(open(model_cfg_path, 'r')) pprint(analysis_config) if 'analysis' not in analysis_config: diff --git a/mlreco/iotools/readers.py b/mlreco/iotools/readers.py index 16bde6ec..1f884491 100644 --- a/mlreco/iotools/readers.py +++ b/mlreco/iotools/readers.py @@ -10,7 +10,7 @@ class HDF5Reader: More documentation to come. ''' - def __init__(self, file_keys, entry_list=[], skip_entry_list=[], larcv_particles=False): + def __init__(self, file_keys, entry_list=[], skip_entry_list=[], to_larcv=False): ''' Load up the HDF5 file. @@ -47,8 +47,8 @@ def __init__(self, file_keys, entry_list=[], skip_entry_list=[], larcv_particles self.entry_list = self.get_entry_list(entry_list, skip_entry_list) self.file_index = self.file_index[self.entry_list] - # Set whether or not to load true particle objects as LArCV particles - self.larcv_particles = larcv_particles + # Set whether or not to initialize LArCV objects as such + self.to_larcv = to_larcv def __len__(self): ''' @@ -169,11 +169,11 @@ def load_key(self, file, event, data_blob, result_blob, key, nested): # If the reference points at a simple dataset, return blob[key] = group[key][region_ref] else: - # If the dataset has multiple attributes, it contains particle info + # If the dataset has multiple attributes, it contains an object array = group[key][region_ref] names = array.dtype.names - if self.larcv_particles: - blob[key] = self.make_larcv_particles(array, names) + if self.to_larcv: + blob[key] = self.make_larcv_objects(array, names) else: blob[key] = [] for i in range(len(array)): @@ -191,40 +191,54 @@ def load_key(self, file, event, data_blob, result_blob, key, nested): blob[key] = [blob[key]] @staticmethod - def make_larcv_particles(array, names): + def make_larcv_objects(array, names): ''' - Rebuild `larcv.Particle` objects from the stored information + Rebuild `larcv` objects from the stored information. Supports + `larcv.Particle`, `larcv.Neutrino`, `larcv.Flash` and `larcv.CRTHit` Parameters ---------- array : list - List of dictionary of particle information + List of dictionary of larcv object attributes names: List of class attribute names Returns ------- list - List of filled larcv.Particle objects + List of filled `larcv` objects ''' from larcv import larcv + if len(array): + obj_class = larcv.Particle + if 'bjorken_x' in names: obj_class = larcv.Neutrino + elif 'TotalPE' in names: obj_class = larcv.Flash + elif 'tagger' in names: obj_class = larcv.CRTHit + ret = [] for i in range(len(array)): - # Initialize new larcv.Particle object - part_dict = array[i] - particle = larcv.Particle() + # Initialize new larcv.Particle or larcv.Neutrino object + obj_dict = array[i] + obj = obj_class() # Momentum is particular, deal with it first - particle.momentum(part_dict['px'], part_dict['py'], part_dict['pz']) + if isinstance(obj, (larcv.Particle, larcv.Neutrino)): + obj.momentum(*[obj_dict[f'p{k}'] for k in ['x', 'y', 'z']]) + + # Trajectory for neutrino is also particular, deal with it + if isinstance(obj, larcv.Neutrino): + obj.add_trajectory_point(*[obj_dict[f'traj_{k}'] for k in ['x', 'y', 'z', 't', 'px', 'py', 'pz', 'e']]) + + # Now deal with the rest for name in names: - if name in ['px', 'py', 'pz', 'p']: - continue # Addressed by the momentum setter + if name in ['px', 'py', 'pz', 'p', 'TotalPE'] or name[:5] == 'traj_': + continue # Addressed by other setters if 'position' in name or 'step' in name: - getattr(particle, name)(*part_dict[name]) + getattr(obj, name)(*obj_dict[name]) else: cast = lambda x: x.item() if type(x) != bytes and not isinstance(x, np.ndarray) else x - getattr(particle, name)(cast(part_dict[name])) + getattr(obj, name)(cast(obj_dict[name])) - ret.append(particle) + ret.append(obj) return ret diff --git a/mlreco/iotools/writers.py b/mlreco/iotools/writers.py index d4be6f23..2889d657 100644 --- a/mlreco/iotools/writers.py +++ b/mlreco/iotools/writers.py @@ -102,6 +102,7 @@ def register_key(self, blob, key, category): if not isinstance(blob[key], list): # Single scalar (TODO: Is that thing? If not, why not?) self.key_dict[key]['dtype'] = type(blob[key]) + else: if len(blob[key]) != self.batch_size: # List with a single scalar, regardless of batch_size @@ -111,22 +112,50 @@ def register_key(self, blob, key, category): 'If there is an array of length mismatched with batch_size, '+\ 'it must contain a single scalar.' self.key_dict[key]['dtype'] = type(blob[key][0]) + elif not hasattr(blob[key][0], '__len__'): # List containing a single scalar per batch ID self.key_dict[key]['dtype'] = type(blob[key][0]) + elif isinstance(blob[key][0], (list, np.ndarray)) and\ isinstance(blob[key][0][0], larcv.Particle): # List containing a single list of larcv.Particle object per batch ID - self.key_dict[key]['dtype'] = self.get_particle_dtype(blob[key][0][0]) + if not hasattr(self, 'particle_dtype'): + self.particle_dtype = self.get_object_dtype(blob[key][0][0]) + self.key_dict[key]['dtype'] = self.particle_dtype + + elif isinstance(blob[key][0], (list, np.ndarray)) and\ + isinstance(blob[key][0][0], larcv.Neutrino): + # List containing a single list of larcv.Neutrino object per batch ID + if not hasattr(self, 'neutrino_dtype'): + self.neutrino_dtype = self.get_object_dtype(blob[key][0][0]) + self.key_dict[key]['dtype'] = self.neutrino_dtype + + elif isinstance(blob[key][0], (list, np.ndarray)) and\ + isinstance(blob[key][0][0], larcv.Flash): + # List containing a single list of larcv.Flash object per batch ID + if not hasattr(self, 'flash_dtype'): + self.flash_dtype = self.get_object_dtype(blob[key][0][0]) + self.key_dict[key]['dtype'] = self.flash_dtype + + elif isinstance(blob[key][0], (list, np.ndarray)) and\ + isinstance(blob[key][0][0], larcv.CRTHit): + # List containing a single list of larcv.CRTHit object per batch ID + if not hasattr(self, 'crthit_dtype'): + self.crthit_dtype = self.get_object_dtype(blob[key][0][0]) + self.key_dict[key]['dtype'] = self.crthit_dtype + elif isinstance(blob[key][0], list) and\ not hasattr(blob[key][0][0], '__len__'): # List containing a single list of scalars per batch ID self.key_dict[key]['dtype'] = type(blob[key][0][0]) + elif isinstance(blob[key][0], np.ndarray) and\ not blob[key][0].dtype == np.object: # List containing a single ndarray of scalars per batch ID self.key_dict[key]['dtype'] = blob[key][0].dtype self.key_dict[key]['width'] = blob[key][0].shape[1] if len(blob[key][0].shape) == 2 else 0 + elif isinstance(blob[key][0], (list, np.ndarray)) and isinstance(blob[key][0][0], np.ndarray): # List containing a list (or ndarray) of ndarrays per batch ID widths = [] @@ -140,40 +169,43 @@ def register_key(self, blob, key, category): else: raise TypeError('Do not know how to store output of type', type(blob[key][0])) - def get_particle_dtype(self, particle): + def get_object_dtype(self, obj): ''' - Loop over the members of a particle to figure out what to store. + Loop over the members of a class to figure out what to store. This + function assumes that the the class only posses getters that return + either a scalar, a string, a larcv.Vertex, a list, np.ndarrary or a set. Parameters ---------- - particle : larcv.Particle - LArCV particle object used to identify attribute types + object : class instance + Instance of an object used to identify attribute types Returns ------- list List of (key, dtype) pairs ''' - particle_dtype = [] - members = inspect.getmembers(larcv.Particle) - skip_keys = ['dump', 'momentum', 'boundingbox_2d', 'boundingbox_3d'] +\ + object_dtype = [] + members = inspect.getmembers(obj) + skip_keys = ['add_trajectory_point', 'dump', 'momentum', 'boundingbox_2d', 'boundingbox_3d'] +\ [k+a for k in ['', 'parent_', 'ancestor_'] for a in ['x', 'y', 'z', 't']] attr_names = [k for k, _ in members if '__' not in k and k not in skip_keys] for key in attr_names: - val = getattr(particle, key)() + val = getattr(obj, key)() if isinstance(val, (int, float)): - particle_dtype.append((key, type(val))) + object_dtype.append((key, type(val))) elif isinstance(val, str): - particle_dtype.append((key, h5py.string_dtype())) + object_dtype.append((key, h5py.string_dtype())) elif isinstance(val, larcv.Vertex): - particle_dtype.append((key, h5py.vlen_dtype(np.float32))) + object_dtype.append((key, h5py.vlen_dtype(np.float32))) elif hasattr(val, '__len__') and len(val) and isinstance(val[0], (int, float)): - particle_dtype.append((key, h5py.vlen_dtype(type(val[0])))) + object_dtype.append((key, h5py.vlen_dtype(type(val[0])))) + elif hasattr(val, '__len__'): + pass # Empty list, no point in storing else: raise ValueError('Unexpected key') - self.particle_dtype = particle_dtype - return particle_dtype + return object_dtype def initialize_datasets(self, file): ''' @@ -275,16 +307,26 @@ def append_key(self, file, event, blob, key, batch_id): val = self.key_dict[key] cat = val['category'] if not val['merge'] and not isinstance(val['width'], list): - # Store the scalar. TODO: Does not handle scalars (useful?) + # Store single object obj = blob[key][batch_id] if len(blob[key]) == self.batch_size else blob[key][0] - if not hasattr(obj, '__len__'): obj = [obj] - if not hasattr(self, 'particle_dtype') or val['dtype'] != self.particle_dtype: - self.store(file[cat], event, key, obj) + if not hasattr(obj, '__len__'): + obj = [obj] + + if hasattr(self, 'particle_dtype') and val['dtype'] == self.particle_dtype: + self.store_objects(file[cat], event, key, obj, self.particle_dtype) + elif hasattr(self, 'neutrino_dtype') and val['dtype'] == self.neutrino_dtype: + self.store_objects(file[cat], event, key, obj, self.neutrino_dtype) + elif hasattr(self, 'flash_dtype') and val['dtype'] == self.flash_dtype: + self.store_objects(file[cat], event, key, obj, self.flash_dtype) + elif hasattr(self, 'crthit_dtype') and val['dtype'] == self.crthit_dtype: + self.store_objects(file[cat], event, key, obj, self.crthit_dtype) else: - self.store_particles(file[cat], event, key, obj, self.particle_dtype) + self.store(file[cat], event, key, obj) + elif not val['merge']: # Store the array and its reference for each element in the list self.store_jagged(file[cat], event, key, blob[key][batch_id]) + else: # Store one array of for all in the list and a index to break them self.store_flat(file[cat], event, key, blob[key][batch_id]) @@ -395,10 +437,10 @@ def store_flat(group, event, key, array_list): event[key] = region_ref @staticmethod - def store_particles(group, event, key, array, particle_dtype): + def store_objects(group, event, key, array, obj_dtype): ''' - Stores a list of `larcv.Particle` in the file and stores its mapping - in the event dataset. + Stores a list of objects with understandable attributes in + the file and stores its mapping in the event dataset. Parameters ---------- @@ -410,28 +452,28 @@ def store_particles(group, event, key, array, particle_dtype): Name of the dataset in the file array : np.ndarray Array to be stored - particle_dtype : list + obj_dtype : list List of (key, dtype) pairs which specify what's to store ''' - # Convert list of larcv.Particle to list of storable objects - particles = np.empty(len(array), particle_dtype) - for i, p in enumerate(array): - for k, dtype in particle_dtype: - attr = getattr(p, k)() + # Convert list of objects to list of storable objects + objects = np.empty(len(array), obj_dtype) + for i, o in enumerate(array): + for k, dtype in obj_dtype: + attr = getattr(o, k)() if isinstance(attr, (int, float, str)): - particles[i][k] = attr + objects[i][k] = attr elif isinstance(attr, larcv.Vertex): vertex = np.array([getattr(attr, a)() for a in ['x', 'y', 'z', 't']], dtype=np.float32) - particles[i][k] = vertex + objects[i][k] = vertex elif hasattr(attr, '__len__'): vals = np.array([attr[i] for i in range(len(attr))], dtype=np.int32) - particles[i][k] = vals + objects[i][k] = vals # Extend the dataset, store array dataset = group[key] current_id = len(dataset) dataset.resize(current_id + len(array), axis=0) - dataset[current_id:current_id + len(array)] = particles + dataset[current_id:current_id + len(array)] = objects # Define region reference, store it at the event level region_ref = dataset.regionref[current_id:current_id + len(array)] diff --git a/mlreco/utils/vertex.py b/mlreco/utils/vertex.py index 67e7aca8..6c6527b9 100644 --- a/mlreco/utils/vertex.py +++ b/mlreco/utils/vertex.py @@ -8,7 +8,6 @@ from sklearn.decomposition import PCA from mlreco.utils.gnn.evaluation import primary_assignment from mlreco.utils.groups import type_labels -from analysis.algorithms.calorimetry import get_particle_direction def find_closest_points_of_approach(point1, direction1, point2, direction2): From 583f33f648910f9f05ae285794730f1f5beaf48d Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Sat, 25 Mar 2023 00:18:37 -0700 Subject: [PATCH 075/180] Add option to set iterations to -1 to run over full dataset once --- mlreco/main_funcs.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/mlreco/main_funcs.py b/mlreco/main_funcs.py index 08cffd9c..dcfa235e 100644 --- a/mlreco/main_funcs.py +++ b/mlreco/main_funcs.py @@ -80,6 +80,7 @@ def process_config(cfg, verbose=True): cfg['iotool']['minibatch_size'] = -1 if cfg['iotool']['batch_size'] < 0 and cfg['iotool']['minibatch_size'] < 0: raise ValueError('Cannot have both BATCH_SIZE (-bs) and MINIBATCH_SIZE (-mbs) negative values!') + # Assign non-default values num_gpus = 1 if 'trainval' in cfg: @@ -88,6 +89,7 @@ def process_config(cfg, verbose=True): cfg['iotool']['batch_size'] = int(cfg['iotool']['minibatch_size'] * num_gpus) if cfg['iotool']['minibatch_size'] < 0: cfg['iotool']['minibatch_size'] = int(cfg['iotool']['batch_size'] / num_gpus) + # Check consistency if not (cfg['iotool']['batch_size'] % (cfg['iotool']['minibatch_size'] * num_gpus)) == 0: raise ValueError('BATCH_SIZE (-bs) must be multiples of MINIBATCH_SIZE (-mbs) and GPU count (--gpus)!') @@ -149,6 +151,7 @@ def prepare(cfg, event_list=None): # IO writer handlers.writer = writer_factory(cfg) + if 'trainval' in cfg: # Set random seed for reproducibility np.random.seed(cfg['trainval']['seed']) @@ -172,12 +175,16 @@ def prepare(cfg, event_list=None): if cfg['trainval']['train']: handlers.iteration = loaded_iteration + # If the number of iterations is negative, run over the whole dataset once + if cfg['trainval']['iterations'] < 0: + cfg['trainval']['iterations'] = len(handlers.data_io) + make_directories(cfg, loaded_iteration, handlers=handlers) return handlers -def apply_event_filter(handlers,event_list=None): +def apply_event_filter(handlers, event_list=None): """ Reconfigures IO to apply an event filter INPUT: From ef7e005f4d95bbb16e70adfd4fbed603e05526ef Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Sun, 26 Mar 2023 15:30:12 -0700 Subject: [PATCH 076/180] Bug fix in vertex position labeling --- mlreco/iotools/parsers/cluster.py | 8 ++++---- mlreco/main_funcs.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/mlreco/iotools/parsers/cluster.py b/mlreco/iotools/parsers/cluster.py index e14da666..6360eb53 100644 --- a/mlreco/iotools/parsers/cluster.py +++ b/mlreco/iotools/parsers/cluster.py @@ -141,7 +141,7 @@ def parse_cluster3d(cluster_event, assert particle_event is not None, "Must provide particle tree if particle information is included" particles_v = particle_event.as_vector() particles_mpv_v = particle_mpv_event.as_vector() if particle_mpv_event is not None else None - particles_voxel = parse_particles(particle_event, cluster_event) + particles_v_v = parse_particles(particle_event, cluster_event) labels['cluster'] = np.array([p.id() for p in particles_v]) labels['group'] = np.array([p.group_id() for p in particles_v]) @@ -150,9 +150,9 @@ def parse_cluster3d(cluster_event, labels['type'] = get_particle_id(particles_v, labels['nu'], type_include_mpr, type_include_secondary) labels['pshower'] = get_shower_primary_id(cluster_event, particles_v) labels['pgroup'] = get_group_primary_id(particles_v, labels['nu'], primary_include_mpr) - labels['vtx_x'] = np.array([p.ancestor_position().x() for p in particles_v]) - labels['vtx_y'] = np.array([p.ancestor_position().y() for p in particles_v]) - labels['vtx_z'] = np.array([p.ancestor_position().z() for p in particles_v]) + labels['vtx_x'] = np.array([p.ancestor_position().x() for p in particles_v_v]) + labels['vtx_y'] = np.array([p.ancestor_position().y() for p in particles_v_v]) + labels['vtx_z'] = np.array([p.ancestor_position().z() for p in particles_v_v]) labels['p'] = np.array([p.p()/1e3 for p in particles_v]) # In GeV labels['shape'] = np.array([p.shape() for p in particles_v]) diff --git a/mlreco/main_funcs.py b/mlreco/main_funcs.py index dcfa235e..c0b09b21 100644 --- a/mlreco/main_funcs.py +++ b/mlreco/main_funcs.py @@ -76,7 +76,7 @@ def process_config(cfg, verbose=True): cfg['iotool']['sampler']['seed'] = int(cfg['iotool']['sampler']['seed']) # Batch size checker - if cfg['iotool'].get('minibatch_size',None) is None: + if cfg['iotool'].get('minibatch_size', None) is None: cfg['iotool']['minibatch_size'] = -1 if cfg['iotool']['batch_size'] < 0 and cfg['iotool']['minibatch_size'] < 0: raise ValueError('Cannot have both BATCH_SIZE (-bs) and MINIBATCH_SIZE (-mbs) negative values!') From 9859dfe35e4c0ddfd9bf1417f887806dd6f1b7c5 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Sun, 26 Mar 2023 19:59:28 -0700 Subject: [PATCH 077/180] Remove kinematics label from analysis trools --- analysis/classes/evaluator.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index 801338ca..63db901b 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -127,12 +127,6 @@ class FullChainEvaluator(FullChainPredictor): - parse_particle_points_with_tagging - sparse3d_pcluster - particle_corrected - kinematics_label: - - parse_cluster3d_kinematics_clean - - cluster3d_pcluster - - particle_corrected - #- particle_mpv - - sparse3d_pcluster_semantics particle_graph: - parse_particle_graph_corrected - particle_corrected @@ -476,10 +470,11 @@ def get_true_vertices(self, entry): for inter_idx in inter_idxs: if inter_idx < 0: continue - vtx = get_vertex(self.data_blob['kinematics_label'], + vtx = get_vertex(self.data_blob['cluster_label'], self.data_blob['cluster_label'], data_idx=entry, - inter_idx=inter_idx) + inter_idx=inter_idx, + vtx_col=12) out[inter_idx] = vtx return out From f7fa545b874068e6d02eb80d02ea5e3e3e2fc365 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Sun, 26 Mar 2023 20:01:11 -0700 Subject: [PATCH 078/180] Change to use proper importing of globals --- analysis/classes/evaluator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index 63db901b..1d747bae 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -1,6 +1,8 @@ from typing import List import numpy as np +from mlreco.utils.globals import VTX_COLS + from analysis.classes import TruthParticleFragment, TruthParticle, Interaction from analysis.classes.particle import (match_particles_fn, match_interactions_fn, @@ -474,7 +476,7 @@ def get_true_vertices(self, entry): self.data_blob['cluster_label'], data_idx=entry, inter_idx=inter_idx, - vtx_col=12) + vtx_col=VTX_COLS[0]) out[inter_idx] = vtx return out From d693878d1f6989c309a77522b8e63e99b480ba0e Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Sun, 26 Mar 2023 22:41:12 -0700 Subject: [PATCH 079/180] Volume handling hotfix for flashmatching --- analysis/classes/evaluator.py | 17 +++++++++++------ analysis/classes/particle.py | 15 +++++++++++---- analysis/classes/predictor.py | 3 +++ 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index 1d747bae..d70702b6 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -319,7 +319,7 @@ def get_true_fragments(self, entry, verbose=False) -> List[TruthParticleFragment def get_true_particles(self, entry, only_primaries=True, - verbose=False) -> List[TruthParticle]: + verbose=False, volume=None) -> List[TruthParticle]: ''' Get list of instances for given batch id. @@ -372,9 +372,6 @@ def get_true_particles(self, entry, only_primaries=True, mask_noghost = mask_noghost & (labels_noghost[:, -1].astype(int) == 2) coords = self.result['input_rescaled'][entry][mask][:, 1:4] - volume_labels = self.result['input_rescaled'][entry][mask][:, 0] - volume_id, cts = np.unique(volume_labels, return_counts=True) - volume_id = int(volume_id[cts.argmax()]) voxel_indices = np.where(mask)[0] fragments = np.unique(labels[mask][:, 5].astype(int)) depositions_MeV = labels[mask][:, 4] @@ -382,6 +379,10 @@ def get_true_particles(self, entry, only_primaries=True, coords_noghost = labels_noghost[mask_noghost][:, 1:4] depositions_noghost = labels_noghost[mask_noghost][:, 4].squeeze() + volume_labels = labels_noghost[mask_noghost][:, 0] + volume_id, cts = np.unique(volume_labels, return_counts=True) + volume_id = int(volume_id[cts.argmax()]) + # 2. Process particle-level labels if p.pdg_code() not in TYPE_LABELS: # print("PID {} not in TYPE LABELS".format(pid)) @@ -426,6 +427,8 @@ def get_true_particles(self, entry, only_primaries=True, if only_primaries: out_particles_list = [p for p in out_particles_list if p.is_primary] + if volume is not None: + out_particles_list = [p for p in out_particles_list if p.volume == volume] return out_particles_list @@ -433,12 +436,15 @@ def get_true_particles(self, entry, only_primaries=True, def get_true_interactions(self, entry, drop_nonprimary_particles=True, min_particle_voxel_count=-1, compute_vertex=True, + volume=None, tag_pi0=False) -> List[Interaction]: if min_particle_voxel_count < 0: min_particle_voxel_count = self.min_particle_voxel_count out = [] - true_particles = self.get_true_particles(entry, only_primaries=drop_nonprimary_particles) + true_particles = self.get_true_particles(entry, + only_primaries=drop_nonprimary_particles, + volume=volume) out = group_particles_to_interactions_fn(true_particles, get_nu_id=True, mode='truth', @@ -448,7 +454,6 @@ def get_true_interactions(self, entry, drop_nonprimary_particles=True, for ia in out: if compute_vertex and ia.id in vertices: ia.vertex = vertices[ia.id] - # ia.volume = volume return out diff --git a/analysis/classes/particle.py b/analysis/classes/particle.py index acb83566..b166beaf 100644 --- a/analysis/classes/particle.py +++ b/analysis/classes/particle.py @@ -1,8 +1,8 @@ import numpy as np import pandas as pd -from typing import Counter, List, Union -from collections import defaultdict, OrderedDict +from typing import List, Union +from collections import defaultdict, OrderedDict, Counter from functools import partial from itertools import combinations import re @@ -461,15 +461,22 @@ def group_particles_to_interactions_fn(particles : List[Particle], nu_id = nu_id[0] else: nu_id = nu_id[0] + + counter = Counter([p.volume for p in particles if p.volume != -1]) + if not bool(counter): + volume_id = -1 + else: + volume_id = counter.most_common(1)[0][0] particles_dict = OrderedDict({p.id : p for p in particles}) if mode == 'pred': - interactions[int_id] = Interaction(int_id, particles_dict, nu_id=nu_id) + interactions[int_id] = Interaction(int_id, particles_dict, nu_id=nu_id, volume=volume_id) elif mode == 'truth': - interactions[int_id] = TruthInteraction(int_id, particles_dict, nu_id=nu_id) + interactions[int_id] = TruthInteraction(int_id, particles_dict, nu_id=nu_id, volume=volume_id) else: raise ValueError if tag_pi0: tagged = tag_neutral_pions(particles, mode=mode) interactions[int_id]._pi0_tagged_photons = tagged + return list(interactions.values()) diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index c24a1521..728ac328 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -875,6 +875,9 @@ def get_particles(self, entry, only_primaries=False, else: continue + if volume is not None: + out = [p for p in out if p.volume == volume] + return out From 0f2956a96794590ca22c9aae5b4a9cacd691ce6a Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Mon, 27 Mar 2023 01:25:49 -0700 Subject: [PATCH 080/180] Pi0 tagging fix --- analysis/algorithms/selections/template.py | 4 +++- analysis/classes/predictor.py | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/analysis/algorithms/selections/template.py b/analysis/algorithms/selections/template.py index f40cb0a8..5293a086 100644 --- a/analysis/algorithms/selections/template.py +++ b/analysis/algorithms/selections/template.py @@ -36,6 +36,7 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): matching_mode = analysis_cfg['analysis']['matching_mode'] compute_energy = analysis_cfg['analysis'].get('compute_energy', False) flash_matching_cfg = analysis_cfg['analysis'].get('flash_matching_cfg', '') + tag_pi0 = analysis_cfg['analysis'].get('tag_pi0', False) # FullChainEvaluator config processor_cfg = analysis_cfg['analysis'].get('processor_cfg', {}) @@ -93,7 +94,8 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): compute_vertex=compute_vertex, vertex_mode=vertex_mode, overlap_mode=predictor.overlap_mode, - matching_mode=matching_mode) + matching_mode=matching_mode, + tag_pi0=tag_pi0) # 1 a) Check outputs from interaction matching if len(matches) == 0: diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index 728ac328..3fca1fb2 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -19,6 +19,8 @@ from mlreco.utils.gnn.cluster import get_cluster_label from mlreco.utils.volumes import VolumeBoundaries +from scipy.special import softmax + class FullChainPredictor: ''' @@ -85,6 +87,7 @@ def __init__(self, data_blob, result, cfg, predictor_cfg={}, self.prune_vertex = predictor_cfg.get('prune_vertex', True) self.track_endpoints_mode = predictor_cfg.get('track_endpoints_mode', 'node_features') self.track_point_corrector = predictor_cfg.get('track_point_corrector', 'ppn') + self.primary_score_threshold = predictor_cfg.get('primary_score_threshold', None) # This is used to apply fiducial volume cuts. # Min/max boundaries in each dimension haev to be specified. self.volume_boundaries = predictor_cfg.get('volume_boundaries', None) @@ -798,14 +801,25 @@ def get_particles(self, entry, only_primaries=False, assert node_pred_vtx.shape[0] == len(particles) primary_labels = -np.ones(len(node_pred_vtx)).astype(int) + primary_scores = np.zeros(len(node_pred_vtx)).astype(float) if self.pred_vtx_positions: assert node_pred_vtx.shape[1] == 5 - primary_labels = np.argmax(node_pred_vtx[:, 3:], axis=1) + # primary_labels = np.argmax(node_pred_vtx[:, 3:], axis=1) + primary_scores = node_pred_vtx[:, 3:] else: assert node_pred_vtx.shape[1] == 2 - primary_labels = np.argmax(node_pred_vtx, axis=1) + # primary_labels = np.argmax(node_pred_vtx, axis=1) + primary_scores = node_pred_vtx + + primary_scores = softmax(node_pred_vtx, axis=1) + assert primary_labels.shape[0] == len(particles) + if self.primary_score_threshold is None: + primary_labels = np.argmax(node_pred_vtx, axis=1) + else: + primary_labels = node_pred_vtx[:, 0] > self.primary_score_threshold + if ('particle_group_pred' in self.result) and ('particle_clusts' in self.result) and len(particles) > 0: assert len(self.result['particle_group_pred'][entry]) == len(particles) From a22f47e171bee9ec017b0efa1ef72d0328bf12a6 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Mon, 27 Mar 2023 11:53:50 -0700 Subject: [PATCH 081/180] Add process_config to analysis/run.py --- analysis/run.py | 1 + 1 file changed, 1 insertion(+) diff --git a/analysis/run.py b/analysis/run.py index 98657220..984cfd18 100644 --- a/analysis/run.py +++ b/analysis/run.py @@ -27,6 +27,7 @@ def main(analysis_cfg_path, model_cfg_path): analysis_config = yaml.safe_load(open(analysis_cfg_path, 'r')) config = yaml.safe_load(open(model_cfg_path, 'r')) + process_config(config, verbose=False) pprint(analysis_config) if 'analysis' not in analysis_config: From 65187468047ac1dcf0ae21c5c56bf0a63d36c6f0 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Mon, 27 Mar 2023 11:57:02 -0700 Subject: [PATCH 082/180] Particle primary score threshold fix --- analysis/classes/predictor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index 3fca1fb2..b67d29f4 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -818,7 +818,7 @@ def get_particles(self, entry, only_primaries=False, if self.primary_score_threshold is None: primary_labels = np.argmax(node_pred_vtx, axis=1) else: - primary_labels = node_pred_vtx[:, 0] > self.primary_score_threshold + primary_labels = node_pred_vtx[:, 1] > self.primary_score_threshold if ('particle_group_pred' in self.result) and ('particle_clusts' in self.result) and len(particles) > 0: From 1dee8af43f84d807b8e3d8961da570cf157a3bd1 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Tue, 28 Mar 2023 12:29:25 -0700 Subject: [PATCH 083/180] slight change in evaluator to handle primary scores --- analysis/classes/evaluator.py | 2 +- analysis/classes/predictor.py | 27 ++++++++++++--------------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index d70702b6..b105e4e7 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -473,7 +473,7 @@ def get_true_vertices(self, entry): """ out = {} inter_idxs = np.unique( - self.result['cluster_label_adapted'][entry][:, 7].astype(int)) + self.data_blob['cluster_label'][entry][:, 7].astype(int)) for inter_idx in inter_idxs: if inter_idx < 0: continue diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index b67d29f4..1c6c56c7 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -54,10 +54,7 @@ class FullChainPredictor: ''' def __init__(self, data_blob, result, cfg, predictor_cfg={}, enable_flash_matching=False, flash_matching_cfg="", opflash_keys=[]): - self.module_config = cfg['model']['modules'] - self.cfg = cfg - - self.pred_vtx_positions = self.module_config['grappa_inter']['vertex_net'].get('pred_vtx_positions', None) + # self.module_config = cfg['model']['modules'] self.data_blob = data_blob self.result = result @@ -123,7 +120,7 @@ def __init__(self, data_blob, result, cfg, predictor_cfg={}, self.enable_flash_matching = enable_flash_matching self.fm = None - self._num_volumes = len(np.unique(self.data_blob['cluster_label'][0][:, 0])) + self._num_volumes = len(np.unique(self.result['input_rescaled'][0][:, 0])) if enable_flash_matching: reflash_merging_window = predictor_cfg.get('reflash_merging_window', None) @@ -352,8 +349,8 @@ def _fit_predict_gspice_fragments(self, entry): index_mapping = { key : val for key, val in zip( range(0, len(graph_info.Index.unique())), self.index)} - min_points = self.module_config['graph_spice'].get('min_points', 1) - invert = self.module_config['graph_spice_loss'].get('invert', True) + # min_points = self.module_config['graph_spice'].get('min_points', 1) + # invert = self.module_config['graph_spice_loss'].get('invert', True) graph_info['Index'] = graph_info['Index'].map(index_mapping) constructor_cfg = self.cluster_graph_constructor.constructor_cfg @@ -363,8 +360,8 @@ def _fit_predict_gspice_fragments(self, entry): batch_col=0, training=False) pred, G, subgraph = gs_manager.fit_predict_one(entry, - invert=invert, - min_points=min_points) + invert=True, + min_points=1) return pred, G, subgraph @@ -588,7 +585,7 @@ def get_fragments(self, entry, only_primaries=False, min_particle_voxel_count=-1, attaching_threshold=2, semantic_type=None, verbose=False, - true_id=False, volume=None) -> List[Particle]: + true_id=False, volume=None, allow_nodes=[0, 2, 3]) -> List[Particle]: ''' Method for retriving fragment list for given batch index. @@ -630,7 +627,7 @@ def get_fragments(self, entry, only_primaries=False, fragments = self.result['fragment_clusts'][entry] fragments_seg = self.result['fragment_seg'][entry] - shower_mask = np.isin(fragments_seg, self.module_config['grappa_shower']['base']['node_type']) + shower_mask = np.isin(fragments_seg, allow_nodes) shower_frag_primary = np.argmax(self.result['shower_fragment_node_pred'][entry], axis=1) if 'shower_fragment_node_features' in self.result: @@ -802,14 +799,14 @@ def get_particles(self, entry, only_primaries=False, assert node_pred_vtx.shape[0] == len(particles) primary_labels = -np.ones(len(node_pred_vtx)).astype(int) primary_scores = np.zeros(len(node_pred_vtx)).astype(float) - if self.pred_vtx_positions: - assert node_pred_vtx.shape[1] == 5 + if node_pred_vtx.shape[1] == 5: # primary_labels = np.argmax(node_pred_vtx[:, 3:], axis=1) primary_scores = node_pred_vtx[:, 3:] - else: - assert node_pred_vtx.shape[1] == 2 + elif node_pred_vtx.shape[1] == 2: # primary_labels = np.argmax(node_pred_vtx, axis=1) primary_scores = node_pred_vtx + else: + raise ValueError(' must either be (N, 5) or (N, 2)') primary_scores = softmax(node_pred_vtx, axis=1) From dac0c29fbf9a3dcc9b78cdc3f854644a047f1d45 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 29 Mar 2023 07:50:42 -0700 Subject: [PATCH 084/180] Remove ChunkCSVData and instead use CSVData --- analysis/decorator.py | 33 +++++++++++++++---------- mlreco/utils/utils.py | 56 +++++++++++++++++------------------------- mlreco/utils/vertex.py | 7 +++--- 3 files changed, 47 insertions(+), 49 deletions(-) diff --git a/analysis/decorator.py b/analysis/decorator.py index eac272e0..d0151936 100644 --- a/analysis/decorator.py +++ b/analysis/decorator.py @@ -12,7 +12,7 @@ from mlreco.iotools.factories import loader_factory from mlreco.iotools.readers import HDF5Reader -from mlreco.utils.utils import ChunkCSVData +from mlreco.utils.utils import CSVData def evaluate(filenames, mode='per_image'): @@ -63,15 +63,16 @@ def process_dataset(analysis_config, cfg=None, profile=True): append = analysis_config['analysis'].get('append', True) chunksize = analysis_config['analysis'].get('chunksize', 100) - output_logs = [] - header_recorded = [] - + output_logs = {} for fname in filenames: - fout = os.path.join(log_dir, fname + '.csv') - output_logs.append(ChunkCSVData(fout, append=append, chunksize=chunksize)) - header_recorded.append(False) + f = os.path.join(log_dir, '{}.csv'.format(fname)) + output_logs[fname] = CSVData(f, append=append) + output_logs[fname].open() + + headers = False while iteration < max_iteration: + if profile: start = time.time() if 'reader' not in analysis_config: @@ -95,18 +96,24 @@ def process_dataset(analysis_config, cfg=None, profile=True): else: raise Exception("Evaluation mode {} is invalid!".format(mode)) for i, fname in enumerate(fname_to_update_list): - df = pd.DataFrame(fname_to_update_list[fname]) - if len(df): - output_logs[i].record(df) - header_recorded[i] = True - # disable pandas from appending additional header lines - if header_recorded[i]: output_logs[i].header = False + for row_dict in fname_to_update_list[fname]: + keys, vals = row_dict.keys(), row_dict.values() + output_logs[fname].record(list(keys), list(vals)) + if not headers: + output_logs[fname].write_headers(list(keys)) + headers = True + output_logs[fname].write_data(str_format='{}') + output_logs[fname].flush() + os.fsync(output_logs[fname]._fout.fileno()) iteration += 1 if profile: end = time.time() print("Iteration %d (total %d s)" % (iteration, end - start)) torch.cuda.empty_cache() + for fname in filenames: + output_logs[fname].close() + process_dataset._filenames = filenames process_dataset._mode = mode return process_dataset diff --git a/mlreco/utils/utils.py b/mlreco/utils/utils.py index daa5174d..c01d07ad 100644 --- a/mlreco/utils/utils.py +++ b/mlreco/utils/utils.py @@ -177,12 +177,32 @@ def __init__(self,fout,append=False): self._str = None self._dict = {} self.append = append + self._headers = [] def record(self, keys, vals): for i, key in enumerate(keys): self._dict[key] = vals[i] - def write(self): + def open(self): + self._fout=open(self.name,'w') + + def write_headers(self, headers): + self._header_str = '' + for i, key in enumerate(headers): + self._fout.write(key) + if i < len(headers)-1: self._fout.write(',') + self._headers.append(key) + self._fout.write('\n') + + def write_data(self, str_format='{:f}'): + self._str = '' + for i, key in enumerate(self._dict.keys()): + if i: self._str += ',' + self._str += str_format + self._str += '\n' + self._fout.write(self._str.format(*(self._dict.values()))) + + def write(self, str_format='{:f}'): if self._str is None: mode = 'a' if self.append else 'w' self._fout=open(self.name,mode) @@ -192,7 +212,7 @@ def write(self): if not self.append: self._fout.write(',') self._str += ',' if not self.append: self._fout.write(key) - self._str+='{:f}' + self._str+=str_format if not self.append: self._fout.write('\n') self._str+='\n' self._fout.write(self._str.format(*(self._dict.values()))) @@ -202,34 +222,4 @@ def flush(self): def close(self): if self._str is not None: - self._fout.close() - - -class ChunkCSVData: - - def __init__(self, fout, append=True, chunksize=1000): - self.name = fout - if append: - self.append = 'a' - else: - self.append = 'w' - self.chunksize = chunksize - - self.header = True - - if not os.path.exists(os.path.dirname(self.name)): - os.makedirs(os.path.dirname(self.name)) - - with open(self.name, 'w') as f: - pass - # df = pd.DataFrame(list()) - # df.to_csv(self.name, mode='w') - - def record(self, df, verbose=False): - if verbose: - print(df) - df.to_csv(self.name, - mode=self.append, - chunksize=self.chunksize, - index=False, - header=self.header) + self._fout.close() \ No newline at end of file diff --git a/mlreco/utils/vertex.py b/mlreco/utils/vertex.py index 6c6527b9..cddbbf31 100644 --- a/mlreco/utils/vertex.py +++ b/mlreco/utils/vertex.py @@ -8,6 +8,7 @@ from sklearn.decomposition import PCA from mlreco.utils.gnn.evaluation import primary_assignment from mlreco.utils.groups import type_labels +from mlreco.utils.globals import INTER_COL, PGRP_COL, VTX_COLS def find_closest_points_of_approach(point1, direction1, point2, direction2): @@ -474,9 +475,9 @@ def get_vertex(kinematics, cluster_label, data_idx, inter_idx, np.ndarray True vertex coordinates. Shape (3,) """ - inter_mask = cluster_label[data_idx][:, 7] == inter_idx - primary_mask = kinematics[data_idx][:, vtx_col+3] == primary_label + inter_mask = cluster_label[data_idx][:, INTER_COL] == inter_idx + primary_mask = kinematics[data_idx][:, PGRP_COL] == primary_label mask = inter_mask if (inter_mask & primary_mask).sum() == 0 else inter_mask & primary_mask - vtx, counts = np.unique(kinematics[data_idx][mask][:, [vtx_col, vtx_col+1, vtx_col+2]], axis=0, return_counts=True) + vtx, counts = np.unique(kinematics[data_idx][mask][:, [VTX_COLS[0], VTX_COLS[1], VTX_COLS[2]]], axis=0, return_counts=True) vtx = vtx[np.argmax(counts)] return vtx From aa7088396cc4621d54f3f172aa70954367064763 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 29 Mar 2023 09:17:40 -0700 Subject: [PATCH 085/180] Change true vertex getter so that it picks the closest nonghost point --- analysis/algorithms/utils.py | 2 +- analysis/classes/evaluator.py | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/analysis/algorithms/utils.py b/analysis/algorithms/utils.py index 485a4e11..1843a25b 100644 --- a/analysis/algorithms/utils.py +++ b/analysis/algorithms/utils.py @@ -48,7 +48,7 @@ def correct_track_points(particle): particle.startpoint = x[x1] particle.endpoint = x[x2] elif label == 1: - # Closest point x2 is adj to an endpoint + # point x2 is adj to an endpoint particle.endpoint = x[x1] particle.startpoint = x[x2] else: diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index b105e4e7..e80dbf41 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -1,7 +1,7 @@ from typing import List import numpy as np -from mlreco.utils.globals import VTX_COLS +from mlreco.utils.globals import VTX_COLS, INTER_COL, COORD_COLS from analysis.classes import TruthParticleFragment, TruthParticle, Interaction from analysis.classes.particle import (match_particles_fn, @@ -473,7 +473,7 @@ def get_true_vertices(self, entry): """ out = {} inter_idxs = np.unique( - self.data_blob['cluster_label'][entry][:, 7].astype(int)) + self.data_blob['cluster_label'][entry][:, INTER_COL].astype(int)) for inter_idx in inter_idxs: if inter_idx < 0: continue @@ -482,7 +482,10 @@ def get_true_vertices(self, entry): data_idx=entry, inter_idx=inter_idx, vtx_col=VTX_COLS[0]) - out[inter_idx] = vtx + mask = self.data_blob['cluster_label'][entry][:, INTER_COL].astype(int) == inter_idx + points = self.data_blob['cluster_label'][entry][:, COORD_COLS[0]:COORD_COLS[-1]+1] + new_vtx = points[mask][np.linalg.norm(points[mask] - vtx, axis=1).argmin()] + out[inter_idx] = new_vtx return out From f9305cbcc0af4ed29b97c546fe45c5000f91352b Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 29 Mar 2023 11:41:14 -0700 Subject: [PATCH 086/180] Move obsolete selection scripts and fix Anatools header generation issue --- .../algorithms/{selections => arxiv}/example_nue.py | 0 .../{selections => arxiv}/flash_matching.py | 0 .../{selections => arxiv}/michel_electrons.py | 0 .../algorithms/{selections => arxiv}/muon_decay.py | 0 .../algorithms/{selections => arxiv}/particles.py | 0 .../algorithms/{selections => arxiv}/statistics.py | 0 .../{selections => arxiv}/stopping_muons.py | 0 .../{selections => arxiv}/through_going_muons.py | 0 analysis/algorithms/scripts/__init__.py | 2 ++ .../algorithms/{selections => scripts}/benchmark.py | 0 .../algorithms/{selections => scripts}/template.py | 0 analysis/algorithms/selections/__init__.py | 10 ---------- analysis/algorithms/utils.py | 1 - analysis/decorator.py | 5 ++--- analysis/run.py | 2 +- mlreco/iotools/README.md | 13 +++++++++++++ 16 files changed, 18 insertions(+), 15 deletions(-) rename analysis/algorithms/{selections => arxiv}/example_nue.py (100%) rename analysis/algorithms/{selections => arxiv}/flash_matching.py (100%) rename analysis/algorithms/{selections => arxiv}/michel_electrons.py (100%) rename analysis/algorithms/{selections => arxiv}/muon_decay.py (100%) rename analysis/algorithms/{selections => arxiv}/particles.py (100%) rename analysis/algorithms/{selections => arxiv}/statistics.py (100%) rename analysis/algorithms/{selections => arxiv}/stopping_muons.py (100%) rename analysis/algorithms/{selections => arxiv}/through_going_muons.py (100%) create mode 100644 analysis/algorithms/scripts/__init__.py rename analysis/algorithms/{selections => scripts}/benchmark.py (100%) rename analysis/algorithms/{selections => scripts}/template.py (100%) delete mode 100644 analysis/algorithms/selections/__init__.py diff --git a/analysis/algorithms/selections/example_nue.py b/analysis/algorithms/arxiv/example_nue.py similarity index 100% rename from analysis/algorithms/selections/example_nue.py rename to analysis/algorithms/arxiv/example_nue.py diff --git a/analysis/algorithms/selections/flash_matching.py b/analysis/algorithms/arxiv/flash_matching.py similarity index 100% rename from analysis/algorithms/selections/flash_matching.py rename to analysis/algorithms/arxiv/flash_matching.py diff --git a/analysis/algorithms/selections/michel_electrons.py b/analysis/algorithms/arxiv/michel_electrons.py similarity index 100% rename from analysis/algorithms/selections/michel_electrons.py rename to analysis/algorithms/arxiv/michel_electrons.py diff --git a/analysis/algorithms/selections/muon_decay.py b/analysis/algorithms/arxiv/muon_decay.py similarity index 100% rename from analysis/algorithms/selections/muon_decay.py rename to analysis/algorithms/arxiv/muon_decay.py diff --git a/analysis/algorithms/selections/particles.py b/analysis/algorithms/arxiv/particles.py similarity index 100% rename from analysis/algorithms/selections/particles.py rename to analysis/algorithms/arxiv/particles.py diff --git a/analysis/algorithms/selections/statistics.py b/analysis/algorithms/arxiv/statistics.py similarity index 100% rename from analysis/algorithms/selections/statistics.py rename to analysis/algorithms/arxiv/statistics.py diff --git a/analysis/algorithms/selections/stopping_muons.py b/analysis/algorithms/arxiv/stopping_muons.py similarity index 100% rename from analysis/algorithms/selections/stopping_muons.py rename to analysis/algorithms/arxiv/stopping_muons.py diff --git a/analysis/algorithms/selections/through_going_muons.py b/analysis/algorithms/arxiv/through_going_muons.py similarity index 100% rename from analysis/algorithms/selections/through_going_muons.py rename to analysis/algorithms/arxiv/through_going_muons.py diff --git a/analysis/algorithms/scripts/__init__.py b/analysis/algorithms/scripts/__init__.py new file mode 100644 index 00000000..fda0f6e8 --- /dev/null +++ b/analysis/algorithms/scripts/__init__.py @@ -0,0 +1,2 @@ +from .template import run_inference +from .benchmark import benchmark \ No newline at end of file diff --git a/analysis/algorithms/selections/benchmark.py b/analysis/algorithms/scripts/benchmark.py similarity index 100% rename from analysis/algorithms/selections/benchmark.py rename to analysis/algorithms/scripts/benchmark.py diff --git a/analysis/algorithms/selections/template.py b/analysis/algorithms/scripts/template.py similarity index 100% rename from analysis/algorithms/selections/template.py rename to analysis/algorithms/scripts/template.py diff --git a/analysis/algorithms/selections/__init__.py b/analysis/algorithms/selections/__init__.py deleted file mode 100644 index e32b2452..00000000 --- a/analysis/algorithms/selections/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from .stopping_muons import stopping_muons -from .through_going_muons import through_going_muons -from .michel_electrons import michel_electrons -from .example_nue import debug_pid -from .template import run_inference -from .statistics import statistics -from .flash_matching import flash_matching -from .muon_decay import muon_decay -from .benchmark import benchmark -from .particles import run_inference_particles \ No newline at end of file diff --git a/analysis/algorithms/utils.py b/analysis/algorithms/utils.py index 1843a25b..77ad9bf6 100644 --- a/analysis/algorithms/utils.py +++ b/analysis/algorithms/utils.py @@ -77,7 +77,6 @@ def handle_singleton_ppn_candidate(p, pts, ppn_candidates): p.startpoint = pt_far.reshape(-1) - def correct_track_endpoints_ppn(p): assert p.semantic_type == 1 pts = np.vstack([p.startpoint, p.endpoint]) diff --git a/analysis/decorator.py b/analysis/decorator.py index d0151936..315596f3 100644 --- a/analysis/decorator.py +++ b/analysis/decorator.py @@ -69,8 +69,6 @@ def process_dataset(analysis_config, cfg=None, profile=True): output_logs[fname] = CSVData(f, append=append) output_logs[fname].open() - headers = False - while iteration < max_iteration: if profile: @@ -96,10 +94,11 @@ def process_dataset(analysis_config, cfg=None, profile=True): else: raise Exception("Evaluation mode {} is invalid!".format(mode)) for i, fname in enumerate(fname_to_update_list): + headers = False for row_dict in fname_to_update_list[fname]: keys, vals = row_dict.keys(), row_dict.values() output_logs[fname].record(list(keys), list(vals)) - if not headers: + if not iteration and not headers: output_logs[fname].write_headers(list(keys)) headers = True output_logs[fname].write_data(str_format='{}') diff --git a/analysis/run.py b/analysis/run.py index 984cfd18..b49c2bfd 100644 --- a/analysis/run.py +++ b/analysis/run.py @@ -20,7 +20,7 @@ from mlreco.main_funcs import process_config from analysis.decorator import evaluate # Folder `selections` contains several scripts -from analysis.algorithms.selections import * +from analysis.algorithms.scripts import * def main(analysis_cfg_path, model_cfg_path): diff --git a/mlreco/iotools/README.md b/mlreco/iotools/README.md index f5597788..07fa2632 100644 --- a/mlreco/iotools/README.md +++ b/mlreco/iotools/README.md @@ -7,3 +7,16 @@ To add your own I/O functions: You can write your own sampling function in `samplers.py`. + +### 1. Writing and Reading HDF5 Files + +``` +iotool: + writer: + name: HDF5Writer + filename: output.h5 + input_keys: None + skip_input_keys: [] + result_keys: None + skip_result_keys: [] +``` From d7ae6e1d427f91c5be9bcde992f6122699ebc471 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 29 Mar 2023 11:58:36 -0700 Subject: [PATCH 087/180] Crash fix due to removing ChunkCSVLoader --- mlreco/post_processing/metrics/bayes_segnet_mcdropout.py | 3 +-- mlreco/post_processing/metrics/evidential_segnet.py | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/mlreco/post_processing/metrics/bayes_segnet_mcdropout.py b/mlreco/post_processing/metrics/bayes_segnet_mcdropout.py index 8cc7d94e..ce2d51ef 100644 --- a/mlreco/post_processing/metrics/bayes_segnet_mcdropout.py +++ b/mlreco/post_processing/metrics/bayes_segnet_mcdropout.py @@ -3,7 +3,6 @@ import os from mlreco.utils import CSVData -from mlreco.utils import CSVData, ChunkCSVData from scipy.special import softmax as softmax_func from scipy.stats import entropy @@ -38,7 +37,7 @@ def bayes_segnet_mcdropout(cfg, fout = CSVData( os.path.join(logdir, 'bayes-segnet-metrics.csv'), append=append) - fout_voxel = ChunkCSVData( + fout_voxel = CSVData( os.path.join(logdir, 'bayes-segnet-metrics-voxels.csv'), append=append) for batch_id, event_id in enumerate(index): diff --git a/mlreco/post_processing/metrics/evidential_segnet.py b/mlreco/post_processing/metrics/evidential_segnet.py index 2199b881..0787c3bd 100644 --- a/mlreco/post_processing/metrics/evidential_segnet.py +++ b/mlreco/post_processing/metrics/evidential_segnet.py @@ -3,7 +3,7 @@ import sys, os, re from mlreco.post_processing import post_processing -from mlreco.utils import CSVData, ChunkCSVData +from mlreco.utils import CSVData from scipy.special import softmax as softmax_func from scipy.stats import entropy @@ -35,7 +35,7 @@ def evidential_segnet_metrics(cfg, processor_cfg, data_blob, result, logdir, ite else: append = False - fout_voxel = ChunkCSVData(os.path.join(logdir, 'evidential-segnet-metrics-voxels.csv'), append=append) + fout_voxel = CSVData(os.path.join(logdir, 'evidential-segnet-metrics-voxels.csv'), append=append) fout = CSVData( os.path.join(logdir, 'evidential-segnet-metrics.csv'), append=append) From b41811ef42a8aed09bddb8b9c6a117c7b3749458 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Wed, 29 Mar 2023 14:17:25 -0700 Subject: [PATCH 088/180] [WIP] Modify analysis tools to be write to HDF5 files --- analysis/algorithms/selections/benchmark.py | 2 +- analysis/algorithms/selections/example_nue.py | 2 +- .../algorithms/selections/flash_matching.py | 2 +- .../algorithms/selections/michel_electrons.py | 2 +- analysis/algorithms/selections/muon_decay.py | 2 +- analysis/algorithms/selections/particles.py | 4 +-- analysis/algorithms/selections/statistics.py | 2 +- .../algorithms/selections/stopping_muons.py | 2 +- analysis/algorithms/selections/template.py | 4 +-- .../selections/through_going_muons.py | 2 +- analysis/decorator.py | 35 ++++++++++--------- 11 files changed, 31 insertions(+), 28 deletions(-) diff --git a/analysis/algorithms/selections/benchmark.py b/analysis/algorithms/selections/benchmark.py index 38266cc4..0fea3b73 100644 --- a/analysis/algorithms/selections/benchmark.py +++ b/analysis/algorithms/selections/benchmark.py @@ -7,7 +7,7 @@ import numpy as np import os, sys -@evaluate(['test'], mode='per_batch') +@evaluate(['test']) def benchmark(data_blob, res, data_idx, analysis_cfg, cfg): """ Dummy script to see how long FullChainEvaluator initialization takes. diff --git a/analysis/algorithms/selections/example_nue.py b/analysis/algorithms/selections/example_nue.py index f49d255b..79432a68 100644 --- a/analysis/algorithms/selections/example_nue.py +++ b/analysis/algorithms/selections/example_nue.py @@ -10,7 +10,7 @@ import numpy as np -@evaluate(['interactions', 'particles'], mode='per_batch') +@evaluate(['interactions', 'particles']) def debug_pid(data_blob, res, data_idx, analysis_cfg, cfg): """ Example of analysis script for nue analysis. diff --git a/analysis/algorithms/selections/flash_matching.py b/analysis/algorithms/selections/flash_matching.py index d6a1a668..27bc2fbb 100644 --- a/analysis/algorithms/selections/flash_matching.py +++ b/analysis/algorithms/selections/flash_matching.py @@ -42,7 +42,7 @@ def find_true_x(interaction): return values[np.argmax(counts)] -@evaluate(['interactions', 'flashes', 'matches'], mode='per_batch') +@evaluate(['interactions', 'flashes', 'matches']) def flash_matching(data_blob, res, data_idx, analysis_cfg, cfg): # Setup OpT0finder #sys.path.append('/sdf/group/neutrino/ldomine/OpT0Finder/python') diff --git a/analysis/algorithms/selections/michel_electrons.py b/analysis/algorithms/selections/michel_electrons.py index 056894f2..52376742 100644 --- a/analysis/algorithms/selections/michel_electrons.py +++ b/analysis/algorithms/selections/michel_electrons.py @@ -142,7 +142,7 @@ def find_true_cosmic_angle(muon, michel, particles_asis_voxels, radius=30): endpoint = muon.points[muon_id] return find_cosmic_angle(muon, michel, endpoint, radius=radius) -@evaluate(['michels_pred', 'michels_true'], mode='per_batch') +@evaluate(['michels_pred', 'michels_true']) def michel_electrons(data_blob, res, data_idx, analysis_cfg, cfg): """ Selection of Michel electrons diff --git a/analysis/algorithms/selections/muon_decay.py b/analysis/algorithms/selections/muon_decay.py index ae49dbcd..0b8f9fca 100644 --- a/analysis/algorithms/selections/muon_decay.py +++ b/analysis/algorithms/selections/muon_decay.py @@ -14,7 +14,7 @@ from scipy.spatial.distance import cdist -@evaluate(['michels'], mode='per_batch') +@evaluate(['michels']) def muon_decay(data_blob, res, data_idx, analysis_cfg, cfg): """ Muon lifetime measurement. diff --git a/analysis/algorithms/selections/particles.py b/analysis/algorithms/selections/particles.py index 588a3fab..65c2d7a2 100644 --- a/analysis/algorithms/selections/particles.py +++ b/analysis/algorithms/selections/particles.py @@ -17,7 +17,7 @@ from analysis.algorithms.calorimetry import get_csda_range_spline -@evaluate(['particles'], mode='per_batch') +@evaluate(['particles']) def run_inference_particles(data_blob, res, data_idx, analysis_cfg, cfg): """ Analysis tools inference script for particle-level information. @@ -130,4 +130,4 @@ def run_inference_particles(data_blob, res, data_idx, analysis_cfg, cfg): particles.append(part_dict) - return [particles] \ No newline at end of file + return [particles] diff --git a/analysis/algorithms/selections/statistics.py b/analysis/algorithms/selections/statistics.py index 9a2909a0..1e8f2848 100644 --- a/analysis/algorithms/selections/statistics.py +++ b/analysis/algorithms/selections/statistics.py @@ -10,7 +10,7 @@ import numpy as np -@evaluate(['particles', 'interactions', 'events', 'opflash', 'ohmflash'], mode='per_batch') +@evaluate(['particles', 'interactions', 'events', 'opflash', 'ohmflash']) def statistics(data_blob, res, data_idx, analysis_cfg, cfg): """ Collect statistics of predicted particles/interactions. diff --git a/analysis/algorithms/selections/stopping_muons.py b/analysis/algorithms/selections/stopping_muons.py index 28d06172..66492a28 100644 --- a/analysis/algorithms/selections/stopping_muons.py +++ b/analysis/algorithms/selections/stopping_muons.py @@ -14,7 +14,7 @@ from scipy.spatial.distance import cdist -@evaluate(['stopping_muons_cells', 'stopping_muons_pred', 'stopping_muons_true'], mode='per_batch') +@evaluate(['stopping_muons_cells', 'stopping_muons_pred', 'stopping_muons_true']) def stopping_muons(data_blob, res, data_idx, analysis_cfg, cfg): """ Selection of stopping muons diff --git a/analysis/algorithms/selections/template.py b/analysis/algorithms/selections/template.py index 5293a086..e733c576 100644 --- a/analysis/algorithms/selections/template.py +++ b/analysis/algorithms/selections/template.py @@ -17,7 +17,7 @@ from analysis.algorithms.calorimetry import get_csda_range_spline from analysis.algorithms.vertex import estimate_vertex -@evaluate(['interactions', 'particles'], mode='per_batch') +@evaluate(['interactions', 'particles']) def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): """ Example of analysis script for nue analysis. @@ -245,4 +245,4 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): particles.append(part_dict) - return [interactions, particles] \ No newline at end of file + return [interactions, particles] diff --git a/analysis/algorithms/selections/through_going_muons.py b/analysis/algorithms/selections/through_going_muons.py index 60a956e1..7495c7e6 100644 --- a/analysis/algorithms/selections/through_going_muons.py +++ b/analysis/algorithms/selections/through_going_muons.py @@ -21,7 +21,7 @@ def must_invert(x, invert_regions): -@evaluate(['acpt_muons_cells', 'acpt_muons'], mode='per_batch') +@evaluate(['acpt_muons_cells', 'acpt_muons']) def through_going_muons(data_blob, res, data_idx, analysis_cfg, cfg): """ Selection of through going muons diff --git a/analysis/decorator.py b/analysis/decorator.py index d0151936..684ff862 100644 --- a/analysis/decorator.py +++ b/analysis/decorator.py @@ -11,11 +11,10 @@ from mlreco.trainval import trainval from mlreco.iotools.factories import loader_factory from mlreco.iotools.readers import HDF5Reader +from mlreco.iotools.writers import HDF5Writer, CSVWriter -from mlreco.utils.utils import CSVData - -def evaluate(filenames, mode='per_image'): +def evaluate(filenames): ''' Inputs ------ @@ -25,11 +24,14 @@ def evaluate(filenames, mode='per_image'): def decorate(func): @wraps(func) - def process_dataset(analysis_config, cfg=None, profile=True): + def process_dataset(analysis_config, cfg, profile=True): - assert cfg is not None or 'reader' in analysis_config + # Total number of iterations to process max_iteration = analysis_config['analysis']['iteration'] + + # Initialize the process which produces the reconstruction output if 'reader' not in analysis_config: + # If there is not reader, initialize the full chain io_cfg = cfg['iotool'] module_config = cfg['model']['modules'] @@ -48,7 +50,9 @@ def process_dataset(analysis_config, cfg=None, profile=True): if max_iteration == -1: max_iteration = len(loader.dataset) assert max_iteration <= len(loader.dataset) + else: + # If there is a reader, simply load reconstructed data file_keys = analysis_config['reader']['file_keys'] entry_list = analysis_config['reader'].get('entry_list', []) skip_entry_list = analysis_config['reader'].get('skip_entry_list', []) @@ -57,11 +61,15 @@ def process_dataset(analysis_config, cfg=None, profile=True): max_iteration = len(Reader) assert max_iteration <= len(Reader) + # Initialize the writer + writer_cfg = analysis_cfg.get('writer', {}) + writer_cfg['name'] = writer_cfg.get('name', 'CSVWriter') + for name in file + iteration = 0 log_dir = analysis_config['analysis']['log_dir'] append = analysis_config['analysis'].get('append', True) - chunksize = analysis_config['analysis'].get('chunksize', 100) output_logs = {} for fname in filenames: @@ -82,21 +90,16 @@ def process_dataset(analysis_config, cfg=None, profile=True): if profile: print("Forward took %d s" % (time.time() - start)) img_indices = data_blob['index'] + fname_to_update_list = defaultdict(list) - if mode == 'per_batch': - # list of (list of dicts) - dict_list = func(data_blob, res, None, analysis_config, cfg) + for batch_index, img_index in enumerate(img_indices): + dict_list = func(data_blob, res, batch_index, analysis_config, cfg) for i, analysis_dict in enumerate(dict_list): fname_to_update_list[filenames[i]].extend(analysis_dict) - elif mode == 'per_image': - for batch_index, img_index in enumerate(img_indices): - dict_list = func(data_blob, res, batch_index, analysis_config, cfg) - for i, analysis_dict in enumerate(dict_list): - fname_to_update_list[filenames[i]].extend(analysis_dict) - else: - raise Exception("Evaluation mode {} is invalid!".format(mode)) + for i, fname in enumerate(fname_to_update_list): for row_dict in fname_to_update_list[fname]: + keys, vals = row_dict.keys(), row_dict.values() output_logs[fname].record(list(keys), list(vals)) if not headers: From 09ed1ab57c3efe3913531ac38a3d2c1a005f864a Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 29 Mar 2023 15:25:35 -0700 Subject: [PATCH 089/180] Fix for empty truthparticles crashing mpvmpr inference --- analysis/algorithms/calorimetry.py | 50 ------------------------------ analysis/algorithms/vertex.py | 18 ++++++----- analysis/classes/particle.py | 1 - 3 files changed, 10 insertions(+), 59 deletions(-) diff --git a/analysis/algorithms/calorimetry.py b/analysis/algorithms/calorimetry.py index f322683c..6ab71899 100644 --- a/analysis/algorithms/calorimetry.py +++ b/analysis/algorithms/calorimetry.py @@ -85,58 +85,8 @@ def compute_range_based_energy(particle, f, **kwargs): def get_particle_direction(p: Particle, **kwargs): - startpoint = p.startpoint v = cluster_direction(p.points, p.startpoint, **kwargs) return v - - -# # Deprecated -# def compute_particle_direction(p: Particle, -# start_segment_radius=17, -# vertex=None, -# return_explained_variance=False): -# """ -# Given a Particle, compute the start direction. Within `start_segment_radius` -# of the start point, find PCA axis and measure direction. - -# If not start point is found, returns (-1, -1, -1). - -# Parameters -# ---------- -# p: Particle -# start_segment_radius: float, optional - -# Returns -# ------- -# np.ndarray -# Shape (3,) -# """ -# pca = PCA(n_components=2) -# direction = None -# if p.startpoint is not None and p.startpoint[0] >= 0.: -# startpoint = p.startpoint -# if p.endpoint is not None and vertex is not None: # make sure we pick the one closest to vertex -# use_end = np.argmin([ -# np.sqrt(((vertex-p.startpoint)**2).sum()), -# np.sqrt(((vertex-p.endpoint)**2).sum()) -# ]) -# startpoint = p.endpoint if use_end else p.startpoint -# d = np.sqrt(((p.points - startpoint)**2).sum(axis=1)) -# if (d < start_segment_radius).sum() >= 2: -# direction = pca.fit(p.points[d < start_segment_radius]).components_[0, :] -# if direction is None: # we could not find a startpoint -# if len(p.points) >= 2: # just all voxels -# direction = pca.fit(p.points).components_[0, :] -# else: -# direction = np.array([-1, -1, -1]) -# if not return_explained_variance: -# return direction -# else: -# return direction, np.array([-1, -1]) -# if not return_explained_variance: -# return direction -# else: -# return direction, pca.explained_variance_ratio_ def compute_track_dedx(p, bin_size=17): diff --git a/analysis/algorithms/vertex.py b/analysis/algorithms/vertex.py index a55e4a0b..9391e9d8 100644 --- a/analysis/algorithms/vertex.py +++ b/analysis/algorithms/vertex.py @@ -56,11 +56,13 @@ def get_track_shower_poca(particles, return_annot=False, start_segment_radius=10 ''' candidates = [] + + valid_particles = [p for p in particles if len(p.points) > 0] - track_ids, shower_ids = np.array([p.id for p in particles if p.semantic_type == 1]), [] - track_starts = np.array([p.startpoint for p in particles if p.semantic_type == 1]) + track_ids, shower_ids = np.array([p.id for p in valid_particles if p.semantic_type == 1]), [] + track_starts = np.array([p.startpoint for p in valid_particles if p.semantic_type == 1]) shower_starts, shower_dirs = [], [] - for p in particles: + for p in valid_particles: vec = get_particle_direction(p, optimize=True) if p.semantic_type == 0 and (vec != -1).all(): shower_dirs.append(vec) @@ -120,20 +122,20 @@ def compute_vertex_matrix_inversion(particles, """ pseudovtx = np.zeros((dim, )) + valid_particles = [p for p in particles if len(p.points) > 0] + if use_primaries: - particles = [p for p in particles if (p.is_primary and p.startpoint is not None)] + valid_particles = [p for p in valid_particles if (p.is_primary and p.startpoint is not None)] - if len(particles) < 2: + if len(valid_particles) < 2: return np.array([-1, -1, -1]) S = np.zeros((dim, dim)) C = np.zeros((dim, )) - for p in particles: + for p in valid_particles: vec = get_particle_direction(p, optimize=True) w = 1.0 - # if weight: - # w = np.exp(-(var[0] - 1)**2 / (2.0 * var_sigma)**2) S += w * (np.outer(vec, vec) - np.eye(dim)) C += w * (np.outer(vec, vec) - np.eye(dim)) @ p.startpoint # print(S, C) diff --git a/analysis/classes/particle.py b/analysis/classes/particle.py index b166beaf..d3d1be59 100644 --- a/analysis/classes/particle.py +++ b/analysis/classes/particle.py @@ -417,7 +417,6 @@ def _tag_neutral_pions_reco(particles, threshold=5): out.append((p1.id, p2.id)) return out - def tag_neutral_pions(particles, mode): if mode == 'truth': return _tag_neutral_pions_true(particles) From d720174b9fdd23e616d3f15c6772a1a720b2b4e8 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Wed, 29 Mar 2023 16:27:49 -0700 Subject: [PATCH 090/180] Added CSVWriter for analysis tools, removed double scalar nesting in HDF5Reader --- analysis/decorator.py | 47 ++++------ mlreco/iotools/factories.py | 12 ++- mlreco/iotools/readers.py | 26 ++++-- mlreco/iotools/writers.py | 168 +++++++++++++++++++++++++++++++----- mlreco/utils/wrapper.py | 22 +++-- 5 files changed, 208 insertions(+), 67 deletions(-) diff --git a/analysis/decorator.py b/analysis/decorator.py index 874d6c04..944547a5 100644 --- a/analysis/decorator.py +++ b/analysis/decorator.py @@ -9,9 +9,8 @@ from mlreco.main_funcs import cycle from mlreco.trainval import trainval -from mlreco.iotools.factories import loader_factory +from mlreco.iotools.factories import loader_factory, writer_factory from mlreco.iotools.readers import HDF5Reader -from mlreco.iotools.writers import HDF5Writer, CSVWriter def evaluate(filenames): @@ -61,24 +60,25 @@ def process_dataset(analysis_config, cfg, profile=True): max_iteration = len(Reader) assert max_iteration <= len(Reader) - # Initialize the writer - writer_cfg = analysis_cfg.get('writer', {}) - writer_cfg['name'] = writer_cfg.get('name', 'CSVWriter') - for name in file - - iteration = 0 - + # Initialize the writer(s) log_dir = analysis_config['analysis']['log_dir'] - append = analysis_config['analysis'].get('append', True) + append = analysis_config['analysis'].get('append', False) - output_logs = {} - for fname in filenames: - f = os.path.join(log_dir, '{}.csv'.format(fname)) - output_logs[fname] = CSVData(f, append=append) - output_logs[fname].open() + writer_cfg = analysis_config.get('writer', {}) + writer_cfg['name'] = writer_cfg.get('name', 'CSVWriter') + writer_cfg['append_file'] = append + + writers = {} + for file_name in filenames: + writer_cfg['file_name'] = f'{log_dir}/{file_name}.csv' + cfg['iotool']['writer'] = writer_cfg + writers[file_name] = writer_factory(cfg) + # Loop over the number of requested iterations + iteration = 0 while iteration < max_iteration: + # Load data batch if profile: start = time.time() if 'reader' not in analysis_config: @@ -89,34 +89,25 @@ def process_dataset(analysis_config, cfg, profile=True): print("Forward took %d s" % (time.time() - start)) img_indices = data_blob['index'] + # Build the output dictionary fname_to_update_list = defaultdict(list) for batch_index, img_index in enumerate(img_indices): dict_list = func(data_blob, res, batch_index, analysis_config, cfg) for i, analysis_dict in enumerate(dict_list): fname_to_update_list[filenames[i]].extend(analysis_dict) + # Store for i, fname in enumerate(fname_to_update_list): headers = False for row_dict in fname_to_update_list[fname]: + writers[fname].append(row_dict) - keys, vals = row_dict.keys(), row_dict.values() - output_logs[fname].record(list(keys), list(vals)) - if not iteration and not headers: - output_logs[fname].write_headers(list(keys)) - headers = True - output_logs[fname].write_data(str_format='{}') - output_logs[fname].flush() - os.fsync(output_logs[fname]._fout.fileno()) + # Increment iteration count iteration += 1 if profile: end = time.time() print("Iteration %d (total %d s)" % (iteration, end - start)) - torch.cuda.empty_cache() - - for fname in filenames: - output_logs[fname].close() process_dataset._filenames = filenames - process_dataset._mode = mode return process_dataset return decorate diff --git a/mlreco/iotools/factories.py b/mlreco/iotools/factories.py index 972aea07..af061fd2 100644 --- a/mlreco/iotools/factories.py +++ b/mlreco/iotools/factories.py @@ -1,3 +1,4 @@ +from copy import deepcopy from torch.utils.data import DataLoader @@ -108,8 +109,11 @@ def writer_factory(cfg): ---- Currently the choice is limited to `HDF5Writer` only. """ + if 'writer' not in cfg['iotool']: + return None + import mlreco.iotools.writers - params = cfg['iotool'].get('writer', None) - if params is not None: - return getattr(mlreco.iotools.writers, params['name'])(params) - return None + params = deepcopy(cfg['iotool']['writer']) + name = params.pop('name') + writer = getattr(mlreco.iotools.writers, name)(**params) + return writer diff --git a/mlreco/iotools/readers.py b/mlreco/iotools/readers.py index 1f884491..c8b388ce 100644 --- a/mlreco/iotools/readers.py +++ b/mlreco/iotools/readers.py @@ -18,10 +18,12 @@ def __init__(self, file_keys, entry_list=[], skip_entry_list=[], to_larcv=False) ---------- file_paths : list List of paths to the HDF5 files to be read - entry_list: list(int) - Entry IDs to be accessed - skip_entry_list: list(int) + entry_list: list(int), optional + Entry IDs to be accessed. If not specified, expose all entries + skip_entry_list: list(int), optional Entry IDs to be skipped + to_larcv : bool, default False + Convert dictionary of LArCV object properties to LArCV objects ''' # Convert the file keys to a list of file paths with glob self.file_paths = [] @@ -127,8 +129,11 @@ def get_entry_list(self, entry_list, skip_entry_list): list List of integer entry IDs in the index ''' - if not entry_list: - entry_list = np.arange(self.num_entries, dtype=int) + entry_index = np.empty(self.num_entries, dtype=int) + for i in np.unique(self.file_index): + file_mask = np.where(self.file_index==i)[0] + entry_index[file_mask] = np.arange(len(file_mask)) + if skip_entry_list: assert np.all(np.asarray(entry_list) < self.num_entries) entry_list = set(entry_list) @@ -136,9 +141,12 @@ def get_entry_list(self, entry_list, skip_entry_list): if s in entry_list: entry_list.pop(s) entry_list = list(entry_list) - - assert len(entry_list), 'Must at least have one entry to load' - return entry_list + + if entry_list: + entry_index = entry_index[entry_list] + + assert len(entry_index), 'Must at least have one entry to load' + return entry_index def load_key(self, file, event, data_blob, result_blob, key, nested): ''' @@ -168,6 +176,8 @@ def load_key(self, file, event, data_blob, result_blob, key, nested): if not group[key].dtype.names: # If the reference points at a simple dataset, return blob[key] = group[key][region_ref] + if 'scalar' in group[key].attrs and group[key].attrs['scalar']: + blob[key] = blob[key][0] else: # If the dataset has multiple attributes, it contains an object array = group[key][region_ref] diff --git a/mlreco/iotools/writers.py b/mlreco/iotools/writers.py index 2889d657..87afc6b6 100644 --- a/mlreco/iotools/writers.py +++ b/mlreco/iotools/writers.py @@ -8,28 +8,47 @@ class HDF5Writer: ''' - Class which build an HDF5 file to store the output - (and optionally input) of the reconstruction chain. + Class which builds an HDF5 file to store the input + and/or the output of the reconstruction chain. It + can also be used to append an existing HDF5 file with + information coming out of the analysis tools. More documentation to come. ''' - def __init__(self, cfg): + def __init__(self, + file_name: str = 'output.h5', + input_keys: list = None, + skip_input_keys: list = [], + result_keys: list = None, + skip_result_keys: list = [], + add_results: bool = False): ''' Initialize the basics of the output file Parameters ---------- - cfg : dict - Writer configuration parameter (TODO: turn this into a list of named parameters) + file_name : str, default 'output.h5' + Name of the output HDF5 file + input_keys : list, optional + List of input keys to store. If not specified, stores all of the input keys + skip_input_keys: list, optional + List of input keys to skip + result_keys : list, optional + List of result keys to store. If not specified, stores all of the result keys + skip_result_keys: list, optional + List of result keys to skip + add_results: bool, default False + Whether to append an existing HDF5 file with more results, or to create one from scratch ''' # Store attributes - self.file_name = cfg.get('file_name', 'output.h5') - self.input_keys = cfg.get('input_keys', None) - self.skip_input_keys = cfg.get('skip_input_keys', []) - self.result_keys = cfg.get('result_keys', None) - self.skip_result_keys = cfg.get('skip_result_keys', []) - self.created = False + self.file_name = file_name + self.input_keys = input_keys + self.skip_input_keys = skip_input_keys + self.result_keys = result_keys + self.skip_result_keys = skip_result_keys + self.add_results = add_results + self.ready = False def create(self, cfg, data_blob, result_blob=None): ''' @@ -48,7 +67,7 @@ def create(self, cfg, data_blob, result_blob=None): self.batch_size = len(data_blob['index']) # Initialize a dictionary to store keys and their properties (dtype and shape) - self.key_dict = defaultdict(lambda: {'category': None, 'dtype':None, 'width':0, 'merge':False}) + self.key_dict = defaultdict(lambda: {'category': None, 'dtype':None, 'width':0, 'merge':False, 'scalar':False}) # If requested, loop over input_keys and add them to what needs to be tracked if self.input_keys is None: self.input_keys = data_blob.keys() @@ -82,7 +101,35 @@ def create(self, cfg, data_blob, result_blob=None): self.initialize_datasets(file) # Mark file as ready for use - self.created = True + self.ready = True + + def add_keys(self, result_blob): + ''' + Create the output file structure based on the data and result blobs. + + Parameters + ---------- + result_blob : dict + Dictionary containing the additional output to store + ''' + # Get the expected batch_size from the data_blob (index must be present) + self.batch_size = 1 + + # Initialize a dictionary to store keys and their properties (dtype and shape) + self.key_dict = defaultdict(lambda: {'category': None, 'dtype':None, 'width':0, 'merge':False, 'scalar':False}) + + # Loop over the result_keys and add them to what needs to be tracked + self.result_keys = list(result_blob.keys()) + for key in self.result_keys: + self.register_key(result_blob, key, 'result') + + # Initialize the output HDF5 file + with h5py.File(self.file_name, 'a') as file: + # Initialize the event dataset and the corresponding reference array datasets + self.initialize_datasets(file) + + # Mark file as ready for use + self.ready = True def register_key(self, blob, key, category): ''' @@ -101,7 +148,8 @@ def register_key(self, blob, key, category): self.key_dict[key]['category'] = category if not isinstance(blob[key], list): # Single scalar (TODO: Is that thing? If not, why not?) - self.key_dict[key]['dtype'] = type(blob[key]) + self.key_dict[key]['dtype'] = type(blob[key]) + self.key_dict[key]['scalar'] = True else: if len(blob[key]) != self.batch_size: @@ -111,11 +159,13 @@ def register_key(self, blob, key, category): not hasattr(blob[key][0], '__len__'),\ 'If there is an array of length mismatched with batch_size, '+\ 'it must contain a single scalar.' - self.key_dict[key]['dtype'] = type(blob[key][0]) + self.key_dict[key]['dtype'] = type(blob[key][0]) + self.key_dict[key]['scalar'] = True elif not hasattr(blob[key][0], '__len__'): # List containing a single scalar per batch ID - self.key_dict[key]['dtype'] = type(blob[key][0]) + self.key_dict[key]['dtype'] = type(blob[key][0]) + self.key_dict[key]['scalar'] = True elif isinstance(blob[key][0], (list, np.ndarray)) and\ isinstance(blob[key][0][0], larcv.Particle): @@ -227,6 +277,10 @@ def initialize_datasets(self, file): w = val['width'] shape, maxshape = [(0, w), (None, w)] if w else [(0,), (None,)] grp.create_dataset(key, shape, maxshape=maxshape, dtype=val['dtype']) + if val['scalar']: + print('SCALAR') + grp[key].attrs['scalar'] = True + elif not val['merge']: # If the elements of the list are of variable widths, refer to one # dataset per element. An index is stored alongside the dataset to break @@ -237,6 +291,7 @@ def initialize_datasets(self, file): for i, w in enumerate(val['width']): shape, maxshape = [(0, w), (None, w)] if w else [(0,), (None,)] subgrp.create_dataset(f'element_{i}', shape, maxshape=maxshape, dtype=val['dtype']) + else: # If the elements of the list are of equal width, store them all # to one dataset. An index is stored alongside the dataset to break @@ -249,7 +304,7 @@ def initialize_datasets(self, file): file.create_dataset('events', (0,), maxshape=(None,), dtype=self.event_dtype) - def append(self, cfg, data_blob, result_blob): + def append(self, cfg=None, data_blob=None, result_blob=None): ''' Append the HDF5 file with the content of a batch. @@ -263,8 +318,15 @@ def append(self, cfg, data_blob, result_blob): Dictionary containing the output of the reconstruction chain ''' # If this function has never been called, initialiaze the HDF5 file - if not self.created: - self.create(cfg, data_blob, result_blob) + if not self.ready: + if not self.add_results: + assert cfg is not None and data_blob is not None and result_blob is not None,\ + 'Need to provide a reconstruction config, a data_blob and a result_blob to store' + self.create(cfg, data_blob, result_blob) + else: + assert result_blob is not None, 'Need to provide a result dictionary to append' + self.add_keys(result_blob) + self.ready = True # Append file with h5py.File(self.file_name, 'a') as file: @@ -358,7 +420,6 @@ def store(group, event, key, array): region_ref = dataset.regionref[current_id:current_id + len(array)] event[key] = region_ref - @staticmethod def store_jagged(group, event, key, array_list): ''' @@ -478,3 +539,70 @@ def store_objects(group, event, key, array, obj_dtype): # Define region reference, store it at the event level region_ref = dataset.regionref[current_id:current_id + len(array)] event[key] = region_ref + + +class CSVWriter: + ''' + Class which builds a CSV file to store the output + of analysis tools. It can only be used to store + relatively basic quantities (scalars, strings, etc.) + + More documentation to come. + ''' + + def __init__(self, + file_name: str = 'output.csv', + append_file: bool = False): + ''' + Initialize the basics of the output file + + Parameters + ---------- + file_name : str, default 'output.csv' + Name of the output CSV file + append_file : bool, default False + Add more rows to an existing CSV file + ''' + self.file_name = file_name + self.append_file = append_file + self.result_keys = None + if self.append_file: + with open(self.file_name, 'r') as file: + self.result_keys = file.readline().split(', ') + print('KEYS', self.result_keys) + + def create(self, result_blob): + ''' + Initialize the header of the CSV file, + record the keys to be stored. + + Parameters + ---------- + result_blob : dict + Dictionary containing the output of the reconstruction chain + ''' + # Save the list of keys to store + self.result_keys = list(result_blob.keys()) + + # Create a header and write it to file + with open(self.file_name, 'w') as file: + header_str = ', '.join(self.result_keys)+'\n' + file.write(header_str) + + def append(self, result_blob): + ''' + Append the CSV file with the output + + Parameters + ---------- + result_blob : dict + Dictionary containing the output of the reconstruction chain + ''' + # If this function has never been called, initialiaze the CSV file + if self.result_keys is None: + self.create(result_blob) + + # Append file + with open(self.file_name, 'a') as file: + result_str = ', '.join([str(result_blob[k]) for k in self.result_keys])+'\n' + file.write(result_str) diff --git a/mlreco/utils/wrapper.py b/mlreco/utils/wrapper.py index b132ef02..4d394f33 100644 --- a/mlreco/utils/wrapper.py +++ b/mlreco/utils/wrapper.py @@ -10,13 +10,21 @@ def numba_wrapper(cast_args=[], list_args=[], keep_torch=False, ref_arg=None): Function which wraps a *numba* function with some checks on the input to make the relevant conversions to numpy where necessary. - Args: - cast_args ([str]): List of arguments to be cast to numpy - list_args ([str]): List of arguments which need to be cast to a numba typed list - keep_torch (bool): Make the output a torch object, if the reference argument is one - ref_arg (str) : Reference argument used to assign a type and device to the torch output - Returns: - Function + Parameters + ---------- + cast_args : list(str), optional + List of arguments to be cast to numpy + list_args : list(str), optional + List of arguments which need to be cast to a numba typed list + keep_torch : bool, default False + Make the output a torch object, if the reference argument is one + ref_arg : str, optional + Reference argument used to assign a type and device to the torch output + + Returns + ------- + callable + Wrapped function ''' def outer(fn): @wraps(fn) From b1950ff09acd2bb0e8678143279b3bf79b0b172a Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Fri, 31 Mar 2023 11:23:33 -0700 Subject: [PATCH 091/180] Make sure scalars are not double-nested when loaded with HDF5Reader --- analysis/decorator.py | 17 ++-- mlreco/iotools/writers.py | 201 +++++++++++++++++++++----------------- mlreco/main_funcs.py | 2 +- 3 files changed, 118 insertions(+), 102 deletions(-) diff --git a/analysis/decorator.py b/analysis/decorator.py index 944547a5..a991638b 100644 --- a/analysis/decorator.py +++ b/analysis/decorator.py @@ -9,8 +9,9 @@ from mlreco.main_funcs import cycle from mlreco.trainval import trainval -from mlreco.iotools.factories import loader_factory, writer_factory +from mlreco.iotools.factories import loader_factory from mlreco.iotools.readers import HDF5Reader +from mlreco.iotools.writers import CSVWriter def evaluate(filenames): @@ -64,15 +65,9 @@ def process_dataset(analysis_config, cfg, profile=True): log_dir = analysis_config['analysis']['log_dir'] append = analysis_config['analysis'].get('append', False) - writer_cfg = analysis_config.get('writer', {}) - writer_cfg['name'] = writer_cfg.get('name', 'CSVWriter') - writer_cfg['append_file'] = append - writers = {} for file_name in filenames: - writer_cfg['file_name'] = f'{log_dir}/{file_name}.csv' - cfg['iotool']['writer'] = writer_cfg - writers[file_name] = writer_factory(cfg) + writers[file_name] = CSVWriter(f'{log_dir}/{file_name}.csv', append) # Loop over the number of requested iterations iteration = 0 @@ -86,10 +81,11 @@ def process_dataset(analysis_config, cfg, profile=True): else: data_blob, res = Reader.get(iteration, nested=True) if profile: - print("Forward took %d s" % (time.time() - start)) + print("Forward took %.2f s" % (time.time() - start)) img_indices = data_blob['index'] # Build the output dictionary + stime = time.time() fname_to_update_list = defaultdict(list) for batch_index, img_index in enumerate(img_indices): dict_list = func(data_blob, res, batch_index, analysis_config, cfg) @@ -98,7 +94,6 @@ def process_dataset(analysis_config, cfg, profile=True): # Store for i, fname in enumerate(fname_to_update_list): - headers = False for row_dict in fname_to_update_list[fname]: writers[fname].append(row_dict) @@ -106,7 +101,7 @@ def process_dataset(analysis_config, cfg, profile=True): iteration += 1 if profile: end = time.time() - print("Iteration %d (total %d s)" % (iteration, end - start)) + print("Iteration %d (total %.2f s)" % (iteration, end - start)) process_dataset._filenames = filenames return process_dataset diff --git a/mlreco/iotools/writers.py b/mlreco/iotools/writers.py index 87afc6b6..5a92dd0b 100644 --- a/mlreco/iotools/writers.py +++ b/mlreco/iotools/writers.py @@ -1,7 +1,8 @@ -import numpy as np -import h5py +import os import yaml +import h5py import inspect +import numpy as np from collections import defaultdict from larcv import larcv @@ -22,9 +23,10 @@ def __init__(self, skip_input_keys: list = [], result_keys: list = None, skip_result_keys: list = [], + append_file: bool = False, add_results: bool = False): ''' - Initialize the basics of the output file + Initializes the basics of the output file Parameters ---------- @@ -38,8 +40,10 @@ def __init__(self, List of result keys to store. If not specified, stores all of the result keys skip_result_keys: list, optional List of result keys to skip + append_file: bool, default False + Add new values to the end of an existing file add_results: bool, default False - Whether to append an existing HDF5 file with more results, or to create one from scratch + Add new keys to an existing file (must match existing length) ''' # Store attributes self.file_name = file_name @@ -47,23 +51,29 @@ def __init__(self, self.skip_input_keys = skip_input_keys self.result_keys = result_keys self.skip_result_keys = skip_result_keys + self.append_file = append_file self.add_results = add_results self.ready = False - def create(self, cfg, data_blob, result_blob=None): + assert not (append_file and add_results), 'Cannot append a file with new keys' + + def create(self, data_blob, result_blob=None, cfg=None): ''' Create the output file structure based on the data and result blobs. Parameters ---------- - cfg : dict - Dictionary containing the ML chain configuration data_blob : dict Dictionary containing the input data result_blob : dict Dictionary containing the output of the reconstruction chain + cfg : dict + Dictionary containing the ML chain configuration ''' - # Get the expected batch_size from the data_blob (index must be present) + # Make sure there is something to store + assert data_blob or result_blob, 'Must provide a non-empty data blob or result blob' + + # Get the expected batch_size (index is alaways provided by the reco. chain) self.batch_size = len(data_blob['index']) # Initialize a dictionary to store keys and their properties (dtype and shape) @@ -72,17 +82,16 @@ def create(self, cfg, data_blob, result_blob=None): # If requested, loop over input_keys and add them to what needs to be tracked if self.input_keys is None: self.input_keys = data_blob.keys() self.input_keys = set(self.input_keys) - if 'index' not in self.input_keys: self.input_keys.add('index') + if 'index' not in self.input_keys: + self.input_keys.add('index') for key in self.skip_input_keys: if key in self.input_keys: self.input_keys.remove(key) for key in self.input_keys: self.register_key(data_blob, key, 'data') - # Loop over the result_keys and add them to what needs to be tracked - assert self.result_keys is None or result_blob is not None,\ - 'No result provided, cannot request keys from it' - if self.result_keys is None: self.result_keys = result_blob.keys() + # If requested, loop over the result_keys and add them to what needs to be tracked + if self.result_keys is None: self.result_keys = result_blob.keys() if result_blob is not None else [] self.result_keys = set(self.result_keys) for key in self.skip_result_keys: if key in self.result_keys: @@ -93,9 +102,9 @@ def create(self, cfg, data_blob, result_blob=None): # Initialize the output HDF5 file with h5py.File(self.file_name, 'w') as file: # Initialize the info dataset that stores top-level description of what is stored - # TODO: This needs to be fleshed out, currently dumping the config as a single string... - file.create_dataset('info', (0,), maxshape=(None,), dtype=None) - file['info'].attrs['cfg'] = yaml.dump(cfg) + if cfg is not None: + file.create_dataset('info', (0,), maxshape=(None,), dtype=None) + file['info'].attrs['cfg'] = yaml.dump(cfg) # Initialize the event dataset and the corresponding reference array datasets self.initialize_datasets(file) @@ -105,13 +114,16 @@ def create(self, cfg, data_blob, result_blob=None): def add_keys(self, result_blob): ''' - Create the output file structure based on the data and result blobs. + Add more keys to the results group of an existing file. Parameters ---------- result_blob : dict Dictionary containing the additional output to store ''' + # Make sure there is something to store + assert result_blob, 'Must provide a non-empty result blob' + # Get the expected batch_size from the data_blob (index must be present) self.batch_size = 1 @@ -146,78 +158,70 @@ def register_key(self, blob, key, category): ''' # Store the necessary information to know how to store a key self.key_dict[key]['category'] = category - if not isinstance(blob[key], list): - # Single scalar (TODO: Is that thing? If not, why not?) - self.key_dict[key]['dtype'] = type(blob[key]) + if self.is_scalar(blob[key]): + # Single scalar + self.key_dict[key]['dtype'] = h5py.string_dtype() if isinstance(blob[key], str) else type(blob[key]) self.key_dict[key]['scalar'] = True else: - if len(blob[key]) != self.batch_size: + if len(blob[key]) != self.batch_size: # TODO: Get rid of this possibility upstream # List with a single scalar, regardless of batch_size - # TODO: understand why single scalars are in arrays... - assert len(blob[key]) == 1 and\ - not hasattr(blob[key][0], '__len__'),\ + assert len(blob[key]) == 1 and self.is_scalar(blob[key][0]),\ 'If there is an array of length mismatched with batch_size, '+\ 'it must contain a single scalar.' - self.key_dict[key]['dtype'] = type(blob[key][0]) - self.key_dict[key]['scalar'] = True - elif not hasattr(blob[key][0], '__len__'): + if self.is_scalar(blob[key][0]): # List containing a single scalar per batch ID - self.key_dict[key]['dtype'] = type(blob[key][0]) + self.key_dict[key]['dtype'] = h5py.string_dtype() if isinstance(blob[key][0], str) else type(blob[key][0]) self.key_dict[key]['scalar'] = True - elif isinstance(blob[key][0], (list, np.ndarray)) and\ - isinstance(blob[key][0][0], larcv.Particle): - # List containing a single list of larcv.Particle object per batch ID - if not hasattr(self, 'particle_dtype'): - self.particle_dtype = self.get_object_dtype(blob[key][0][0]) - self.key_dict[key]['dtype'] = self.particle_dtype - - elif isinstance(blob[key][0], (list, np.ndarray)) and\ - isinstance(blob[key][0][0], larcv.Neutrino): - # List containing a single list of larcv.Neutrino object per batch ID - if not hasattr(self, 'neutrino_dtype'): - self.neutrino_dtype = self.get_object_dtype(blob[key][0][0]) - self.key_dict[key]['dtype'] = self.neutrino_dtype - - elif isinstance(blob[key][0], (list, np.ndarray)) and\ - isinstance(blob[key][0][0], larcv.Flash): - # List containing a single list of larcv.Flash object per batch ID - if not hasattr(self, 'flash_dtype'): - self.flash_dtype = self.get_object_dtype(blob[key][0][0]) - self.key_dict[key]['dtype'] = self.flash_dtype - - elif isinstance(blob[key][0], (list, np.ndarray)) and\ - isinstance(blob[key][0][0], larcv.CRTHit): - # List containing a single list of larcv.CRTHit object per batch ID - if not hasattr(self, 'crthit_dtype'): - self.crthit_dtype = self.get_object_dtype(blob[key][0][0]) - self.key_dict[key]['dtype'] = self.crthit_dtype - - elif isinstance(blob[key][0], list) and\ - not hasattr(blob[key][0][0], '__len__'): - # List containing a single list of scalars per batch ID - self.key_dict[key]['dtype'] = type(blob[key][0][0]) - - elif isinstance(blob[key][0], np.ndarray) and\ - not blob[key][0].dtype == np.object: - # List containing a single ndarray of scalars per batch ID - self.key_dict[key]['dtype'] = blob[key][0].dtype - self.key_dict[key]['width'] = blob[key][0].shape[1] if len(blob[key][0].shape) == 2 else 0 - - elif isinstance(blob[key][0], (list, np.ndarray)) and isinstance(blob[key][0][0], np.ndarray): - # List containing a list (or ndarray) of ndarrays per batch ID - widths = [] - for i in range(len(blob[key][0])): - widths.append(blob[key][0][i].shape[1] if len(blob[key][0][i].shape) == 2 else 0) - same_width = np.all([widths[i] == widths[0] for i in range(len(widths))]) - - self.key_dict[key]['dtype'] = blob[key][0][0].dtype - self.key_dict[key]['width'] = widths - self.key_dict[key]['merge'] = same_width else: - raise TypeError('Do not know how to store output of type', type(blob[key][0])) + # List containing a list/array of objects per batch ID + if isinstance(blob[key][0][0], larcv.Particle): + # List containing a single list of larcv.Particle object per batch ID + if not hasattr(self, 'particle_dtype'): + self.particle_dtype = self.get_object_dtype(blob[key][0][0]) + self.key_dict[key]['dtype'] = self.particle_dtype + + elif isinstance(blob[key][0][0], larcv.Neutrino): + # List containing a single list of larcv.Neutrino object per batch ID + if not hasattr(self, 'neutrino_dtype'): + self.neutrino_dtype = self.get_object_dtype(blob[key][0][0]) + self.key_dict[key]['dtype'] = self.neutrino_dtype + + elif isinstance(blob[key][0][0], larcv.Flash): + # List containing a single list of larcv.Flash object per batch ID + if not hasattr(self, 'flash_dtype'): + self.flash_dtype = self.get_object_dtype(blob[key][0][0]) + self.key_dict[key]['dtype'] = self.flash_dtype + + elif isinstance(blob[key][0][0], larcv.CRTHit): + # List containing a single list of larcv.CRTHit object per batch ID + if not hasattr(self, 'crthit_dtype'): + self.crthit_dtype = self.get_object_dtype(blob[key][0][0]) + self.key_dict[key]['dtype'] = self.crthit_dtype + + elif not hasattr(blob[key][0][0], '__len__'): + # List containing a single list of scalars per batch ID + self.key_dict[key]['dtype'] = type(blob[key][0][0]) + + elif not blob[key][0].dtype == np.object: + # List containing a single ndarray of scalars per batch ID + self.key_dict[key]['dtype'] = blob[key][0].dtype + self.key_dict[key]['width'] = blob[key][0].shape[1] if len(blob[key][0].shape) == 2 else 0 + + elif isinstance(blob[key][0][0], np.ndarray): + # List containing a list (or ndarray) of ndarrays per batch ID + widths = [] + for i in range(len(blob[key][0])): + widths.append(blob[key][0][i].shape[1] if len(blob[key][0][i].shape) == 2 else 0) + same_width = np.all([widths[i] == widths[0] for i in range(len(widths))]) + + self.key_dict[key]['dtype'] = blob[key][0][0].dtype + self.key_dict[key]['width'] = widths + self.key_dict[key]['merge'] = same_width + else: + raise TypeError('Do not know how to store output of type', type(blob[key][0])) def get_object_dtype(self, obj): ''' @@ -278,7 +282,6 @@ def initialize_datasets(self, file): shape, maxshape = [(0, w), (None, w)] if w else [(0,), (None,)] grp.create_dataset(key, shape, maxshape=maxshape, dtype=val['dtype']) if val['scalar']: - print('SCALAR') grp[key].attrs['scalar'] = True elif not val['merge']: @@ -304,27 +307,25 @@ def initialize_datasets(self, file): file.create_dataset('events', (0,), maxshape=(None,), dtype=self.event_dtype) - def append(self, cfg=None, data_blob=None, result_blob=None): + def append(self, data_blob=None, result_blob=None, cfg=None): ''' Append the HDF5 file with the content of a batch. Parameters ---------- - cfg : dict - Dictionary containing the ML chain configuration - data_blob : dict - Dictionary containing the input data result_blob : dict Dictionary containing the output of the reconstruction chain + data_blob : dict + Dictionary containing the input data + cfg : dict + Dictionary containing the ML chain configuration ''' # If this function has never been called, initialiaze the HDF5 file if not self.ready: if not self.add_results: - assert cfg is not None and data_blob is not None and result_blob is not None,\ - 'Need to provide a reconstruction config, a data_blob and a result_blob to store' - self.create(cfg, data_blob, result_blob) + if not self.append_file or not os.path.isfile(self.file_name): + self.create(data_blob, result_blob, cfg) else: - assert result_blob is not None, 'Need to provide a result dictionary to append' self.add_keys(result_blob) self.ready = True @@ -370,7 +371,10 @@ def append_key(self, file, event, blob, key, batch_id): cat = val['category'] if not val['merge'] and not isinstance(val['width'], list): # Store single object - obj = blob[key][batch_id] if len(blob[key]) == self.batch_size else blob[key][0] + if self.is_scalar(blob[key]): + obj = blob[key] + else: + obj = blob[key][batch_id] if len(blob[key]) == self.batch_size else blob[key][0] if not hasattr(obj, '__len__'): obj = [obj] @@ -393,6 +397,24 @@ def append_key(self, file, event, blob, key, batch_id): # Store one array of for all in the list and a index to break them self.store_flat(file[cat], event, key, blob[key][batch_id]) + @staticmethod + def is_scalar(obj): + ''' + Returns true if the object has no __len__ + attribute or is a string object. + + Parameters + ---------- + object : class instance + Instance of an object used to check typing + + Returns + ------- + bool + True if the object is a scalar or a string + ''' + return not hasattr(obj, '__len__') or isinstance(obj, str) + @staticmethod def store(group, event, key, array): ''' @@ -569,7 +591,6 @@ def __init__(self, if self.append_file: with open(self.file_name, 'r') as file: self.result_keys = file.readline().split(', ') - print('KEYS', self.result_keys) def create(self, result_blob): ''' diff --git a/mlreco/main_funcs.py b/mlreco/main_funcs.py index c0b09b21..ca678804 100644 --- a/mlreco/main_funcs.py +++ b/mlreco/main_funcs.py @@ -397,7 +397,7 @@ def inference_loop(handlers): tsum, result_blob, handlers.cfg, epoch, data_blob['index'][0]) if handlers.writer: - handlers.writer.append(handlers.cfg, data_blob, result_blob) + handlers.writer.append(data_blob, result_blob, handlers.cfg) handlers.iteration += 1 From fb2201b05be185a5d40ca574eec690edfaf1b938 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Sun, 2 Apr 2023 23:15:03 -0700 Subject: [PATCH 092/180] Refactored cluster3d tensor cleaning, achieved x10 speedup --- mlreco/iotools/parsers/clean_data.py | 200 ++++++++++++++++++++++----- mlreco/iotools/parsers/cluster.py | 14 +- mlreco/utils/globals.py | 14 ++ mlreco/utils/groups.py | 148 -------------------- 4 files changed, 184 insertions(+), 192 deletions(-) diff --git a/mlreco/iotools/parsers/clean_data.py b/mlreco/iotools/parsers/clean_data.py index 6bf0cc5f..109bf6bb 100644 --- a/mlreco/iotools/parsers/clean_data.py +++ b/mlreco/iotools/parsers/clean_data.py @@ -1,50 +1,178 @@ import numpy as np -from mlreco.utils.groups import filter_duplicate_voxels_ref, filter_nonimg_voxels +import numba as nb +from mlreco.utils.globals import SHAPE_COL, SHAPE_PREC -def clean_sparse_data(grp_voxels, grp_data, img_voxels, img_data, meta, precedence): - """ + +def clean_sparse_data(cluster_voxels, cluster_data, sparse_voxels): + ''' Helper that factorizes common cleaning operations required - when trying to match true sparse3d and cluster3d data products. + when trying to match cluster3d data products to sparse3d data products: + 1. Lexicographically sort group data (images are lexicographically sorted) + 2. Remove voxels from group data that are not in image + 3. Choose only one group per voxel (by lexicographic order) + + The set of sparse voxels must be a subset of the set of cluster voxels and + it must not contain any duplicates. + + Parameters + ---------- + cluster_voxels: np.ndarray + (N, 3) Matrix of voxel coordinates in the cluster3d tensor + cluster_data: np.ndarray + (N, F) Matrix of voxel values corresponding to each voxel in the cluster3d tensor + sparse_voxels: np.ndarray + (M, 3) Matrix of voxel coordinates in the reference sparse tensor + + Returns + ------- + cluster_voxels: np.ndarray + (M, 3) Ordered and filtered set of voxel coordinates + cluster_data: np.ndarray + (M, F) Ordered and filtered set of voxel values + ''' + # Lexicographically sort cluster and sparse data + perm = np.lexsort(cluster_voxels.T) + cluster_voxels = cluster_voxels[perm] + cluster_data = cluster_data[perm] + + perm = np.lexsort(sparse_voxels.T) + sparse_voxels = sparse_voxels[perm] - 1) lexicographically sort group data (images are lexicographically sorted) + # Remove duplicates + duplicate_mask = filter_duplicate_voxels_ref(cluster_voxels, cluster_data[:, SHAPE_COL], nb.typed.List(SHAPE_PREC)) + duplicate_index = np.where(duplicate_mask)[0] + cluster_voxels = cluster_voxels[duplicate_index] + cluster_data = cluster_data[duplicate_index] - 2) remove voxels from group data that are not in image + # Remove voxels not present in the sparse matrix + non_ref_mask = filter_voxels_ref(cluster_voxels, sparse_voxels) + non_ref_index = np.where(non_ref_mask)[0] + cluster_voxels = cluster_voxels[non_ref_index] + cluster_data = cluster_data[non_ref_index] - 3) choose only one group per voxel (by lexicographic order) + return cluster_voxels, cluster_data + + +@nb.njit(cache=True) +def filter_duplicate_voxels(data: nb.int32[:,:]) -> nb.boolean[:]: + ''' + Returns an array with no duplicate voxel coordinates. + If there are multiple voxels with the same coordinates, + this algorithm simply picks the first one. Parameters ---------- - grp_voxels: np.ndarray - grp_data: np.ndarray - img_voxels: np.ndarray - img_data: np.ndarray - meta: larcv::Meta + data: np.ndarray + (N, 3) Lexicographically sorted matrix of voxel coordinates + + Returns + ------- + np.ndarray + (N', 3) Matrix that does not contain duplicate voxel coordinates + ''' + # For each voxel, check if the next one shares its coordinates + n = data.shape[0] + ret = np.ones(n, dtype=np.bool_) + for i in range(1, n): + if np.all(data[i-1] == data[i]): + ret[i-1] = False + + return ret + + +@nb.njit(cache=True) +def filter_duplicate_voxels_ref(data: nb.int32[:,:], + reference: nb.int32[:], + precedence: nb.types.List(nb.int32)) -> nb.boolean[:]: + ''' + Returns an array with no duplicate voxel coordinates. + If there are multiple voxels with the same coordinates, + this algorithm picks the voxel which has the shape label that + comes first in order of precedence. If multiple voxels + with the same precedence index share voxel coordinates, + the first one is picked. + + Parameters + ---------- + data: np.ndarray + (N, 3) Lexicographically sorted matrix of voxel coordinates + reference: np.ndarray + (N) Array of values which have to follow the precedence order precedence: list + (C) Array of classes in the reference array, ordered by precedence + + Returns + ------- + np.ndarray + (N', 3) Matrix that does not contain duplicate voxel coordinates + ''' + # Find all the voxels which are duplicated and organize them in groups + n = data.shape[0] + ret = np.ones(n, dtype=np.bool_) + temp_list = nb.typed.List.empty_list(nb.int64) + groups = [] + for i in range(1, n): + same = np.all(data[i-1] == data[i]) + if same: + if not len(temp_list): + temp_list.extend([i-1, i]) + else: + temp_list.append(i) + if len(temp_list) and (not same or i == n-1): + groups.append(temp_list) + temp_list = nb.typed.List.empty_list(nb.int64) + + # For each group, pick the voxel with the label that comes first in order of precedence + for group in groups: + group = np.asarray(group) + ref = np.array([precedence.index(int(r)) for r in reference[group]]) + args = np.argsort(-ref, kind='mergesort') # Must preserve of order of duplicates + ret[group[args[:-1]]] = False + + return ret + + +@nb.njit(cache=True) +def filter_voxels_ref(data: nb.int32[:,:], + reference: nb.int32[:,:]) -> nb.boolean[:]: + ''' + Returns an array which does not contain any voxels which + do not belong to the reference array. The reference array must + contain a subset of the voxels in the array to be filtered. + + Assumes both arrays are lexicographically sorted, the reference matrix + contains no duplicates and is a subset of the matrix to be filtered. + + Parameters + ---------- + data: np.ndarray + (N, 3) Lexicographically sorted matrix of voxel coordinates to filter + reference: np.ndarray + (N, 3) Lexicographically sorted matrix of voxel coordinates to match Returns ------- - grp_voxels: np.ndarray - grp_data: np.ndarray - """ - # Step 1: lexicographically sort group data - perm = np.lexsort(grp_voxels.T) - grp_voxels = grp_voxels[perm,:] - grp_data = grp_data[perm] - - perm = np.lexsort(img_voxels.T) - img_voxels = img_voxels[perm,:] - img_data = img_data[perm] - - # Step 2: remove duplicates - sel1 = filter_duplicate_voxels_ref(grp_voxels, grp_data[:,-1], meta, usebatch=True, precedence=precedence) - inds1 = np.where(sel1)[0] - grp_voxels = grp_voxels[inds1,:] - grp_data = grp_data[inds1] - - # Step 3: remove voxels not in image - sel2 = filter_nonimg_voxels(grp_voxels, img_voxels, usebatch=False) - inds2 = np.where(sel2)[0] - grp_voxels = grp_voxels[inds2,:] - grp_data = grp_data[inds2] - return grp_voxels, grp_data + np.ndarray + (N', 3) Matrix that does not contain voxels absent from the reference matrix + ''' + # Try to match each voxel in the data tensor to one in the reference tensor + n_data, n_ref = data.shape[0], reference.shape[0] + d, r = 0, 0 + ret = np.ones(n_data, dtype=np.bool_) + while d < n_data and r < n_ref: + if np.all(data[d] == reference[r]): + # Voxel is in both matrices + d += 1 + r += 1 + else: + # Voxel is in data, but not reference + ret[d] = False + d += 1 + + # Need to go through rest of data, if any is left + while d < n_data: + ret[d] = False + d += 1 + + return ret diff --git a/mlreco/iotools/parsers/cluster.py b/mlreco/iotools/parsers/cluster.py index 6360eb53..0265ac8e 100644 --- a/mlreco/iotools/parsers/cluster.py +++ b/mlreco/iotools/parsers/cluster.py @@ -2,8 +2,10 @@ import numpy as np from larcv import larcv from sklearn.cluster import DBSCAN -from mlreco.utils.groups import get_interaction_id, get_nu_id, get_particle_id, get_shower_primary_id, get_group_primary_id + from mlreco.utils.groups import type_labels as TYPE_LABELS +from mlreco.utils.groups import get_interaction_id, get_nu_id, get_particle_id, get_shower_primary_id, get_group_primary_id + from mlreco.iotools.parsers.sparse import parse_sparse3d from mlreco.iotools.parsers.particles import parse_particles from mlreco.iotools.parsers.clean_data import clean_sparse_data @@ -63,7 +65,6 @@ def parse_cluster3d(cluster_event, add_particle_info = True, add_kinematics_info = False, clean_data = True, - precedence = [1,2,0,3,4], type_include_mpr = False, type_include_secondary = False, primary_include_mpr = True, @@ -85,7 +86,6 @@ def parse_cluster3d(cluster_event, sparse_value_event: sparse3d_pcluster add_particle_info: true clean_data: true - precedence: [1,2,0,3,4] type_include_mpr: false type_include_secondary: false primary_include_mpr: true @@ -100,7 +100,6 @@ def parse_cluster3d(cluster_event, sparse_value_event: larcv::EventSparseTensor3D add_particle_info: bool clean_data: bool - precedence: list type_include_mpr: bool type_include_secondary: bool primary_include_mpr: bool @@ -197,8 +196,8 @@ def parse_cluster3d(cluster_event, if clean_data: assert sparse_semantics_event is not None, 'Need to provide a semantics tensor to clean up output' sem_voxels, sem_features = parse_sparse3d([sparse_semantics_event]) - np_voxels, np_features = clean_sparse_data(np_voxels, np_features, sem_voxels, sem_features, meta, precedence) - np_features[:,-1] = sem_features[:,-1] # Match semantic column to semantic tensor + np_voxels, np_features = clean_sparse_data(np_voxels, np_features, sem_voxels) + np_features[:,-1] = sem_features[:,-1] # Match semantic column to semantic tensor (probably superfluous) np_features[sem_features[:,-1] > 3, 1:-1] = -1 # Set all cluster labels to -1 if semantic class is LE or ghost # If a value tree is provided, override value colum @@ -217,7 +216,6 @@ def parse_cluster3d_charge_rescaled(cluster_event, add_particle_info = False, add_kinematics_info = False, clean_data = True, - precedence = [1,2,0,3,4], type_include_mpr = False, type_include_secondary = False, primary_include_mpr = True, @@ -226,7 +224,7 @@ def parse_cluster3d_charge_rescaled(cluster_event, # Produces cluster3d labels with sparse3d_reco_rescaled on the fly on datasets that do not have it np_voxels, np_features = parse_cluster3d(cluster_event, particle_event, particle_mpv_event, sparse_semantics_event, None, - add_particle_info, add_kinematics_info, clean_data, precedence, + add_particle_info, add_kinematics_info, clean_data, type_include_mpr, type_include_secondary, primary_include_mpr, break_clusters, min_size) from .sparse import parse_sparse3d_charge_rescaled diff --git a/mlreco/utils/globals.py b/mlreco/utils/globals.py index 68567c0e..d956a5c0 100644 --- a/mlreco/utils/globals.py +++ b/mlreco/utils/globals.py @@ -1,3 +1,5 @@ +from larcv import larcv + # Column which specifies the batch ID in a sparse tensor BATCH_COL = 0 @@ -20,3 +22,15 @@ # Colum which specifies the shape ID of a voxel in a sparse tensor SHAPE_COL = -1 + +# Shape ID of each type of voxel category +SHOW_SHP = larcv.kShapeShower # 0 +TRACK_SHP = larcv.kShapeTrack # 1 +MICH_SHP = larcv.kShapeMichel # 2 +DELTA_SHP = larcv.kShapeDelta # 3 +LOWE_SHP = larcv.kShapeLEScatter # 4 +GHOST_SHP = larcv.kShapeGhost # 5 +UNKWN_SHP = larcv.kShapeUnknown # 6 + +# Shape precedence in cluster labels +SHAPE_PREC = [TRACK_SHP, MICH_SHP, SHOW_SHP, DELTA_SHP, LOWE_SHP] diff --git a/mlreco/utils/groups.py b/mlreco/utils/groups.py index 4ace3009..7031b2de 100644 --- a/mlreco/utils/groups.py +++ b/mlreco/utils/groups.py @@ -1,13 +1,3 @@ -# utility function to reconcile groups data with energy deposition and 5-types data: -# problem: parse_cluster3d and parse_sparse3d will not output voxels in same order -# additionally, some voxels in groups data do not deposit energy, so do not appear in images -# also, some voxels have more than one group. -# plan is to put in a function to: -# 1) lexicographically sort group data (images are lexicographically sorted) -# 2) remove voxels from group data that are not in image -# 3) choose only one group per voxel (by lexicographic order) -# WARNING: (3) is certainly not a canonical choice - import numpy as np import torch from larcv import larcv @@ -45,144 +35,6 @@ def get_group_types(particle_v, meta, point_type="3d"): return np.array(gt_types) -def filter_duplicate_voxels(data, usebatch=True): - """ - return array that will filter out duplicate voxels - Only first instance of voxel will appear - Assume data[:4] = [x,y,z,batchid] - Assumes data is lexicographically sorted in x,y,z,batch order - """ - # set number of cols to look at - if usebatch: - k = 4 - else: - k = 3 - n = data.shape[0] - ret = np.empty(n, dtype=bool) - for i in range(1,n): - if np.all(data[i-1,:k] == data[i,:k]): - # duplicate voxel - ret[i-1] = False - else: - # new voxel - ret[i-1] = True - ret[n-1] = True - # ret[0] = True - # for i in range(n-1): - # if np.all(data[i,:k] == data[i+1,:k]): - # # duplicate voxel - # ret[i+1] = False - # else: - # # new voxel - # ret[i+1] = True - return ret - - -def filter_duplicate_voxels_ref(data, reference, meta, usebatch=True, precedence=[1,2,0,3,4]): - """ - return array that will filter out duplicate voxels - Sort with respect to a reference and following the specified precedence order - Assume data[:4] = [x,y,z,batchid] - Assumes data is lexicographically sorted in x,y,z,batch order - """ - # set number of cols to look at - if usebatch: - k = 4 - else: - k = 3 - n = data.shape[0] - ret = np.full(n, True, dtype=bool) - duplicates = {} - for i in range(1,n): - if np.all(data[i-1,:k] == data[i,:k]): - x, y, z = int(data[i,0]), int(data[i,1]), int(data[i,2]) - id = meta.index(x, y, z) - if id in duplicates: - duplicates[id].append(i) - else: - duplicates[id] = [i-1, i] - for d in duplicates.values(): - ref = np.array([precedence.index(r) for r in reference[d]]) - args = np.argsort(-ref, kind='mergesort') # Must preserve of order of duplicates - ret[np.array(d)[args[:-1]]] = False - - return ret - - -def filter_nonimg_voxels(data_grp, data_img, usebatch=True): - """ - return array that will filter out voxels in data_grp that are not in data_img - ASSUME: data_grp and data_img are lexicographically sorted in x,y,z,batch order - ASSUME: all points in data_img are also in data_grp - ASSUME: all voxels in data are unique - """ - # set number of cols to look at - if usebatch: - k = 4 - else: - k = 3 - ngrp = data_grp.shape[0] - nimg = data_img.shape[0] - igrp = 0 - iimg = 0 - ret = np.empty(ngrp, dtype=bool) # return array - while igrp < ngrp and iimg < nimg: - if np.all(data_grp[igrp,:k] == data_img[iimg,:k]): - # voxel is in both data - ret[igrp] = True - igrp += 1 - iimg += 1 - else: - # voxel is in data_grp, but not data_img - ret[igrp] = False - igrp += 1 - # need to go through rest of data_grp if any left - while igrp < ngrp: - ret[igrp] = False - igrp += 1 - return ret - - -def filter_group_data(data_grp, data_img): - """ - return return array that will permute and filter out voxels so that data_grp and data_img have same voxel locations - 1) lexicographically sort group data (images are lexicographically sorted) - 2) remove voxels from group data that are not in image - 3) choose only one group per voxel (by lexicographic order) - WARNING: (3) is certainly not a canonical choice - """ - # step 1: lexicographically sort group data - perm = np.lexsort(data_grp[:,:-1:].T) - data_grp = data_grp[perm,:] - - # step 2: remove duplicates - sel1 = filter_duplicate_voxels(data_grp) - inds1 = np.where(sel1)[0] - data_grp = data_grp[inds1,:] - - # step 3: remove voxels not in image - sel2 = filter_nonimg_voxels(data_grp, data_img) - inds2 = np.where(sel2)[0] - - return perm[inds1[inds2]] - - -def process_group_data(data_grp, data_img): - """ - return processed group data - 1) lexicographically sort group data (images are lexicographically sorted) - 2) remove voxels from group data that are not in image - 3) choose only one group per voxel (by lexicographic order) - WARNING: (3) is certainly not a canonical choice - """ - data_grp_np = data_grp.cpu().detach().numpy() - data_img_np = data_img.cpu().detach().numpy() - - inds = filter_group_data(data_grp_np, data_img_np) - - return data_grp[inds,:] - - def get_interaction_id(particle_v, num_ancestor_loop=1): ''' A function to sort out interaction ids. From 3f4ec29ea7e20c987de3450a98f09b613188a273 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Sun, 2 Apr 2023 23:35:43 -0700 Subject: [PATCH 093/180] Changed default parse_cluster3d behavior to doing the bare minimum (only get cluster ID, no filtering) --- mlreco/iotools/parsers/cluster.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/mlreco/iotools/parsers/cluster.py b/mlreco/iotools/parsers/cluster.py index 0265ac8e..f9bcaf78 100644 --- a/mlreco/iotools/parsers/cluster.py +++ b/mlreco/iotools/parsers/cluster.py @@ -62,9 +62,9 @@ def parse_cluster3d(cluster_event, particle_mpv_event = None, sparse_semantics_event = None, sparse_value_event = None, - add_particle_info = True, + add_particle_info = False, add_kinematics_info = False, - clean_data = True, + clean_data = False, type_include_mpr = False, type_include_secondary = False, primary_include_mpr = True, @@ -193,11 +193,17 @@ def parse_cluster3d(cluster_event, np_features = np.concatenate(clusters_features, axis=0) # If requested, remove duplicate voxels (cluster overlaps) and account for semantics + if (sparse_semantics_event is not None or sparse_value_event is not None) and not clean_data: + from warnings import warn + warn('You should set `clean_data` to True if you specify a sparse tensor in parse_cluster3d') + clean_data = True + if clean_data: + assert add_particle_info, 'Need to add particle info to fetch particle semantics for each voxel' assert sparse_semantics_event is not None, 'Need to provide a semantics tensor to clean up output' sem_voxels, sem_features = parse_sparse3d([sparse_semantics_event]) np_voxels, np_features = clean_sparse_data(np_voxels, np_features, sem_voxels) - np_features[:,-1] = sem_features[:,-1] # Match semantic column to semantic tensor (probably superfluous) + np_features[:,-1] = sem_features[:,-1] # Match semantic column to semantic tensor np_features[sem_features[:,-1] > 3, 1:-1] = -1 # Set all cluster labels to -1 if semantic class is LE or ghost # If a value tree is provided, override value colum @@ -215,7 +221,7 @@ def parse_cluster3d_charge_rescaled(cluster_event, sparse_value_event_list = None, add_particle_info = False, add_kinematics_info = False, - clean_data = True, + clean_data = False, type_include_mpr = False, type_include_secondary = False, primary_include_mpr = True, From eece31465a4cb7b0856017c233381fc0a73645cf Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Mon, 3 Apr 2023 00:06:29 -0700 Subject: [PATCH 094/180] Moved parse_cluster3d labeling tools under iotools.parser --- mlreco/iotools/parsers/cluster.py | 10 ++-- .../parsers/label_data.py} | 51 ++----------------- mlreco/iotools/parsers/particles.py | 8 +-- mlreco/utils/globals.py | 14 ++++- 4 files changed, 25 insertions(+), 58 deletions(-) rename mlreco/{utils/groups.py => iotools/parsers/label_data.py} (89%) diff --git a/mlreco/iotools/parsers/cluster.py b/mlreco/iotools/parsers/cluster.py index f9bcaf78..1e3d92bb 100644 --- a/mlreco/iotools/parsers/cluster.py +++ b/mlreco/iotools/parsers/cluster.py @@ -3,12 +3,10 @@ from larcv import larcv from sklearn.cluster import DBSCAN -from mlreco.utils.groups import type_labels as TYPE_LABELS -from mlreco.utils.groups import get_interaction_id, get_nu_id, get_particle_id, get_shower_primary_id, get_group_primary_id - -from mlreco.iotools.parsers.sparse import parse_sparse3d -from mlreco.iotools.parsers.particles import parse_particles -from mlreco.iotools.parsers.clean_data import clean_sparse_data +from .sparse import parse_sparse3d +from .particles import parse_particles +from .clean_data import clean_sparse_data +from .label_data import get_interaction_id, get_nu_id, get_particle_id, get_shower_primary_id, get_group_primary_id def parse_cluster2d(cluster_event): diff --git a/mlreco/utils/groups.py b/mlreco/iotools/parsers/label_data.py similarity index 89% rename from mlreco/utils/groups.py rename to mlreco/iotools/parsers/label_data.py index 7031b2de..b50f1e64 100644 --- a/mlreco/utils/groups.py +++ b/mlreco/iotools/parsers/label_data.py @@ -1,38 +1,7 @@ import numpy as np import torch -from larcv import larcv - -def get_group_types(particle_v, meta, point_type="3d"): - """ - Gets particle classes for voxel groups - """ - if point_type not in ["3d", "xy", "yz", "zx"]: - raise Exception("Point type not supported in PPN I/O.") - gt_types = [] - for particle in particle_v: - pdg_code = abs(particle.pdg_code()) - prc = particle.creation_process() - - # Determine point type - if (pdg_code == 2212): - gt_type = 0 # proton - elif pdg_code != 22 and pdg_code != 11: - gt_type = 1 - elif pdg_code == 22: - gt_type = 2 - else: - if prc == "primary" or prc == "nCapture" or prc == "conv": - gt_type = 2 # em shower - elif prc == "muIoni" or prc == "hIoni": - gt_type = 3 # delta - elif prc == "muMinusCaptureAtRest" or prc == "muPlusCaptureAtRest" or prc == "Decay": - gt_type = 4 # michel - else: - gt_type = -1 # not well defined - - gt_types.append(gt_type) - return np.array(gt_types) +from mlreco.utils.globals import SHOW_SHP, TRACK_SHP, PDG_TO_PID def get_interaction_id(particle_v, num_ancestor_loop=1): @@ -158,18 +127,6 @@ def get_nu_id(cluster_event, particle_v, interaction_ids, particle_mpv=None): return nu_id -type_labels = { - 22: 0, # photon - 11: 1, # e- - -11: 1, # e+ - 13: 2, # mu- - -13: 2, # mu+ - 211: 3, # pi+ - -211: 3, # pi- - 2212: 4, # protons -} - - def get_particle_id(particles_v, nu_ids, include_mpr=False, include_secondary=False): ''' Function that gives one of five labels to particles of @@ -210,8 +167,8 @@ def get_particle_id(particles_v, nu_ids, include_mpr=False, include_secondary=Fa # If the particle type exists in the predefined list, assign group_id = int(particles_v[i].group_id()) t = int(particles_v[group_id].pdg_code()) - if t in type_labels.keys(): - particle_ids[i] = type_labels[t] + if t in PDG_TO_PID.keys(): + particle_ids[i] = PDG_TO_PID[t] else: particle_ids[i] = -1 @@ -297,7 +254,7 @@ def get_group_primary_id(particles_v, nu_ids=None, include_mpr=True): continue # If the particle is not a shower or a track, it is not a primary - if p.shape() != larcv.kShapeShower and p.shape() != larcv.kShapeTrack: + if p.shape() != SHOW_SHP and p.shape() != TRACK_SHP: primary_ids[i] = 0 continue diff --git a/mlreco/iotools/parsers/particles.py b/mlreco/iotools/parsers/particles.py index 60592eb9..56749392 100644 --- a/mlreco/iotools/parsers/particles.py +++ b/mlreco/iotools/parsers/particles.py @@ -1,8 +1,8 @@ import numpy as np from larcv import larcv -from mlreco.utils.ppn import get_ppn_info -from mlreco.utils.groups import type_labels as TYPE_LABELS +from mlreco.utils.globals import PDG_TO_PID +from mlreco.utils.ppn import get_ppn_info def parse_particles(particle_event, cluster_event=None, voxel_coordinates=True): """ @@ -302,8 +302,8 @@ def parse_particle_singlep_pdg(particle_event): pdg = -1 for p in particle_event.as_vector(): if not p.track_id() == 1: continue - if int(p.pdg_code()) in TYPE_LABELS.keys(): - pdg = TYPE_LABELS[int(p.pdg_code())] + if int(p.pdg_code()) in PDG_TO_PID.keys(): + pdg = PDG_TO_PID[int(p.pdg_code())] else: pdg = -1 return np.asarray([pdg]) diff --git a/mlreco/utils/globals.py b/mlreco/utils/globals.py index d956a5c0..6796c378 100644 --- a/mlreco/utils/globals.py +++ b/mlreco/utils/globals.py @@ -32,5 +32,17 @@ GHOST_SHP = larcv.kShapeGhost # 5 UNKWN_SHP = larcv.kShapeUnknown # 6 -# Shape precedence in cluster labels +# Shape precedence used in the cluster labeling process SHAPE_PREC = [TRACK_SHP, MICH_SHP, SHOW_SHP, DELTA_SHP, LOWE_SHP] + +# Mapping between particle PDG code and particle ID labels +PDG_TO_PID = { + 22: 0, # photon + 11: 1, # e- + -11: 1, # e+ + 13: 2, # mu- + -13: 2, # mu+ + 211: 3, # pi+ + -211: 3, # pi- + 2212: 4, # protons +} From 8c30d289848c9781d8e400d07629d16a42049446 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Mon, 3 Apr 2023 00:12:19 -0700 Subject: [PATCH 095/180] Got rid of all reference to type_labels and replaced them with PDG_TO_PID --- analysis/classes/evaluator.py | 13 ++++++------- analysis/classes/predictor.py | 1 - mlreco/post_processing/analysis/nue_selection.py | 4 ++-- mlreco/utils/vertex.py | 13 ++++++------- 4 files changed, 14 insertions(+), 17 deletions(-) diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index e80dbf41..3170a29e 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -1,7 +1,7 @@ from typing import List import numpy as np -from mlreco.utils.globals import VTX_COLS, INTER_COL, COORD_COLS +from mlreco.utils.globals import VTX_COLS, INTER_COL, COORD_COLS, PDG_TO_PID from analysis.classes import TruthParticleFragment, TruthParticle, Interaction from analysis.classes.particle import (match_particles_fn, @@ -11,7 +11,6 @@ match_particles_optimal) from analysis.algorithms.point_matching import * -from mlreco.utils.groups import type_labels as TYPE_LABELS from mlreco.utils.vertex import get_vertex from analysis.classes.predictor import FullChainPredictor @@ -59,7 +58,7 @@ def get_true_particle_labels(labels, mask, pid=-1, verbose=False): def handle_empty_true_particles(labels_noghost, mask_noghost, p, entry, verbose=False): pid = int(p.id()) - pdg = TYPE_LABELS.get(p.pdg_code(), -1) + pdg = PDG_TO_PID.get(p.pdg_code(), -1) is_primary = p.group_id() == p.parent_id() semantic_type, interaction_id, nu_id = -1, -1, -1 @@ -221,7 +220,7 @@ def _apply_true_voxel_cut(self, entry): if pid not in particle_ids: continue is_primary = p.group_id() == p.parent_id() - if p.pdg_code() not in TYPE_LABELS: + if p.pdg_code() not in PDG_TO_PID: continue mask = labels[:, 6].astype(int) == pid coords = labels[mask][:, 1:4] @@ -349,7 +348,7 @@ def get_true_particles(self, entry, only_primaries=True, for idx, p in enumerate(self.data_blob['particles_asis'][entry]): pid = int(p.id()) - pdg = TYPE_LABELS.get(p.pdg_code(), -1) + pdg = PDG_TO_PID.get(p.pdg_code(), -1) is_primary = p.group_id() == p.parent_id() mask_noghost = labels_noghost[:, 6].astype(int) == pid if np.count_nonzero(mask_noghost) <= 0: @@ -384,7 +383,7 @@ def get_true_particles(self, entry, only_primaries=True, volume_id = int(volume_id[cts.argmax()]) # 2. Process particle-level labels - if p.pdg_code() not in TYPE_LABELS: + if p.pdg_code() not in PDG_TO_PID: # print("PID {} not in TYPE LABELS".format(pid)) continue exclude_ids = self._apply_true_voxel_cut(entry) @@ -624,4 +623,4 @@ def match_interactions(self, entry, mode='pred_to_true', if return_counts: return matched_interactions, counts else: - return matched_interactions \ No newline at end of file + return matched_interactions diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index 1c6c56c7..d8a8feec 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -12,7 +12,6 @@ from analysis.classes.particle import group_particles_to_interactions_fn from analysis.algorithms.point_matching import * -from mlreco.utils.groups import type_labels as TYPE_LABELS from analysis.algorithms.vertex import estimate_vertex from analysis.algorithms.utils import get_track_points diff --git a/mlreco/post_processing/analysis/nue_selection.py b/mlreco/post_processing/analysis/nue_selection.py index 434e5ab5..9623f58b 100644 --- a/mlreco/post_processing/analysis/nue_selection.py +++ b/mlreco/post_processing/analysis/nue_selection.py @@ -4,7 +4,7 @@ from mlreco.post_processing import post_processing from mlreco.utils.gnn.cluster import get_cluster_label from mlreco.utils.vertex import predict_vertex, get_vertex -from mlreco.utils.groups import type_labels +from mlreco.utils.globals import PDG_TO_PID @post_processing(['nue-selection-true', 'nue-selection-primaries'], @@ -49,7 +49,7 @@ def nue_selection(cfg, module_cfg, data_blob, res, logdir, iteration, inter_threshold = module_cfg.get('inter_threshold', 10) # Translate into particle type labels - primary_types = np.unique([type_labels[pdg] for pdg in primary_pdgs]) + primary_types = np.unique([PDG_TO_PID[pdg] for pdg in primary_pdgs]) row_names_true, row_values_true = [], [] row_names_primaries, row_values_primaries = [], [] diff --git a/mlreco/utils/vertex.py b/mlreco/utils/vertex.py index cddbbf31..f118c406 100644 --- a/mlreco/utils/vertex.py +++ b/mlreco/utils/vertex.py @@ -7,8 +7,7 @@ from mlreco.utils.ppn import get_track_endpoints_geo from sklearn.decomposition import PCA from mlreco.utils.gnn.evaluation import primary_assignment -from mlreco.utils.groups import type_labels -from mlreco.utils.globals import INTER_COL, PGRP_COL, VTX_COLS +from mlreco.utils.globals import INTER_COL, PGRP_COL, VTX_COLS, PDG_TO_PID def find_closest_points_of_approach(point1, direction1, point2, direction2): @@ -284,7 +283,7 @@ def predict_vertex(inter_idx, data_idx, input_data, res, # Identify PID among primary particles pid = np.argmax(res['node_pred_type'][data_idx][inter_mask][primary_particles], axis=1) - photon_label = type_labels[22] + photon_label = PDG_TO_PID[22] # # Get PPN candidates for vertex, listed per primary particle @@ -357,12 +356,12 @@ def predict_vertex(inter_idx, data_idx, input_data, res, # Ignore photons if # - at least 3 primary particles involved # - at least 2 non-photon primary - use_gamma_threshold = (pid[c_indices] != type_labels[22]).sum() <= 1 + use_gamma_threshold = (pid[c_indices] != PDG_TO_PID[22]).sum() <= 1 for c_idx, c2 in enumerate(c_candidates): if c_idx == p_idx: continue # Ignore photons - # if no_photon_count > 0 and pid[c_indices[c_idx]] == type_labels[22]: continue - if ~use_gamma_threshold and pid[c_indices[c_idx]] == type_labels[22]: continue + # if no_photon_count > 0 and pid[c_indices[c_idx]] == PDG_TO_PID[22]: continue + if ~use_gamma_threshold and pid[c_indices[c_idx]] == PDG_TO_PID[22]: continue d2 = scipy.spatial.distance.cdist(all_voxels[c_candidates[p_idx], coords_col[0]:coords_col[1]], all_voxels[c2, coords_col[0]:coords_col[1]]) distance_to_other_primaries.append(d2.min()) d3 = scipy.spatial.distance.cdist(points, all_voxels[c2, coords_col[0]:coords_col[1]]) @@ -383,7 +382,7 @@ def predict_vertex(inter_idx, data_idx, input_data, res, # # Apply T_B threshold # - use_gamma_threshold = (current_pid == type_labels[22]) or use_gamma_threshold + use_gamma_threshold = (current_pid == PDG_TO_PID[22]) or use_gamma_threshold if use_gamma_threshold and (other_primaries_gamma_threshold > -1) and (distance_to_other_primaries.min() >= other_primaries_gamma_threshold): #print('Skipping photon') continue From aea723205a93927fc307875b81d7765bd43ebf53 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Mon, 3 Apr 2023 17:12:25 -0700 Subject: [PATCH 096/180] Make arxiv in post_processing and done cleaning analysis tools --- analysis/algorithms/arxiv/example_nue.py | 2 +- analysis/algorithms/arxiv/muon_decay.py | 4 +- analysis/algorithms/calorimetry.py | 3 +- analysis/algorithms/interactions.py | 104 ++++++++++ analysis/algorithms/particles.py | 175 ++++++++++++++++ analysis/algorithms/point_matching.py | 4 +- analysis/algorithms/scripts/template.py | 174 +++------------- analysis/algorithms/utils.py | 189 +----------------- analysis/classes/TruthInteraction.py | 4 +- analysis/classes/TruthParticle.py | 2 +- analysis/classes/evaluator.py | 29 ++- .../{particle.py => particle_utils.py} | 0 analysis/classes/predictor.py | 4 +- .../{ => arxiv}/analysis/__init__.py | 0 .../analysis/instance_clustering.py | 0 .../analysis/michel_reconstruction.py | 0 .../analysis/michel_reconstruction_2d.py | 0 .../analysis/michel_reconstruction_noghost.py | 0 .../analysis/muon_residual_range.py | 0 .../{ => arxiv}/analysis/nue_selection.py | 0 .../{ => arxiv}/analysis/stopping_muons.py | 0 .../{ => arxiv}/analysis/through_muons.py | 0 .../{ => arxiv}/analysis/track_clustering.py | 0 .../{ => arxiv}/metrics/__init__.py | 0 .../metrics/bayes_segnet_mcdropout.py | 0 .../metrics/cluster_cnn_metrics.py | 0 .../metrics/cluster_gnn_metrics.py | 0 .../metrics/cosmic_discriminator_metrics.py | 0 .../{ => arxiv}/metrics/deghosting_metrics.py | 0 .../{ => arxiv}/metrics/doublet_metrics.py | 0 .../{ => arxiv}/metrics/duq_metrics.py | 0 .../{ => arxiv}/metrics/evidential_gnn.py | 0 .../{ => arxiv}/metrics/evidential_metrics.py | 0 .../{ => arxiv}/metrics/evidential_segnet.py | 0 .../metrics/graph_spice_metrics.py | 0 .../{ => arxiv}/metrics/kinematics_metrics.py | 0 .../{ => arxiv}/metrics/multi_particle.py | 0 .../{ => arxiv}/metrics/pid_metrics.py | 0 .../{ => arxiv}/metrics/ppn_metrics.py | 0 .../{ => arxiv}/metrics/ppn_simple.py | 0 .../{ => arxiv}/metrics/single_particle.py | 0 .../{ => arxiv}/metrics/singlep_mcdropout.py | 0 .../{ => arxiv}/metrics/uresnet_metrics.py | 0 .../{ => arxiv}/metrics/vertex_metrics.py | 0 .../{ => arxiv}/store/__init__.py | 0 .../{ => arxiv}/store/store_input.py | 0 .../{ => arxiv}/store/store_output.py | 0 .../{ => arxiv}/store/store_uresnet.py | 0 .../{ => arxiv}/store/store_uresnet_ppn.py | 0 mlreco/utils/globals.py | 11 + 50 files changed, 355 insertions(+), 350 deletions(-) create mode 100644 analysis/algorithms/interactions.py create mode 100644 analysis/algorithms/particles.py rename analysis/classes/{particle.py => particle_utils.py} (100%) rename mlreco/post_processing/{ => arxiv}/analysis/__init__.py (100%) rename mlreco/post_processing/{ => arxiv}/analysis/instance_clustering.py (100%) rename mlreco/post_processing/{ => arxiv}/analysis/michel_reconstruction.py (100%) rename mlreco/post_processing/{ => arxiv}/analysis/michel_reconstruction_2d.py (100%) rename mlreco/post_processing/{ => arxiv}/analysis/michel_reconstruction_noghost.py (100%) rename mlreco/post_processing/{ => arxiv}/analysis/muon_residual_range.py (100%) rename mlreco/post_processing/{ => arxiv}/analysis/nue_selection.py (100%) rename mlreco/post_processing/{ => arxiv}/analysis/stopping_muons.py (100%) rename mlreco/post_processing/{ => arxiv}/analysis/through_muons.py (100%) rename mlreco/post_processing/{ => arxiv}/analysis/track_clustering.py (100%) rename mlreco/post_processing/{ => arxiv}/metrics/__init__.py (100%) rename mlreco/post_processing/{ => arxiv}/metrics/bayes_segnet_mcdropout.py (100%) rename mlreco/post_processing/{ => arxiv}/metrics/cluster_cnn_metrics.py (100%) rename mlreco/post_processing/{ => arxiv}/metrics/cluster_gnn_metrics.py (100%) rename mlreco/post_processing/{ => arxiv}/metrics/cosmic_discriminator_metrics.py (100%) rename mlreco/post_processing/{ => arxiv}/metrics/deghosting_metrics.py (100%) rename mlreco/post_processing/{ => arxiv}/metrics/doublet_metrics.py (100%) rename mlreco/post_processing/{ => arxiv}/metrics/duq_metrics.py (100%) rename mlreco/post_processing/{ => arxiv}/metrics/evidential_gnn.py (100%) rename mlreco/post_processing/{ => arxiv}/metrics/evidential_metrics.py (100%) rename mlreco/post_processing/{ => arxiv}/metrics/evidential_segnet.py (100%) rename mlreco/post_processing/{ => arxiv}/metrics/graph_spice_metrics.py (100%) rename mlreco/post_processing/{ => arxiv}/metrics/kinematics_metrics.py (100%) rename mlreco/post_processing/{ => arxiv}/metrics/multi_particle.py (100%) rename mlreco/post_processing/{ => arxiv}/metrics/pid_metrics.py (100%) rename mlreco/post_processing/{ => arxiv}/metrics/ppn_metrics.py (100%) rename mlreco/post_processing/{ => arxiv}/metrics/ppn_simple.py (100%) rename mlreco/post_processing/{ => arxiv}/metrics/single_particle.py (100%) rename mlreco/post_processing/{ => arxiv}/metrics/singlep_mcdropout.py (100%) rename mlreco/post_processing/{ => arxiv}/metrics/uresnet_metrics.py (100%) rename mlreco/post_processing/{ => arxiv}/metrics/vertex_metrics.py (100%) rename mlreco/post_processing/{ => arxiv}/store/__init__.py (100%) rename mlreco/post_processing/{ => arxiv}/store/store_input.py (100%) rename mlreco/post_processing/{ => arxiv}/store/store_output.py (100%) rename mlreco/post_processing/{ => arxiv}/store/store_uresnet.py (100%) rename mlreco/post_processing/{ => arxiv}/store/store_uresnet_ppn.py (100%) diff --git a/analysis/algorithms/arxiv/example_nue.py b/analysis/algorithms/arxiv/example_nue.py index f49d255b..da7e2913 100644 --- a/analysis/algorithms/arxiv/example_nue.py +++ b/analysis/algorithms/arxiv/example_nue.py @@ -3,7 +3,7 @@ from analysis.classes.evaluator import FullChainEvaluator from analysis.decorator import evaluate -from analysis.classes.particle import match_particles_fn, matrix_iou, match_particles_optimal +from lartpc_mlreco3d.analysis.classes.particle_utils import match_particles_fn, matrix_iou, match_particles_optimal from pprint import pprint import time, os diff --git a/analysis/algorithms/arxiv/muon_decay.py b/analysis/algorithms/arxiv/muon_decay.py index ae49dbcd..15535446 100644 --- a/analysis/algorithms/arxiv/muon_decay.py +++ b/analysis/algorithms/arxiv/muon_decay.py @@ -4,8 +4,8 @@ from analysis.algorithms.calorimetry import compute_track_length from analysis.decorator import evaluate -from analysis.classes.particle import match_particles_fn, matrix_iou -from analysis.algorithms.selections.michel_electrons import get_bounding_box, is_attached_at_edge +from lartpc_mlreco3d.analysis.classes.particle_utils import match_particles_fn, matrix_iou +from analysis.algorithms.arxiv.michel_electrons import get_bounding_box, is_attached_at_edge from pprint import pprint import time diff --git a/analysis/algorithms/calorimetry.py b/analysis/algorithms/calorimetry.py index 6ab71899..f926bb77 100644 --- a/analysis/algorithms/calorimetry.py +++ b/analysis/algorithms/calorimetry.py @@ -1,10 +1,9 @@ -from analysis.classes.particle import Particle import numpy as np -import numba as nb from sklearn.decomposition import PCA from scipy.interpolate import CubicSpline from mlreco.utils.gnn.cluster import cluster_direction import pandas as pd +from analysis.classes import Particle # CONSTANTS (MeV) PROTON_MASS = 938.272 diff --git a/analysis/algorithms/interactions.py b/analysis/algorithms/interactions.py new file mode 100644 index 00000000..5a01e8bf --- /dev/null +++ b/analysis/algorithms/interactions.py @@ -0,0 +1,104 @@ +import numpy as np + +from analysis.classes import Interaction +from collections import OrderedDict, Counter +from analysis.algorithms.utils import attach_prefix +from analysis.algorithms.logger import AnalysisLogger +from mlreco.utils.globals import PID_LABEL_TO_PARTICLE, PARTICLE_TO_PID_LABEL +from analysis.classes import TruthInteraction + +class InteractionLogger(AnalysisLogger): + + def __init__(self, fieldnames: dict): + super(InteractionLogger, self).__init__(fieldnames) + + @staticmethod + def id(ia): + out = {'interaction_id': -1} + if hasattr(ia, 'id'): + out['interaction_id'] = ia.id + return out + + @staticmethod + def size(ia): + out = {'interaction_size': -1} + if hasattr(ia, 'size'): + out['interaction_size'] = ia.size + return out + + @staticmethod + def count_primary_particles(ia, ptypes=None): + all_types = sorted(list(PID_LABEL_TO_PARTICLE.keys())) + if ptypes is None: + ptypes = all_types + elif set(ptypes).issubset(set(all_types)): + pass + elif len(ptypes) == 0: + return {} + else: + raise ValueError('"ptypes under count_primary_particles must \ + either be None or a list of particle type ids \ + to be counted.') + + out = OrderedDict({'count_primary_'+name.lower() : 0 \ + for name in PARTICLE_TO_PID_LABEL.keys() \ + if PARTICLE_TO_PID_LABEL[name] in ptypes}) + + if ia is not None and hasattr(ia, 'primary_particle_counts'): + out.update({'count_primary_'+key.lower() : val \ + for key, val in ia.primary_particle_counts.items() \ + if key.upper() != 'OTHER' \ + and PARTICLE_TO_PID_LABEL[key.upper()] in ptypes}) + return out + + + @staticmethod + def is_contained(ia, vb, threshold=30): + + out = {'interaction_is_contained': False} + if ia is not None and len(ia.points) > 0: + if not isinstance(threshold, np.ndarray): + threshold = threshold * np.ones((3,)) + else: + assert len(threshold) == 3 + assert len(threshold.shape) == 1 + + vb = np.array(vb) + + x = (vb[0, 0] + threshold[0] <= ia.points[:, 0]) \ + & (ia.points[:, 0] <= vb[0, 1] - threshold[0]) + y = (vb[1, 0] + threshold[1] <= ia.points[:, 1]) \ + & (ia.points[:, 1] <= vb[1, 1] - threshold[1]) + z = (vb[2, 0] + threshold[2] <= ia.points[:, 2]) \ + & (ia.points[:, 2] <= vb[2, 1] - threshold[2]) + + out['interaction_is_contained'] = (x & y & z).all() + return out + + @staticmethod + def vertex(ia): + out = { + # 'has_vertex': False, + 'vertex_x': -1, + 'vertex_y': -1, + 'vertex_z': -1, + # 'vertex_info': None + } + if ia is not None and hasattr(ia, 'vertex'): + out['vertex_x'] = ia.vertex[0] + out['vertex_y'] = ia.vertex[1] + out['vertex_z'] = ia.vertex[2] + return out + + @staticmethod + def nu_info(ia): + assert type(ia) is TruthInteraction + out = { + 'nu_interaction_type': 'N/A', + 'nu_interaction_mode': 'N/A', + 'nu_current_type': 'N/A', + 'nu_energy_init': 'N/A' + } + if ia.nu_id == 1 and hasattr(ia, 'nu_info'): + out.update(ia.nu_info) + return out \ No newline at end of file diff --git a/analysis/algorithms/particles.py b/analysis/algorithms/particles.py new file mode 100644 index 00000000..3a40c709 --- /dev/null +++ b/analysis/algorithms/particles.py @@ -0,0 +1,175 @@ +from collections import OrderedDict +from functools import partial, partialmethod +import numpy as np +import sys +from analysis.algorithms.logger import AnalysisLogger + +from analysis.classes import Particle, TruthParticle +from analysis.algorithms.utils import attach_prefix +from analysis.algorithms.calorimetry import get_particle_direction, compute_track_length + + +class ParticleLogger(AnalysisLogger): + + def __init__(self, fieldnames: dict): + super(ParticleLogger, self).__init__(fieldnames) + + @staticmethod + def id(particle): + out = {'particle_id': -1} + if hasattr(particle, 'id'): + out['particle_id'] = particle.id + return out + + @staticmethod + def interaction_id(particle): + out = {'particle_interaction_id': -1} + if hasattr(particle, 'interaction_id'): + out['particle_interaction_id'] = particle.interaction_id + return out + + @staticmethod + def pdg_type(particle): + out = {'particle_type': -1} + if hasattr(particle, 'pid'): + out['particle_type'] = particle.pid + return out + + @staticmethod + def semantic_type(particle): + out = {'particle_semantic_type': -1} + if hasattr(particle, 'semantic_type'): + out['particle_semantic_type'] = particle.semantic_type + return out + + @staticmethod + def size(particle): + out = {'particle_size': -1} + if hasattr(particle, 'size'): + out['particle_size'] = particle.size + return out + + @staticmethod + def is_primary(particle): + out = {'particle_is_primary': -1} + if hasattr(particle, 'is_primary'): + out['particle_is_primary'] = particle.is_primary + return out + + @staticmethod + def startpoint(particle): + out = { + 'particle_has_startpoint': False, + 'particle_startpoint_x': -1, + 'particle_startpoint_y': -1, + 'particle_startpoint_z': -1 + } + if hasattr(particle, 'startpoint') \ + and not (particle.startpoint == -1).all(): + out['particle_has_startpoint'] = True + out['particle_startpoint_x'] = particle.startpoint[0] + out['particle_startpoint_y'] = particle.startpoint[1] + out['particle_startpoint_z'] = particle.startpoint[2] + return out + + @staticmethod + def endpoint(particle): + out = { + 'particle_has_endpoint': False, + 'particle_endpoint_x': -1, + 'particle_endpoint_y': -1, + 'particle_endpoint_z': -1 + } + if hasattr(particle, 'endpoint') \ + and not (particle.endpoint == -1).all(): + out['particle_has_endpoint'] = True + out['particle_endpoint_x'] = particle.endpoint[0] + out['particle_endpoint_y'] = particle.endpoint[1] + out['particle_endpoint_z'] = particle.endpoint[2] + return out + + @staticmethod + def startpoint_is_touching(particle, threshold=5.0): + out = {'particle_startpoint_is_touching': True} + if type(particle) is TruthParticle: + if particle.size > 0: + diff = particle.points - particle.startpoint.reshape(1, -1) + dists = np.linalg.norm(diff, axis=1) + min_dist = np.min(dists) + if min_dist > threshold: + out['particle_startpoint_is_touching'] = False + return out + + @staticmethod + def creation_process(particle): + out = {'particle_creation_process': 'N/A'} + if type(particle) is TruthParticle: + out['particle_creation_process'] = particle.asis.creation_process() + return out + + @staticmethod + def momentum(particle): + min_int = -sys.maxsize - 1 + out = { + 'particle_px': min_int, + 'particle_py': min_int, + 'particle_pz': min_int, + } + if type(particle) is TruthParticle: + out['particle_px'] = particle.asis.px() + out['particle_py'] = particle.asis.py() + out['particle_pz'] = particle.asis.pz() + return out + + @staticmethod + def reco_direction(particle, **kwargs): + out = { + 'particle_dir_x': 0, + 'particle_dir_y': 0, + 'particle_dir_z': 0 + } + if particle is not None: + v = get_particle_direction(particle, **kwargs) + out['particle_dir_x'] = v[0] + out['particle_dir_y'] = v[1] + out['particle_dir_z'] = v[2] + return out + + @staticmethod + def reco_length(particle): + out = {'particle_length': -1} + if particle is not None \ + and particle.semantic_type == 1 \ + and len(particle.points) > 0: + out['particle_length'] = compute_track_length(particle.points) + return out + + @staticmethod + def is_contained(particle, vb, threshold=30): + + out = {'particle_is_contained': False} + if particle is not None and len(particle.points) > 0: + if not isinstance(threshold, np.ndarray): + threshold = threshold * np.ones((3,)) + else: + assert len(threshold) == 3 + assert len(threshold.shape) == 1 + + vb = np.array(vb) + + x = (vb[0, 0] + threshold[0] <= particle.points[:, 0]) \ + & (particle.points[:, 0] <= vb[0, 1] - threshold[0]) + y = (vb[1, 0] + threshold[1] <= particle.points[:, 1]) \ + & (particle.points[:, 1] <= vb[1, 1] - threshold[1]) + z = (vb[2, 0] + threshold[2] <= particle.points[:, 2]) \ + & (particle.points[:, 2] <= vb[2, 1] - threshold[2]) + + out['particle_is_contained'] = (x & y & z).all() + return out + + @staticmethod + def sum_edep(particle): + out = {'particle_sum_edep': -1} + if particle is not None: + out['particle_sum_edep'] = particle.sum_edep + return out \ No newline at end of file diff --git a/analysis/algorithms/point_matching.py b/analysis/algorithms/point_matching.py index 69e71ca1..26b2423b 100644 --- a/analysis/algorithms/point_matching.py +++ b/analysis/algorithms/point_matching.py @@ -1,10 +1,8 @@ from typing import List import numpy as np -import pandas as pd from scipy.spatial.distance import cdist -from scipy.special import expit -from ..classes.particle import Particle +from analysis.classes.Particle import Particle def match_points_to_particles(ppn_points : np.ndarray, particles : List[Particle], diff --git a/analysis/algorithms/scripts/template.py b/analysis/algorithms/scripts/template.py index 5293a086..18811693 100644 --- a/analysis/algorithms/scripts/template.py +++ b/analysis/algorithms/scripts/template.py @@ -1,21 +1,12 @@ +import copy from collections import OrderedDict -import os, copy, sys - -# Flash Matching -# sys.path.append('/sdf/group/neutrino/koh0207/OpT0Finder/python') from analysis.decorator import evaluate from analysis.classes.evaluator import FullChainEvaluator from analysis.classes.TruthInteraction import TruthInteraction from analysis.classes.Interaction import Interaction -from analysis.classes.Particle import Particle -from analysis.classes.TruthParticle import TruthParticle -from analysis.algorithms.utils import get_interaction_properties, \ - get_particle_properties, \ - get_mparticles_from_minteractions - -from analysis.algorithms.calorimetry import get_csda_range_spline -from analysis.algorithms.vertex import estimate_vertex +from analysis.algorithms.utils import get_mparticles_from_minteractions +from analysis.algorithms.logger import ParticleLogger, InteractionLogger @evaluate(['interactions', 'particles'], mode='per_batch') def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): @@ -43,22 +34,14 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): # Skeleton for csv output interaction_dict = analysis_cfg['analysis'].get('interaction_dict', {}) - particle_dict = analysis_cfg['analysis'].get('particle_dict', {}) + + particle_fieldnames = analysis_cfg['analysis'].get('particle_fieldnames', {}) + int_fieldnames = analysis_cfg['analysis'].get('interaction_fieldnames', {}) use_primaries_for_vertex = analysis_cfg['analysis'].get('use_primaries_for_vertex', True) run_reco_vertex = analysis_cfg['analysis'].get('run_reco_vertex', False) test_containment = analysis_cfg['analysis'].get('test_containment', False) - splines = None - skip_classes = set([3, 4]) - - if compute_energy: - - splines = { - 'proton': get_csda_range_spline('proton', '/sdf/group/neutrino/koh0207/lartpc_mlreco3d/analysis/algorithms/tables/pE_liquid_argon.txt'), - 'muon': get_csda_range_spline('muon', '/sdf/group/neutrino/koh0207/lartpc_mlreco3d/analysis/algorithms/tables/muE_liquid_argon.txt') - } - # Load data into evaluator if enable_flash_matching: predictor = FullChainEvaluator(data_blob, res, cfg, processor_cfg, @@ -101,148 +84,43 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): if len(matches) == 0: continue - particle_matches, particle_matches_values = get_mparticles_from_minteractions(matches) + particle_matches, particle_match_counts = get_mparticles_from_minteractions(matches) # 2. Process interaction level information + interaction_logger = InteractionLogger(int_fieldnames) + interaction_logger.prepare() for i, interaction_pair in enumerate(matches): - int_dict = copy.deepcopy(interaction_dict) + int_dict = copy.deepcopy(interaction_dict) int_dict.update(index_dict) - int_dict['interaction_match_counts'] = counts[i] true_int, pred_int = interaction_pair[0], interaction_pair[1] assert (type(true_int) is TruthInteraction) or (true_int is None) assert (type(pred_int) is Interaction) or (pred_int is None) - true_int_dict = get_interaction_properties(true_int, spatial_size, prefix='true') - pred_int_dict = get_interaction_properties(pred_int, spatial_size, prefix='pred') - fmatch_dict = {} - - if true_int is not None: - # This means there is a true interaction corresponding to - # this predicted interaction. Hence: - pred_int_dict['pred_interaction_has_match'] = True - true_int_dict['true_nu_id'] = true_int.nu_id - - if run_reco_vertex: - - reco_vtx, _ = estimate_vertex(true_int.particles, - use_primaries=use_primaries_for_vertex, - mode=vertex_mode, - prune_candidates=predictor.prune_vertex, - return_candidate_count=True) - - true_int_dict['true_reco_vtx_x'] = reco_vtx[0] - true_int_dict['true_reco_vtx_y'] = reco_vtx[1] - true_int_dict['true_reco_vtx_z'] = reco_vtx[2] - - if 'neutrino_asis' in data_blob and true_int.nu_id > 0: - # assert 'particles_asis' in data_blob - # particles = data_blob['particles_asis'][i] - neutrinos = data_blob['neutrino_asis'][idx] - if len(neutrinos) > 1 or len(neutrinos) == 0: continue - nu = neutrinos[0] - # Get larcv::Particle objects for each - # particle of the true interaction - # true_particles = np.array(particles)[np.array([p.id for p in true_int.particles])] - # true_particles_track_ids = [p.track_id() for p in true_particles] - # for nu in neutrinos: - # if nu.mct_index() not in true_particles_track_ids: continue - true_int_dict['true_nu_interaction_type'] = nu.interaction_type() - true_int_dict['true_nu_interaction_mode'] = nu.interaction_mode() - true_int_dict['true_nu_current_type'] = nu.current_type() - true_int_dict['true_nu_energy'] = nu.energy_init() - if pred_int is not None: - # Similarly: - pred_int_dict['pred_vertex_candidate_count'] = pred_int.vertex_candidate_count - true_int_dict['true_interaction_has_match'] = True - - if enable_flash_matching: - volume = true_int.volume if true_int is not None else pred_int.volume - flash_matches = flash_matches_cryoW if volume == 1 else flash_matches_cryoE - if pred_int is not None: - for interaction, flash, match in flash_matches: - if interaction.id != pred_int.id: continue - fmatch_dict['fmatched'] = True - fmatch_dict['fmatch_time'] = flash.time() - fmatch_dict['fmatch_total_pe'] = flash.TotalPE() - fmatch_dict['fmatch_id'] = flash.id() - break - - for k1, v1 in true_int_dict.items(): - if k1 in int_dict: - int_dict[k1] = v1 - else: - raise ValueError("{} not in pre-defined fieldnames.".format(k1)) - for k2, v2 in pred_int_dict.items(): - if k2 in int_dict: - int_dict[k2] = v2 - else: - raise ValueError("{} not in pre-defined fieldnames.".format(k2)) - if enable_flash_matching: - for k3, v3 in fmatch_dict.items(): - if k3 in int_dict: - int_dict[k3] = v3 - else: - raise ValueError("{} not in pre-defined fieldnames.".format(k3)) - interactions.append(int_dict) + true_int_dict = interaction_logger.produce(true_int, mode='true') + pred_int_dict = interaction_logger.produce(pred_int, mode='pred') + int_dict = OrderedDict() + int_dict.update(true_int_dict) + int_dict.update(pred_int_dict) + interactions.append(int_dict) # 3. Process particle level information + particle_logger = ParticleLogger(particle_fieldnames) + particle_logger.prepare() + for i, mparticles in enumerate(particle_matches): true_p, pred_p = mparticles[0], mparticles[1] - assert (type(true_p) is TruthParticle) or true_p is None - assert (type(pred_p) is Particle) or pred_p is None - - part_dict = copy.deepcopy(particle_dict) - - part_dict.update(index_dict) - part_dict['particle_match_value'] = particle_matches_values[i] - - pred_particle_dict = get_particle_properties(pred_p, - prefix='pred', splines=splines, compute_energy=compute_energy) - true_particle_dict = get_particle_properties(true_p, - prefix='true', splines=splines, compute_energy=compute_energy) - - if true_p is not None: - pred_particle_dict['pred_particle_has_match'] = True - if test_containment and true_p.size > 0: - true_particle_dict['true_particle_is_contained'] = predictor.is_contained(true_p.points) - true_particle_dict['true_particle_interaction_id'] = true_p.interaction_id - if 'particles_asis' in data_blob: - particles_asis = data_blob['particles_asis'][idx] - if len(particles_asis) > true_p.id: - true_part = particles_asis[true_p.id] - true_particle_dict['true_particle_energy_init'] = true_part.energy_init() - true_particle_dict['true_particle_energy_deposit'] = true_part.energy_deposit() - true_particle_dict['true_particle_creation_process'] = true_part.creation_process() - # If no children other than itself: particle is stopping. - children = true_part.children_id() - children = [x for x in children if x != true_part.id()] - true_particle_dict['true_particle_children_count'] = len(children) - - if pred_p is not None: - if test_containment: - pred_particle_dict['pred_particle_is_contained'] = predictor.is_contained(pred_p.points) - true_particle_dict['true_particle_has_match'] = True - pred_particle_dict['pred_particle_interaction_id'] = pred_p.interaction_id - - - for k1, v1 in true_particle_dict.items(): - if k1 in part_dict: - part_dict[k1] = v1 - else: - raise ValueError("{} not in pre-defined fieldnames.".format(k1)) - - for k2, v2 in pred_particle_dict.items(): - if k2 in part_dict: - part_dict[k2] = v2 - else: - raise ValueError("{} not in pre-defined fieldnames.".format(k2)) - - + true_p_dict = particle_logger.produce(true_p, mode='true') + pred_p_dict = particle_logger.produce(pred_p, mode='pred') + + part_dict = OrderedDict() + part_dict['particle_match_counts'] = particle_match_counts[i] + part_dict.update(true_p_dict) + part_dict.update(pred_p_dict) particles.append(part_dict) return [interactions, particles] \ No newline at end of file diff --git a/analysis/algorithms/utils.py b/analysis/algorithms/utils.py index 77ad9bf6..14554c00 100644 --- a/analysis/algorithms/utils.py +++ b/analysis/algorithms/utils.py @@ -1,15 +1,15 @@ +import numpy as np from collections import OrderedDict from turtle import up -from analysis.classes.particle import Interaction, Particle, TruthParticle -from analysis.algorithms.calorimetry import * - from sklearn.decomposition import PCA from scipy.spatial.distance import cdist + +from analysis.classes import Particle +from analysis.classes import TruthParticle +from analysis.algorithms.calorimetry import * from analysis.algorithms.point_matching import get_track_endpoints_max_dist from analysis.algorithms.calorimetry import compute_track_dedx, get_particle_direction -import numpy as np - def attach_prefix(update_dict, prefix): if prefix is None: @@ -194,185 +194,6 @@ def get_track_points(p, correction_mode='ppn', brute_force=False): raise ValueError("Track extrema correction mode {} not defined!".format(correction_mode)) -def get_interaction_properties(interaction: Interaction, spatial_size, prefix=None): - - update_dict = OrderedDict({ - 'interaction_id': -1, - 'interaction_size': -1, - 'count_primary_particles': -1, - 'vertex_x': -1, - 'vertex_y': -1, - 'vertex_z': -1, - 'has_vertex': False, - 'vertex_valid': 'Default Invalid', - 'count_primary_photons': -1, - 'count_primary_electrons': -1, - 'count_primary_muons': -1, - 'count_primary_pions': -1, - 'count_primary_protons': -1, - 'count_pi0': -1 - # 'nu_reco_energy': -1 - }) - - if interaction is None: - out = attach_prefix(update_dict, prefix) - return out - else: - count_primary_muons = {} - count_primary_particles = {} - count_primary_protons = {} - count_primary_electrons = {} - count_primary_photons = {} - count_primary_pions = {} - - for p in interaction.particles: - if p.is_primary: - count_primary_particles[p.id] = True - if p.pid == 0: - count_primary_photons[p.id] = True - if p.pid == 1: - count_primary_electrons[p.id] = True - if p.pid == 2: - count_primary_muons[p.id] = True - if p.pid == 3: - count_primary_pions[p.id] = True - if p.pid == 4: - count_primary_protons[p.id] = True - - update_dict['count_pi0'] = len(interaction._pi0_tagged_photons) - - update_dict['interaction_id'] = interaction.id - update_dict['interaction_size'] = interaction.size - update_dict['count_primary_muons'] = sum(count_primary_muons.values()) - update_dict['count_primary_photons'] = sum(count_primary_photons.values()) - update_dict['count_primary_pions'] = sum(count_primary_pions.values()) - update_dict['count_primary_particles'] = sum(count_primary_particles.values()) - update_dict['count_primary_protons'] = sum(count_primary_protons.values()) - update_dict['count_primary_electrons'] = sum(count_primary_electrons.values()) - - within_volume = np.all(interaction.vertex <= spatial_size) and np.all(interaction.vertex >= 0) - - if within_volume: - update_dict['has_vertex'] = True - update_dict['vertex_x'] = interaction.vertex[0] - update_dict['vertex_y'] = interaction.vertex[1] - update_dict['vertex_z'] = interaction.vertex[2] - update_dict['vertex_valid'] = 'Valid' - else: - if ((np.abs(np.array(interaction.vertex)) > 1e6).any()): - update_dict['vertex_valid'] = 'Invalid Magnitude' - else: - update_dict['vertex_valid'] = 'Outside Volume' - update_dict['has_vertex'] = True - update_dict['vertex_x'] = interaction.vertex[0] - update_dict['vertex_y'] = interaction.vertex[1] - update_dict['vertex_z'] = interaction.vertex[2] - out = attach_prefix(update_dict, prefix) - - return out - - -def get_particle_properties(particle: Particle, - prefix=None, - save_feats=False, - splines=None, - compute_energy=False): - - update_dict = OrderedDict({ - 'particle_id': -1, - 'particle_interaction_id': -1, - 'particle_type': -1, - 'particle_semantic_type': -1, - 'particle_size': -1, - 'particle_is_primary': False, - 'particle_has_startpoint': False, - 'particle_has_endpoint': False, - 'particle_startpoint_x': -1, - 'particle_startpoint_y': -1, - 'particle_startpoint_z': -1, - 'particle_endpoint_x': -1, - 'particle_endpoint_y': -1, - 'particle_endpoint_z': -1, - 'particle_startpoint_is_touching': True, - 'particle_creation_process': "Default Invalid", - 'particle_num_ppn_candidates': -1, - # 'particle_is_contained': False - }) - - if compute_energy: - update_dict.update(OrderedDict({ - 'particle_dir_x': -1, - 'particle_dir_y': -1, - 'particle_dir_z': -1, - 'particle_length': -1, - 'particle_reco_energy': -1, - 'particle_sum_edep': -1 - })) - - if save_feats: - node_dict = OrderedDict({'node_feat_{}'.format(i) : -1 for i in range(28)}) - update_dict.update(node_dict) - - if particle is None: - out = attach_prefix(update_dict, prefix) - return out - else: - update_dict['particle_id'] = particle.id - update_dict['particle_interaction_id'] = particle.interaction_id - update_dict['particle_type'] = particle.pid - update_dict['particle_semantic_type'] = particle.semantic_type - update_dict['particle_size'] = particle.size - update_dict['particle_is_primary'] = particle.is_primary - # update_dict['particle_is_contained'] = particle.is_contained - if particle.startpoint is not None: - update_dict['particle_has_startpoint'] = True - update_dict['particle_startpoint_x'] = particle.startpoint[0] - update_dict['particle_startpoint_y'] = particle.startpoint[1] - update_dict['particle_startpoint_z'] = particle.startpoint[2] - if particle.endpoint is not None: - update_dict['particle_has_endpoint'] = True - update_dict['particle_endpoint_x'] = particle.endpoint[0] - update_dict['particle_endpoint_y'] = particle.endpoint[1] - update_dict['particle_endpoint_z'] = particle.endpoint[2] - - if hasattr(particle, 'ppn_candidates'): - assert particle.ppn_candidates.shape[1] == 7 - update_dict['particle_num_ppn_candidates'] = len(particle.ppn_candidates) - - if isinstance(particle, TruthParticle): - if particle.size > 0: - dists = np.linalg.norm(particle.points - particle.startpoint.reshape(1, -1), axis=1) - min_dist = np.min(dists) - if min_dist > 5.0: - update_dict['particle_startpoint_is_touching'] = False - creation_process = particle.particle_asis.creation_process() - update_dict['particle_creation_process'] = creation_process - update_dict['particle_px'] = float(particle.particle_asis.px()) - update_dict['particle_py'] = float(particle.particle_asis.py()) - update_dict['particle_pz'] = float(particle.particle_asis.pz()) - if compute_energy and particle.size > 0: - update_dict['particle_sum_edep'] = particle.sum_edep - direction = get_particle_direction(particle, optimize=True) - assert len(direction) == 3 - update_dict['particle_dir_x'] = direction[0] - update_dict['particle_dir_y'] = direction[1] - update_dict['particle_dir_z'] = direction[2] - if particle.semantic_type == 1: - length = compute_track_length(particle.points) - update_dict['particle_length'] = length - particle.length = length - if splines is not None and particle.pid == 4: - reco_energy = compute_range_based_energy(particle, splines['proton']) - update_dict['particle_reco_energy'] = reco_energy - if splines is not None and particle.pid == 2: - reco_energy = compute_range_based_energy(particle, splines['muon']) - update_dict['particle_reco_energy'] = reco_energy - - out = attach_prefix(update_dict, prefix) - - return out - - def get_mparticles_from_minteractions(int_matches): ''' Given list of Tuple[(Truth)Interaction, (Truth)Interaction], diff --git a/analysis/classes/TruthInteraction.py b/analysis/classes/TruthInteraction.py index d881953f..d53b4329 100644 --- a/analysis/classes/TruthInteraction.py +++ b/analysis/classes/TruthInteraction.py @@ -1,6 +1,6 @@ import numpy as np import pandas as pd -from collections import OrderedDict, Counter +from collections import OrderedDict from . import Interaction, TruthParticle @@ -18,7 +18,7 @@ def __init__(self, *args, **kwargs): self.depositions_MeV.append(p.depositions_MeV) if p.is_primary: self.num_primaries += 1 self.depositions_MeV = np.hstack(self.depositions_MeV) - self._pi0_tagged_photons = [] + self.nu_info = None @property diff --git a/analysis/classes/TruthParticle.py b/analysis/classes/TruthParticle.py index 95d7f0fd..c480e1d7 100644 --- a/analysis/classes/TruthParticle.py +++ b/analysis/classes/TruthParticle.py @@ -60,7 +60,7 @@ def __str__(self): def is_contained(self, spatial_size): - p = self.particle_asis + p = self.asis check_contained = p.position().x() >= 0 and p.position().x() <= spatial_size \ and p.position().y() >= 0 and p.position().y() <= spatial_size \ and p.position().z() >= 0 and p.position().z() <= spatial_size \ diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index e80dbf41..7286fb49 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -4,11 +4,11 @@ from mlreco.utils.globals import VTX_COLS, INTER_COL, COORD_COLS from analysis.classes import TruthParticleFragment, TruthParticle, Interaction -from analysis.classes.particle import (match_particles_fn, - match_interactions_fn, - group_particles_to_interactions_fn, - match_interactions_optimal, - match_particles_optimal) +from analysis.classes.particle_utils import (match_particles_fn, + match_interactions_fn, + group_particles_to_interactions_fn, + match_interactions_optimal, + match_particles_optimal) from analysis.algorithms.point_matching import * from mlreco.utils.groups import type_labels as TYPE_LABELS @@ -455,6 +455,25 @@ def get_true_interactions(self, entry, drop_nonprimary_particles=True, if compute_vertex and ia.id in vertices: ia.vertex = vertices[ia.id] + if 'neutrino_asis' in self.data_blob and ia.nu_id > 0: + # assert 'particles_asis' in data_blob + # particles = data_blob['particles_asis'][i] + neutrinos = self.data_blob['neutrino_asis'][entry] + if len(neutrinos) > 1 or len(neutrinos) == 0: continue + nu = neutrinos[0] + # Get larcv::Particle objects for each + # particle of the true interaction + # true_particles = np.array(particles)[np.array([p.id for p in true_int.particles])] + # true_particles_track_ids = [p.track_id() for p in true_particles] + # for nu in neutrinos: + # if nu.mct_index() not in true_particles_track_ids: continue + ia.nu_info = { + 'interaction_type': nu.interaction_type(), + 'interaction_mode': nu.interaction_mode(), + 'current_type': nu.current_type(), + 'energy_init': nu.energy_init() + } + return out diff --git a/analysis/classes/particle.py b/analysis/classes/particle_utils.py similarity index 100% rename from analysis/classes/particle.py rename to analysis/classes/particle_utils.py diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index 1c6c56c7..de2b32d4 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -1,4 +1,4 @@ -from typing import Callable, Tuple, List +from typing import List import numpy as np import os import time @@ -9,7 +9,7 @@ from scipy.special import softmax from analysis.classes import Particle, ParticleFragment, Interaction, FlashManager -from analysis.classes.particle import group_particles_to_interactions_fn +from analysis.classes.particle_utils import group_particles_to_interactions_fn from analysis.algorithms.point_matching import * from mlreco.utils.groups import type_labels as TYPE_LABELS diff --git a/mlreco/post_processing/analysis/__init__.py b/mlreco/post_processing/arxiv/analysis/__init__.py similarity index 100% rename from mlreco/post_processing/analysis/__init__.py rename to mlreco/post_processing/arxiv/analysis/__init__.py diff --git a/mlreco/post_processing/analysis/instance_clustering.py b/mlreco/post_processing/arxiv/analysis/instance_clustering.py similarity index 100% rename from mlreco/post_processing/analysis/instance_clustering.py rename to mlreco/post_processing/arxiv/analysis/instance_clustering.py diff --git a/mlreco/post_processing/analysis/michel_reconstruction.py b/mlreco/post_processing/arxiv/analysis/michel_reconstruction.py similarity index 100% rename from mlreco/post_processing/analysis/michel_reconstruction.py rename to mlreco/post_processing/arxiv/analysis/michel_reconstruction.py diff --git a/mlreco/post_processing/analysis/michel_reconstruction_2d.py b/mlreco/post_processing/arxiv/analysis/michel_reconstruction_2d.py similarity index 100% rename from mlreco/post_processing/analysis/michel_reconstruction_2d.py rename to mlreco/post_processing/arxiv/analysis/michel_reconstruction_2d.py diff --git a/mlreco/post_processing/analysis/michel_reconstruction_noghost.py b/mlreco/post_processing/arxiv/analysis/michel_reconstruction_noghost.py similarity index 100% rename from mlreco/post_processing/analysis/michel_reconstruction_noghost.py rename to mlreco/post_processing/arxiv/analysis/michel_reconstruction_noghost.py diff --git a/mlreco/post_processing/analysis/muon_residual_range.py b/mlreco/post_processing/arxiv/analysis/muon_residual_range.py similarity index 100% rename from mlreco/post_processing/analysis/muon_residual_range.py rename to mlreco/post_processing/arxiv/analysis/muon_residual_range.py diff --git a/mlreco/post_processing/analysis/nue_selection.py b/mlreco/post_processing/arxiv/analysis/nue_selection.py similarity index 100% rename from mlreco/post_processing/analysis/nue_selection.py rename to mlreco/post_processing/arxiv/analysis/nue_selection.py diff --git a/mlreco/post_processing/analysis/stopping_muons.py b/mlreco/post_processing/arxiv/analysis/stopping_muons.py similarity index 100% rename from mlreco/post_processing/analysis/stopping_muons.py rename to mlreco/post_processing/arxiv/analysis/stopping_muons.py diff --git a/mlreco/post_processing/analysis/through_muons.py b/mlreco/post_processing/arxiv/analysis/through_muons.py similarity index 100% rename from mlreco/post_processing/analysis/through_muons.py rename to mlreco/post_processing/arxiv/analysis/through_muons.py diff --git a/mlreco/post_processing/analysis/track_clustering.py b/mlreco/post_processing/arxiv/analysis/track_clustering.py similarity index 100% rename from mlreco/post_processing/analysis/track_clustering.py rename to mlreco/post_processing/arxiv/analysis/track_clustering.py diff --git a/mlreco/post_processing/metrics/__init__.py b/mlreco/post_processing/arxiv/metrics/__init__.py similarity index 100% rename from mlreco/post_processing/metrics/__init__.py rename to mlreco/post_processing/arxiv/metrics/__init__.py diff --git a/mlreco/post_processing/metrics/bayes_segnet_mcdropout.py b/mlreco/post_processing/arxiv/metrics/bayes_segnet_mcdropout.py similarity index 100% rename from mlreco/post_processing/metrics/bayes_segnet_mcdropout.py rename to mlreco/post_processing/arxiv/metrics/bayes_segnet_mcdropout.py diff --git a/mlreco/post_processing/metrics/cluster_cnn_metrics.py b/mlreco/post_processing/arxiv/metrics/cluster_cnn_metrics.py similarity index 100% rename from mlreco/post_processing/metrics/cluster_cnn_metrics.py rename to mlreco/post_processing/arxiv/metrics/cluster_cnn_metrics.py diff --git a/mlreco/post_processing/metrics/cluster_gnn_metrics.py b/mlreco/post_processing/arxiv/metrics/cluster_gnn_metrics.py similarity index 100% rename from mlreco/post_processing/metrics/cluster_gnn_metrics.py rename to mlreco/post_processing/arxiv/metrics/cluster_gnn_metrics.py diff --git a/mlreco/post_processing/metrics/cosmic_discriminator_metrics.py b/mlreco/post_processing/arxiv/metrics/cosmic_discriminator_metrics.py similarity index 100% rename from mlreco/post_processing/metrics/cosmic_discriminator_metrics.py rename to mlreco/post_processing/arxiv/metrics/cosmic_discriminator_metrics.py diff --git a/mlreco/post_processing/metrics/deghosting_metrics.py b/mlreco/post_processing/arxiv/metrics/deghosting_metrics.py similarity index 100% rename from mlreco/post_processing/metrics/deghosting_metrics.py rename to mlreco/post_processing/arxiv/metrics/deghosting_metrics.py diff --git a/mlreco/post_processing/metrics/doublet_metrics.py b/mlreco/post_processing/arxiv/metrics/doublet_metrics.py similarity index 100% rename from mlreco/post_processing/metrics/doublet_metrics.py rename to mlreco/post_processing/arxiv/metrics/doublet_metrics.py diff --git a/mlreco/post_processing/metrics/duq_metrics.py b/mlreco/post_processing/arxiv/metrics/duq_metrics.py similarity index 100% rename from mlreco/post_processing/metrics/duq_metrics.py rename to mlreco/post_processing/arxiv/metrics/duq_metrics.py diff --git a/mlreco/post_processing/metrics/evidential_gnn.py b/mlreco/post_processing/arxiv/metrics/evidential_gnn.py similarity index 100% rename from mlreco/post_processing/metrics/evidential_gnn.py rename to mlreco/post_processing/arxiv/metrics/evidential_gnn.py diff --git a/mlreco/post_processing/metrics/evidential_metrics.py b/mlreco/post_processing/arxiv/metrics/evidential_metrics.py similarity index 100% rename from mlreco/post_processing/metrics/evidential_metrics.py rename to mlreco/post_processing/arxiv/metrics/evidential_metrics.py diff --git a/mlreco/post_processing/metrics/evidential_segnet.py b/mlreco/post_processing/arxiv/metrics/evidential_segnet.py similarity index 100% rename from mlreco/post_processing/metrics/evidential_segnet.py rename to mlreco/post_processing/arxiv/metrics/evidential_segnet.py diff --git a/mlreco/post_processing/metrics/graph_spice_metrics.py b/mlreco/post_processing/arxiv/metrics/graph_spice_metrics.py similarity index 100% rename from mlreco/post_processing/metrics/graph_spice_metrics.py rename to mlreco/post_processing/arxiv/metrics/graph_spice_metrics.py diff --git a/mlreco/post_processing/metrics/kinematics_metrics.py b/mlreco/post_processing/arxiv/metrics/kinematics_metrics.py similarity index 100% rename from mlreco/post_processing/metrics/kinematics_metrics.py rename to mlreco/post_processing/arxiv/metrics/kinematics_metrics.py diff --git a/mlreco/post_processing/metrics/multi_particle.py b/mlreco/post_processing/arxiv/metrics/multi_particle.py similarity index 100% rename from mlreco/post_processing/metrics/multi_particle.py rename to mlreco/post_processing/arxiv/metrics/multi_particle.py diff --git a/mlreco/post_processing/metrics/pid_metrics.py b/mlreco/post_processing/arxiv/metrics/pid_metrics.py similarity index 100% rename from mlreco/post_processing/metrics/pid_metrics.py rename to mlreco/post_processing/arxiv/metrics/pid_metrics.py diff --git a/mlreco/post_processing/metrics/ppn_metrics.py b/mlreco/post_processing/arxiv/metrics/ppn_metrics.py similarity index 100% rename from mlreco/post_processing/metrics/ppn_metrics.py rename to mlreco/post_processing/arxiv/metrics/ppn_metrics.py diff --git a/mlreco/post_processing/metrics/ppn_simple.py b/mlreco/post_processing/arxiv/metrics/ppn_simple.py similarity index 100% rename from mlreco/post_processing/metrics/ppn_simple.py rename to mlreco/post_processing/arxiv/metrics/ppn_simple.py diff --git a/mlreco/post_processing/metrics/single_particle.py b/mlreco/post_processing/arxiv/metrics/single_particle.py similarity index 100% rename from mlreco/post_processing/metrics/single_particle.py rename to mlreco/post_processing/arxiv/metrics/single_particle.py diff --git a/mlreco/post_processing/metrics/singlep_mcdropout.py b/mlreco/post_processing/arxiv/metrics/singlep_mcdropout.py similarity index 100% rename from mlreco/post_processing/metrics/singlep_mcdropout.py rename to mlreco/post_processing/arxiv/metrics/singlep_mcdropout.py diff --git a/mlreco/post_processing/metrics/uresnet_metrics.py b/mlreco/post_processing/arxiv/metrics/uresnet_metrics.py similarity index 100% rename from mlreco/post_processing/metrics/uresnet_metrics.py rename to mlreco/post_processing/arxiv/metrics/uresnet_metrics.py diff --git a/mlreco/post_processing/metrics/vertex_metrics.py b/mlreco/post_processing/arxiv/metrics/vertex_metrics.py similarity index 100% rename from mlreco/post_processing/metrics/vertex_metrics.py rename to mlreco/post_processing/arxiv/metrics/vertex_metrics.py diff --git a/mlreco/post_processing/store/__init__.py b/mlreco/post_processing/arxiv/store/__init__.py similarity index 100% rename from mlreco/post_processing/store/__init__.py rename to mlreco/post_processing/arxiv/store/__init__.py diff --git a/mlreco/post_processing/store/store_input.py b/mlreco/post_processing/arxiv/store/store_input.py similarity index 100% rename from mlreco/post_processing/store/store_input.py rename to mlreco/post_processing/arxiv/store/store_input.py diff --git a/mlreco/post_processing/store/store_output.py b/mlreco/post_processing/arxiv/store/store_output.py similarity index 100% rename from mlreco/post_processing/store/store_output.py rename to mlreco/post_processing/arxiv/store/store_output.py diff --git a/mlreco/post_processing/store/store_uresnet.py b/mlreco/post_processing/arxiv/store/store_uresnet.py similarity index 100% rename from mlreco/post_processing/store/store_uresnet.py rename to mlreco/post_processing/arxiv/store/store_uresnet.py diff --git a/mlreco/post_processing/store/store_uresnet_ppn.py b/mlreco/post_processing/arxiv/store/store_uresnet_ppn.py similarity index 100% rename from mlreco/post_processing/store/store_uresnet_ppn.py rename to mlreco/post_processing/arxiv/store/store_uresnet_ppn.py diff --git a/mlreco/utils/globals.py b/mlreco/utils/globals.py index 68567c0e..f808a9f0 100644 --- a/mlreco/utils/globals.py +++ b/mlreco/utils/globals.py @@ -20,3 +20,14 @@ # Colum which specifies the shape ID of a voxel in a sparse tensor SHAPE_COL = -1 + +# Convention for particle type labels +PARTICLE_TO_PID_LABEL = { + 'PHOTON': 0, + 'ELECTRON': 1, + 'MUON': 2, + 'PION': 3, + 'PROTON': 4 +} + +PID_LABEL_TO_PARTICLE = {val : key for key, val in PARTICLE_TO_PID_LABEL.items()} \ No newline at end of file From 1f1a83f7614b8ae508ffa400c081647a7f737207 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Mon, 3 Apr 2023 17:47:34 -0700 Subject: [PATCH 097/180] Handle true-only information such as nu interaction information --- analysis/algorithms/interactions.py | 104 -------------- analysis/algorithms/particles.py | 175 ------------------------ analysis/algorithms/scripts/template.py | 11 +- analysis/classes/evaluator.py | 10 +- 4 files changed, 10 insertions(+), 290 deletions(-) delete mode 100644 analysis/algorithms/interactions.py delete mode 100644 analysis/algorithms/particles.py diff --git a/analysis/algorithms/interactions.py b/analysis/algorithms/interactions.py deleted file mode 100644 index 5a01e8bf..00000000 --- a/analysis/algorithms/interactions.py +++ /dev/null @@ -1,104 +0,0 @@ -import numpy as np - -from analysis.classes import Interaction -from collections import OrderedDict, Counter -from analysis.algorithms.utils import attach_prefix -from analysis.algorithms.logger import AnalysisLogger -from mlreco.utils.globals import PID_LABEL_TO_PARTICLE, PARTICLE_TO_PID_LABEL -from analysis.classes import TruthInteraction - -class InteractionLogger(AnalysisLogger): - - def __init__(self, fieldnames: dict): - super(InteractionLogger, self).__init__(fieldnames) - - @staticmethod - def id(ia): - out = {'interaction_id': -1} - if hasattr(ia, 'id'): - out['interaction_id'] = ia.id - return out - - @staticmethod - def size(ia): - out = {'interaction_size': -1} - if hasattr(ia, 'size'): - out['interaction_size'] = ia.size - return out - - @staticmethod - def count_primary_particles(ia, ptypes=None): - all_types = sorted(list(PID_LABEL_TO_PARTICLE.keys())) - if ptypes is None: - ptypes = all_types - elif set(ptypes).issubset(set(all_types)): - pass - elif len(ptypes) == 0: - return {} - else: - raise ValueError('"ptypes under count_primary_particles must \ - either be None or a list of particle type ids \ - to be counted.') - - out = OrderedDict({'count_primary_'+name.lower() : 0 \ - for name in PARTICLE_TO_PID_LABEL.keys() \ - if PARTICLE_TO_PID_LABEL[name] in ptypes}) - - if ia is not None and hasattr(ia, 'primary_particle_counts'): - out.update({'count_primary_'+key.lower() : val \ - for key, val in ia.primary_particle_counts.items() \ - if key.upper() != 'OTHER' \ - and PARTICLE_TO_PID_LABEL[key.upper()] in ptypes}) - return out - - - @staticmethod - def is_contained(ia, vb, threshold=30): - - out = {'interaction_is_contained': False} - if ia is not None and len(ia.points) > 0: - if not isinstance(threshold, np.ndarray): - threshold = threshold * np.ones((3,)) - else: - assert len(threshold) == 3 - assert len(threshold.shape) == 1 - - vb = np.array(vb) - - x = (vb[0, 0] + threshold[0] <= ia.points[:, 0]) \ - & (ia.points[:, 0] <= vb[0, 1] - threshold[0]) - y = (vb[1, 0] + threshold[1] <= ia.points[:, 1]) \ - & (ia.points[:, 1] <= vb[1, 1] - threshold[1]) - z = (vb[2, 0] + threshold[2] <= ia.points[:, 2]) \ - & (ia.points[:, 2] <= vb[2, 1] - threshold[2]) - - out['interaction_is_contained'] = (x & y & z).all() - return out - - @staticmethod - def vertex(ia): - out = { - # 'has_vertex': False, - 'vertex_x': -1, - 'vertex_y': -1, - 'vertex_z': -1, - # 'vertex_info': None - } - if ia is not None and hasattr(ia, 'vertex'): - out['vertex_x'] = ia.vertex[0] - out['vertex_y'] = ia.vertex[1] - out['vertex_z'] = ia.vertex[2] - return out - - @staticmethod - def nu_info(ia): - assert type(ia) is TruthInteraction - out = { - 'nu_interaction_type': 'N/A', - 'nu_interaction_mode': 'N/A', - 'nu_current_type': 'N/A', - 'nu_energy_init': 'N/A' - } - if ia.nu_id == 1 and hasattr(ia, 'nu_info'): - out.update(ia.nu_info) - return out \ No newline at end of file diff --git a/analysis/algorithms/particles.py b/analysis/algorithms/particles.py deleted file mode 100644 index 3a40c709..00000000 --- a/analysis/algorithms/particles.py +++ /dev/null @@ -1,175 +0,0 @@ -from collections import OrderedDict -from functools import partial, partialmethod -import numpy as np -import sys -from analysis.algorithms.logger import AnalysisLogger - -from analysis.classes import Particle, TruthParticle -from analysis.algorithms.utils import attach_prefix -from analysis.algorithms.calorimetry import get_particle_direction, compute_track_length - - -class ParticleLogger(AnalysisLogger): - - def __init__(self, fieldnames: dict): - super(ParticleLogger, self).__init__(fieldnames) - - @staticmethod - def id(particle): - out = {'particle_id': -1} - if hasattr(particle, 'id'): - out['particle_id'] = particle.id - return out - - @staticmethod - def interaction_id(particle): - out = {'particle_interaction_id': -1} - if hasattr(particle, 'interaction_id'): - out['particle_interaction_id'] = particle.interaction_id - return out - - @staticmethod - def pdg_type(particle): - out = {'particle_type': -1} - if hasattr(particle, 'pid'): - out['particle_type'] = particle.pid - return out - - @staticmethod - def semantic_type(particle): - out = {'particle_semantic_type': -1} - if hasattr(particle, 'semantic_type'): - out['particle_semantic_type'] = particle.semantic_type - return out - - @staticmethod - def size(particle): - out = {'particle_size': -1} - if hasattr(particle, 'size'): - out['particle_size'] = particle.size - return out - - @staticmethod - def is_primary(particle): - out = {'particle_is_primary': -1} - if hasattr(particle, 'is_primary'): - out['particle_is_primary'] = particle.is_primary - return out - - @staticmethod - def startpoint(particle): - out = { - 'particle_has_startpoint': False, - 'particle_startpoint_x': -1, - 'particle_startpoint_y': -1, - 'particle_startpoint_z': -1 - } - if hasattr(particle, 'startpoint') \ - and not (particle.startpoint == -1).all(): - out['particle_has_startpoint'] = True - out['particle_startpoint_x'] = particle.startpoint[0] - out['particle_startpoint_y'] = particle.startpoint[1] - out['particle_startpoint_z'] = particle.startpoint[2] - return out - - @staticmethod - def endpoint(particle): - out = { - 'particle_has_endpoint': False, - 'particle_endpoint_x': -1, - 'particle_endpoint_y': -1, - 'particle_endpoint_z': -1 - } - if hasattr(particle, 'endpoint') \ - and not (particle.endpoint == -1).all(): - out['particle_has_endpoint'] = True - out['particle_endpoint_x'] = particle.endpoint[0] - out['particle_endpoint_y'] = particle.endpoint[1] - out['particle_endpoint_z'] = particle.endpoint[2] - return out - - @staticmethod - def startpoint_is_touching(particle, threshold=5.0): - out = {'particle_startpoint_is_touching': True} - if type(particle) is TruthParticle: - if particle.size > 0: - diff = particle.points - particle.startpoint.reshape(1, -1) - dists = np.linalg.norm(diff, axis=1) - min_dist = np.min(dists) - if min_dist > threshold: - out['particle_startpoint_is_touching'] = False - return out - - @staticmethod - def creation_process(particle): - out = {'particle_creation_process': 'N/A'} - if type(particle) is TruthParticle: - out['particle_creation_process'] = particle.asis.creation_process() - return out - - @staticmethod - def momentum(particle): - min_int = -sys.maxsize - 1 - out = { - 'particle_px': min_int, - 'particle_py': min_int, - 'particle_pz': min_int, - } - if type(particle) is TruthParticle: - out['particle_px'] = particle.asis.px() - out['particle_py'] = particle.asis.py() - out['particle_pz'] = particle.asis.pz() - return out - - @staticmethod - def reco_direction(particle, **kwargs): - out = { - 'particle_dir_x': 0, - 'particle_dir_y': 0, - 'particle_dir_z': 0 - } - if particle is not None: - v = get_particle_direction(particle, **kwargs) - out['particle_dir_x'] = v[0] - out['particle_dir_y'] = v[1] - out['particle_dir_z'] = v[2] - return out - - @staticmethod - def reco_length(particle): - out = {'particle_length': -1} - if particle is not None \ - and particle.semantic_type == 1 \ - and len(particle.points) > 0: - out['particle_length'] = compute_track_length(particle.points) - return out - - @staticmethod - def is_contained(particle, vb, threshold=30): - - out = {'particle_is_contained': False} - if particle is not None and len(particle.points) > 0: - if not isinstance(threshold, np.ndarray): - threshold = threshold * np.ones((3,)) - else: - assert len(threshold) == 3 - assert len(threshold.shape) == 1 - - vb = np.array(vb) - - x = (vb[0, 0] + threshold[0] <= particle.points[:, 0]) \ - & (particle.points[:, 0] <= vb[0, 1] - threshold[0]) - y = (vb[1, 0] + threshold[1] <= particle.points[:, 1]) \ - & (particle.points[:, 1] <= vb[1, 1] - threshold[1]) - z = (vb[2, 0] + threshold[2] <= particle.points[:, 2]) \ - & (particle.points[:, 2] <= vb[2, 1] - threshold[2]) - - out['particle_is_contained'] = (x & y & z).all() - return out - - @staticmethod - def sum_edep(particle): - out = {'particle_sum_edep': -1} - if particle is not None: - out['particle_sum_edep'] = particle.sum_edep - return out \ No newline at end of file diff --git a/analysis/algorithms/scripts/template.py b/analysis/algorithms/scripts/template.py index 18811693..0e0e7bfe 100644 --- a/analysis/algorithms/scripts/template.py +++ b/analysis/algorithms/scripts/template.py @@ -33,7 +33,7 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): processor_cfg = analysis_cfg['analysis'].get('processor_cfg', {}) # Skeleton for csv output - interaction_dict = analysis_cfg['analysis'].get('interaction_dict', {}) + # interaction_dict = analysis_cfg['analysis'].get('interaction_dict', {}) particle_fieldnames = analysis_cfg['analysis'].get('particle_fieldnames', {}) int_fieldnames = analysis_cfg['analysis'].get('interaction_fieldnames', {}) @@ -91,7 +91,7 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): interaction_logger.prepare() for i, interaction_pair in enumerate(matches): - int_dict = copy.deepcopy(interaction_dict) + int_dict = OrderedDict() int_dict.update(index_dict) int_dict['interaction_match_counts'] = counts[i] true_int, pred_int = interaction_pair[0], interaction_pair[1] @@ -100,9 +100,7 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): assert (type(pred_int) is Interaction) or (pred_int is None) true_int_dict = interaction_logger.produce(true_int, mode='true') - pred_int_dict = interaction_logger.produce(pred_int, mode='pred') - - int_dict = OrderedDict() + pred_int_dict = interaction_logger.produce(pred_int, mode='reco') int_dict.update(true_int_dict) int_dict.update(pred_int_dict) interactions.append(int_dict) @@ -115,9 +113,10 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): true_p, pred_p = mparticles[0], mparticles[1] true_p_dict = particle_logger.produce(true_p, mode='true') - pred_p_dict = particle_logger.produce(pred_p, mode='pred') + pred_p_dict = particle_logger.produce(pred_p, mode='reco') part_dict = OrderedDict() + part_dict.update(index_dict) part_dict['particle_match_counts'] = particle_match_counts[i] part_dict.update(true_p_dict) part_dict.update(pred_p_dict) diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index 7286fb49..8f70d5e3 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -455,7 +455,7 @@ def get_true_interactions(self, entry, drop_nonprimary_particles=True, if compute_vertex and ia.id in vertices: ia.vertex = vertices[ia.id] - if 'neutrino_asis' in self.data_blob and ia.nu_id > 0: + if 'neutrino_asis' in self.data_blob and ia.nu_id == 1: # assert 'particles_asis' in data_blob # particles = data_blob['particles_asis'][i] neutrinos = self.data_blob['neutrino_asis'][entry] @@ -468,10 +468,10 @@ def get_true_interactions(self, entry, drop_nonprimary_particles=True, # for nu in neutrinos: # if nu.mct_index() not in true_particles_track_ids: continue ia.nu_info = { - 'interaction_type': nu.interaction_type(), - 'interaction_mode': nu.interaction_mode(), - 'current_type': nu.current_type(), - 'energy_init': nu.energy_init() + 'nu_interaction_type': nu.interaction_type(), + 'nu_interaction_mode': nu.interaction_mode(), + 'nu_current_type': nu.current_type(), + 'nu_energy_init': nu.energy_init() } return out From bdecdc6c612eeaa1211c11d000501d91241d3e10 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Tue, 4 Apr 2023 00:52:40 -0700 Subject: [PATCH 098/180] Refactored cluster interaction and neutrino labeling --- mlreco/iotools/parsers/cluster.py | 15 +- mlreco/iotools/parsers/label_data.py | 212 ++++++++++++--------------- mlreco/utils/globals.py | 3 + 3 files changed, 107 insertions(+), 123 deletions(-) diff --git a/mlreco/iotools/parsers/cluster.py b/mlreco/iotools/parsers/cluster.py index 1e3d92bb..895f47f2 100644 --- a/mlreco/iotools/parsers/cluster.py +++ b/mlreco/iotools/parsers/cluster.py @@ -6,7 +6,7 @@ from .sparse import parse_sparse3d from .particles import parse_particles from .clean_data import clean_sparse_data -from .label_data import get_interaction_id, get_nu_id, get_particle_id, get_shower_primary_id, get_group_primary_id +from .label_data import get_interaction_ids, get_nu_ids, get_particle_id, get_shower_primary_id, get_group_primary_id def parse_cluster2d(cluster_event): @@ -58,6 +58,7 @@ def parse_cluster2d(cluster_event): def parse_cluster3d(cluster_event, particle_event = None, particle_mpv_event = None, + neutrino_event = None, sparse_semantics_event = None, sparse_value_event = None, add_particle_info = False, @@ -80,6 +81,7 @@ def parse_cluster3d(cluster_event, cluster_event: cluster3d_pcluster particle_event: particle_pcluster particle_mpv_event: particle_mpv + neutrino_event: neutrino_mpv sparse_semantics_event: sparse3d_semantics sparse_value_event: sparse3d_pcluster add_particle_info: true @@ -87,13 +89,14 @@ def parse_cluster3d(cluster_event, type_include_mpr: false type_include_secondary: false primary_include_mpr: true - break_clusters: True + break_clusters: false Configuration ------------- cluster_event: larcv::EventClusterVoxel3D particle_event: larcv::EventParticle particle_mpv_event: larcv::EventParticle + particle_mpv_event: larcv::EventNeutrino sparse_semantics_event: larcv::EventSparseTensor3D sparse_value_event: larcv::EventSparseTensor3D add_particle_info: bool @@ -137,13 +140,14 @@ def parse_cluster3d(cluster_event, if add_particle_info: assert particle_event is not None, "Must provide particle tree if particle information is included" particles_v = particle_event.as_vector() - particles_mpv_v = particle_mpv_event.as_vector() if particle_mpv_event is not None else None particles_v_v = parse_particles(particle_event, cluster_event) + particles_mpv_v = particle_mpv_event.as_vector() if particle_mpv_event is not None else None + neutrinos_v = neutrino_event.as_vector() if neutrino_event is not None else None labels['cluster'] = np.array([p.id() for p in particles_v]) labels['group'] = np.array([p.group_id() for p in particles_v]) - labels['inter'] = get_interaction_id(particles_v) - labels['nu'] = get_nu_id(cluster_event, particles_v, labels['inter'], particles_mpv_v) + labels['inter'] = get_interaction_ids(particles_v) + labels['nu'] = get_nu_ids(labels['inter'], particles_v, particles_mpv_v, neutrinos_v) labels['type'] = get_particle_id(particles_v, labels['nu'], type_include_mpr, type_include_secondary) labels['pshower'] = get_shower_primary_id(cluster_event, particles_v) labels['pgroup'] = get_group_primary_id(particles_v, labels['nu'], primary_include_mpr) @@ -215,6 +219,7 @@ def parse_cluster3d(cluster_event, def parse_cluster3d_charge_rescaled(cluster_event, particle_event = None, particle_mpv_event = None, + neutrino_event = None, sparse_semantics_event = None, sparse_value_event_list = None, add_particle_info = False, diff --git a/mlreco/iotools/parsers/label_data.py b/mlreco/iotools/parsers/label_data.py index b50f1e64..02433d57 100644 --- a/mlreco/iotools/parsers/label_data.py +++ b/mlreco/iotools/parsers/label_data.py @@ -1,130 +1,106 @@ import numpy as np import torch -from mlreco.utils.globals import SHOW_SHP, TRACK_SHP, PDG_TO_PID +from mlreco.utils.globals import SHOW_SHP, TRACK_SHP, LOWE_SHP, INVAL_TID, PDG_TO_PID -def get_interaction_id(particle_v, num_ancestor_loop=1): +def get_interaction_ids(particles): ''' - A function to sort out interaction ids. - Note that this assumes cluster_id==particle_id. - Inputs: - - particle_v (array) : larcv::EventParticle.as_vector() - - num_ancestor_loop (int): number of ancestor loops (default 1) - Outputs: - - interaction_ids: a numpy array with the shape (n,) + A function which gets the interaction ID of each of the + particle in the input particle list. It leverages shared + ancestor position as a basis for interaction building and + sets the interaction ID to -1 for parrticles with invalid + ancestor track IDs. + + Parameters + ---------- + particles : list(larcv.Particle) + List of true particle instances + + Results + ------- + np.ndarray + List of interaction IDs, one per true particle instance ''' - ########################################################################## - # sort out the interaction ids using the information of ancestor vtx info - # then loop over to make sure the ancestor particles having the same interaction ids - ########################################################################## - # get the particle ancestor vtx array first - # and the track ids - # and the ancestor track ids - ancestor_vtxs = [] - track_ids = [] - ancestor_track_ids = np.empty(0, dtype=int) - for particle in particle_v: - ancestor_vtx = [ - particle.ancestor_x(), - particle.ancestor_y(), - particle.ancestor_z(), - ] - ancestor_vtxs.append(ancestor_vtx) - track_ids.append(particle.track_id()) - ancestor_track_ids = np.append(ancestor_track_ids, [particle.ancestor_track_id()]) - ancestor_vtxs = np.asarray(ancestor_vtxs) - # get the list of unique interaction vertexes - interaction_vtx_list = np.unique( - ancestor_vtxs, - axis=0, - ).tolist() - # loop over each cluster to assign interaction ids - interaction_ids = np.ones(particle_v.size(), dtype=int)*(-1) - for clust_id in range(particle_v.size()): - # get the interaction id from the unique list (index is the id) - interaction_ids[clust_id] = interaction_vtx_list.index( - ancestor_vtxs[clust_id].tolist() - ) - # Loop over ancestor, making sure particle having the same interaction id as ancestor - for _ in range(num_ancestor_loop): - for clust_id, ancestor_track_id in enumerate(ancestor_track_ids): - if ancestor_track_id in track_ids: - ancestor_clust_index = track_ids.index(ancestor_track_id) - interaction_ids[clust_id] = interaction_ids[ancestor_clust_index] - - return interaction_ids - - -def get_nu_id(cluster_event, particle_v, interaction_ids, particle_mpv=None): + # Define the interaction ID on the basis of sharing an ancestor vertex position + anc_pos = np.vstack([[getattr(p, f'ancestor_{a}')() for a in ['x', 'y', 'z']] for p in particles]) + inter_ids = np.unique(anc_pos, axis=0, return_inverse=True)[-1] + + # Now set the interaction ID of particles with an undefined ancestor to -1 + if len(particles): + anc_ids = np.array([p.ancestor_track_id() for p in particles]) + inter_ids[anc_ids == INVAL_TID] = -1 + + return inter_ids + + +def get_nu_ids(inter_ids, particles, particles_mpv=None, neutrinos=None): ''' - A function to sorts interactions into nu or not nu (0 for cosmic, 1 for nu). - CAVEAT: Dirty way to sort out nu_ids - Assuming only one nu interaction is generated and first group/cluster belongs to such interaction - Inputs: - - cluster_event (larcv::EventClusterVoxel3D): (N) Array of cluster tensors - - particle_v vector: larcv::EventParticle.as_vector() - - interaction_id: a numpy array with shape (n, 1) where 1 is interaction id - - (optional) particle_mpv: vector of particles from mpv generator, used to work around - the lack of proper interaction id for the time being. - Outputs: - - nu_id: a numpy array with the shape (n,1) + A function which gets the neutrino-like ID (0 for cosmic, 1 for + neutrino) of each of the particle in the input particle list. + + If `particles_mpv` and `neutrinos` are not specified, it assumes that + there is only one neutrino-like interaction, the first valid one, and + it enforces that it must contain at least two true primaries. + + If a list of multi-particle vertex (MPV) particles or neutrinos is + provided, that information is leveraged to identify which interaction + is neutrino-like and which is not. + + Parameters + ---------- + inter_ids : np.ndarray + Array of interaction ID values, one per true particle instance + particles : list(larcv.Particle) + List of true particle instances + particles_mpv : list(larcv.Particle), optional + List of true MPV particle instances + neutrinos : list(larcv.Neutrino), optional + List of true neutrino instances + + Results + ------- + np.ndarray + List of neutrino IDs, one per true particle instance ''' - # initiate the nu_id - nu_id = np.zeros(len(particle_v)) - - if particle_mpv is None: - # find the first cluster that has nonzero size - sizes = np.array([cluster_event.as_vector()[i].as_vector().size() for i in range(len(particle_v))]) - nonzero = np.where(sizes > 0)[0] - if not len(nonzero): - return nu_id - first_clust_id = nonzero[0] - # the corresponding interaction id - nu_interaction_id = interaction_ids[first_clust_id] - # Get clust indexes for interaction_id = nu_interaction_id - inds = np.where(interaction_ids == nu_interaction_id)[0] - # Check whether there're at least two clusts coming from 'primary' process - num_primary = 0 - for i, part in enumerate(particle_v): - if i not in inds: - continue - create_prc = part.creation_process() - parent_pdg = part.parent_pdg_code() - if create_prc == 'primary' or parent_pdg == 111: - num_primary += 1 - # if there is nu interaction + # Make sure there is only either MPV particles or neutrinos specified, not both + assert particles_mpv is None or neutrinos is None,\ + 'Do not specify both particle_mpv_event and neutrino_event in parse_cluster3d' + + # Initialize neutrino IDs + nu_ids = np.zeros(len(inter_ids), dtype=inter_ids.dtype) + nu_ids[inter_ids == -1] = -1 + if particles_mpv is None and neutrinos is None: + # Find the first particle with a valid interaction ID + valid_mask = np.where(inter_ids > -1)[0] + if not len(valid_mask): + return nu_ids + + # Identify the interaction ID of that particle + inter_id = inter_ids[valid_mask[0]] + inter_index = np.where(inter_ids == inter_id)[0] + + # If there are at least two primaries, the interaction is nu-like + primary_ids = get_group_primary_id(particles) + num_primary = np.sum(primary_ids[inter_index]) if num_primary > 1: - nu_id[inds] = 1 - elif len(particle_mpv) > 0: - # Find mpv particles - is_mpv = np.zeros((len(particle_v),)) - # mpv_ids = [p.id() for p in particle_mpv] - mpv_pdg = np.array([p.pdg_code() for p in particle_mpv]).reshape((-1,)) - mpv_energy = np.array([p.energy_init() for p in particle_mpv]).reshape((-1,)) - for idx, part in enumerate(particle_v): - # track_id - 1 in `particle_pcluster_tree` corresponds to id (or track_id) in `particle_mpv_tree` - # if (part.track_id()-1) in mpv_ids or (part.ancestor_track_id()-1) in mpv_ids: - # FIXME the above was wrong I think. - close = np.isclose(part.energy_init()*1e-3, mpv_energy) - pdg = part.pdg_code() == mpv_pdg - if (close & pdg).any(): - is_mpv[idx] = 1. - # else: - # print("fake cosmic", part.pdg_code(), part.shape(), part.creation_process(), part.track_id(), part.ancestor_track_id(), mpv_ids) - is_mpv = is_mpv.astype(bool) - nu_interaction_ids = np.unique(interaction_ids[is_mpv]) - for idx, x in enumerate(nu_interaction_ids): - # # Check whether there're at least two clusts coming from 'primary' process - # num_primary = 0 - # for part in particle_v[interaction_ids == x]: - # if part.creation_process() == 'primary': - # num_primary += 1 - # if num_primary > 1: - nu_id[interaction_ids == x] = 1 # Only tells whether neutrino or not - # nu_id[interaction_ids == x] = idx - - return nu_id + nu_ids[inter_index] = 1 + else: + # Find the reference positions gauge if a particle comes from a neutrino-like interaction + ref_pos = None + if particles_mpv: + ref_pos = np.vstack([[getattr(p, f'{a}')() for a in ['x', 'y', 'z']] for p in particles_mpv]) + elif neutrinos: + ref_pos = np.vstack([[getattr(n, f'{a}')() for a in ['x', 'y', 'z']] for n in neutrinos]) + + # If a particle shares its ancestor position with an MPV particle + # or a neutrino, it belongs to a neutrino-like interaction + if ref_pos is not None and len(ref_pos): + anc_pos = np.vstack([[getattr(p, f'ancestor_{a}')() for a in ['x', 'y', 'z']] for p in particles]) + for pos in ref_pos: + nu_ids[(anc_pos == pos).all(axis=1)] = 1 + + return nu_ids def get_particle_id(particles_v, nu_ids, include_mpr=False, include_secondary=False): @@ -254,7 +230,7 @@ def get_group_primary_id(particles_v, nu_ids=None, include_mpr=True): continue # If the particle is not a shower or a track, it is not a primary - if p.shape() != SHOW_SHP and p.shape() != TRACK_SHP: + if p.shape() != SHOW_SHP and p.shape() != TRACK_SHP and p.shape() != LOWE_SHP: primary_ids[i] = 0 continue diff --git a/mlreco/utils/globals.py b/mlreco/utils/globals.py index 6796c378..9cf68f23 100644 --- a/mlreco/utils/globals.py +++ b/mlreco/utils/globals.py @@ -35,6 +35,9 @@ # Shape precedence used in the cluster labeling process SHAPE_PREC = [TRACK_SHP, MICH_SHP, SHOW_SHP, DELTA_SHP, LOWE_SHP] +# Invalid labels +INVAL_TID = larcv.kINVALID_UINT + # Mapping between particle PDG code and particle ID labels PDG_TO_PID = { 22: 0, # photon From 0758d9d15087a1234f0f97a304d29e630b24463f Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Tue, 4 Apr 2023 02:00:15 -0700 Subject: [PATCH 099/180] Interface for post-processing done. --- .../algorithms/{ => arxiv}/calorimetry.py | 10 +- analysis/algorithms/arxiv/michel_electrons.py | 2 +- analysis/algorithms/arxiv/muon_decay.py | 2 +- analysis/algorithms/arxiv/particles.py | 2 +- analysis/algorithms/arxiv/statistics.py | 2 +- .../algorithms/tables/muE_liquid_argon.txt | 146 ---------------- .../algorithms/tables/pE_liquid_argon.txt | 133 --------------- analysis/algorithms/utils.py | 4 +- analysis/algorithms/vertex.py | 3 +- analysis/classes/particle_utils.py | 5 +- analysis/decorator.py | 2 - mlreco/main_funcs.py | 25 ++- mlreco/post_processing/__init__.py | 4 +- mlreco/post_processing/common.py | 56 +++++++ mlreco/post_processing/decorator.py | 158 ++---------------- .../reconstruction/__init__.py | 1 + .../reconstruction/calorimetry.py | 113 +++++++++++++ .../reconstruction/particle_points.py | 0 .../post_processing/reconstruction/vertex.py | 0 mlreco/utils/globals.py | 10 +- 20 files changed, 227 insertions(+), 451 deletions(-) rename analysis/algorithms/{ => arxiv}/calorimetry.py (97%) delete mode 100644 analysis/algorithms/tables/muE_liquid_argon.txt delete mode 100644 analysis/algorithms/tables/pE_liquid_argon.txt create mode 100644 mlreco/post_processing/reconstruction/__init__.py create mode 100644 mlreco/post_processing/reconstruction/calorimetry.py create mode 100644 mlreco/post_processing/reconstruction/particle_points.py create mode 100644 mlreco/post_processing/reconstruction/vertex.py diff --git a/analysis/algorithms/calorimetry.py b/analysis/algorithms/arxiv/calorimetry.py similarity index 97% rename from analysis/algorithms/calorimetry.py rename to analysis/algorithms/arxiv/calorimetry.py index f926bb77..d8c6dce0 100644 --- a/analysis/algorithms/calorimetry.py +++ b/analysis/algorithms/arxiv/calorimetry.py @@ -4,15 +4,7 @@ from mlreco.utils.gnn.cluster import cluster_direction import pandas as pd from analysis.classes import Particle - -# CONSTANTS (MeV) -PROTON_MASS = 938.272 -MUON_MASS = 105.7 -ELECTRON_MASS = 0.511998 -ARGON_DENSITY = 1.396 -ADC_TO_MEV = 1. / 350. -ARGON_MASS = 37211 -PIXELS_TO_CM = 0.3 +from mlreco.utils.globals import * def compute_track_length(points, bin_size=17): diff --git a/analysis/algorithms/arxiv/michel_electrons.py b/analysis/algorithms/arxiv/michel_electrons.py index 056894f2..8bd13602 100644 --- a/analysis/algorithms/arxiv/michel_electrons.py +++ b/analysis/algorithms/arxiv/michel_electrons.py @@ -5,7 +5,7 @@ from analysis.classes.predictor import FullChainPredictor from analysis.classes.evaluator import FullChainEvaluator from analysis.decorator import evaluate -from analysis.algorithms.calorimetry import compute_track_length +from lartpc_mlreco3d.analysis.algorithms.arxiv.calorimetry import compute_track_length from pprint import pprint import time diff --git a/analysis/algorithms/arxiv/muon_decay.py b/analysis/algorithms/arxiv/muon_decay.py index 15535446..2125c160 100644 --- a/analysis/algorithms/arxiv/muon_decay.py +++ b/analysis/algorithms/arxiv/muon_decay.py @@ -1,7 +1,7 @@ from collections import OrderedDict from analysis.classes.predictor import FullChainPredictor from analysis.classes.evaluator import FullChainEvaluator -from analysis.algorithms.calorimetry import compute_track_length +from lartpc_mlreco3d.analysis.algorithms.arxiv.calorimetry import compute_track_length from analysis.decorator import evaluate from lartpc_mlreco3d.analysis.classes.particle_utils import match_particles_fn, matrix_iou diff --git a/analysis/algorithms/arxiv/particles.py b/analysis/algorithms/arxiv/particles.py index 588a3fab..e693ac86 100644 --- a/analysis/algorithms/arxiv/particles.py +++ b/analysis/algorithms/arxiv/particles.py @@ -15,7 +15,7 @@ get_particle_properties, \ get_mparticles_from_minteractions -from analysis.algorithms.calorimetry import get_csda_range_spline +from lartpc_mlreco3d.analysis.algorithms.arxiv.calorimetry import get_csda_range_spline @evaluate(['particles'], mode='per_batch') def run_inference_particles(data_blob, res, data_idx, analysis_cfg, cfg): diff --git a/analysis/algorithms/arxiv/statistics.py b/analysis/algorithms/arxiv/statistics.py index 9a2909a0..4fcf337d 100644 --- a/analysis/algorithms/arxiv/statistics.py +++ b/analysis/algorithms/arxiv/statistics.py @@ -2,7 +2,7 @@ from turtle import update from sklearn.decomposition import PCA -from analysis.algorithms.calorimetry import compute_track_length, get_particle_direction +from lartpc_mlreco3d.analysis.algorithms.arxiv.calorimetry import compute_track_length, get_particle_direction from analysis.classes.predictor import FullChainPredictor from analysis.classes.evaluator import FullChainEvaluator from analysis.decorator import evaluate diff --git a/analysis/algorithms/tables/muE_liquid_argon.txt b/analysis/algorithms/tables/muE_liquid_argon.txt deleted file mode 100644 index 4b5fb082..00000000 --- a/analysis/algorithms/tables/muE_liquid_argon.txt +++ /dev/null @@ -1,146 +0,0 @@ - T p Ionization brems pair photonuc Radloss dE/dx CSDARange delta beta dE/dx_R - 1.000E+00 1.457E+01 2.404E+00 0.000E+00 0.000E+00 4.526E-05 4.526E-05 4.808E+00 2.831E-03 0.0000 0.13661 3.355E+01 - 1.200E+00 1.597E+01 2.920E+01 0.000E+00 0.000E+00 4.534E-05 4.534E-05 2.920E+01 9.238E-03 0.0000 0.14944 2.920E+01 - 1.400E+00 1.726E+01 2.595E+01 0.000E+00 0.000E+00 4.542E-05 4.542E-05 2.595E+01 1.652E-02 0.0000 0.16119 2.595E+01 - 1.700E+00 1.903E+01 2.234E+01 0.000E+00 0.000E+00 4.555E-05 4.555E-05 2.234E+01 2.902E-02 0.0000 0.17725 2.234E+01 - 2.000E+00 2.066E+01 1.970E+01 0.000E+00 0.000E+00 4.568E-05 4.568E-05 1.970E+01 4.335E-02 0.0000 0.19186 1.970E+01 - 2.500E+00 2.312E+01 1.655E+01 0.000E+00 0.000E+00 4.589E-05 4.589E-05 1.655E+01 7.117E-02 0.0000 0.21376 1.655E+01 - 3.000E+00 2.536E+01 1.435E+01 0.000E+00 0.000E+00 4.610E-05 4.610E-05 1.435E+01 1.037E-01 0.0000 0.23336 1.417E+01 - 3.500E+00 2.742E+01 1.272E+01 0.000E+00 0.000E+00 4.632E-05 4.632E-05 1.272E+01 1.408E-01 0.0000 0.25120 1.240E+01 - 4.000E+00 2.935E+01 1.146E+01 0.000E+00 0.000E+00 4.653E-05 4.653E-05 1.146E+01 1.822E-01 0.0000 0.26763 1.106E+01 - 4.500E+00 3.116E+01 1.046E+01 0.000E+00 0.000E+00 4.674E-05 4.674E-05 1.046E+01 2.280E-01 0.0000 0.28290 9.998E+00 - 5.000E+00 3.289E+01 9.635E+00 0.000E+00 0.000E+00 4.695E-05 4.695E-05 9.635E+00 2.778E-01 0.0000 0.29720 9.141E+00 - 5.500E+00 3.453E+01 8.949E+00 0.000E+00 0.000E+00 4.716E-05 4.716E-05 8.949E+00 3.317E-01 0.0000 0.31066 8.434E+00 - 6.000E+00 3.611E+01 8.368E+00 0.000E+00 0.000E+00 4.738E-05 4.738E-05 8.368E+00 3.895E-01 0.0000 0.32339 7.839E+00 - 7.000E+00 3.909E+01 7.435E+00 0.000E+00 0.000E+00 4.780E-05 4.780E-05 7.435E+00 5.166E-01 0.0000 0.34700 6.894E+00 - 8.000E+00 4.189E+01 6.719E+00 0.000E+00 0.000E+00 4.823E-05 4.823E-05 6.719E+00 6.583E-01 0.0000 0.36854 6.177E+00 - 9.000E+00 4.453E+01 6.150E+00 0.000E+00 0.000E+00 4.865E-05 4.865E-05 6.150E+00 8.141E-01 0.0000 0.38836 5.613E+00 - 1.000E+01 4.704E+01 5.687E+00 0.000E+00 0.000E+00 4.907E-05 4.907E-05 5.687E+00 9.833E-01 0.0000 0.40675 5.159E+00 - 1.200E+01 5.177E+01 4.979E+00 0.000E+00 0.000E+00 4.992E-05 4.992E-05 4.979E+00 1.360E+00 0.0000 0.43998 4.469E+00 - 1.400E+01 5.616E+01 4.461E+00 0.000E+00 0.000E+00 5.077E-05 5.077E-05 4.461E+00 1.786E+00 0.0000 0.46937 3.971E+00 - 1.700E+01 6.230E+01 3.901E+00 0.000E+00 0.000E+00 5.204E-05 5.204E-05 3.902E+00 2.507E+00 0.0000 0.50792 3.438E+00 - 2.000E+01 6.802E+01 3.502E+00 0.000E+00 0.000E+00 5.332E-05 5.332E-05 3.502E+00 3.321E+00 0.0000 0.54129 3.061E+00 - 2.500E+01 7.686E+01 3.042E+00 0.000E+00 0.000E+00 5.544E-05 5.544E-05 3.042E+00 4.859E+00 0.0000 0.58827 2.631E+00 - 3.000E+01 8.509E+01 2.731E+00 0.000E+00 0.000E+00 5.756E-05 5.756E-05 2.731E+00 6.598E+00 0.0000 0.62720 2.343E+00 - 3.500E+01 9.285E+01 2.508E+00 0.000E+00 0.000E+00 5.968E-05 5.968E-05 2.508E+00 8.512E+00 0.0000 0.66011 2.136E+00 - 4.000E+01 1.003E+02 2.340E+00 0.000E+00 0.000E+00 6.180E-05 6.180E-05 2.340E+00 1.058E+01 0.0000 0.68834 1.982E+00 - 4.500E+01 1.074E+02 2.210E+00 0.000E+00 0.000E+00 6.392E-05 6.392E-05 2.210E+00 1.278E+01 0.0000 0.71286 1.862E+00 - 5.000E+01 1.143E+02 2.107E+00 0.000E+00 0.000E+00 6.605E-05 6.605E-05 2.107E+00 1.510E+01 0.0000 0.73434 1.767E+00 - 5.500E+01 1.210E+02 2.023E+00 3.229E-07 0.000E+00 6.817E-05 6.849E-05 2.023E+00 1.752E+01 0.0000 0.75332 1.690E+00 - 6.000E+01 1.276E+02 1.954E+00 1.490E-06 0.000E+00 7.029E-05 7.178E-05 1.954E+00 2.004E+01 0.0000 0.77019 1.626E+00 - 7.000E+01 1.403E+02 1.848E+00 3.928E-06 0.000E+00 7.453E-05 7.846E-05 1.848E+00 2.531E+01 0.0000 0.79887 1.528E+00 - 8.000E+01 1.527E+02 1.771E+00 6.495E-06 0.000E+00 7.877E-05 8.527E-05 1.771E+00 3.084E+01 0.0000 0.82227 1.456E+00 - 9.000E+01 1.647E+02 1.713E+00 9.185E-06 0.000E+00 8.302E-05 9.220E-05 1.713E+00 3.659E+01 0.0000 0.84166 1.401E+00 - 1.000E+02 1.764E+02 1.669E+00 1.199E-05 0.000E+00 8.726E-05 9.925E-05 1.670E+00 4.250E+01 0.0010 0.85794 1.359E+00 - 1.200E+02 1.994E+02 1.608E+00 1.793E-05 0.000E+00 9.575E-05 1.137E-04 1.609E+00 5.473E+01 0.0098 0.88361 1.298E+00 - 1.400E+02 2.218E+02 1.570E+00 2.428E-05 0.000E+00 1.042E-04 1.285E-04 1.570E+00 6.732E+01 0.0247 0.90278 1.258E+00 - 1.700E+02 2.546E+02 1.536E+00 3.448E-05 0.000E+00 1.170E-04 1.514E-04 1.536E+00 8.666E+01 0.0541 0.92363 1.219E+00 - 2.000E+02 2.868E+02 1.518E+00 4.544E-05 0.000E+00 1.297E-04 1.751E-04 1.519E+00 1.063E+02 0.0884 0.93835 1.195E+00 - 2.500E+02 3.396E+02 1.508E+00 6.515E-05 0.000E+00 1.509E-04 2.161E-04 1.508E+00 1.394E+02 0.1508 0.95485 1.172E+00 - 3.000E+02 3.917E+02 1.509E+00 8.648E-05 0.000E+00 1.721E-04 2.586E-04 1.510E+00 1.725E+02 0.2157 0.96548 1.162E+00 - 3.500E+02 4.432E+02 1.516E+00 1.092E-04 0.000E+00 1.933E-04 3.025E-04 1.517E+00 2.056E+02 0.2809 0.97274 1.157E+00 - 4.000E+02 4.945E+02 1.526E+00 1.332E-04 0.000E+00 2.146E-04 3.477E-04 1.526E+00 2.385E+02 0.3453 0.97793 1.155E+00 - 4.500E+02 5.455E+02 1.536E+00 1.583E-04 0.000E+00 2.358E-04 3.941E-04 1.537E+00 2.711E+02 0.4084 0.98176 1.155E+00 - 5.000E+02 5.964E+02 1.547E+00 1.845E-04 0.000E+00 2.570E-04 4.414E-04 1.548E+00 3.035E+02 0.4698 0.98467 1.156E+00 - 5.500E+02 6.471E+02 1.558E+00 2.115E-04 0.000E+00 2.782E-04 4.897E-04 1.559E+00 3.357E+02 0.5296 0.98693 1.158E+00 - 6.000E+02 6.977E+02 1.569E+00 2.395E-04 0.000E+00 2.994E-04 5.389E-04 1.570E+00 3.677E+02 0.5876 0.98873 1.160E+00 - 7.000E+02 7.987E+02 1.590E+00 2.978E-04 0.000E+00 3.418E-04 6.396E-04 1.591E+00 4.310E+02 0.6986 0.99136 1.165E+00 - 8.000E+02 8.995E+02 1.610E+00 3.589E-04 0.000E+00 3.843E-04 7.432E-04 1.610E+00 4.934E+02 0.8032 0.99317 1.170E+00 - 9.000E+02 1.000E+03 1.627E+00 4.225E-04 0.000E+00 4.267E-04 8.492E-04 1.628E+00 5.552E+02 0.9021 0.99447 1.174E+00 - 1.000E+03 1.101E+03 1.644E+00 4.884E-04 1.833E-05 4.691E-04 9.759E-04 1.645E+00 6.163E+02 0.9957 0.99542 1.179E+00 - 1.200E+03 1.301E+03 1.673E+00 6.263E-04 1.159E-04 5.540E-04 1.296E-03 1.675E+00 7.368E+02 1.1691 0.99672 1.187E+00 - 1.400E+03 1.502E+03 1.699E+00 7.712E-04 2.268E-04 6.389E-04 1.637E-03 1.700E+00 8.552E+02 1.3269 0.99753 1.194E+00 - 1.700E+03 1.803E+03 1.731E+00 9.996E-04 4.144E-04 7.661E-04 2.180E-03 1.733E+00 1.030E+03 1.5399 0.99829 1.202E+00 - 2.000E+03 2.103E+03 1.758E+00 1.239E-03 6.238E-04 8.967E-04 2.760E-03 1.761E+00 1.202E+03 1.7300 0.99874 1.209E+00 - 2.500E+03 2.604E+03 1.795E+00 1.660E-03 1.013E-03 1.126E-03 3.800E-03 1.799E+00 1.482E+03 2.0079 0.99918 1.219E+00 - 3.000E+03 3.104E+03 1.825E+00 2.103E-03 1.444E-03 1.359E-03 4.906E-03 1.829E+00 1.758E+03 2.2491 0.99942 1.226E+00 - 3.500E+03 3.604E+03 1.849E+00 2.565E-03 1.910E-03 1.594E-03 6.068E-03 1.855E+00 2.029E+03 2.4623 0.99957 1.231E+00 - 4.000E+03 4.104E+03 1.870E+00 3.042E-03 2.407E-03 1.831E-03 7.279E-03 1.877E+00 2.297E+03 2.6536 0.99967 1.236E+00 - 4.500E+03 4.604E+03 1.888E+00 3.533E-03 2.929E-03 2.069E-03 8.532E-03 1.897E+00 2.562E+03 2.8273 0.99974 1.239E+00 - 5.000E+03 5.105E+03 1.904E+00 4.038E-03 3.478E-03 2.305E-03 9.821E-03 1.914E+00 2.825E+03 2.9865 0.99979 1.243E+00 - 5.500E+03 5.605E+03 1.919E+00 4.562E-03 4.058E-03 2.523E-03 1.114E-02 1.930E+00 3.085E+03 3.1334 0.99982 1.245E+00 - 6.000E+03 6.105E+03 1.932E+00 5.097E-03 4.658E-03 2.740E-03 1.249E-02 1.944E+00 3.343E+03 3.2699 0.99985 1.248E+00 - 7.000E+03 7.105E+03 1.954E+00 6.196E-03 5.912E-03 3.171E-03 1.528E-02 1.969E+00 3.854E+03 3.5172 0.99989 1.251E+00 - 8.000E+03 8.105E+03 1.973E+00 7.329E-03 7.232E-03 3.601E-03 1.816E-02 1.991E+00 4.359E+03 3.7367 0.99992 1.254E+00 - 9.000E+03 9.105E+03 1.989E+00 8.493E-03 8.607E-03 4.029E-03 2.113E-02 2.010E+00 4.859E+03 3.9342 0.99993 1.257E+00 - 1.000E+04 1.011E+04 2.003E+00 9.685E-03 1.004E-02 4.454E-03 2.417E-02 2.028E+00 5.354E+03 4.1137 0.99995 1.259E+00 - 1.200E+04 1.211E+04 2.027E+00 1.216E-02 1.307E-02 5.279E-03 3.050E-02 2.058E+00 6.333E+03 4.4307 0.99996 1.262E+00 - 1.400E+04 1.411E+04 2.047E+00 1.471E-02 1.627E-02 6.095E-03 3.707E-02 2.084E+00 7.298E+03 4.7044 0.99997 1.264E+00 - 1.700E+04 1.711E+04 2.071E+00 1.867E-02 2.131E-02 7.306E-03 4.729E-02 2.119E+00 8.726E+03 5.0560 0.99998 1.266E+00 - 2.000E+04 2.011E+04 2.091E+00 2.277E-02 2.661E-02 8.504E-03 5.788E-02 2.149E+00 1.013E+04 5.3556 0.99999 1.268E+00 - 2.500E+04 2.511E+04 2.116E+00 2.985E-02 3.611E-02 1.050E-02 7.646E-02 2.193E+00 1.243E+04 5.7742 0.99999 1.270E+00 - 3.000E+04 3.011E+04 2.137E+00 3.719E-02 4.614E-02 1.247E-02 9.580E-02 2.232E+00 1.469E+04 6.1216 0.99999 1.271E+00 - 3.500E+04 3.511E+04 2.153E+00 4.473E-02 5.660E-02 1.443E-02 1.158E-01 2.269E+00 1.692E+04 6.4186 1.00000 1.271E+00 - 4.000E+04 4.011E+04 2.167E+00 5.246E-02 6.743E-02 1.637E-02 1.363E-01 2.304E+00 1.910E+04 6.6781 1.00000 1.272E+00 - 4.500E+04 4.511E+04 2.179E+00 6.035E-02 7.858E-02 1.829E-02 1.572E-01 2.337E+00 2.126E+04 6.9084 1.00000 1.272E+00 - 5.000E+04 5.011E+04 2.190E+00 6.837E-02 9.001E-02 2.021E-02 1.786E-01 2.369E+00 2.338E+04 7.1154 1.00000 1.272E+00 - 5.500E+04 5.511E+04 2.200E+00 7.648E-02 1.015E-01 2.215E-02 2.001E-01 2.400E+00 2.548E+04 7.3034 1.00000 1.273E+00 - 6.000E+04 6.011E+04 2.208E+00 8.469E-02 1.132E-01 2.409E-02 2.219E-01 2.430E+00 2.755E+04 7.4756 1.00000 1.273E+00 - 7.000E+04 7.011E+04 2.223E+00 1.014E-01 1.371E-01 2.795E-02 2.664E-01 2.490E+00 3.161E+04 7.7816 1.00000 1.273E+00 - 8.000E+04 8.011E+04 2.236E+00 1.185E-01 1.617E-01 3.178E-02 3.119E-01 2.548E+00 3.558E+04 8.0475 1.00000 1.273E+00 - 9.000E+04 9.011E+04 2.248E+00 1.359E-01 1.869E-01 3.560E-02 3.583E-01 2.606E+00 3.946E+04 8.2825 1.00000 1.273E+00 - 1.000E+05 1.001E+05 2.258E+00 1.535E-01 2.126E-01 3.941E-02 4.055E-01 2.663E+00 4.326E+04 8.4929 1.00000 1.273E+00 - 1.200E+05 1.201E+05 2.275E+00 1.891E-01 2.646E-01 4.714E-02 5.009E-01 2.776E+00 5.062E+04 8.8572 1.00000 1.273E+00 - 1.400E+05 1.401E+05 2.289E+00 2.256E-01 3.181E-01 5.484E-02 5.985E-01 2.888E+00 5.768E+04 9.1653 1.00000 1.273E+00 - 1.700E+05 1.701E+05 2.307E+00 2.814E-01 4.006E-01 6.636E-02 7.483E-01 3.055E+00 6.778E+04 9.5533 1.00000 1.273E+00 - 2.000E+05 2.001E+05 2.322E+00 3.384E-01 4.853E-01 7.784E-02 9.016E-01 3.224E+00 7.734E+04 9.8782 1.00000 1.273E+00 - 2.500E+05 2.501E+05 2.343E+00 4.341E-01 6.243E-01 9.727E-02 1.156E+00 3.498E+00 9.222E+04 10.3243 1.00000 1.273E+00 - 3.000E+05 3.001E+05 2.360E+00 5.318E-01 7.663E-01 1.167E-01 1.415E+00 3.774E+00 1.060E+05 10.6888 1.00000 1.273E+00 - 3.500E+05 3.501E+05 2.374E+00 6.312E-01 9.111E-01 1.361E-01 1.678E+00 4.052E+00 1.188E+05 10.9970 1.00000 1.273E+00 - 4.000E+05 4.001E+05 2.386E+00 7.320E-01 1.058E+00 1.556E-01 1.946E+00 4.332E+00 1.307E+05 11.2639 1.00000 1.273E+00 - 4.500E+05 4.501E+05 2.397E+00 8.341E-01 1.207E+00 1.750E-01 2.216E+00 4.613E+00 1.419E+05 11.4995 1.00000 1.273E+00 - 5.000E+05 5.001E+05 2.407E+00 9.373E-01 1.358E+00 1.944E-01 2.489E+00 4.896E+00 1.524E+05 11.7101 1.00000 1.273E+00 - 5.500E+05 5.501E+05 2.416E+00 1.040E+00 1.506E+00 2.143E-01 2.759E+00 5.175E+00 1.623E+05 11.9007 1.00000 1.273E+00 - 6.000E+05 6.001E+05 2.424E+00 1.143E+00 1.654E+00 2.343E-01 3.031E+00 5.455E+00 1.717E+05 12.0747 1.00000 1.273E+00 - 7.000E+05 7.001E+05 2.438E+00 1.351E+00 1.955E+00 2.743E-01 3.580E+00 6.018E+00 1.892E+05 12.3830 1.00000 1.273E+00 - 8.000E+05 8.001E+05 2.451E+00 1.562E+00 2.259E+00 3.144E-01 4.135E+00 6.585E+00 2.051E+05 12.6500 1.00000 1.273E+00 - 9.000E+05 9.001E+05 2.462E+00 1.774E+00 2.565E+00 3.547E-01 4.694E+00 7.156E+00 2.196E+05 12.8855 1.00000 1.273E+00 - 1.000E+06 1.000E+06 2.472E+00 1.989E+00 2.875E+00 3.950E-01 5.258E+00 7.730E+00 2.331E+05 13.0962 1.00000 1.273E+00 - 1.200E+06 1.200E+06 2.489E+00 2.415E+00 3.487E+00 4.773E-01 6.380E+00 8.868E+00 2.572E+05 13.4608 1.00000 1.273E+00 - 1.400E+06 1.400E+06 2.503E+00 2.847E+00 4.105E+00 5.600E-01 7.511E+00 1.001E+01 2.784E+05 13.7691 1.00000 1.273E+00 - 1.700E+06 1.700E+06 2.522E+00 3.501E+00 5.040E+00 6.848E-01 9.226E+00 1.175E+01 3.061E+05 14.1574 1.00000 1.273E+00 - 2.000E+06 2.000E+06 2.538E+00 4.161E+00 5.985E+00 8.104E-01 1.096E+01 1.349E+01 3.299E+05 14.4824 1.00000 1.273E+00 - 2.500E+06 2.500E+06 2.559E+00 5.256E+00 7.542E+00 1.024E+00 1.382E+01 1.638E+01 3.634E+05 14.9287 1.00000 1.273E+00 - 3.000E+06 3.000E+06 2.577E+00 6.360E+00 9.110E+00 1.240E+00 1.671E+01 1.929E+01 3.915E+05 15.2933 1.00000 1.273E+00 - 3.500E+06 3.500E+06 2.592E+00 7.473E+00 1.069E+01 1.458E+00 1.962E+01 2.221E+01 4.157E+05 15.6016 1.00000 1.273E+00 - 4.000E+06 4.000E+06 2.606E+00 8.592E+00 1.227E+01 1.677E+00 2.254E+01 2.515E+01 4.368E+05 15.8686 1.00000 1.273E+00 - 4.500E+06 4.500E+06 2.617E+00 9.717E+00 1.386E+01 1.898E+00 2.548E+01 2.810E+01 4.556E+05 16.1042 1.00000 1.273E+00 - 5.000E+06 5.000E+06 2.628E+00 1.085E+01 1.546E+01 2.120E+00 2.843E+01 3.106E+01 4.725E+05 16.3149 1.00000 1.273E+00 - 5.500E+06 5.500E+06 2.637E+00 1.197E+01 1.704E+01 2.346E+00 3.136E+01 3.400E+01 4.879E+05 16.5055 1.00000 1.273E+00 - 6.000E+06 6.000E+06 2.646E+00 1.309E+01 1.863E+01 2.573E+00 3.429E+01 3.694E+01 5.020E+05 16.6796 1.00000 1.273E+00 - 7.000E+06 7.000E+06 2.662E+00 1.534E+01 2.181E+01 3.031E+00 4.018E+01 4.284E+01 5.272E+05 16.9879 1.00000 1.273E+00 - 8.000E+06 8.000E+06 2.676E+00 1.761E+01 2.500E+01 3.493E+00 4.609E+01 4.877E+01 5.490E+05 17.2549 1.00000 1.273E+00 - 9.000E+06 9.000E+06 2.688E+00 1.988E+01 2.819E+01 3.959E+00 5.203E+01 5.471E+01 5.684E+05 17.4905 1.00000 1.273E+00 - 1.000E+07 1.000E+07 2.698E+00 2.215E+01 3.140E+01 4.427E+00 5.798E+01 6.068E+01 5.857E+05 17.7012 1.00000 1.273E+00 - 1.200E+07 1.200E+07 2.717E+00 2.669E+01 3.777E+01 5.382E+00 6.984E+01 7.255E+01 6.158E+05 18.0658 1.00000 1.273E+00 - 1.400E+07 1.400E+07 2.734E+00 3.124E+01 4.416E+01 6.347E+00 8.174E+01 8.447E+01 6.413E+05 18.3741 1.00000 1.273E+00 - 1.700E+07 1.700E+07 2.754E+00 3.808E+01 5.376E+01 7.811E+00 9.965E+01 1.024E+02 6.735E+05 18.7624 1.00000 1.273E+00 - 2.000E+07 2.000E+07 2.771E+00 4.496E+01 6.339E+01 9.292E+00 1.176E+02 1.204E+02 7.005E+05 19.0875 1.00000 1.273E+00 - 2.500E+07 2.500E+07 2.795E+00 5.635E+01 7.938E+01 1.182E+01 1.476E+02 1.503E+02 7.376E+05 19.5338 1.00000 1.273E+00 - 3.000E+07 3.000E+07 2.815E+00 6.777E+01 9.540E+01 1.439E+01 1.776E+02 1.804E+02 7.679E+05 19.8984 1.00000 1.273E+00 - 3.500E+07 3.500E+07 2.832E+00 7.921E+01 1.114E+02 1.699E+01 2.076E+02 2.105E+02 7.936E+05 20.2067 1.00000 1.273E+00 - 4.000E+07 4.000E+07 2.847E+00 9.067E+01 1.275E+02 1.962E+01 2.378E+02 2.406E+02 8.158E+05 20.4738 1.00000 1.273E+00 - 4.500E+07 4.500E+07 2.860E+00 1.022E+02 1.436E+02 2.227E+01 2.680E+02 2.709E+02 8.354E+05 20.7093 1.00000 1.273E+00 - 5.000E+07 5.000E+07 2.871E+00 1.137E+02 1.597E+02 2.494E+01 2.983E+02 3.011E+02 8.529E+05 20.9200 1.00000 1.273E+00 - 5.500E+07 5.500E+07 2.882E+00 1.251E+02 1.757E+02 2.765E+01 3.285E+02 3.314E+02 8.687E+05 21.1107 1.00000 1.273E+00 - 6.000E+07 6.000E+07 2.892E+00 1.366E+02 1.918E+02 3.038E+01 3.587E+02 3.616E+02 8.831E+05 21.2847 1.00000 1.273E+00 - 7.000E+07 7.000E+07 2.909E+00 1.595E+02 2.239E+02 3.590E+01 4.193E+02 4.222E+02 9.087E+05 21.5930 1.00000 1.273E+00 - 8.000E+07 8.000E+07 2.924E+00 1.825E+02 2.560E+02 4.148E+01 4.800E+02 4.829E+02 9.308E+05 21.8601 1.00000 1.273E+00 - 9.000E+07 9.000E+07 2.938E+00 2.055E+02 2.882E+02 4.711E+01 5.408E+02 5.437E+02 9.503E+05 22.0956 1.00000 1.273E+00 - 1.000E+08 1.000E+08 2.950E+00 2.285E+02 3.203E+02 5.279E+01 6.016E+02 6.046E+02 9.678E+05 22.3063 1.00000 1.273E+00 - 1.200E+08 1.200E+08 2.971E+00 2.742E+02 3.844E+02 6.335E+01 7.220E+02 7.249E+02 9.979E+05 22.6710 1.00000 1.273E+00 - 1.400E+08 1.400E+08 2.989E+00 3.199E+02 4.485E+02 7.391E+01 8.423E+02 8.453E+02 1.023E+06 22.9793 1.00000 1.273E+00 - 1.700E+08 1.700E+08 3.012E+00 3.885E+02 5.446E+02 8.974E+01 1.023E+03 1.026E+03 1.056E+06 23.3676 1.00000 1.273E+00 - 2.000E+08 2.000E+08 3.031E+00 4.570E+02 6.407E+02 1.056E+02 1.203E+03 1.206E+03 1.083E+06 23.6926 1.00000 1.273E+00 - 2.500E+08 2.500E+08 3.058E+00 5.713E+02 8.008E+02 1.320E+02 1.504E+03 1.507E+03 1.120E+06 24.1389 1.00000 1.273E+00 - 3.000E+08 3.000E+08 3.080E+00 6.856E+02 9.610E+02 1.584E+02 1.805E+03 1.808E+03 1.150E+06 24.5036 1.00000 1.273E+00 - 3.500E+08 3.500E+08 3.098E+00 7.998E+02 1.121E+03 1.848E+02 2.106E+03 2.109E+03 1.175E+06 24.8119 1.00000 1.273E+00 - 4.000E+08 4.000E+08 3.115E+00 9.141E+02 1.281E+03 2.112E+02 2.407E+03 2.410E+03 1.198E+06 25.0789 1.00000 1.273E+00 - 4.500E+08 4.500E+08 3.129E+00 1.028E+03 1.441E+03 2.376E+02 2.707E+03 2.711E+03 1.217E+06 25.3145 1.00000 1.273E+00 - 5.000E+08 5.000E+08 3.142E+00 1.143E+03 1.602E+03 2.640E+02 3.008E+03 3.011E+03 1.235E+06 25.5252 1.00000 1.273E+00 - 5.500E+08 5.500E+08 3.154E+00 1.257E+03 1.762E+03 2.903E+02 3.309E+03 3.312E+03 1.250E+06 25.7158 1.00000 1.273E+00 - 6.000E+08 6.000E+08 3.165E+00 1.371E+03 1.922E+03 3.167E+02 3.610E+03 3.613E+03 1.265E+06 25.8899 1.00000 1.273E+00 - 7.000E+08 7.000E+08 3.185E+00 1.600E+03 2.242E+03 3.695E+02 4.211E+03 4.215E+03 1.290E+06 26.1982 1.00000 1.273E+00 - 8.000E+08 8.000E+08 3.202E+00 1.828E+03 2.563E+03 4.223E+02 4.813E+03 4.816E+03 1.313E+06 26.4652 1.00000 1.273E+00 - 9.000E+08 9.000E+08 3.217E+00 2.057E+03 2.883E+03 4.751E+02 5.415E+03 5.418E+03 1.332E+06 26.7008 1.00000 1.273E+00 - 1.000E+09 1.000E+09 3.230E+00 2.285E+03 3.203E+03 5.279E+02 6.016E+03 6.020E+03 1.350E+06 26.9115 1.00000 1.273E+00 diff --git a/analysis/algorithms/tables/pE_liquid_argon.txt b/analysis/algorithms/tables/pE_liquid_argon.txt deleted file mode 100644 index 9469da28..00000000 --- a/analysis/algorithms/tables/pE_liquid_argon.txt +++ /dev/null @@ -1,133 +0,0 @@ -T eStoppingPower nucStoppingPower dE/dx CSDARange ProjectedRange Detour -1.000E-03 8.608E+01 7.470E+00 9.355E+01 1.741E-05 4.206E-06 0.2416 -1.500E-03 1.054E+02 6.891E+00 1.123E+02 2.223E-05 6.141E-06 0.2762 -2.000E-03 1.217E+02 6.398E+00 1.281E+02 2.639E-05 8.047E-06 0.3049 -2.500E-03 1.361E+02 5.980E+00 1.421E+02 3.009E-05 9.911E-06 0.3294 -3.000E-03 1.491E+02 5.623E+00 1.547E+02 3.346E-05 1.174E-05 0.3507 -4.000E-03 1.722E+02 5.045E+00 1.772E+02 3.949E-05 1.527E-05 0.3867 -5.000E-03 1.925E+02 4.594E+00 1.971E+02 4.483E-05 1.867E-05 0.4164 -6.000E-03 2.109E+02 4.231E+00 2.151E+02 4.968E-05 2.194E-05 0.4415 -7.000E-03 2.277E+02 3.931E+00 2.317E+02 5.416E-05 2.509E-05 0.4632 -8.000E-03 2.435E+02 3.678E+00 2.472E+02 5.834E-05 2.813E-05 0.4823 -9.000E-03 2.582E+02 3.460E+00 2.617E+02 6.227E-05 3.109E-05 0.4992 -1.000E-02 2.722E+02 3.271E+00 2.755E+02 6.599E-05 3.395E-05 0.5145 -1.250E-02 2.997E+02 2.890E+00 3.026E+02 7.463E-05 4.082E-05 0.5470 -1.500E-02 3.235E+02 2.599E+00 3.261E+02 8.258E-05 4.737E-05 0.5736 -1.750E-02 3.445E+02 2.368E+00 3.469E+02 9.001E-05 5.365E-05 0.5961 -2.000E-02 3.633E+02 2.180E+00 3.655E+02 9.703E-05 5.971E-05 0.6154 -2.250E-02 3.802E+02 2.023E+00 3.822E+02 1.037E-04 6.556E-05 0.6322 -2.500E-02 3.953E+02 1.890E+00 3.972E+02 1.101E-04 7.126E-05 0.6470 -2.750E-02 4.090E+02 1.775E+00 4.108E+02 1.163E-04 7.680E-05 0.6603 -3.000E-02 4.214E+02 1.675E+00 4.230E+02 1.223E-04 8.223E-05 0.6723 -3.500E-02 4.425E+02 1.509E+00 4.440E+02 1.338E-04 9.277E-05 0.6932 -4.000E-02 4.594E+02 1.376E+00 4.608E+02 1.449E-04 1.030E-04 0.7108 -4.500E-02 4.728E+02 1.266E+00 4.741E+02 1.556E-04 1.130E-04 0.7261 -5.000E-02 4.831E+02 1.175E+00 4.843E+02 1.660E-04 1.228E-04 0.7395 -5.500E-02 4.907E+02 1.097E+00 4.918E+02 1.762E-04 1.324E-04 0.7514 -6.000E-02 4.960E+02 1.030E+00 4.970E+02 1.864E-04 1.420E-04 0.7622 -6.500E-02 4.992E+02 9.711E-01 5.002E+02 1.964E-04 1.516E-04 0.7719 -7.000E-02 5.007E+02 9.194E-01 5.017E+02 2.064E-04 1.611E-04 0.7809 -7.500E-02 5.008E+02 8.734E-01 5.016E+02 2.163E-04 1.707E-04 0.7891 -8.000E-02 4.995E+02 8.323E-01 5.003E+02 2.263E-04 1.803E-04 0.7967 -8.500E-02 4.972E+02 7.952E-01 4.980E+02 2.363E-04 1.899E-04 0.8037 -9.000E-02 4.940E+02 7.616E-01 4.947E+02 2.464E-04 1.997E-04 0.8104 -9.500E-02 4.900E+02 7.310E-01 4.907E+02 2.565E-04 2.095E-04 0.8166 -1.000E-01 4.855E+02 7.029E-01 4.862E+02 2.668E-04 2.194E-04 0.8224 -1.250E-01 4.574E+02 5.920E-01 4.580E+02 3.197E-04 2.708E-04 0.8472 -1.500E-01 4.267E+02 5.134E-01 4.272E+02 3.762E-04 3.260E-04 0.8666 -1.750E-01 3.977E+02 4.546E-01 3.982E+02 4.368E-04 3.854E-04 0.8822 -2.000E-01 3.719E+02 4.088E-01 3.724E+02 5.018E-04 4.491E-04 0.8949 -2.250E-01 3.495E+02 3.719E-01 3.499E+02 5.711E-04 5.172E-04 0.9056 -2.500E-01 3.301E+02 3.417E-01 3.304E+02 6.447E-04 5.895E-04 0.9144 -2.750E-01 3.132E+02 3.163E-01 3.135E+02 7.224E-04 6.660E-04 0.9220 -3.000E-01 2.985E+02 2.947E-01 2.988E+02 8.041E-04 7.465E-04 0.9284 -3.500E-01 2.742E+02 2.598E-01 2.745E+02 9.789E-04 9.189E-04 0.9387 -4.000E-01 2.549E+02 2.328E-01 2.551E+02 1.168E-03 1.106E-03 0.9465 -4.500E-01 2.390E+02 2.112E-01 2.392E+02 1.371E-03 1.306E-03 0.9525 -5.000E-01 2.256E+02 1.935E-01 2.258E+02 1.586E-03 1.518E-03 0.9574 -5.500E-01 2.144E+02 1.787E-01 2.146E+02 1.813E-03 1.743E-03 0.9613 -6.000E-01 2.047E+02 1.662E-01 2.048E+02 2.052E-03 1.979E-03 0.9645 -6.500E-01 1.961E+02 1.554E-01 1.962E+02 2.301E-03 2.226E-03 0.9672 -7.000E-01 1.884E+02 1.460E-01 1.885E+02 2.561E-03 2.483E-03 0.9694 -7.500E-01 1.813E+02 1.378E-01 1.815E+02 2.832E-03 2.751E-03 0.9714 -8.000E-01 1.749E+02 1.304E-01 1.750E+02 3.112E-03 3.029E-03 0.9731 -8.500E-01 1.689E+02 1.239E-01 1.691E+02 3.403E-03 3.316E-03 0.9745 -9.000E-01 1.634E+02 1.181E-01 1.635E+02 3.704E-03 3.614E-03 0.9758 -9.500E-01 1.582E+02 1.128E-01 1.583E+02 4.015E-03 3.922E-03 0.9770 -1.000E+00 1.533E+02 1.080E-01 1.534E+02 4.336E-03 4.240E-03 0.9780 -1.250E+00 1.330E+02 8.925E-02 1.331E+02 6.090E-03 5.979E-03 0.9818 -1.500E+00 1.182E+02 7.633E-02 1.183E+02 8.087E-03 7.960E-03 0.9843 -1.750E+00 1.068E+02 6.682E-02 1.069E+02 1.031E-02 1.017E-02 0.9860 -2.000E+00 9.772E+01 5.950E-02 9.778E+01 1.276E-02 1.260E-02 0.9872 -2.250E+00 9.027E+01 5.370E-02 9.032E+01 1.543E-02 1.524E-02 0.9881 -2.500E+00 8.401E+01 4.898E-02 8.406E+01 1.830E-02 1.809E-02 0.9889 -2.750E+00 7.867E+01 4.505E-02 7.872E+01 2.137E-02 2.115E-02 0.9895 -3.000E+00 7.405E+01 4.174E-02 7.409E+01 2.465E-02 2.440E-02 0.9900 -3.500E+00 6.643E+01 3.645E-02 6.647E+01 3.179E-02 3.149E-02 0.9907 -4.000E+00 6.039E+01 3.239E-02 6.043E+01 3.969E-02 3.934E-02 0.9913 -4.500E+00 5.547E+01 2.919E-02 5.550E+01 4.833E-02 4.793E-02 0.9917 -5.000E+00 5.138E+01 2.658E-02 5.141E+01 5.770E-02 5.724E-02 0.9921 -5.500E+00 4.791E+01 2.442E-02 4.793E+01 6.778E-02 6.726E-02 0.9924 -6.000E+00 4.493E+01 2.259E-02 4.495E+01 7.856E-02 7.798E-02 0.9926 -6.500E+00 4.233E+01 2.104E-02 4.236E+01 9.002E-02 8.937E-02 0.9928 -7.000E+00 4.006E+01 1.969E-02 4.008E+01 1.022E-01 1.014E-01 0.9930 -7.500E+00 3.804E+01 1.850E-02 3.806E+01 1.150E-01 1.142E-01 0.9931 -8.000E+00 3.624E+01 1.746E-02 3.626E+01 1.284E-01 1.276E-01 0.9933 -8.500E+00 3.462E+01 1.654E-02 3.463E+01 1.425E-01 1.416E-01 0.9934 -9.000E+00 3.315E+01 1.571E-02 3.317E+01 1.573E-01 1.563E-01 0.9935 -9.500E+00 3.182E+01 1.496E-02 3.183E+01 1.727E-01 1.716E-01 0.9936 -1.000E+01 3.060E+01 1.428E-02 3.061E+01 1.887E-01 1.875E-01 0.9937 -1.250E+01 2.579E+01 1.167E-02 2.581E+01 2.780E-01 2.764E-01 0.9940 -1.500E+01 2.241E+01 9.887E-03 2.242E+01 3.823E-01 3.801E-01 0.9943 -1.750E+01 1.989E+01 8.590E-03 1.989E+01 5.009E-01 4.981E-01 0.9945 -2.000E+01 1.792E+01 7.601E-03 1.793E+01 6.335E-01 6.301E-01 0.9946 -2.500E+01 1.506E+01 6.192E-03 1.507E+01 9.390E-01 9.342E-01 0.9949 -2.750E+01 1.398E+01 5.671E-03 1.399E+01 1.111E+00 1.106E+00 0.9949 -3.000E+01 1.306E+01 5.233E-03 1.307E+01 1.296E+00 1.290E+00 0.9950 -3.500E+01 1.158E+01 4.537E-03 1.159E+01 1.704E+00 1.695E+00 0.9952 -4.000E+01 1.044E+01 4.008E-03 1.044E+01 2.159E+00 2.149E+00 0.9953 -4.500E+01 9.525E+00 3.592E-03 9.529E+00 2.661E+00 2.649E+00 0.9954 -5.000E+01 8.780E+00 3.256E-03 8.783E+00 3.208E+00 3.193E+00 0.9954 -5.500E+01 8.159E+00 2.979E-03 8.162E+00 3.799E+00 3.782E+00 0.9955 -6.000E+01 7.633E+00 2.746E-03 7.636E+00 4.433E+00 4.413E+00 0.9956 -6.500E+01 7.182E+00 2.548E-03 7.184E+00 5.108E+00 5.086E+00 0.9956 -7.000E+01 6.790E+00 2.377E-03 6.792E+00 5.824E+00 5.799E+00 0.9957 -7.500E+01 6.446E+00 2.228E-03 6.449E+00 6.580E+00 6.552E+00 0.9957 -8.000E+01 6.142E+00 2.097E-03 6.145E+00 7.375E+00 7.344E+00 0.9958 -8.500E+01 5.872E+00 1.981E-03 5.874E+00 8.207E+00 8.173E+00 0.9958 -9.000E+01 5.629E+00 1.878E-03 5.631E+00 9.077E+00 9.040E+00 0.9959 -9.500E+01 5.410E+00 1.785E-03 5.412E+00 9.983E+00 9.942E+00 0.9959 -1.000E+02 5.212E+00 1.701E-03 5.213E+00 1.092E+01 1.088E+01 0.9959 -1.250E+02 4.443E+00 1.378E-03 4.445E+00 1.614E+01 1.608E+01 0.9961 -1.500E+02 3.918E+00 1.161E-03 3.919E+00 2.215E+01 2.207E+01 0.9962 -1.750E+02 3.536E+00 1.003E-03 3.537E+00 2.888E+01 2.877E+01 0.9963 -2.000E+02 3.246E+00 8.844E-04 3.246E+00 3.627E+01 3.614E+01 0.9964 -2.250E+02 3.017E+00 7.912E-04 3.018E+00 4.426E+01 4.411E+01 0.9965 -2.500E+02 2.834E+00 7.162E-04 2.834E+00 5.282E+01 5.264E+01 0.9966 -2.750E+02 2.683E+00 6.545E-04 2.683E+00 6.189E+01 6.168E+01 0.9966 -3.000E+02 2.556E+00 6.027E-04 2.557E+00 7.144E+01 7.120E+01 0.9967 -3.500E+02 2.358E+00 5.210E-04 2.358E+00 9.184E+01 9.154E+01 0.9968 -4.000E+02 2.210E+00 4.591E-04 2.210E+00 1.138E+02 1.134E+02 0.9969 -4.500E+02 2.095E+00 4.107E-04 2.095E+00 1.370E+02 1.366E+02 0.9970 -5.000E+02 2.004E+00 3.718E-04 2.004E+00 1.614E+02 1.610E+02 0.9971 -5.500E+02 1.931E+00 3.397E-04 1.931E+00 1.869E+02 1.863E+02 0.9972 -6.000E+02 1.871E+00 3.129E-04 1.871E+00 2.132E+02 2.126E+02 0.9972 -6.500E+02 1.821E+00 2.901E-04 1.821E+00 2.403E+02 2.396E+02 0.9973 -7.000E+02 1.779E+00 2.705E-04 1.779E+00 2.681E+02 2.674E+02 0.9974 -7.500E+02 1.744E+00 2.534E-04 1.744E+00 2.965E+02 2.957E+02 0.9974 -8.000E+02 1.713E+00 2.384E-04 1.714E+00 3.254E+02 3.246E+02 0.9975 -8.500E+02 1.688E+00 2.252E-04 1.688E+00 3.548E+02 3.539E+02 0.9975 -9.000E+02 1.665E+00 2.133E-04 1.665E+00 3.846E+02 3.837E+02 0.9976 -9.500E+02 1.646E+00 2.027E-04 1.646E+00 4.148E+02 4.138E+02 0.9976 -1.000E+03 1.629E+00 1.932E-04 1.629E+00 4.454E+02 4.443E+02 0.9977 -1.500E+03 1.542E+00 1.318E-04 1.542E+00 7.626E+02 7.611E+02 0.9980 -2.000E+03 1.521E+00 1.006E-04 1.521E+00 1.090E+03 1.088E+03 0.9983 -2.500E+03 1.523E+00 8.159E-05 1.523E+00 1.418E+03 1.416E+03 0.9984 -3.000E+03 1.535E+00 6.877E-05 1.535E+00 1.745E+03 1.743E+03 0.9986 -4.000E+03 1.567E+00 5.253E-05 1.567E+00 2.391E+03 2.388E+03 0.9988 -5.000E+03 1.601E+00 4.264E-05 1.601E+00 3.022E+03 3.018E+03 0.9989 -6.000E+03 1.634E+00 3.596E-05 1.634E+00 3.640E+03 3.636E+03 0.9990 -7.000E+03 1.665E+00 3.113E-05 1.665E+00 4.246E+03 4.242E+03 0.9991 -8.000E+03 1.693E+00 2.748E-05 1.693E+00 4.842E+03 4.838E+03 0.9992 -9.000E+03 1.719E+00 2.462E-05 1.719E+00 5.428E+03 5.424E+03 0.9992 -1.000E+04 1.743E+00 2.231E-05 1.743E+00 6.005E+03 6.001E+03 0.9993 diff --git a/analysis/algorithms/utils.py b/analysis/algorithms/utils.py index 14554c00..8c5f5b73 100644 --- a/analysis/algorithms/utils.py +++ b/analysis/algorithms/utils.py @@ -6,9 +6,9 @@ from analysis.classes import Particle from analysis.classes import TruthParticle -from analysis.algorithms.calorimetry import * +from lartpc_mlreco3d.analysis.algorithms.arxiv.calorimetry import * from analysis.algorithms.point_matching import get_track_endpoints_max_dist -from analysis.algorithms.calorimetry import compute_track_dedx, get_particle_direction +# from lartpc_mlreco3d.analysis.algorithms.arxiv.calorimetry import compute_track_dedx, get_particle_direction def attach_prefix(update_dict, prefix): diff --git a/analysis/algorithms/vertex.py b/analysis/algorithms/vertex.py index 9391e9d8..7eb58bf1 100644 --- a/analysis/algorithms/vertex.py +++ b/analysis/algorithms/vertex.py @@ -1,7 +1,8 @@ import numpy as np import numba as nb from scipy.spatial.distance import cdist -from analysis.algorithms.calorimetry import get_particle_direction + +# from lartpc_mlreco3d.analysis.algorithms.arxiv.calorimetry import get_particle_direction from mlreco.utils.utils import func_timer from analysis.classes.Interaction import Interaction diff --git a/analysis/classes/particle_utils.py b/analysis/classes/particle_utils.py index d3d1be59..854e61c2 100644 --- a/analysis/classes/particle_utils.py +++ b/analysis/classes/particle_utils.py @@ -1,11 +1,8 @@ import numpy as np -import pandas as pd from typing import List, Union from collections import defaultdict, OrderedDict, Counter -from functools import partial from itertools import combinations -import re from scipy.optimize import linear_sum_assignment from scipy.spatial.distance import cdist @@ -14,7 +11,7 @@ from . import Particle, TruthParticle, Interaction, TruthInteraction from analysis.algorithms.utils import closest_distance_two_lines -from analysis.algorithms.calorimetry import get_particle_direction +from analysis.algorithms.arxiv.calorimetry import get_particle_direction def matrix_counts(particles_x, particles_y): diff --git a/analysis/decorator.py b/analysis/decorator.py index 315596f3..f2a3cee4 100644 --- a/analysis/decorator.py +++ b/analysis/decorator.py @@ -61,7 +61,6 @@ def process_dataset(analysis_config, cfg=None, profile=True): log_dir = analysis_config['analysis']['log_dir'] append = analysis_config['analysis'].get('append', True) - chunksize = analysis_config['analysis'].get('chunksize', 100) output_logs = {} for fname in filenames: @@ -108,7 +107,6 @@ def process_dataset(analysis_config, cfg=None, profile=True): if profile: end = time.time() print("Iteration %d (total %d s)" % (iteration, end - start)) - torch.cuda.empty_cache() for fname in filenames: output_logs[fname].close() diff --git a/mlreco/main_funcs.py b/mlreco/main_funcs.py index c0b09b21..37a7cec9 100644 --- a/mlreco/main_funcs.py +++ b/mlreco/main_funcs.py @@ -9,6 +9,7 @@ pass from mlreco.iotools.factories import loader_factory, writer_factory +from mlreco.post_processing.common import PostProcessor # Important: do not import here anything that might # trigger cuda initialization through PyTorch. # We need to set CUDA_VISIBLE_DEVICES first, which @@ -319,10 +320,17 @@ def train_loop(handlers): # Store output if requested if 'post_processing' in cfg: - for processor_name,processor_cfg in cfg['post_processing'].items(): + + post_processor_interface = PostProcessor(cfg, data_blob, result_blob) + + for processor_name, pcfg in cfg['post_processing'].items(): processor_name = processor_name.split('+')[0] processor = getattr(post_processing,str(processor_name)) - processor(cfg,processor_cfg,data_blob,result_blob,cfg['trainval']['log_dir'],handlers.iteration) + post_processor_interface.register_function(processor, + processor_cfg=pcfg) + + post_processor_output_dict = post_processor_interface.process() + print(post_processor_output_dict) handlers.watch.stop('iteration') tsum += handlers.watch.time('iteration') @@ -385,10 +393,17 @@ def inference_loop(handlers): # Store output if requested if 'post_processing' in handlers.cfg: - for processor_name, processor_cfg in handlers.cfg['post_processing'].items(): + + post_processor_interface = PostProcessor(handlers.cfg, data_blob, result_blob) + + for processor_name, pcfg in handlers.cfg['post_processing'].items(): processor_name = processor_name.split('+')[0] - processor = getattr(post_processing, str(processor_name)) - processor(handlers.cfg, processor_cfg, data_blob, result_blob, handlers.cfg['trainval']['log_dir'], handlers.iteration) + processor = getattr(post_processing,str(processor_name)) + post_processor_interface.register_function(processor, + processor_cfg=pcfg) + + post_processor_output_dict = post_processor_interface.process() + print(post_processor_output_dict) handlers.watch.stop('iteration') tsum += handlers.watch.time('iteration') diff --git a/mlreco/post_processing/__init__.py b/mlreco/post_processing/__init__.py index d9d1f626..1891cc5e 100644 --- a/mlreco/post_processing/__init__.py +++ b/mlreco/post_processing/__init__.py @@ -1,5 +1,3 @@ from .decorator import post_processing -from .analysis import * -from .metrics import * -from .store import * +from .reconstruction import * \ No newline at end of file diff --git a/mlreco/post_processing/common.py b/mlreco/post_processing/common.py index 9a63c8fd..d728d814 100644 --- a/mlreco/post_processing/common.py +++ b/mlreco/post_processing/common.py @@ -1,4 +1,60 @@ import numpy as np +from functools import partial +from collections import defaultdict + +class PostProcessor: + + def __init__(self, cfg, data, result, debug=True): + self._funcs = [] + self._num_batches = cfg['iotool']['batch_size'] + self.data = data + self.result = result + self.debug = debug + + def register_function(self, f, processor_cfg={}): + data_capture, result_capture = f._data_capture, f._result_capture + pf = partial(f, **processor_cfg) + pf._data_capture = data_capture + pf._result_capture = result_capture + self._funcs.append(pf) + + def process_event(self, image_id): + + image_dict = {} + + for f in self._funcs: + data_dict, result_dict = {}, {} + for data_key in f._data_capture: + data_dict[data_key] = self.data[data_key][image_id] + for result_key in f._result_capture: + result_dict[result_key] = self.result[result_key][image_id] + update_dict = f(data_dict, result_dict) + for key, val in update_dict.items(): + if key in image_dict: + msg = 'Output {} in post-processing function {},'\ + ' caused a dictionary key conflict. You may '\ + 'want to change the output dict key for that function.' + raise ValueError(msg) + else: + image_dict[key] = val + + return image_dict + + def process(self): + + out_dict = defaultdict(list) + + for image_id in range(self._num_batches): + image_dict = self.process_event(image_id) + for key, val in image_dict.items(): + out_dict[key].append(val) + + if self.debug: + for key, val in out_dict.items(): + assert len(out_dict[key]) == self._num_batches + + return out_dict + def extent(voxels): centroid = voxels[:, :3].mean(axis=0) diff --git a/mlreco/post_processing/decorator.py b/mlreco/post_processing/decorator.py index dcdf7a6b..391a98c1 100644 --- a/mlreco/post_processing/decorator.py +++ b/mlreco/post_processing/decorator.py @@ -4,158 +4,34 @@ from mlreco.utils.deghosting import adapt_labels_numpy as adapt_labels from functools import wraps +from pprint import pprint - -def post_processing(filename, data_capture, output_capture): +def post_processing(data_capture, result_capture): """ - Decorator to capture the common boilerplate between all postprocessing scripts. - - The corresponding config block should have the same name as the script. + Decorator for common post-processing boilerplate. - parameters + functions take information in data and result and + modifies the result dictionary (output of full chain) in-place, usually + adding a new key, value pair corresponding to some reconstructed quantity. ---------- - filename: string or list of string - Name that will prefix all log files. If a list of strings, several log files - can be created. The order of filenames must match the order of the script return. data_capture: list of string - List of data components needed. Some of them are reserved: clust_data, - seg_label. The rest can be any data label from the config `iotool` section. - output_capture: list of string - List of output components needed. Some of them are reserved: embeddings, - margins, seediness, segmentation. The rest can be anything from any - network output. + List of data keys needed. + result_capture: list of string + List of result keys needed. """ def decorator(func): # This mapping is hardcoded for now... - defaultNameToIO = { - 'clust_data': 'cluster_label', - 'seg_label': 'segment_label', - 'kinematics': 'kinematics_label', - 'points_label': 'particles_label', - 'particles': 'particles_asis' - } @wraps(func) - def wrapper(cfg, module_cfg, data_blob, res, logdir, iteration): - # The config block should have the same name as the analysis function - # module_cfg = cfg['post_processing'].get(func.__name__, {}) - log_name = module_cfg.get('filename', filename) - deghosting = module_cfg.get('ghost', False) - - store_method = module_cfg.get('store_method', 'per-iteration') - store_per_event = store_method == 'per-event' - - fout = [] - if not isinstance(log_name, list): - log_name = [log_name] - for name in log_name: - if store_method == 'per-iteration': - fout.append(CSVData(os.path.join(logdir, '%s-iter-%07d.csv' % (name, iteration)))) - if store_method == 'single-file': - append = True if iteration else False - fout.append(CSVData(os.path.join(logdir, '%s.csv' % name), append=append)) - - kwargs = {} - # Get the relevant data products - index is special, no need to specify it. - kwargs['index'] = data_blob['index'] - # We need true segmentation label for deghosting masks/adapting labels - #if deghosting and 'seg_label' not in data_capture: - if 'seg_label' not in data_capture: - data_capture.append('seg_label') - - for key in data_capture: - if module_cfg.get(key, defaultNameToIO.get(key, key)) in data_blob: - kwargs[key] = data_blob[module_cfg.get(key, defaultNameToIO.get(key, key))] - - for key in output_capture: - if key in ['embeddings', 'margins', 'seediness']: - continue - if not len(module_cfg.get(key, key)): - continue - kwargs[key] = res.get(module_cfg.get(key, key), None) - if key == 'segmentation': - kwargs['segmentation'] = [res['segmentation'][i] for i in range(len(res['segmentation']))] - kwargs['seg_prediction'] = [res['segmentation'][i].argmax(axis=1) for i in range(len(res['segmentation']))] - - if deghosting: - kwargs['ghost_mask'] = [res['ghost'][i].argmax(axis=1) == 0 for i in range(len(res['ghost']))] - kwargs['true_ghost_mask'] = [ kwargs['seg_label'][i][:, -1] < 5 for i in range(len(kwargs['seg_label']))] - - if 'clust_data' in kwargs and kwargs['clust_data'] is not None: - kwargs['clust_data_noghost'] = kwargs['clust_data'] # Save the clust_data before deghosting - kwargs['clust_data'] = adapt_labels(res, kwargs['seg_label'], kwargs['clust_data']) - if 'seg_prediction' in kwargs and kwargs['seg_prediction'] is not None: - kwargs['seg_prediction'] = [kwargs['seg_prediction'][i][kwargs['ghost_mask'][i]] for i in range(len(kwargs['seg_prediction']))] - if 'segmentation' in kwargs and kwargs['segmentation'] is not None: - kwargs['segmentation'] = [kwargs['segmentation'][i][kwargs['ghost_mask'][i]] for i in range(len(kwargs['segmentation']))] - if 'kinematics' in kwargs and kwargs['kinematics'] is not None: - kwargs['kinematics'] = adapt_labels(res, kwargs['seg_label'], kwargs['kinematics']) - # This needs to come last - in adapt_labels seg_label is the original one - if 'seg_label' in kwargs and kwargs['seg_label'] is not None: - kwargs['seg_label_noghost'] = kwargs['seg_label'] - kwargs['seg_label'] = [kwargs['seg_label'][i][kwargs['ghost_mask'][i]] for i in range(len(kwargs['seg_label']))] - - batch_ids = [] - for data_idx, _ in enumerate(kwargs['index']): - if 'seg_label' in kwargs: - n = kwargs['seg_label'][data_idx].shape[0] - elif 'kinematics' in kwargs: - n = kwargs['kinematics'][data_idx].shape[0] - elif 'clust_data' in kwargs: - n = kwargs['clust_data'][data_idx].shape[0] - else: - raise Exception('Need some labels to run postprocessing') - batch_ids.append(np.ones((n,)) * data_idx) - batch_ids = np.hstack(batch_ids) - kwargs['batch_ids'] = batch_ids - - # Loop over events - counter = 0 - for data_idx, tree_idx in enumerate(kwargs['index']): - kwargs['counter'] = counter - kwargs['data_idx'] = data_idx - # Initialize log if one per event - if store_per_event: - for name in log_name: - fout.append(CSVData(os.path.join(logdir, '%s-event-%07d.csv' % (name, tree_idx)))) - - for key in ['embeddings', 'margins', 'seediness']: # add points? - if key in output_capture: - kwargs[key] = np.array(res[key])[batch_ids == data_idx] - - # if np.isin(output_capture, ['embeddings', 'margins', 'seediness']).any(): - # kwargs['embeddings'] = np.array(res['embeddings'])[batch_ids == data_idx] - # kwargs['margins'] = np.array(res['margins'])[batch_ids == data_idx] - # kwargs['seediness'] = np.array(res['seediness'])[batch_ids == data_idx] - - out = func(cfg, module_cfg, data_blob, res, logdir, iteration, **kwargs) - if isinstance(out, tuple): - out = [out] - assert len(out) == len(fout) - - for out_idx, (out_names, out_values) in enumerate(out): - assert len(out_names) == len(out_values) - - if isinstance(out_names, tuple): - assert isinstance(out_values, tuple) - out_names = [out_names] - out_values = [out_values] - - for row_names, row_values in zip(out_names, out_values): - if len(row_names) and len(row_values): - row_names = ('Iteration', 'Index',) + row_names - row_values = (iteration, tree_idx,) + row_values + def wrapper(data_dict, result_dict, **kwargs): - fout[out_idx].record(row_names, row_values) - fout[out_idx].write() - counter += 1 if len(out_names) and len(out_names[0]) else 0 + # TODO: Handle unwrap/non-unwrap - if store_per_event: - for f in fout: - f.close() + out = func(data_dict, result_dict, **kwargs) - if not store_per_event: - for f in fout: - f.close() + return out + + wrapper._data_capture = data_capture + wrapper._result_capture = result_capture return wrapper - return decorator + return decorator \ No newline at end of file diff --git a/mlreco/post_processing/reconstruction/__init__.py b/mlreco/post_processing/reconstruction/__init__.py new file mode 100644 index 00000000..86b31465 --- /dev/null +++ b/mlreco/post_processing/reconstruction/__init__.py @@ -0,0 +1 @@ +from .calorimetry import range_based_track_energy \ No newline at end of file diff --git a/mlreco/post_processing/reconstruction/calorimetry.py b/mlreco/post_processing/reconstruction/calorimetry.py new file mode 100644 index 00000000..d3fe9520 --- /dev/null +++ b/mlreco/post_processing/reconstruction/calorimetry.py @@ -0,0 +1,113 @@ +from pprint import pprint +import os + +import numpy as np +import pandas as pd +from sklearn.decomposition import PCA +from scipy.interpolate import CubicSpline +from functools import lru_cache + +from mlreco.utils.gnn.cluster import cluster_direction +from mlreco.post_processing import post_processing +from mlreco.utils.globals import * + +@post_processing(data_capture=[], result_capture=['particle_clusts', + 'particle_seg', + 'input_rescaled', + 'particle_node_pred_type']) +def range_based_track_energy(data_dict, result_dict, + bin_size=17, include_pids=[2, 3, 4]): + + input_data = result_dict['input_rescaled'] + particles = result_dict['particle_clusts'] + particle_seg = result_dict['particle_seg'] + particle_types = result_dict['particle_node_pred_type'] + + update_dict = { + 'particle_length': np.array([]), + 'particle_range_based_energy': np.array([]) + } + if len(particles) == 0: + return update_dict + + table_path = '/sdf/group/neutrino/koh0207/lartpc_mlreco3d/mlreco/post_processing/reconstruction/tables' + splines = {ptype: get_splines(ptype, table_path) for ptype in include_pids} + + pred_ptypes = np.argmax(particle_types, axis=1) + particle_length = -np.ones(len(particles)) + particle_energy = -np.ones(len(particles)) + + assert len(pred_ptypes) == len(particle_types) + + for i, p in enumerate(particles): + semantic_type = particle_seg[i] + if semantic_type == 1 and pred_ptypes[i] in include_pids: + points = input_data[p] + length = compute_track_length(points, bin_size=bin_size) + particle_length[i] = length + particle_energy[i] = splines[pred_ptypes[i]](length * PIXELS_TO_CM) + + update_dict['particle_length'] = particle_length + update_dict['particle_range_based_energy'] = particle_energy + + return update_dict + + +# Helper Functions +@lru_cache +def get_splines(particle_type, table_path): + ''' + Returns CSDARange (g/cm^2) vs. Kinetic E (MeV/c^2) + ''' + if particle_type == PARTICLE_TO_PID_LABEL['PROTON']: + path = os.path.join(table_path, 'pE_liquid_argon.txt') + tab = pd.read_csv(path, + delimiter=' ', + index_col=False) + elif particle_type == PARTICLE_TO_PID_LABEL['MUON']: + path = os.path.join(table_path, 'muE_liquid_argon.txt') + tab = pd.read_csv(path, + delimiter=' ', + index_col=False) + else: + raise ValueError("Range based energy reconstruction for particle type"\ + " {} is not supported!".format(particle_type)) + # print(tab) + f = CubicSpline(tab['CSDARange'] / ARGON_DENSITY, tab['T']) + return f + + +def compute_track_length(points, bin_size=17): + """ + Compute track length by dividing it into segments + and computing a local PCA axis, then summing the + local lengths of the segments. + + Parameters + ---------- + points: np.ndarray + Shape (N, 3) + bin_size: int, optional + Size (in voxels) of the segments + + Returns + ------- + float + """ + pca = PCA(n_components=2) + length = 0. + if len(points) >= 2: + coords_pca = pca.fit_transform(points)[:, 0] + bins = np.arange(coords_pca.min(), coords_pca.max(), bin_size) + # bin_inds takes values in [1, len(bins)] + bin_inds = np.digitize(coords_pca, bins) + for b_i in np.unique(bin_inds): + mask = bin_inds == b_i + if np.count_nonzero(mask) < 2: continue + # Repeat PCA locally for better measurement of dx + # pca_axis = pca.fit_transform(points[mask]) + pca_axis = coords_pca[mask] + dx = pca_axis.max() - pca_axis.min() + length += dx + return length + diff --git a/mlreco/post_processing/reconstruction/particle_points.py b/mlreco/post_processing/reconstruction/particle_points.py new file mode 100644 index 00000000..e69de29b diff --git a/mlreco/post_processing/reconstruction/vertex.py b/mlreco/post_processing/reconstruction/vertex.py new file mode 100644 index 00000000..e69de29b diff --git a/mlreco/utils/globals.py b/mlreco/utils/globals.py index f808a9f0..8e7452cf 100644 --- a/mlreco/utils/globals.py +++ b/mlreco/utils/globals.py @@ -30,4 +30,12 @@ 'PROTON': 4 } -PID_LABEL_TO_PARTICLE = {val : key for key, val in PARTICLE_TO_PID_LABEL.items()} \ No newline at end of file +PID_LABEL_TO_PARTICLE = {val : key for key, val in PARTICLE_TO_PID_LABEL.items()} + +# CONSTANTS (MeV) +PROTON_MASS = 938.272 +MUON_MASS = 105.7 +ELECTRON_MASS = 0.511998 +ARGON_DENSITY = 1.396 +ADC_TO_MEV = 1. / 350. +PIXELS_TO_CM = 0.3 \ No newline at end of file From dc7968b38647620126c465659e980f7722238df3 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Tue, 4 Apr 2023 09:44:52 -0700 Subject: [PATCH 100/180] Add range-based track reconstruction in post-processing --- mlreco/post_processing/reconstruction/calorimetry.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mlreco/post_processing/reconstruction/calorimetry.py b/mlreco/post_processing/reconstruction/calorimetry.py index d3fe9520..698db5fe 100644 --- a/mlreco/post_processing/reconstruction/calorimetry.py +++ b/mlreco/post_processing/reconstruction/calorimetry.py @@ -16,7 +16,7 @@ 'input_rescaled', 'particle_node_pred_type']) def range_based_track_energy(data_dict, result_dict, - bin_size=17, include_pids=[2, 3, 4]): + bin_size=17, include_pids=[2, 3, 4], table_path=''): input_data = result_dict['input_rescaled'] particles = result_dict['particle_clusts'] @@ -30,7 +30,6 @@ def range_based_track_energy(data_dict, result_dict, if len(particles) == 0: return update_dict - table_path = '/sdf/group/neutrino/koh0207/lartpc_mlreco3d/mlreco/post_processing/reconstruction/tables' splines = {ptype: get_splines(ptype, table_path) for ptype in include_pids} pred_ptypes = np.argmax(particle_types, axis=1) From 6ebad976f36b1f29887ba09a594cc9ea1e633c4e Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Tue, 4 Apr 2023 10:05:54 -0700 Subject: [PATCH 101/180] Add few lines to update result_dict for post-processing --- mlreco/main_funcs.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/mlreco/main_funcs.py b/mlreco/main_funcs.py index 37a7cec9..086bc218 100644 --- a/mlreco/main_funcs.py +++ b/mlreco/main_funcs.py @@ -403,7 +403,15 @@ def inference_loop(handlers): processor_cfg=pcfg) post_processor_output_dict = post_processor_interface.process() - print(post_processor_output_dict) + + for key, val in post_processor_output_dict.items(): + if key in result_blob: + msg = "Post processing script output key {} "\ + "is already in result_dict, you may want"\ + "to rename it.".format(key) + raise RuntimeError(msg) + else: + result_blob[key] = val handlers.watch.stop('iteration') tsum += handlers.watch.time('iteration') From 363eeee0c95c033d01497fe7bb043ba1c2256c7a Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Tue, 4 Apr 2023 17:33:53 -0700 Subject: [PATCH 102/180] Eliminated reliance of cluster labeling on creation_process --- mlreco/iotools/parsers/cluster.py | 37 ++-- mlreco/iotools/parsers/label_data.py | 251 ++++++++++++++------------- mlreco/utils/globals.py | 14 +- 3 files changed, 160 insertions(+), 142 deletions(-) diff --git a/mlreco/iotools/parsers/cluster.py b/mlreco/iotools/parsers/cluster.py index 895f47f2..67ae31a9 100644 --- a/mlreco/iotools/parsers/cluster.py +++ b/mlreco/iotools/parsers/cluster.py @@ -6,7 +6,7 @@ from .sparse import parse_sparse3d from .particles import parse_particles from .clean_data import clean_sparse_data -from .label_data import get_interaction_ids, get_nu_ids, get_particle_id, get_shower_primary_id, get_group_primary_id +from .label_data import get_interaction_ids, get_nu_ids, get_particle_ids, get_shower_primary_ids, get_group_primary_ids def parse_cluster2d(cluster_event): @@ -139,23 +139,24 @@ def parse_cluster3d(cluster_event, labels['cluster'] = np.arange(num_clusters) if add_particle_info: assert particle_event is not None, "Must provide particle tree if particle information is included" - particles_v = particle_event.as_vector() - particles_v_v = parse_particles(particle_event, cluster_event) - particles_mpv_v = particle_mpv_event.as_vector() if particle_mpv_event is not None else None - neutrinos_v = neutrino_event.as_vector() if neutrino_event is not None else None - - labels['cluster'] = np.array([p.id() for p in particles_v]) - labels['group'] = np.array([p.group_id() for p in particles_v]) - labels['inter'] = get_interaction_ids(particles_v) - labels['nu'] = get_nu_ids(labels['inter'], particles_v, particles_mpv_v, neutrinos_v) - labels['type'] = get_particle_id(particles_v, labels['nu'], type_include_mpr, type_include_secondary) - labels['pshower'] = get_shower_primary_id(cluster_event, particles_v) - labels['pgroup'] = get_group_primary_id(particles_v, labels['nu'], primary_include_mpr) - labels['vtx_x'] = np.array([p.ancestor_position().x() for p in particles_v_v]) - labels['vtx_y'] = np.array([p.ancestor_position().y() for p in particles_v_v]) - labels['vtx_z'] = np.array([p.ancestor_position().z() for p in particles_v_v]) - labels['p'] = np.array([p.p()/1e3 for p in particles_v]) # In GeV - labels['shape'] = np.array([p.shape() for p in particles_v]) + particles = list(particle_event.as_vector()) + particles_mpv = list(particle_mpv_event.as_vector()) if particle_mpv_event is not None else None + neutrinos = list(neutrino_event.as_vector()) if neutrino_event is not None else None + + particles_p = parse_particles(particle_event, cluster_event) + + labels['cluster'] = np.array([p.id() for p in particles]) + labels['group'] = np.array([p.group_id() for p in particles]) + labels['inter'] = get_interaction_ids(particles) + labels['nu'] = get_nu_ids(particles, labels['inter'], particles_mpv, neutrinos) + labels['type'] = get_particle_ids(particles, labels['nu'], type_include_mpr, type_include_secondary) + labels['pshower'] = get_shower_primary_ids(particles) + labels['pgroup'] = get_group_primary_ids(particles, labels['nu'], primary_include_mpr) + labels['vtx_x'] = np.array([p.ancestor_position().x() for p in particles_p]) + labels['vtx_y'] = np.array([p.ancestor_position().y() for p in particles_p]) + labels['vtx_z'] = np.array([p.ancestor_position().z() for p in particles_p]) + labels['p'] = np.array([p.p()/1e3 for p in particles]) # In GeV + labels['shape'] = np.array([p.shape() for p in particles]) # Loop over clusters, store info clusters_voxels, clusters_features = [], [] diff --git a/mlreco/iotools/parsers/label_data.py b/mlreco/iotools/parsers/label_data.py index 02433d57..70ad0c4f 100644 --- a/mlreco/iotools/parsers/label_data.py +++ b/mlreco/iotools/parsers/label_data.py @@ -1,40 +1,68 @@ import numpy as np import torch -from mlreco.utils.globals import SHOW_SHP, TRACK_SHP, LOWE_SHP, INVAL_TID, PDG_TO_PID +from mlreco.utils.globals import * + +def get_valid_mask(particles): + ''' + A function which checks that the particle labels have been + filled properly at the SUPERA level. It checks that the ancestor + track ID of each particle is not an invalid number and that + the ancestor creation process is filled. + + Parameters + ---------- + particles : list(larcv.Particle) + (P) List of true particle instances + + Results + ------- + np.ndarray + (P) Boolean list of validity, one per true particle instance + ''' + mask = np.array([p.ancestor_track_id() != INVAL_TID for p in particles]) + mask &= np.array([bool(len(p.ancestor_creation_process())) for p in particles]) + return mask def get_interaction_ids(particles): ''' - A function which gets the interaction ID of each of the - particle in the input particle list. It leverages shared - ancestor position as a basis for interaction building and - sets the interaction ID to -1 for parrticles with invalid - ancestor track IDs. + A function which gets the interaction ID of each of the particle in + the input particle list. If the `interaction_id` member of the + larcv.Particle class is filled, it simply uses that quantity. + + Otherwise, it leverages shared ancestor position as a + basis for interaction building and sets the interaction + ID to -1 for particles with invalid ancestor track IDs. Parameters ---------- particles : list(larcv.Particle) - List of true particle instances + (P) List of true particle instances Results ------- np.ndarray - List of interaction IDs, one per true particle instance + (P) List of interaction IDs, one per true particle instance ''' - # Define the interaction ID on the basis of sharing an ancestor vertex position + # If the interaction IDs are specified in the particle tree, just use that + inter_ids = np.array([p.interaction_id() for p in particles], dtype=np.int32) + if np.any(inter_ids != INVAL_ID): + inter_ids[inter_ids == INVAL_ID] == -1 + return inter_ids + + # Define interaction IDs on the basis of sharing an ancestor vertex position anc_pos = np.vstack([[getattr(p, f'ancestor_{a}')() for a in ['x', 'y', 'z']] for p in particles]) inter_ids = np.unique(anc_pos, axis=0, return_inverse=True)[-1] # Now set the interaction ID of particles with an undefined ancestor to -1 if len(particles): - anc_ids = np.array([p.ancestor_track_id() for p in particles]) - inter_ids[anc_ids == INVAL_TID] = -1 + inter_ids[get_valid_mask(particles)] = -1 return inter_ids -def get_nu_ids(inter_ids, particles, particles_mpv=None, neutrinos=None): +def get_nu_ids(particles, inter_ids, particles_mpv=None, neutrinos=None): ''' A function which gets the neutrino-like ID (0 for cosmic, 1 for neutrino) of each of the particle in the input particle list. @@ -49,14 +77,14 @@ def get_nu_ids(inter_ids, particles, particles_mpv=None, neutrinos=None): Parameters ---------- - inter_ids : np.ndarray - Array of interaction ID values, one per true particle instance particles : list(larcv.Particle) - List of true particle instances + (P) List of true particle instances + inter_ids : np.ndarray + (P) Array of interaction ID values, one per true particle instance particles_mpv : list(larcv.Particle), optional - List of true MPV particle instances + (M) List of true MPV particle instances neutrinos : list(larcv.Neutrino), optional - List of true neutrino instances + (N) List of true neutrino instances Results ------- @@ -75,23 +103,20 @@ def get_nu_ids(inter_ids, particles, particles_mpv=None, neutrinos=None): valid_mask = np.where(inter_ids > -1)[0] if not len(valid_mask): return nu_ids - - # Identify the interaction ID of that particle inter_id = inter_ids[valid_mask[0]] - inter_index = np.where(inter_ids == inter_id)[0] - # If there are at least two primaries, the interaction is nu-like - primary_ids = get_group_primary_id(particles) - num_primary = np.sum(primary_ids[inter_index]) - if num_primary > 1: + # If there are at least two primaries, the interaction is neutrino-like + primary_ids = get_group_primary_ids(particles) + inter_index = np.where(inter_ids == inter_id)[0] + if np.sum(primary_ids[inter_index] == 1) > 1: nu_ids[inter_index] = 1 else: # Find the reference positions gauge if a particle comes from a neutrino-like interaction ref_pos = None if particles_mpv: - ref_pos = np.vstack([[getattr(p, f'{a}')() for a in ['x', 'y', 'z']] for p in particles_mpv]) + ref_pos = np.vstack([[getattr(p, a)() for a in ['x', 'y', 'z']] for p in particles_mpv]) elif neutrinos: - ref_pos = np.vstack([[getattr(n, f'{a}')() for a in ['x', 'y', 'z']] for n in neutrinos]) + ref_pos = np.vstack([[getattr(n, a)() for a in ['x', 'y', 'z']] for n in neutrinos]) # If a particle shares its ancestor position with an MPV particle # or a neutrino, it belongs to a neutrino-like interaction @@ -103,10 +128,10 @@ def get_nu_ids(inter_ids, particles, particles_mpv=None, neutrinos=None): return nu_ids -def get_particle_id(particles_v, nu_ids, include_mpr=False, include_secondary=False): +def get_particle_ids(particles, nu_ids, include_mpr=False, include_secondary=False): ''' - Function that gives one of five labels to particles of - particle species predictions. This function ensures: + Function which gets a particle ID (PID) for each of the particle in + the input particle list. This function ensures: - Particles that do not originate from an MPV are labeled -1, unless the include_mpr flag is set to true - Secondary particles (includes Michel/delta and neutron activity) are @@ -119,136 +144,126 @@ def get_particle_id(particles_v, nu_ids, include_mpr=False, include_secondary=Fa This is handled downstream with the high_purity flag. - Particles that are not in the list target are labeled -1 - Inputs: - - particles_v (array of larcv::Particle) : (N) LArCV Particle objects - - nu_ids: a numpy array with shape (n, 1) where 1 is neutrino id (0 if not an MPV) - - include_mpr: include MPR (cosmic-like) particles to PID target - - include_secondary: include secondary particles into the PID target - Outputs: - - array: (N) list of group ids + Parameters + ---------- + particles : list(larcv.Particle) + (P) List of true particle instances + nu_ids : np.ndarray + (P) Array of neutrino ID values, one per true particle instance + include_mpr : bool, default False + Include cosmic-like particles (MPR or cosmics) to valid PID labels + include_secondary : bool, default False + Inlcude secondary particles to valid PID labels + + Returns + ------- + np.ndarray + (P) List of particle IDs, one per true particle instance ''' - particle_ids = np.empty(len(nu_ids)) - primary_ids = get_group_primary_id(particles_v, nu_ids, include_mpr) + particle_ids = -np.ones(len(nu_ids), dtype=np.int32) + primary_ids = get_group_primary_ids(particles, nu_ids, include_mpr) for i in range(len(particle_ids)): - # If the primary ID is invalid, assign invalid + # If the primary ID is invalid, skip if primary_ids[i] < 0: - particle_ids[i] = -1 continue - # If secondary particles are not included and primary_id < 1, assign invalid + # If secondary particles are not included and primary_id < 1, skip if not include_secondary and primary_ids[i] < 1: - particle_ids[i] = -1 continue # If the particle type exists in the predefined list, assign - group_id = int(particles_v[i].group_id()) - t = int(particles_v[group_id].pdg_code()) + group_id = particles[i].group_id() + t = particles[group_id].pdg_code() if t in PDG_TO_PID.keys(): particle_ids[i] = PDG_TO_PID[t] - else: - particle_ids[i] = -1 return particle_ids -def get_shower_primary_id(cluster_event, particles_v): +def get_shower_primary_ids(particles): ''' - Function that assigns valid primary tags to shower fragments. + Function which gets primary labels for shower fragments. This could be handled somewhere else (e.g. SUPERA) - Inputs: - - cluster_event (larcv::EventClusterVoxel3D): (N) Array of cluster tensors - - particles_v (array of larcv::Particle) : (N) LArCV Particle objects - Outputs: - - array: (N) list of group ids - ''' - # Loop over the list of particles - group_ids = np.array([p.group_id() for p in particles_v]) - primary_ids = np.empty(particles_v.size(), dtype=np.int32) - for i, p in enumerate(particles_v): - # If the particle is a track or a low energy cluster, it is not a primary shower fragment - if p.shape() == 1 or p.shape() == 4: - primary_ids[i] = 0 - continue + Parameters + ---------- + particles : list(larcv.Particle) + (P) List of true particle instances - # If a particle is a Delta or a Michel, it is a primary shower fragment - if p.shape() == 2 or p.shape() == 3: - primary_ids[i] = 1 + Results + ------- + np.ndarray + (P) List of particle shower primary IDs, one per true particle instance + ''' + # Loop over the list of particle groups + primary_ids = np.zeros(len(particles), dtype=np.int32) + group_ids = np.array([p.group_id() for p in particles], dtype=np.int32) + valid_mask = get_valid_mask(particles) + for g in np.unique(group_ids): + # If the particle group has invalid labeling, it does not contain a primary + if g == INVAL_ID or not valid_mask[g]: continue + p = particles[g] - # If the shower fragment originates from nuclear activity, it is not a primary - process = p.creation_process() - parent_pdg_code = abs(p.parent_pdg_code()) - if 'Inelastic' in process or 'Capture' in process or parent_pdg_code == 2112: - primary_ids[i] = 0 + # If a group originates from a Delta or a Michel, that has a primary + if p.shape() == MICHL_SHP or p.shape() == DELTA_SHP: + primary_ids[g] = 1 continue - # If a shower group's parent fragment has size zero, there is no valid primary in the group - gid = int(p.group_id()) - parent_size = cluster_event.as_vector()[gid].as_vector().size() - if not parent_size: - primary_ids[i] = 0 + # If a group does not originate from EM activity, it does not contain a primary + if p.shape() != SHOWR_SHP: continue - # If a shower group's parent fragment is not the first in time, there is no valid primary in the group - idxs = np.where(group_ids == gid)[0] - clust_times = np.array([particles_v[int(j)].first_step().t() for j in idxs]) + # If a shower group's parent fragment the first in time, it is a valid primary + group_index = np.where(group_ids == g)[0] + clust_times = np.array([particles[i].first_step().t() for i in group_index]) min_id = np.argmin(clust_times) - if idxs[min_id] != gid : - primary_ids[i] = 0 - continue - - # If all conditions are met, label shower fragments which have identical ID and group ID as primary - primary_ids[i] = int(gid == i) + if group_index[min_id] == g: + primary_ids[g] = 1 return primary_ids -def get_group_primary_id(particles_v, nu_ids=None, include_mpr=True): +def get_group_primary_ids(particles, nu_ids=None, include_mpr=True): ''' - Function that assigns valid primary tags to particle groups. - This could be handled somewhere else (e.g. SUPERA) + Parameters + ---------- + particles : list(larcv.Particle) + (P) List of true particle instances + nu_ids : np.ndarray, optional + (P) List of neutrino IDs, one per particle instance + include_mpr : bool, default False + Include cosmic-like particles (MPR or cosmics) to valid primary labels - Inputs: - - particles_v (array of larcv::Particle) : (N) LArCV Particle objects - - nu_ids: a numpy array with shape (n, 1) where 1 is neutrino id (0 if not an MPV) - - include_mpr: include MPR (cosmic-like) particles to primary target - Outputs: - - array: (N) list of group ids + Results + ------- + np.ndarray + (P) List of particle shower primary IDs, one per true particle instance ''' # Loop over the list of particles - primary_ids = np.empty(particles_v.size(), dtype=np.int32) - for i, p in enumerate(particles_v): - # If MPR particles are not included and the nu_id < 1, assign invalid - if not include_mpr and nu_ids[i] < 1: + primary_ids = np.empty(len(particles), dtype=np.int32) + valid_mask = get_valid_mask(particles) + for i, p in enumerate(particles): + # If the particle has invalid labeling, it does not contain a primary + if p.group_id() == INVAL_ID or not valid_mask[i]: primary_ids[i] = -1 continue - # If the ancestor particle is unknown (no creation process), assign invalid (TODO: fix in supera) - if not p.ancestor_creation_process(): + # If MPR particles are not included and the nu_id < 1, assign invalid + if not include_mpr and nu_ids is not None and nu_ids[i] < 1: primary_ids[i] = -1 continue - # If the particle is not a shower or a track, it is not a primary - if p.shape() != SHOW_SHP and p.shape() != TRACK_SHP and p.shape() != LOWE_SHP: - primary_ids[i] = 0 - continue - - # If the particle group originates from nuclear activity, it is not a primary - gid = int(p.group_id()) - process = particles_v[gid].creation_process() - parent_pdg_code = abs(particles_v[gid].parent_pdg_code()) - ancestor_pdg_code = abs(particles_v[gid].ancestor_pdg_code()) - if 'Inelastic' in process or 'Capture' in process or parent_pdg_code == 2112 or ancestor_pdg_code == 2112: - primary_ids[i] = 0 - continue - - # If the parent is a pi0, make sure that it is a primary pi0 (pi0s are not stored in particle list) - if parent_pdg_code == 111 and ancestor_pdg_code != 111: - primary_ids[i] = 0 + # If the particle originates from a primary pi0, label as primary + # Small issue with photo-nuclear activity here, but very rare + group_p = particles[p.group_id()] + if group_p.ancestor_pdg_code() == 111: + primary_ids[i] = 1 continue - # If the parent ID of the primary particle in the group is the same as the group ID, it is a primary - primary_ids[i] = int(particles_v[gid].parent_id() == gid) + # If the origin of a particle agrees with the origin of its ancestor, label as primary + group_pos = np.array([getattr(group_p, a)() for a in ['x', 'y', 'z']]) + anc_pos = np.array([getattr(p, f'ancestor_{a}')() for a in ['x', 'y', 'z']]) + primary_ids[i] = (group_pos == anc_pos).all() return primary_ids diff --git a/mlreco/utils/globals.py b/mlreco/utils/globals.py index 9cf68f23..47027a8f 100644 --- a/mlreco/utils/globals.py +++ b/mlreco/utils/globals.py @@ -24,19 +24,21 @@ SHAPE_COL = -1 # Shape ID of each type of voxel category -SHOW_SHP = larcv.kShapeShower # 0 +SHOWR_SHP = larcv.kShapeShower # 0 TRACK_SHP = larcv.kShapeTrack # 1 -MICH_SHP = larcv.kShapeMichel # 2 +MICHL_SHP = larcv.kShapeMichel # 2 DELTA_SHP = larcv.kShapeDelta # 3 -LOWE_SHP = larcv.kShapeLEScatter # 4 +LOWES_SHP = larcv.kShapeLEScatter # 4 GHOST_SHP = larcv.kShapeGhost # 5 UNKWN_SHP = larcv.kShapeUnknown # 6 # Shape precedence used in the cluster labeling process -SHAPE_PREC = [TRACK_SHP, MICH_SHP, SHOW_SHP, DELTA_SHP, LOWE_SHP] +SHAPE_PREC = [TRACK_SHP, MICHL_SHP, SHOWR_SHP, DELTA_SHP, LOWES_SHP] -# Invalid labels -INVAL_TID = larcv.kINVALID_UINT +# Invalid larcv.Particle labels +INVAL_ID = larcv.kINVALID_INSTANCEID # Particle group/parent/interaction ID +INVAL_TID = larcv.kINVALID_UINT # Particle Geant4 track ID +INVAL_PDG = 0 # Patricle PDG code # Mapping between particle PDG code and particle ID labels PDG_TO_PID = { From 512b726d8457e80f6883731a87ce0f253dbead04 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Tue, 4 Apr 2023 17:59:39 -0700 Subject: [PATCH 103/180] Vertex post processor done --- analysis/algorithms/point_matching.py | 119 +------ analysis/algorithms/utils.py | 173 +--------- analysis/algorithms/vertex.py | 269 ---------------- mlreco/post_processing/__init__.py | 1 - mlreco/post_processing/common.py | 55 ++-- mlreco/post_processing/decorator.py | 8 +- .../reconstruction/__init__.py | 5 +- .../reconstruction/calorimetry.py | 33 +- .../reconstruction/particle_points.py | 257 +++++++++++++++ .../post_processing/reconstruction/utils.py | 36 +++ .../post_processing/reconstruction/vertex.py | 303 ++++++++++++++++++ 11 files changed, 669 insertions(+), 590 deletions(-) delete mode 100644 analysis/algorithms/vertex.py create mode 100644 mlreco/post_processing/reconstruction/utils.py diff --git a/analysis/algorithms/point_matching.py b/analysis/algorithms/point_matching.py index 26b2423b..4e41b937 100644 --- a/analysis/algorithms/point_matching.py +++ b/analysis/algorithms/point_matching.py @@ -42,121 +42,4 @@ def match_points_to_particles(ppn_points : np.ndarray, for particle in particles: dist = cdist(ppn_coords, particle.points) matches = ppn_points_type[dist.min(axis=1) < ppn_distance_threshold] - particle.ppn_candidates = matches.reshape(-1, 7) - -# Deprecated -def get_track_endpoints(particle : Particle, verbose=False): - """Function for getting endpoints of tracks (DEPRECATED) - - Using ppn_candiates attached to , get two - endpoints of tracks by farthest distance from the track's - spatial centroid. - - Parameters - ---------- - particle : object - Track particle for which to obtain endpoint coordinates - verbose : bool - If set to True, output print message indicating whether - particle has no or only one PPN candidate. - - Returns - ------- - endpoints : (2, 3) np.array - Xyz coordinates of two endpoints predicted or manually found - by network. - """ - if verbose: - print("Found {} PPN candidate points for particle {}".format( - particle.ppn_candidates.shape[0], particle.id)) - if particle.semantic_type != 1: - raise AttributeError( - "Particle {} has type {}, can only give"\ - " endpoints to tracks!".format(particle.id, - particle.semantic_type)) - if particle.ppn_candidates.shape[0] == 0: - if verbose: - print("Particle {} has no PPN candidates!"\ - " Running brute-force endpoint finder...".format(particle.id)) - startpoint, endpoint = get_track_endpoints_max_dist(particle) - elif particle.ppn_candidates.shape[0] == 1: - if verbose: - print("Particle {} has only one PPN candidate!"\ - " Running brute-force endpoint finder...".format(particle.id)) - startpoint, endpoint = get_track_endpoints_max_dist(particle) - else: - centroid = particle.points.mean(axis=0) - ppn_coordinates = particle.ppn_candidates[:, :3] - dist = cdist(centroid.reshape(1, -1), ppn_coordinates).squeeze() - endpt_inds = dist.argsort()[-2:] - endpoints = particle.ppn_candidates[endpt_inds] - particle.endpoints = endpoints - assert endpoints.shape[0] == 2 - return endpoints - - -def get_track_endpoints_max_dist(particle): - """Helper function for getting track endpoints. - - Computes track endpoints without ppn predictions by - selecting the farthest two points from the coordinate centroid. - - Parameters - ---------- - particle : object - - Returns - ------- - endpoints : (2, 3) np.array - Xyz coordinates of two endpoints predicted or manually found - by network. - """ - coords = particle.points - dist = cdist(coords, coords) - inds = np.unravel_index(dist.argmax(), dist.shape) - return coords[inds[0]], coords[inds[1]] - - -# Deprecated -def get_shower_startpoint(particle : Particle, verbose=False): - """Function for getting startpoint of EM showers. (DEPRECATED) - - Using ppn_candiates attached to , get one - startpoint of shower by nearest hausdorff distance. - - Parameters - ---------- - particle : object - Track particle for which to obtain endpoint coordinates - verbose : bool - If set to True, output print message indicating whether - particle has no or only one PPN candidate. - - Returns - ------- - - endpoints : (2, 3) np.array - Xyz coordinates of two endpoints predicted or manually found - by network. - """ - if particle.semantic_type != 0: - raise AttributeError( - "Particle {} has type {}, can only give"\ - " startpoints to shower fragments!".format( - particle.id, particle.semantic_type)) - if verbose: - print("Found {} PPN candidate points for particle {}".format( - particle.ppn_candidates.shape[0], particle.id)) - if particle.ppn_candidates.shape[0] == 0: - if verbose: - print("Particle {} has no PPN candidates!".format(particle.id)) - startpoint = -np.ones(3) - else: - centroid = particle.points.mean(axis=0) - ppn_coordinates = particle.ppn_candidates[:, :3] - dist = np.linalg.norm((ppn_coordinates - centroid), axis=1) - index = dist.argsort()[0] - startpoint = ppn_coordinates[index] - particle.startpoint = startpoint - assert sum(startpoint.shape) == 3 - return startpoint + particle.ppn_candidates = matches.reshape(-1, 7) \ No newline at end of file diff --git a/analysis/algorithms/utils.py b/analysis/algorithms/utils.py index 3fc64369..6dead02b 100644 --- a/analysis/algorithms/utils.py +++ b/analysis/algorithms/utils.py @@ -1,4 +1,5 @@ import numpy as np +import numba as nb from collections import OrderedDict from turtle import up from sklearn.decomposition import PCA @@ -22,177 +23,6 @@ def attach_prefix(update_dict, prefix): return out -def correct_track_points(particle): - ''' - Correct track startpoint and endpoint using PPN's - prediction. - - Warning: only meant for tracks, operation is in-place. - ''' - assert particle.semantic_type == 1 - num_candidates = particle.ppn_candidates.shape[0] - - x = np.vstack([particle.startpoint, particle.endpoint]) - - if num_candidates == 0: - pass - elif num_candidates == 1: - # Get closest candidate and place candidate's label - # print(x.shape, particle.ppn_candidates[0, :3]) - dist = cdist(x, particle.ppn_candidates[:, :3]).squeeze() - label = np.argmax(particle.ppn_candidates[0, 5:]) - x1, x2 = np.argmin(dist), np.argmax(dist) - if label == 0: - # Closest point x1 is adj to a startpoint - particle.startpoint = x[x1] - particle.endpoint = x[x2] - elif label == 1: - # point x2 is adj to an endpoint - particle.endpoint = x[x1] - particle.startpoint = x[x2] - else: - raise ValueError("Track endpoint label should be either 0 or 1, \ - got {}, which should not happen!".format(label)) - else: - dist = cdist(x, particle.ppn_candidates[:, :3]) - # Classify endpoint scores associated with x - scores = particle.ppn_candidates[dist.argmin(axis=1)][:, 5:] - particle.startpoint = x[scores[:, 0].argmax()] - particle.endpoint = x[scores[:, 1].argmax()] - - -def handle_singleton_ppn_candidate(p, pts, ppn_candidates): - assert ppn_candidates.shape[0] == 1 - score = ppn_candidates[0][5:] - label = np.argmax(score) - dist = cdist(pts, ppn_candidates[:, :3]) - pt_near = pts[dist.argmin(axis=0)] - pt_far = pts[dist.argmax(axis=0)] - if label == 0: - p.startpoint = pt_near.reshape(-1) - p.endpoint = pt_far.reshape(-1) - else: - p.endpoint = pt_near.reshape(-1) - p.startpoint = pt_far.reshape(-1) - - -def correct_track_endpoints_ppn(p): - assert p.semantic_type == 1 - pts = np.vstack([p.startpoint, p.endpoint]) - - if p.ppn_candidates.shape[0] == 0: - p.startpoint = pts[0] - p.endpoint = pts[1] - elif p.ppn_candidates.shape[0] == 1: - # If only one ppn candidate, find track endpoint closer to - # ppn candidate and give the candidate's label to that track point - handle_singleton_ppn_candidate(p, pts, p.ppn_candidates) - else: - dist1 = cdist(np.atleast_2d(p.ppn_candidates[:, :3]), - np.atleast_2d(pts[0])).reshape(-1) - dist2 = cdist(np.atleast_2d(p.ppn_candidates[:, :3]), - np.atleast_2d(pts[1])).reshape(-1) - - ind1, ind2 = dist1.argmin(), dist2.argmin() - if ind1 == ind2: - ppn_candidates = p.ppn_candidates[dist1.argmin()].reshape(1, 7) - handle_singleton_ppn_candidate(p, pts, ppn_candidates) - else: - pt1_score = p.ppn_candidates[ind1][5:] - pt2_score = p.ppn_candidates[ind2][5:] - - labels = np.array([pt1_score.argmax(), pt2_score.argmax()]) - scores = np.array([pt1_score.max(), pt2_score.max()]) - - if labels[0] == 0 and labels[1] == 1: - p.startpoint = pts[0] - p.endpoint = pts[1] - elif labels[0] == 1 and labels[1] == 0: - p.startpoint = pts[1] - p.endpoint = pts[0] - elif labels[0] == 0 and labels[1] == 0: - # print("Particle {} has no endpoint".format(p.id)) - # Select point with larger score as startpoint - ix = np.argmax(scores) - iy = np.argmin(scores) - # print(ix, iy, pts, scores) - p.startpoint = pts[ix] - p.endpoint = pts[iy] - elif labels[0] == 1 and labels[1] == 1: - ix = np.argmax(scores) # point with higher endpoint score - iy = np.argmin(scores) - p.startpoint = pts[iy] - p.endpoint = pts[ix] - else: - raise ValueError("Classify endpoints feature dimension must be 2, got something else!") - if np.linalg.norm(p.startpoint - p.endpoint) < 1e-6: - p.startpoint = pts[0] - p.endpoint = pts[1] - - -def correct_track_endpoints_local_density(p, r=5): - pca = PCA(n_components=2) - assert p.semantic_type == 1 - mask_st = np.linalg.norm(p.startpoint - p.points, axis=1) < r - if np.count_nonzero(mask_st) < 2: - return - pca_axis = pca.fit_transform(p.points[mask_st]) - length = pca_axis[:, 0].max() - pca_axis[:, 0].min() - local_d_start = p.depositions[mask_st].sum() / length - mask_end = np.linalg.norm(p.endpoint - p.points, axis=1) < r - if np.count_nonzero(mask_end) < 2: - return - pca_axis = pca.fit_transform(p.points[mask_end]) - length = pca_axis[:, 0].max() - pca_axis[:, 0].min() - local_d_end = p.depositions[mask_end].sum() / length - # Startpoint must have lowest local density - if local_d_start > local_d_end: - p1, p2 = p.startpoint, p.endpoint - p.startpoint = p2 - p.endpoint = p1 - - -def correct_track_endpoints_linfit(p, bin_size=17): - if len(p.points) >= 2: - dedx = compute_track_dedx(p, bin_size=bin_size) - if len(dedx) > 1: - x = np.arange(len(dedx)) - params = np.polyfit(x, dedx, 1) - if params[0] < 0: - p1, p2 = p.startpoint, p.endpoint - p.startpoint = p2 - p.endpoint = p1 - - -def correct_track_endpoints_direction(p): - assert p.semantic_type == 1 - vec = p.endpoint - p.startpoint - vec = vec / np.linalg.norm(vec) - direction = get_particle_direction(p, optimize=True) - direction = direction / np.linalg.norm(direction) - if np.sum(vec * direction) < 0: - p1, p2 = p.startpoint, p.endpoint - p.startpoint = p2 - p.endpoint = p1 - - -def get_track_points(p, correction_mode='ppn', brute_force=False): - if brute_force: - pts = np.vstack(get_track_endpoints_max_dist(p)) - else: - pts = np.vstack([p.startpoint, p.endpoint]) - if correction_mode == 'ppn': - correct_track_endpoints_ppn(p) - elif correction_mode == 'local_density': - correct_track_endpoints_local_density(p) - elif correction_mode == 'linfit': - correct_track_endpoints_linfit(p) - elif correction_mode == 'direction': - correct_track_endpoints_direction(p) - else: - raise ValueError("Track extrema correction mode {} not defined!".format(correction_mode)) - - def get_mparticles_from_minteractions(int_matches): ''' Given list of Tuple[(Truth)Interaction, (Truth)Interaction], @@ -231,6 +61,7 @@ def get_mparticles_from_minteractions(int_matches): match_counts.append(p._match_counts[match_id]) return matched_particles, np.array(match_counts) +@nb.njit def closest_distance_two_lines(a0, u0, a1, u1): ''' a0, u0: point (a0) and unit vector (u0) defining line 1 diff --git a/analysis/algorithms/vertex.py b/analysis/algorithms/vertex.py deleted file mode 100644 index 7eb58bf1..00000000 --- a/analysis/algorithms/vertex.py +++ /dev/null @@ -1,269 +0,0 @@ -import numpy as np -import numba as nb -from scipy.spatial.distance import cdist - -# from lartpc_mlreco3d.analysis.algorithms.arxiv.calorimetry import get_particle_direction -from mlreco.utils.utils import func_timer -from analysis.classes.Interaction import Interaction - - -@nb.njit(cache=True) -def point_to_line_distance_(p1, p2, v2): - dist = np.sqrt(np.sum(np.cross(v2, (p2 - p1))**2)+1e-8) - return dist - - -@nb.njit(cache=True) -def point_to_line_distance(P1, P2, V2): - dist = np.zeros((P1.shape[0], P2.shape[0])) - for i, p1 in enumerate(P1): - for j, p2 in enumerate(P2): - d = point_to_line_distance_(p1, p2, V2[j]) - dist[i, j] = d - return dist - - -def get_centroid_adj_pairs(particles, r1=5.0, return_annot=False): - ''' - From N x 3 array of N particle startpoint coordinates, find - two points which touch each other within r1, and return the - barycenter of such pairs. - ''' - candidates = [] - vp_startpoints = np.vstack([p.startpoint for p in particles]) - vp_labels = np.array([p.id for p in particles]) - dist = cdist(vp_startpoints, vp_startpoints) - dist += -np.eye(dist.shape[0]) - idx, idy = np.where( (dist < r1) & (dist > 0)) - # Keep track of duplicate pairs - duplicates = [] - # Append barycenter of two touching points within radius r1 to candidates - for ix, iy in zip(idx, idy): - center = (vp_startpoints[ix] + vp_startpoints[iy]) / 2.0 - if not((ix, iy) in duplicates or (iy, ix) in duplicates): - if return_annot: - candidates.append((center, str((vp_labels[ix], vp_labels[iy])))) - else: - candidates.append(center) - duplicates.append((ix, iy)) - return candidates - - -def get_track_shower_poca(particles, return_annot=False, start_segment_radius=10, r2=5.0): - ''' - From list of particles, find startpoints of track particles that lie - within r2 distance away from the closest line defined by a shower - direction vector. - ''' - - candidates = [] - - valid_particles = [p for p in particles if len(p.points) > 0] - - track_ids, shower_ids = np.array([p.id for p in valid_particles if p.semantic_type == 1]), [] - track_starts = np.array([p.startpoint for p in valid_particles if p.semantic_type == 1]) - shower_starts, shower_dirs = [], [] - for p in valid_particles: - vec = get_particle_direction(p, optimize=True) - if p.semantic_type == 0 and (vec != -1).all(): - shower_dirs.append(vec) - shower_starts.append(p.startpoint) - shower_ids.append(p.id) - - assert len(shower_starts) == len(shower_dirs) - assert len(shower_dirs) == len(shower_ids) - - shower_dirs = np.array(shower_dirs) - shower_starts = np.array(shower_starts) - shower_ids = np.array(shower_ids) - - if len(track_ids) == 0 or len(shower_ids) == 0: - return [] - - dist = point_to_line_distance(track_starts, shower_starts, shower_dirs) - idx, idy = np.where(dist < r2) - for ix, iy in zip(idx, idy): - if return_annot: - candidates.append((track_starts[ix], str((track_ids[ix], shower_ids[iy])))) - else: - candidates.append(track_starts[ix]) - - return candidates - - -def compute_vertex_matrix_inversion(particles, - dim=3, - use_primaries=True): - """ - Given a set of particles, compute the vertex by the following method: - - 1) Estimate the direction of each particle - 2) Using infinite lines defined by the direction and the startpoint of - each particle, compute the point of closest approach. - 3) Solve the least squares optimization problem. - - The least squares problem in this case has an analytic solution - which could be solved by matrix pseudoinversion. - - Obviously, we require at least two particles. - - Parameters - ---------- - particles: List of Particle - dim: dimension of image (2D, 3D) - use_primaries: option to only consider primaries in defining lines - weight: if True, the function will use the information from PCA's - percentage of explained variance to weigh each contribution to the cost. - This is to avoid ill defined directions to affect the solution. - - Returns - ------- - np.ndarray - Shape (3,) - """ - pseudovtx = np.zeros((dim, )) - - valid_particles = [p for p in particles if len(p.points) > 0] - - if use_primaries: - valid_particles = [p for p in valid_particles if (p.is_primary and p.startpoint is not None)] - - if len(valid_particles) < 2: - return np.array([-1, -1, -1]) - - S = np.zeros((dim, dim)) - C = np.zeros((dim, )) - - for p in valid_particles: - vec = get_particle_direction(p, optimize=True) - w = 1.0 - S += w * (np.outer(vec, vec) - np.eye(dim)) - C += w * (np.outer(vec, vec) - np.eye(dim)) @ p.startpoint - # print(S, C) - pseudovtx = np.linalg.pinv(S) @ C - return pseudovtx - - -def compute_vertex_candidates(particles, - use_primaries=True, - valid_semantic_types=[0,1], - r1=5.0, - r2=5.0, - return_annot=False): - - candidates = [] - - # Exclude unwanted particles - valid_particles = [] - for p in particles: - check = p.is_primary or (not use_primaries) - if check and (p.semantic_type in valid_semantic_types): - valid_particles.append(p) - - if len(valid_particles) == 0: - return [], None - elif len(valid_particles) == 1: - startpoint = p.startpoint if p.startpoint is not None else -np.ones(3) - return [startpoint], None - else: - # 1. Select two startpoints within dist r1 - candidates.extend(get_centroid_adj_pairs(valid_particles, - r1=r1, - return_annot=return_annot)) - # 2. Select a track start point which is close - # to a line defined by shower direction - candidates.extend(get_track_shower_poca(valid_particles, - r2=r2, - return_annot=return_annot)) - # 3. Select POCA of all primary tracks and showers - pseudovtx = compute_vertex_matrix_inversion(valid_particles, - dim=3, - use_primaries=True) - # if not (pseudovtx < 0).all(): - # candidates.append(pseudovtx) - - return candidates, pseudovtx - - -def prune_vertex_candidates(candidates, pseudovtx, r=30): - dist = np.linalg.norm(candidates - pseudovtx.reshape(1, -1), axis=1) - pruned = candidates[dist < r] - return pruned - - -def estimate_vertex(particles, - use_primaries=True, - r_adj=10, - r_poca=10, - r_pvtx=30, - prune_candidates=False, - return_candidate_count=False, - mode='all'): - - # Exclude unwanted particles - valid_particles = [] - for p in particles: - check = p.is_primary or (not use_primaries) - if check and (p.semantic_type in [0,1]): - valid_particles.append(p) - - if len(valid_particles) == 0: - candidates = [] - elif len(valid_particles) == 1: - startpoint = p.startpoint if p.startpoint is not None else -np.ones(3) - candidates = [startpoint] - else: - if mode == 'adj': - candidates = get_centroid_adj_pairs(valid_particles, r1=r_adj) - elif mode == 'track_shower_pair': - candidates = get_track_shower_poca(valid_particles, r2=r_poca) - elif mode == 'all': - candidates, pseudovtx = compute_vertex_candidates(valid_particles, - use_primaries=True, - r1=r_adj, - r2=r_poca) - else: - raise ValueError("Mode {} for vertex selection not supported!".format(mode)) - - out = np.array([-1, -1, -1]) - - if len(candidates) == 0: - out = np.array([-1, -1, -1]) - elif len(candidates) == 1: - out = candidates[0] - else: - candidates = np.vstack(candidates) - if mode == 'all' and prune_candidates: - pruned = prune_vertex_candidates(candidates, pseudovtx, r=r_pvtx) - else: - pruned = candidates - if pruned.shape[0] > 0: - out = pruned.mean(axis=0) - else: - out = pseudovtx - - if return_candidate_count: - return out, len(candidates) - else: - return out - -def correct_primary_with_vertex(ia, r_adj=10, r_bt=10, start_segment_radius=10): - assert type(ia) is Interaction - if ia.vertex is not None and (ia.vertex > 0).all(): - for p in ia.particles: - if p.semantic_type == 1: - dist = np.linalg.norm(p.startpoint - ia.vertex) - # print(p.id, p.is_primary, p.semantic_type, dist) - if dist < r_adj: - p.is_primary = True - else: - p.is_primary = False - if p.semantic_type == 0: - vec = get_particle_direction(p, start_segment_radius=start_segment_radius) - dist = point_to_line_distance_(ia.vertex, p.startpoint, vec) - if np.linalg.norm(p.startpoint - ia.vertex) < r_adj: - p.is_primary = True - elif dist < r_bt: - p.is_primary = True - else: - p.is_primary = False \ No newline at end of file diff --git a/mlreco/post_processing/__init__.py b/mlreco/post_processing/__init__.py index 1891cc5e..06fcb4cd 100644 --- a/mlreco/post_processing/__init__.py +++ b/mlreco/post_processing/__init__.py @@ -1,3 +1,2 @@ from .decorator import post_processing - from .reconstruction import * \ No newline at end of file diff --git a/mlreco/post_processing/common.py b/mlreco/post_processing/common.py index d728d814..5f970948 100644 --- a/mlreco/post_processing/common.py +++ b/mlreco/post_processing/common.py @@ -1,33 +1,38 @@ import numpy as np from functools import partial -from collections import defaultdict +from collections import defaultdict, OrderedDict class PostProcessor: def __init__(self, cfg, data, result, debug=True): - self._funcs = [] + self._funcs = defaultdict(list) self._num_batches = cfg['iotool']['batch_size'] self.data = data self.result = result self.debug = debug - def register_function(self, f, processor_cfg={}): + def register_function(self, f, priority, processor_cfg={}): data_capture, result_capture = f._data_capture, f._result_capture + result_capture_optional = f._result_capture_optional pf = partial(f, **processor_cfg) - pf._data_capture = data_capture - pf._result_capture = result_capture - self._funcs.append(pf) + pf._data_capture = data_capture + pf._result_capture = result_capture + pf._result_capture_optional = result_capture_optional + self._funcs[priority].append(pf) - def process_event(self, image_id): + def process_event(self, image_id, f_list): image_dict = {} - for f in self._funcs: + for f in f_list: data_dict, result_dict = {}, {} for data_key in f._data_capture: data_dict[data_key] = self.data[data_key][image_id] for result_key in f._result_capture: result_dict[result_key] = self.result[result_key][image_id] + for result_key in f._result_capture_optional: + if result_key in self.result: + result_dict[result_key] = self.result[result_key][image_id] update_dict = f(data_dict, result_dict) for key, val in update_dict.items(): if key in image_dict: @@ -40,20 +45,30 @@ def process_event(self, image_id): return image_dict - def process(self): - - out_dict = defaultdict(list) - - for image_id in range(self._num_batches): - image_dict = self.process_event(image_id) - for key, val in image_dict.items(): - out_dict[key].append(val) + def process_and_modify(self): + """ - if self.debug: - for key, val in out_dict.items(): - assert len(out_dict[key]) == self._num_batches + """ + sorted_processors = sorted([x for x in self._funcs.items()], reverse=True) + for priority, f_list in sorted_processors: + out_dict = defaultdict(list) + for image_id in range(self._num_batches): + image_dict = self.process_event(image_id, f_list) + for key, val in image_dict.items(): + out_dict[key].append(val) + + if self.debug: + for key, val in out_dict.items(): + assert len(out_dict[key]) == self._num_batches - return out_dict + for key, val in out_dict.items(): + if key in self.result: + msg = "Post processing script output key {} "\ + "is already in result_dict, you may want"\ + "to rename it.".format(key) + raise RuntimeError(msg) + else: + self.result[key] = val def extent(voxels): diff --git a/mlreco/post_processing/decorator.py b/mlreco/post_processing/decorator.py index 391a98c1..e34faaae 100644 --- a/mlreco/post_processing/decorator.py +++ b/mlreco/post_processing/decorator.py @@ -6,7 +6,8 @@ from functools import wraps from pprint import pprint -def post_processing(data_capture, result_capture): +def post_processing(data_capture, result_capture, + result_capture_optional=[]): """ Decorator for common post-processing boilerplate. @@ -30,8 +31,9 @@ def wrapper(data_dict, result_dict, **kwargs): return out - wrapper._data_capture = data_capture - wrapper._result_capture = result_capture + wrapper._data_capture = data_capture + wrapper._result_capture = result_capture + wrapper._result_capture_optional = result_capture_optional return wrapper return decorator \ No newline at end of file diff --git a/mlreco/post_processing/reconstruction/__init__.py b/mlreco/post_processing/reconstruction/__init__.py index 86b31465..4899fdff 100644 --- a/mlreco/post_processing/reconstruction/__init__.py +++ b/mlreco/post_processing/reconstruction/__init__.py @@ -1 +1,4 @@ -from .calorimetry import range_based_track_energy \ No newline at end of file +from .calorimetry import range_based_track_energy +from .particle_points import assign_particle_extrema +from .utils import reconstruct_direction +from .vertex import reconstruct_vertex \ No newline at end of file diff --git a/mlreco/post_processing/reconstruction/calorimetry.py b/mlreco/post_processing/reconstruction/calorimetry.py index 698db5fe..a3a302db 100644 --- a/mlreco/post_processing/reconstruction/calorimetry.py +++ b/mlreco/post_processing/reconstruction/calorimetry.py @@ -7,14 +7,14 @@ from scipy.interpolate import CubicSpline from functools import lru_cache -from mlreco.utils.gnn.cluster import cluster_direction from mlreco.post_processing import post_processing from mlreco.utils.globals import * -@post_processing(data_capture=[], result_capture=['particle_clusts', - 'particle_seg', - 'input_rescaled', - 'particle_node_pred_type']) +@post_processing(data_capture=[], + result_capture=['particle_clusts', + 'particle_seg', + 'input_rescaled', + 'particle_node_pred_type']) def range_based_track_energy(data_dict, result_dict, bin_size=17, include_pids=[2, 3, 4], table_path=''): @@ -41,7 +41,7 @@ def range_based_track_energy(data_dict, result_dict, for i, p in enumerate(particles): semantic_type = particle_seg[i] if semantic_type == 1 and pred_ptypes[i] in include_pids: - points = input_data[p] + points = input_data[p][:, 1:4] length = compute_track_length(points, bin_size=bin_size) particle_length[i] = length particle_energy[i] = splines[pred_ptypes[i]](length * PIXELS_TO_CM) @@ -109,4 +109,23 @@ def compute_track_length(points, bin_size=17): dx = pca_axis.max() - pca_axis.min() length += dx return length - + + +def compute_track_dedx(points, startpoint, endpoint, depositions, bin_size=17): + assert len(points) >= 2 + vec = endpoint - startpoint + vec_norm = np.linalg.norm(vec) + vec = (vec / (vec_norm + 1e-6)).astype(np.float64) + proj = points - startpoint + proj = np.dot(proj, vec) + bins = np.arange(proj.min(), proj.max(), bin_size) + bin_inds = np.digitize(proj, bins) + dedx = np.zeros(np.unique(bin_inds).shape[0]).astype(np.float64) + for i, b_i in enumerate(np.unique(bin_inds)): + mask = bin_inds == b_i + sum_energy = depositions[mask].sum() + if np.count_nonzero(mask) < 2: continue + # Repeat PCA locally for better measurement of dx + dx = proj[mask].max() - proj[mask].min() + dedx[i] = sum_energy / dx + return dedx \ No newline at end of file diff --git a/mlreco/post_processing/reconstruction/particle_points.py b/mlreco/post_processing/reconstruction/particle_points.py index e69de29b..8cb08498 100644 --- a/mlreco/post_processing/reconstruction/particle_points.py +++ b/mlreco/post_processing/reconstruction/particle_points.py @@ -0,0 +1,257 @@ +import numpy as np +import numba as nb +from scipy.spatial.distance import cdist +from sklearn.decomposition import PCA + +from mlreco.post_processing import post_processing +from mlreco.post_processing.reconstruction.calorimetry import compute_track_dedx + +@post_processing(data_capture=[], + result_capture=['particle_start_points', + 'particle_end_points', + 'input_rescaled', + 'particle_seg', + 'particle_clusts']) +def assign_particle_extrema(data_dict, result_dict, + mode='local_density'): + """Post processing for assigning track startpoint and endpoint, with + added correction modules. + + Parameters + ---------- + mode: algorithm to correct track startpoint/endpoint misplacement. + The following modes are available: + - linfit: computes local energy deposition density throughout the + track, computes the overall slope (linear fit) of the energy density + variation to estimate the direction. + - local_desnity: computes local energy deposition density only at + the extrema and chooses the higher one as the endpoint. + - ppn: uses ppn candidate predictions (classify_endpoints) to assign + start and endpoints. + + Returns + ------- + update_dict: dict + Empty dictionary (operation is in-place) + """ + + startpts = result_dict['particle_start_points'][:, 1:4] + endpts = result_dict['particle_end_points'][:, 1:4] + input_data = result_dict['input_rescaled'] + particle_seg = result_dict['particle_seg'] + particles = result_dict['particle_clusts'] + + update_dict = {} + + assert len(startpts) == len(endpts) + assert len(startpts) == len(particles) + + for i, p in enumerate(particles): + semantic_type = particle_seg[i] + if semantic_type == 1: + points = input_data[p][:, 1:4] + depositions = input_data[p][:, 4] + startpoint = startpts[i] + endpoint = endpts[i] + new_startpoint, new_endpoint = get_track_points(points, + startpoint, + endpoint, + depositions, + correction_mode=mode) + result_dict['particle_start_points'][i][1:4] = new_startpoint + result_dict['particle_end_points'][i][1:4] = new_endpoint + + return update_dict + + + +def handle_singleton_ppn_candidate(pts, ppn_candidates): + """Function for handling ppn endpoint correction cases in which + there's only one ppn candidate associated with a particle instance. + + Parameters + ---------- + pts: (2 x 3 np.array) + xyz coordinates of startpoint and endpoint + ppn_candidates: (N x 5 np.array) + ppn predictions associated with a single particle instance. + + Returns + ------- + new_points: (2 x 3 np.array) + Rearranged startpoint and endpoint based on proximity to + ppn candidate point and endpoint score. + + """ + assert ppn_candidates.shape[0] == 1 + score = ppn_candidates[0][5:] + label = np.argmax(score) + dist = cdist(pts, ppn_candidates[:, :3]) + pt_near = pts[dist.argmin(axis=0)] + pt_far = pts[dist.argmax(axis=0)] + if label == 0: + startpoint = pt_near.reshape(-1) + endpoint = pt_far.reshape(-1) + else: + endpoint = pt_near.reshape(-1) + startpoint = pt_far.reshape(-1) + + new_points = np.vstack([startpoint, endpoint]) + + return new_points + + +def correct_track_endpoints_ppn(startpoint: np.ndarray, + endpoint: np.ndarray, + ppn_candidates: np.ndarray): + + + pts = np.vstack([startpoint, endpoint]) + + new_points = np.copy(pts) + if ppn_candidates.shape[0] == 0: + startpoint = pts[0] + endpoint = pts[1] + elif ppn_candidates.shape[0] == 1: + # If only one ppn candidate, find track endpoint closer to + # ppn candidate and give the candidate's label to that track point + new_points = handle_singleton_ppn_candidate(pts, ppn_candidates) + else: + dist1 = cdist(np.atleast_2d(ppn_candidates[:, :3]), + np.atleast_2d(pts[0])).reshape(-1) + dist2 = cdist(np.atleast_2d(ppn_candidates[:, :3]), + np.atleast_2d(pts[1])).reshape(-1) + + ind1, ind2 = dist1.argmin(), dist2.argmin() + if ind1 == ind2: + ppn_candidates = ppn_candidates[dist1.argmin()].reshape(1, 7) + new_points = handle_singleton_ppn_candidate(pts, ppn_candidates) + else: + pt1_score = ppn_candidates[ind1][5:] + pt2_score = ppn_candidates[ind2][5:] + + labels = np.array([pt1_score.argmax(), pt2_score.argmax()]) + scores = np.array([pt1_score.max(), pt2_score.max()]) + + if labels[0] == 0 and labels[1] == 1: + new_points[0] = pts[0] + new_points[1] = pts[1] + elif labels[0] == 1 and labels[1] == 0: + new_points[0] = pts[1] + new_points[1] = pts[0] + elif labels[0] == 0 and labels[1] == 0: + # print("Particle {} has no endpoint".format(p.id)) + # Select point with larger score as startpoint + ix = np.argmax(scores) + iy = np.argmin(scores) + # print(ix, iy, pts, scores) + new_points[0] = pts[ix] + new_points[1] = pts[iy] + elif labels[0] == 1 and labels[1] == 1: + ix = np.argmax(scores) # point with higher endpoint score + iy = np.argmin(scores) + new_points[0] = pts[iy] + new_points[1] = pts[ix] + else: + raise ValueError("Classify endpoints feature dimension must be 2, got something else!") + + return new_points[0], new_points[1] + + +def correct_track_endpoints_local_density(points: np.ndarray, + startpoint: np.ndarray, + endpoint: np.ndarray, + depositions: np.ndarray, + r=5): + new_startpoint, new_endpoint = np.copy(startpoint), np.copy(endpoint) + pca = PCA(n_components=2) + mask_st = np.linalg.norm(startpoint - points, axis=1) < r + if np.count_nonzero(mask_st) < 2: + return new_startpoint, new_endpoint + pca_axis = pca.fit_transform(points[mask_st]) + length = pca_axis[:, 0].max() - pca_axis[:, 0].min() + local_d_start = depositions[mask_st].sum() / length + mask_end = np.linalg.norm(endpoint - points, axis=1) < r + if np.count_nonzero(mask_end) < 2: + return new_startpoint, new_endpoint + pca_axis = pca.fit_transform(points[mask_end]) + length = pca_axis[:, 0].max() - pca_axis[:, 0].min() + local_d_end = depositions[mask_end].sum() / length + # Startpoint must have lowest local density + if local_d_start > local_d_end: + p1, p2 = startpoint, endpoint + new_startpoint = p2 + new_endpoint = p1 + return new_startpoint, new_endpoint + + +def correct_track_endpoints_linfit(points, + startpoint, + endpoint, + depositions, + bin_size=17): + if len(points) >= 2: + dedx = compute_track_dedx(points, + startpoint, + endpoint, + depositions, + bin_size=bin_size) + new_startpoint, new_endpoint = np.copy(startpoint), np.copy(endpoint) + if len(dedx) > 1: + x = np.arange(len(dedx)) + params = np.polyfit(x, dedx, 1) + if params[0] < 0: + p1, p2 = startpoint, endpoint + new_startpoint = p2 + new_endpoint = p1 + return new_startpoint, new_endpoint + + +def get_track_endpoints_max_dist(points): + """Helper function for getting track endpoints. + + Computes track endpoints without ppn predictions by + selecting the farthest two points from the coordinate centroid. + + Parameters + ---------- + points: (N x 3) particle voxel coordinates + + Returns + ------- + endpoints : (2, 3) np.array + Xyz coordinates of two endpoints predicted or manually found + by network. + """ + coords = points + dist = cdist(coords, coords) + inds = np.unravel_index(dist.argmax(), dist.shape) + return coords[inds[0]], coords[inds[1]] + + +def get_track_points(points, + startpoint, + endpoint, + depositions, + correction_mode='ppn', + **kwargs): + if correction_mode == 'ppn': + ppn_candidates = kwargs['ppn_candidates'] + new_startpoint, new_endpoint = correct_track_endpoints_ppn(startpoint, + endpoint, + ppn_candidates) + elif correction_mode == 'local_density': + new_startpoint, new_endpoint = correct_track_endpoints_local_density(points, + startpoint, + endpoint, + depositions, + **kwargs) + elif correction_mode == 'linfit': + new_startpoint, new_endpoint = correct_track_endpoints_linfit(points, + startpoint, + endpoint, + depositions, + **kwargs) + else: + raise ValueError("Track extrema correction mode {} not defined!".format(correction_mode)) + return new_startpoint, new_endpoint \ No newline at end of file diff --git a/mlreco/post_processing/reconstruction/utils.py b/mlreco/post_processing/reconstruction/utils.py new file mode 100644 index 00000000..58627877 --- /dev/null +++ b/mlreco/post_processing/reconstruction/utils.py @@ -0,0 +1,36 @@ +import numpy as np +import numba as nb +from scipy.spatial.distance import cdist + +from mlreco.utils.gnn.cluster import cluster_direction +from mlreco.post_processing import post_processing +from mlreco.utils.globals import COORD_COLS + + +@post_processing(data_capture=[], + result_capture=['particle_clusts', + 'input_rescaled', + 'particle_start_points']) +def reconstruct_direction(data_dict, result_dict, + max_dist=-1, optimize=True): + """Post processing for reconstructing particle direction. + + """ + startpts = result_dict['particle_start_points'][:, COORD_COLS[0]:COORD_COLS[-1]+1] + coords = result_dict['input_rescaled'][:, COORD_COLS[0]:COORD_COLS[-1]+1] + particles = result_dict['particle_clusts'] + + update_dict = {} + + particle_dirs = [] + for i, mask in enumerate(particles): + pts = coords[mask] + vec = cluster_direction(pts, startpts[i], + max_dist=max_dist, optimize=optimize) + particle_dirs.append(vec) + if len(particle_dirs) > 0: + particle_dirs = np.vstack(particle_dirs) + update_dict['particle_dirs'] = particle_dirs + else: + update_dict['particle_dirs'] = np.array(particle_dirs) + return update_dict \ No newline at end of file diff --git a/mlreco/post_processing/reconstruction/vertex.py b/mlreco/post_processing/reconstruction/vertex.py index e69de29b..ac89bb08 100644 --- a/mlreco/post_processing/reconstruction/vertex.py +++ b/mlreco/post_processing/reconstruction/vertex.py @@ -0,0 +1,303 @@ +import sys + +import numpy as np +import numba as nb +from scipy.spatial.distance import cdist + +from mlreco.utils.gnn.cluster import cluster_direction +from mlreco.post_processing import post_processing +from mlreco.utils.globals import COORD_COLS + +@post_processing(data_capture=[], + result_capture=['particle_clusts', + 'particle_seg', + 'particle_start_points', + 'particle_group_pred', + 'particle_node_pred_vtx', + 'input_rescaled'], + result_capture_optional=['particle_dirs']) +def reconstruct_vertex(data_dict, result_dict, + mode='all', + include_semantics=[0,1], + use_primaries=True, + r1=5.0, + r2=10.0): + """Post processing for reconstructing interaction vertex. + + """ + + particles = result_dict['particle_clusts'] + particle_group_pred = result_dict['particle_group_pred'] + primary_ids = np.argmax(result_dict['particle_node_pred_vtx'], axis=1) + particle_seg = result_dict['particle_seg'] + input_coords = result_dict['input_rescaled'][:, COORD_COLS[0]:COORD_COLS[-1]+1] + startpoints = result_dict['particle_start_points'][:, COORD_COLS[0]:COORD_COLS[-1]+1] + + # Optional + particle_dirs = result_dict.get('particle_dirs', None) + + assert len(primary_ids) == len(particles) + + if particle_dirs is not None: + assert len(particle_dirs) == len(particles) + + vertices = [] + interaction_ids = [] + # Loop over interactions: + for ia in np.unique(particle_group_pred): + interaction_ids.append(ia) + # Default bogus value for no vertex + candidates = [] + vertex = np.array([-sys.maxsize, -sys.maxsize, -sys.maxsize]) + + int_mask = particle_group_pred == ia + particles_int = [] + startpoints_int = [] + particle_seg_int = [] + primaries_int = [] + + dirs_int = None + if particle_dirs is not None: + dirs_int = [p for i, p in enumerate(particle_dirs[int_mask]) \ + if particle_seg[int_mask][i] in include_semantics] + + for i, primary_id in enumerate(primary_ids[int_mask]): + if particle_seg[int_mask][i] not in include_semantics: + continue + if not use_primaries or primary_id == 1: + particles_int.append(particles[int_mask][i]) + particle_seg_int.append(particle_seg[int_mask][i]) + primaries_int.append(primary_id) + startpoints_int.append(startpoints[int_mask][i]) + if particle_dirs is not None: + dirs_int.append(particle_dirs[int_mask][i]) + + if len(startpoints_int) > 0: + startpoints_int = np.vstack(startpoints_int) + + # Gather vertex candidates from each algorithm + vertices_1 = get_centroid_adj_pairs(startpoints_int, r1=r1) + vertices_2 = get_track_shower_poca(startpoints_int, + particles_int, + particle_seg_int, + input_coords, + r2=r2, + particle_dirs=dirs_int) + if len(particles_int) >= 2: + pseudovertex = compute_pseudovertex(particles_int, + startpoints_int, + input_coords, + dim=3, + particle_dirs=dirs_int) + else: + pseudovertex = np.array([]) + + if vertices_1.shape[0] > 0: + candidates.append(vertices_1) + if vertices_2.shape[0] > 0: + candidates.append(vertices_2) + if len(candidates) > 0: + candidates = np.vstack(candidates) + vertex = np.mean(candidates, axis=0) + vertices.append(vertex) + + if len(vertices) > 0: + vertices = np.vstack(vertices) + else: + msg = "Vertex reconstructor saw an image with no interactions, "\ + "maybe there's an image with no voxels?" + raise RuntimeWarning(msg) + vertices = np.array([]) + + interaction_ids = np.array(interaction_ids).reshape(-1, 1) + vertices = np.hstack([interaction_ids, vertices]) + + update_dict = { + 'vertices': vertices + } + + return update_dict + +@nb.njit(cache=True) +def point_to_line_distance_(p1, p2, v2): + dist = np.sqrt(np.sum(np.cross(v2, (p2 - p1))**2)+1e-8) + return dist + +@nb.njit(cache=True) +def point_to_line_distance(P1, P2, V2): + dist = np.zeros((P1.shape[0], P2.shape[0])) + for i, p1 in enumerate(P1): + for j, p2 in enumerate(P2): + d = point_to_line_distance_(p1, p2, V2[j]) + dist[i, j] = d + return dist + + +def get_centroid_adj_pairs(particle_start_points, + r1=5.0): + ''' + From N x 3 array of N particle startpoint coordinates, find + two points which touch each other within r1, and return the + barycenter of such pairs. + ''' + candidates = [] + + startpoints = [] + for i, pts in enumerate(particle_start_points): + startpoints.append(pts) + if len(startpoints) == 0: + return np.array(candidates) + startpoints = np.vstack(startpoints) + dist = cdist(startpoints, startpoints) + dist += -np.eye(dist.shape[0]) + idx, idy = np.where( (dist < r1) & (dist > 0)) + # Keep track of duplicate pairs + duplicates = [] + # Append barycenter of two touching points within radius r1 to candidates + for ix, iy in zip(idx, idy): + center = (startpoints[ix] + startpoints[iy]) / 2.0 + if not((ix, iy) in duplicates or (iy, ix) in duplicates): + candidates.append(center) + duplicates.append((ix, iy)) + candidates = np.array(candidates) + return candidates + + +def get_track_shower_poca(particle_start_points, + particle_clusts, + particle_seg, + input_coords, + r2=5.0, + particle_dirs=None): + ''' + From list of particles, find startpoints of track particles that lie + within r2 distance away from the closest line defined by a shower + direction vector. + ''' + + candidates = [] + + track_starts = [] + shower_starts, shower_dirs = [], [] + for i, mask in enumerate(particle_clusts): + pts = input_coords[mask] + if particle_seg[i] == 0 and len(pts) > 0: + if particle_dirs is not None: + vec = particle_dirs[i] + else: + vec = cluster_direction(pts, + particle_start_points[i], + optimize=True) + shower_dirs.append(vec) + shower_starts.append( + particle_start_points[i]) + if particle_seg[i] == 1: + track_starts.append( + particle_start_points[i]) + + shower_dirs = np.array(shower_dirs) + shower_starts = np.array(shower_starts) + track_starts = np.array(track_starts) + + assert len(shower_dirs) == len(shower_starts) + + if len(shower_dirs) == 0 or len(track_starts) == 0: + return np.array(candidates) + + dist = point_to_line_distance(track_starts, shower_starts, shower_dirs) + idx, idy = np.where(dist < r2) + for ix, iy in zip(idx, idy): + candidates.append(track_starts[ix]) + + candidates = np.array(candidates) + return candidates + + +def compute_pseudovertex(particle_clusts, + particle_start_points, + input_coords, + dim=3, + particle_dirs=None): + """ + Given a set of particles, compute the vertex by the following method: + + 1) Estimate the direction of each particle + 2) Using infinite lines defined by the direction and the startpoint of + each particle, compute the point of closest approach. + 3) Solve the least squares optimization problem. + + The least squares problem in this case has an analytic solution + which could be solved by matrix pseudoinversion. + """ + pseudovtx = np.zeros((dim, )) + S = np.zeros((dim, dim)) + C = np.zeros((dim, )) + + assert len(particle_clusts) >= 2 + + for i, mask in enumerate(particle_clusts): + pts = input_coords[mask] + startpt = particle_start_points[i] + if particle_dirs is not None: + vec = particle_dirs[i] + else: + vec = cluster_direction(pts, startpt, optimize=True) + w = 1.0 + S += w * (np.outer(vec, vec) - np.eye(dim)) + C += w * (np.outer(vec, vec) - np.eye(dim)) @ startpt + + pseudovtx = np.linalg.pinv(S) @ C + return pseudovtx + + +def compute_vertex_candidates(particle_clusts, + particle_seg, + input_coords, + particle_start_points, + r1=5.0, + r2=5.0, + particle_dirs=None): + + candidates = [] + + # 1. Select two startpoints within dist r1 + candidates.append(get_centroid_adj_pairs(particle_start_points, + r1=r1)) + # 2. Select a track start point which is close + # to a line defined by shower direction + candidates.append(get_track_shower_poca(particle_start_points, + particle_clusts, + particle_seg, + input_coords, + r2=r2, + particle_dirs=particle_dirs)) + + return candidates + + +def prune_vertex_candidates(candidates, pseudovtx, r=30): + dist = np.linalg.norm(candidates - pseudovtx.reshape(1, -1), axis=1) + pruned = candidates[dist < r] + return pruned + + +# def correct_primary_with_vertex(ia, r_adj=10, r_bt=10, start_segment_radius=10): +# assert type(ia) is Interaction +# if ia.vertex is not None and (ia.vertex > 0).all(): +# for p in ia.particles: +# if p.semantic_type == 1: +# dist = np.linalg.norm(p.startpoint - ia.vertex) +# # print(p.id, p.is_primary, p.semantic_type, dist) +# if dist < r_adj: +# p.is_primary = True +# else: +# p.is_primary = False +# if p.semantic_type == 0: +# vec = get_particle_direction(p, start_segment_radius=start_segment_radius) +# dist = point_to_line_distance_(ia.vertex, p.startpoint, vec) +# if np.linalg.norm(p.startpoint - ia.vertex) < r_adj: +# p.is_primary = True +# elif dist < r_bt: +# p.is_primary = True +# else: +# p.is_primary = False \ No newline at end of file From 7498531cb023caf1150fa531c5972155f1127eb1 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Tue, 4 Apr 2023 21:41:18 -0700 Subject: [PATCH 104/180] Loosened conditions to quality as a neutrino interaction --- mlreco/iotools/parsers/label_data.py | 45 +++++++++++++--------------- mlreco/utils/globals.py | 5 ++-- 2 files changed, 23 insertions(+), 27 deletions(-) diff --git a/mlreco/iotools/parsers/label_data.py b/mlreco/iotools/parsers/label_data.py index 70ad0c4f..25dc0b39 100644 --- a/mlreco/iotools/parsers/label_data.py +++ b/mlreco/iotools/parsers/label_data.py @@ -45,13 +45,13 @@ def get_interaction_ids(particles): np.ndarray (P) List of interaction IDs, one per true particle instance ''' - # If the interaction IDs are specified in the particle tree, just use that + # If the interaction IDs are set in the particle tree, simply use that inter_ids = np.array([p.interaction_id() for p in particles], dtype=np.int32) if np.any(inter_ids != INVAL_ID): inter_ids[inter_ids == INVAL_ID] == -1 return inter_ids - # Define interaction IDs on the basis of sharing an ancestor vertex position + # Otherwise, define interaction IDs on the basis of sharing an ancestor vertex position anc_pos = np.vstack([[getattr(p, f'ancestor_{a}')() for a in ['x', 'y', 'z']] for p in particles]) inter_ids = np.unique(anc_pos, axis=0, return_inverse=True)[-1] @@ -68,12 +68,12 @@ def get_nu_ids(particles, inter_ids, particles_mpv=None, neutrinos=None): neutrino) of each of the particle in the input particle list. If `particles_mpv` and `neutrinos` are not specified, it assumes that - there is only one neutrino-like interaction, the first valid one, and - it enforces that it must contain at least two true primaries. + only neutrino-like interactions have more than one true primary + particle in a single interaction. If a list of multi-particle vertex (MPV) particles or neutrinos is - provided, that information is leveraged to identify which interaction - is neutrino-like and which is not. + provided, that information is leveraged to identify which interactions + are neutrino-like and which are not. Parameters ---------- @@ -99,17 +99,16 @@ def get_nu_ids(particles, inter_ids, particles_mpv=None, neutrinos=None): nu_ids = np.zeros(len(inter_ids), dtype=inter_ids.dtype) nu_ids[inter_ids == -1] = -1 if particles_mpv is None and neutrinos is None: - # Find the first particle with a valid interaction ID - valid_mask = np.where(inter_ids > -1)[0] - if not len(valid_mask): - return nu_ids - inter_id = inter_ids[valid_mask[0]] - - # If there are at least two primaries, the interaction is neutrino-like + # Loop over the interactions primary_ids = get_group_primary_ids(particles) - inter_index = np.where(inter_ids == inter_id)[0] - if np.sum(primary_ids[inter_index] == 1) > 1: - nu_ids[inter_index] = 1 + for i in np.unique(inter_ids): + # If the interaction ID is invalid, skip + if i < 0: continue + + # If there are at least two primaries, the interaction is neutrino-like + inter_index = np.where(inter_ids == i)[0] + if np.sum(primary_ids[inter_index] == 1) > 1: + nu_ids[inter_index] = 1 else: # Find the reference positions gauge if a particle comes from a neutrino-like interaction ref_pos = None @@ -164,12 +163,10 @@ def get_particle_ids(particles, nu_ids, include_mpr=False, include_secondary=Fal primary_ids = get_group_primary_ids(particles, nu_ids, include_mpr) for i in range(len(particle_ids)): # If the primary ID is invalid, skip - if primary_ids[i] < 0: - continue + if primary_ids[i] < 0: continue # If secondary particles are not included and primary_id < 1, skip - if not include_secondary and primary_ids[i] < 1: - continue + if not include_secondary and primary_ids[i] < 1: continue # If the particle type exists in the predefined list, assign group_id = particles[i].group_id() @@ -201,18 +198,16 @@ def get_shower_primary_ids(particles): valid_mask = get_valid_mask(particles) for g in np.unique(group_ids): # If the particle group has invalid labeling, it does not contain a primary - if g == INVAL_ID or not valid_mask[g]: - continue - p = particles[g] + if g == INVAL_ID or not valid_mask[g]: continue # If a group originates from a Delta or a Michel, that has a primary + p = particles[g] if p.shape() == MICHL_SHP or p.shape() == DELTA_SHP: primary_ids[g] = 1 continue # If a group does not originate from EM activity, it does not contain a primary - if p.shape() != SHOWR_SHP: - continue + if p.shape() != SHOWR_SHP: continue # If a shower group's parent fragment the first in time, it is a valid primary group_index = np.where(group_ids == g)[0] diff --git a/mlreco/utils/globals.py b/mlreco/utils/globals.py index f385145d..b81d2f83 100644 --- a/mlreco/utils/globals.py +++ b/mlreco/utils/globals.py @@ -38,7 +38,7 @@ # Invalid larcv.Particle labels INVAL_ID = larcv.kINVALID_INSTANCEID # Particle group/parent/interaction ID INVAL_TID = larcv.kINVALID_UINT # Particle Geant4 track ID -INVAL_PDG = 0 # Patricle PDG code +INVAL_PDG = 0 # Particle PDG code # Mapping between particle PDG code and particle ID labels PDG_TO_PID = { @@ -52,10 +52,11 @@ 2212: 4, # protons } -# CONSTANTS +# Physical constants MUON_MASS = 105.7 # [MeV/c^2] ELECTRON_MASS = 0.511998 # [MeV/c^2] PROTON_MASS = 938.272 # [MeV/c^2] ARGON_DENSITY = 1.396 # [g/cm^3] + ADC_TO_MEV = 1. / 350. # < MUST GO PIXELS_TO_CM = 0.3 # < MUST GO From 62adb7062caccaffb1c7ffe91c393371d3babb17 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Tue, 4 Apr 2023 22:03:01 -0700 Subject: [PATCH 105/180] Label neutrino-like interactions as a whole --- mlreco/iotools/parsers/label_data.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/mlreco/iotools/parsers/label_data.py b/mlreco/iotools/parsers/label_data.py index 25dc0b39..6cff9352 100644 --- a/mlreco/iotools/parsers/label_data.py +++ b/mlreco/iotools/parsers/label_data.py @@ -117,12 +117,17 @@ def get_nu_ids(particles, inter_ids, particles_mpv=None, neutrinos=None): elif neutrinos: ref_pos = np.vstack([[getattr(n, a)() for a in ['x', 'y', 'z']] for n in neutrinos]) - # If a particle shares its ancestor position with an MPV particle - # or a neutrino, it belongs to a neutrino-like interaction + # If any particle in an interaciton shares its ancestor position with an MPV particle + # or a neutrino, the whole interaction is a neutrino-like interaction. if ref_pos is not None and len(ref_pos): anc_pos = np.vstack([[getattr(p, f'ancestor_{a}')() for a in ['x', 'y', 'z']] for p in particles]) - for pos in ref_pos: - nu_ids[(anc_pos == pos).all(axis=1)] = 1 + for i in np.unique(inter_ids): + inter_index = np.where(inter_ids == i)[0] + if i < 0: continue + for pos in ref_pos: + if np.any((anc_pos[inter_index] == pos).all(axis=1)): + nu_ids[inter_index] = 1 + break return nu_ids From 006281b82e9c46188d3f60ce3f5abc6a485a6b6e Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Tue, 4 Apr 2023 22:43:23 -0700 Subject: [PATCH 106/180] Fixed bug in HDF5 writer --- mlreco/iotools/parsers/label_data.py | 4 ++-- mlreco/iotools/writers.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mlreco/iotools/parsers/label_data.py b/mlreco/iotools/parsers/label_data.py index 6cff9352..81e63345 100644 --- a/mlreco/iotools/parsers/label_data.py +++ b/mlreco/iotools/parsers/label_data.py @@ -110,7 +110,7 @@ def get_nu_ids(particles, inter_ids, particles_mpv=None, neutrinos=None): if np.sum(primary_ids[inter_index] == 1) > 1: nu_ids[inter_index] = 1 else: - # Find the reference positions gauge if a particle comes from a neutrino-like interaction + # Find the reference positions to gauge if a particle comes from a neutrino-like interaction ref_pos = None if particles_mpv: ref_pos = np.vstack([[getattr(p, a)() for a in ['x', 'y', 'z']] for p in particles_mpv]) @@ -238,7 +238,7 @@ def get_group_primary_ids(particles, nu_ids=None, include_mpr=True): Results ------- np.ndarray - (P) List of particle shower primary IDs, one per true particle instance + (P) List of particle primary IDs, one per true particle instance ''' # Loop over the list of particles primary_ids = np.empty(len(particles), dtype=np.int32) diff --git a/mlreco/iotools/writers.py b/mlreco/iotools/writers.py index 5a92dd0b..b8ebd46f 100644 --- a/mlreco/iotools/writers.py +++ b/mlreco/iotools/writers.py @@ -205,7 +205,7 @@ def register_key(self, blob, key, category): # List containing a single list of scalars per batch ID self.key_dict[key]['dtype'] = type(blob[key][0][0]) - elif not blob[key][0].dtype == np.object: + elif not isinstance(blob[key][0], list) and not blob[key][0].dtype == np.object: # List containing a single ndarray of scalars per batch ID self.key_dict[key]['dtype'] = blob[key][0].dtype self.key_dict[key]['width'] = blob[key][0].shape[1] if len(blob[key][0].shape) == 2 else 0 From 91d5345a30fc1231e277c6f1a97e81be414c5421 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Tue, 4 Apr 2023 23:30:32 -0700 Subject: [PATCH 107/180] Modify analysis tools for new post processing compatibility --- analysis/algorithms/arxiv/particles.py | 4 +- analysis/algorithms/scripts/template.py | 34 +++------- analysis/algorithms/utils.py | 80 +---------------------- analysis/classes/evaluator.py | 84 ++++++++++++++++--------- analysis/classes/particle_utils.py | 55 ++-------------- analysis/classes/predictor.py | 54 ++++------------ analysis/decorator.py | 21 +++++-- analysis/run.py | 44 ++++++------- 8 files changed, 122 insertions(+), 254 deletions(-) diff --git a/analysis/algorithms/arxiv/particles.py b/analysis/algorithms/arxiv/particles.py index c65b5d5f..859584da 100644 --- a/analysis/algorithms/arxiv/particles.py +++ b/analysis/algorithms/arxiv/particles.py @@ -11,9 +11,7 @@ from analysis.classes.Interaction import Interaction from analysis.classes.Particle import Particle from analysis.classes.TruthParticle import TruthParticle -from analysis.algorithms.utils import get_interaction_properties, \ - get_particle_properties, \ - get_mparticles_from_minteractions +from analysis.algorithms.utils import get_particle_properties from lartpc_mlreco3d.analysis.algorithms.arxiv.calorimetry import get_csda_range_spline diff --git a/analysis/algorithms/scripts/template.py b/analysis/algorithms/scripts/template.py index 4381d357..34ef294f 100644 --- a/analysis/algorithms/scripts/template.py +++ b/analysis/algorithms/scripts/template.py @@ -4,7 +4,6 @@ from analysis.classes.evaluator import FullChainEvaluator from analysis.classes.TruthInteraction import TruthInteraction from analysis.classes.Interaction import Interaction -from analysis.algorithms.utils import get_mparticles_from_minteractions from analysis.algorithms.logger import ParticleLogger, InteractionLogger @evaluate(['interactions', 'particles']) @@ -17,29 +16,17 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): interactions, particles = [], [] # Analysis tools configuration - deghosting = analysis_cfg['analysis']['deghosting'] primaries = analysis_cfg['analysis']['match_primaries'] enable_flash_matching = analysis_cfg['analysis'].get('enable_flash_matching', False) ADC_to_MeV = analysis_cfg['analysis'].get('ADC_to_MeV', 1./350.) - compute_vertex = analysis_cfg['analysis']['compute_vertex'] - vertex_mode = analysis_cfg['analysis']['vertex_mode'] matching_mode = analysis_cfg['analysis']['matching_mode'] - compute_energy = analysis_cfg['analysis'].get('compute_energy', False) flash_matching_cfg = analysis_cfg['analysis'].get('flash_matching_cfg', '') - tag_pi0 = analysis_cfg['analysis'].get('tag_pi0', False) # FullChainEvaluator config processor_cfg = analysis_cfg['analysis'].get('processor_cfg', {}) - - # Skeleton for csv output - # interaction_dict = analysis_cfg['analysis'].get('interaction_dict', {}) - - particle_fieldnames = analysis_cfg['analysis'].get('particle_fieldnames', {}) - int_fieldnames = analysis_cfg['analysis'].get('interaction_fieldnames', {}) - - use_primaries_for_vertex = analysis_cfg['analysis'].get('use_primaries_for_vertex', True) - run_reco_vertex = analysis_cfg['analysis'].get('run_reco_vertex', False) - test_containment = analysis_cfg['analysis'].get('test_containment', False) + # Particle and Interaction processor names + particle_fieldnames = analysis_cfg['logger'].get('particles', {}) + int_fieldnames = analysis_cfg['logger'].get('interactions', {}) # Load data into evaluator if enable_flash_matching: @@ -68,22 +55,19 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): use_depositions_MeV=False, ADC_to_MeV=ADC_to_MeV) # 1. Match Interactions and log interaction-level information - matches, counts = predictor.match_interactions(idx, + matches, icounts = predictor.match_interactions(idx, mode='true_to_pred', match_particles=True, drop_nonprimary_particles=primaries, return_counts=True, - compute_vertex=compute_vertex, - vertex_mode=vertex_mode, overlap_mode=predictor.overlap_mode, - matching_mode=matching_mode, - tag_pi0=tag_pi0) + matching_mode=matching_mode) # 1 a) Check outputs from interaction matching if len(matches) == 0: continue - particle_matches, particle_match_counts = get_mparticles_from_minteractions(matches) + pmatches, pcounts = predictor.match_parts_within_ints(matches) # 2. Process interaction level information interaction_logger = InteractionLogger(int_fieldnames) @@ -92,7 +76,7 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): int_dict = OrderedDict() int_dict.update(index_dict) - int_dict['interaction_match_counts'] = counts[i] + int_dict['interaction_match_counts'] = icounts[i] true_int, pred_int = interaction_pair[0], interaction_pair[1] assert (type(true_int) is TruthInteraction) or (true_int is None) @@ -108,7 +92,7 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): particle_logger = ParticleLogger(particle_fieldnames) particle_logger.prepare() - for i, mparticles in enumerate(particle_matches): + for i, mparticles in enumerate(pmatches): true_p, pred_p = mparticles[0], mparticles[1] true_p_dict = particle_logger.produce(true_p, mode='true') @@ -116,7 +100,7 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): part_dict = OrderedDict() part_dict.update(index_dict) - part_dict['particle_match_counts'] = particle_match_counts[i] + part_dict['particle_match_counts'] = pcounts[i] part_dict.update(true_p_dict) part_dict.update(pred_p_dict) particles.append(part_dict) diff --git a/analysis/algorithms/utils.py b/analysis/algorithms/utils.py index 6dead02b..998f5791 100644 --- a/analysis/algorithms/utils.py +++ b/analysis/algorithms/utils.py @@ -3,82 +3,4 @@ from collections import OrderedDict from turtle import up from sklearn.decomposition import PCA -from scipy.spatial.distance import cdist - -from analysis.classes import Particle -from analysis.classes import TruthParticle -from analysis.algorithms.point_matching import get_track_endpoints_max_dist -# from lartpc_mlreco3d.analysis.algorithms.arxiv.calorimetry import compute_track_dedx, get_particle_direction - - -def attach_prefix(update_dict, prefix): - if prefix is None: - return update_dict - out = OrderedDict({}) - - for key, val in update_dict.items(): - new_key = "{}_".format(prefix) + str(key) - out[new_key] = val - - return out - - -def get_mparticles_from_minteractions(int_matches): - ''' - Given list of Tuple[(Truth)Interaction, (Truth)Interaction], - return list of particle matches Tuple[TruthParticle, Particle]. - - If no match, (Truth)Particle is replaced with None. - ''' - - matched_particles, match_counts = [], [] - - for m in int_matches: - ia1, ia2 = m[0], m[1] - num_parts_1, num_parts_2 = -1, -1 - if m[0] is not None: - num_parts_1 = len(m[0].particles) - if m[1] is not None: - num_parts_2 = len(m[1].particles) - if num_parts_1 <= num_parts_2: - ia1, ia2 = m[0], m[1] - else: - ia1, ia2 = m[1], m[0] - - for p in ia2.particles: - if len(p.match) == 0: - if type(p) is Particle: - matched_particles.append((None, p)) - match_counts.append(-1) - else: - matched_particles.append((p, None)) - match_counts.append(-1) - for match_id in p.match: - if type(p) is Particle: - matched_particles.append((ia1[match_id], p)) - else: - matched_particles.append((p, ia1[match_id])) - match_counts.append(p._match_counts[match_id]) - return matched_particles, np.array(match_counts) - -@nb.njit -def closest_distance_two_lines(a0, u0, a1, u1): - ''' - a0, u0: point (a0) and unit vector (u0) defining line 1 - a1, u1: point (a1) and unit vector (u1) defining line 2 - ''' - cross = np.cross(u0, u1) - # if the cross product is zero, the lines are parallel - if np.linalg.norm(cross) == 0: - # use any point on line A and project it onto line B - t = np.dot(a1 - a0, u1) - a = a1 + t * u1 # projected point - return np.linalg.norm(a0 - a) - else: - # use the formula from https://en.wikipedia.org/wiki/Skew_lines#Distance - t = np.dot(np.cross(a1 - a0, u1), cross) / np.linalg.norm(cross)**2 - # closest point on line A to line B - p = a0 + t * u0 - # closest point on line B to line A - q = p - cross * np.dot(p - a1, cross) / np.linalg.norm(cross)**2 - return np.linalg.norm(p - q) # distance between p and q \ No newline at end of file +from scipy.spatial.distance import cdist \ No newline at end of file diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index e575c690..52679f3e 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -434,9 +434,7 @@ def get_true_particles(self, entry, only_primaries=True, def get_true_interactions(self, entry, drop_nonprimary_particles=True, min_particle_voxel_count=-1, - compute_vertex=True, - volume=None, - tag_pi0=False) -> List[Interaction]: + volume=None) -> List[Interaction]: if min_particle_voxel_count < 0: min_particle_voxel_count = self.min_particle_voxel_count @@ -446,12 +444,10 @@ def get_true_interactions(self, entry, drop_nonprimary_particles=True, volume=volume) out = group_particles_to_interactions_fn(true_particles, get_nu_id=True, - mode='truth', - tag_pi0=tag_pi0) - if compute_vertex: - vertices = self.get_true_vertices(entry) + mode='truth') + vertices = self.get_true_vertices(entry) for ia in out: - if compute_vertex and ia.id in vertices: + if ia.id in vertices: ia.vertex = vertices[ia.id] if 'neutrino_asis' in self.data_blob and ia.nu_id == 1: @@ -474,6 +470,45 @@ def get_true_interactions(self, entry, drop_nonprimary_particles=True, } return out + + @staticmethod + def match_parts_within_ints(int_matches): + ''' + Given list of Tuple[(Truth)Interaction, (Truth)Interaction], + return list of particle matches Tuple[TruthParticle, Particle]. + + If no match, (Truth)Particle is replaced with None. + ''' + + matched_particles, match_counts = [], [] + + for m in int_matches: + ia1, ia2 = m[0], m[1] + num_parts_1, num_parts_2 = -1, -1 + if m[0] is not None: + num_parts_1 = len(m[0].particles) + if m[1] is not None: + num_parts_2 = len(m[1].particles) + if num_parts_1 <= num_parts_2: + ia1, ia2 = m[0], m[1] + else: + ia1, ia2 = m[1], m[0] + + for p in ia2.particles: + if len(p.match) == 0: + if type(p) is Particle: + matched_particles.append((None, p)) + match_counts.append(-1) + else: + matched_particles.append((p, None)) + match_counts.append(-1) + for match_id in p.match: + if type(p) is Particle: + matched_particles.append((ia1[match_id], p)) + else: + matched_particles.append((p, ia1[match_id])) + match_counts.append(p._match_counts[match_id]) + return matched_particles, np.array(match_counts) def get_true_vertices(self, entry): @@ -530,12 +565,16 @@ def match_particles(self, entry, # print('matching', entries, volume) if mode == 'pred_to_true': # Match each pred to one in true - particles_from = self.get_particles(entry, only_primaries=only_primaries) - particles_to = self.get_true_particles(entry, only_primaries=only_primaries) + particles_from = self.get_particles(entry, + only_primaries=only_primaries) + particles_to = self.get_true_particles(entry, + only_primaries=only_primaries) elif mode == 'true_to_pred': # Match each true to one in pred - particles_to = self.get_particles(entry, only_primaries=only_primaries) - particles_from = self.get_true_particles(entry, only_primaries=only_primaries) + particles_to = self.get_particles(entry, + only_primaries=only_primaries) + particles_from = self.get_true_particles(entry, + only_primaries=only_primaries) else: raise ValueError("Mode {} is not valid. For matching each"\ " prediction to truth, use 'pred_to_true' (and vice versa).".format(mode)) @@ -558,10 +597,7 @@ def match_interactions(self, entry, mode='pred_to_true', drop_nonprimary_particles=True, match_particles=True, return_counts=False, - compute_vertex=True, - vertex_mode='all', matching_mode='one_way', - tag_pi0=False, **kwargs): """ Parameters @@ -583,24 +619,14 @@ def match_interactions(self, entry, mode='pred_to_true', all_matches, all_counts = [], [] if mode == 'pred_to_true': ints_from = self.get_interactions(entry, - drop_nonprimary_particles=drop_nonprimary_particles, - compute_vertex=compute_vertex, - vertex_mode=vertex_mode, - tag_pi0=tag_pi0) + drop_nonprimary_particles=drop_nonprimary_particles) ints_to = self.get_true_interactions(entry, - drop_nonprimary_particles=drop_nonprimary_particles, - compute_vertex=compute_vertex, - tag_pi0=tag_pi0) + drop_nonprimary_particles=drop_nonprimary_particles) elif mode == 'true_to_pred': ints_to = self.get_interactions(entry, - drop_nonprimary_particles=drop_nonprimary_particles, - compute_vertex=compute_vertex, - vertex_mode=vertex_mode, - tag_pi0=tag_pi0) + drop_nonprimary_particles=drop_nonprimary_particles) ints_from = self.get_true_interactions(entry, - drop_nonprimary_particles=drop_nonprimary_particles, - compute_vertex=compute_vertex, - tag_pi0=tag_pi0) + drop_nonprimary_particles=drop_nonprimary_particles) else: raise ValueError("Mode {} is not valid. For matching each"\ " prediction to truth, use 'pred_to_true' (and vice versa).".format(mode)) diff --git a/analysis/classes/particle_utils.py b/analysis/classes/particle_utils.py index 854e61c2..266de2ec 100644 --- a/analysis/classes/particle_utils.py +++ b/analysis/classes/particle_utils.py @@ -2,16 +2,11 @@ from typing import List, Union from collections import defaultdict, OrderedDict, Counter -from itertools import combinations from scipy.optimize import linear_sum_assignment from scipy.spatial.distance import cdist -from pprint import pprint - from . import Particle, TruthParticle, Interaction, TruthInteraction -from analysis.algorithms.utils import closest_distance_two_lines -from analysis.algorithms.arxiv.calorimetry import get_particle_direction def matrix_counts(particles_x, particles_y): @@ -386,46 +381,10 @@ def match_interactions_optimal(ints_from : List[Interaction], return matches, intersections -def _tag_neutral_pions_true(particles): - out = [] - tagged = defaultdict(list) - for part in particles: - num_voxels_noghost = part.coords_noghost.shape[0] - p = part.asis - ancestor = p.ancestor_track_id() - if p.pdg_code() == 22 \ - and p.creation_process() == "Decay" \ - and p.parent_creation_process() == "primary" \ - and p.ancestor_pdg_code() == 111 \ - and num_voxels_noghost > 0: - tagged[ancestor].append(p.id()) - for photon_list in tagged.values(): - out.append(tuple(photon_list)) - return out - -def _tag_neutral_pions_reco(particles, threshold=5): - out = [] - photons = [p for p in particles if p.pid == 0] - for entry in combinations(photons, 2): - p1, p2 = entry - v1, v2 = get_particle_direction(p1), get_particle_direction(p2) - d = closest_distance_two_lines(p1.startpoint, v1, p2.startpoint, v2) - if d < threshold: - out.append((p1.id, p2.id)) - return out - -def tag_neutral_pions(particles, mode): - if mode == 'truth': - return _tag_neutral_pions_true(particles) - elif mode == 'pred': - return _tag_neutral_pions_reco(particles) - else: - raise ValueError - - def group_particles_to_interactions_fn(particles : List[Particle], - get_nu_id=False, mode='pred', - tag_pi0=False): + get_nu_id=False, + mode='pred', + verbose=False): """ Function for grouping particles to its parent interactions. @@ -452,8 +411,9 @@ def group_particles_to_interactions_fn(particles : List[Particle], if get_nu_id: nu_id = np.unique([p.nu_id for p in particles]) if nu_id.shape[0] > 1: - print("Interaction {} has non-unique particle "\ - "nu_ids: {}".format(int_id, str(nu_id))) + if verbose: + print("Interaction {} has non-unique particle "\ + "nu_ids: {}".format(int_id, str(nu_id))) nu_id = nu_id[0] else: nu_id = nu_id[0] @@ -470,9 +430,6 @@ def group_particles_to_interactions_fn(particles : List[Particle], interactions[int_id] = TruthInteraction(int_id, particles_dict, nu_id=nu_id, volume=volume_id) else: raise ValueError - if tag_pi0: - tagged = tag_neutral_pions(particles, mode=mode) - interactions[int_id]._pi0_tagged_photons = tagged return list(interactions.values()) diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index 251c0248..01c58fc8 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -12,11 +12,9 @@ from analysis.classes.particle_utils import group_particles_to_interactions_fn from analysis.algorithms.point_matching import * -from analysis.algorithms.vertex import estimate_vertex -from analysis.algorithms.utils import get_track_points - from mlreco.utils.gnn.cluster import get_cluster_label from mlreco.utils.volumes import VolumeBoundaries +from mlreco.utils.globals import BATCH_COL, COORD_COLS from scipy.special import softmax @@ -74,15 +72,7 @@ def __init__(self, data_blob, result, cfg, predictor_cfg={}, # We want to count how well we identify interactions with some PDGs # as primary particles self.primary_pdgs = np.unique(predictor_cfg.get('primary_pdgs', [])) - # Following 2 parameters are vertex heuristic parameters - self.attaching_threshold = predictor_cfg.get('attaching_threshold', 2) - self.inter_threshold = predictor_cfg.get('inter_threshold', 10) - - # Vertex estimation modes - self.vertex_mode = predictor_cfg.get('vertex_mode', 'all') - self.prune_vertex = predictor_cfg.get('prune_vertex', True) - self.track_endpoints_mode = predictor_cfg.get('track_endpoints_mode', 'node_features') - self.track_point_corrector = predictor_cfg.get('track_point_corrector', 'ppn') + self.primary_score_threshold = predictor_cfg.get('primary_score_threshold', None) # This is used to apply fiducial volume cuts. # Min/max boundaries in each dimension haev to be specified. @@ -873,15 +863,6 @@ def get_particles(self, entry, only_primaries=False, assert(np.sum( np.abs(p.startpoint - p.endpoint)) < 1e-12) p.endpoint = None - elif p.semantic_type == 1: - if self.track_endpoints_mode == 'node_features': - get_track_points(p, correction_mode=self.track_point_corrector) - elif self.track_endpoints_mode == 'brute_force': - get_track_points(p, correction_mode=self.track_point_corrector, - brute_force=True) - else: - raise ValueError("Track endpoint attachment mode {}\ - not supported!".format(self.track_endpoints_mode)) else: continue @@ -894,9 +875,7 @@ def get_particles(self, entry, only_primaries=False, def get_interactions(self, entry, drop_nonprimary_particles=True, volume=None, - compute_vertex=True, - use_primaries_for_vertex=True, - vertex_mode=None, + get_vertex=True, tag_pi0=False) -> List[Interaction]: ''' Method for retriving interaction list for given batch index. @@ -904,8 +883,6 @@ def get_interactions(self, entry, The output particles will have its constituent particles attached as attributes as List[Particle]. - Method also performs vertex prediction for each interaction. - Note ---- Interaction ids are only unique within a volume. @@ -918,30 +895,23 @@ def get_interactions(self, entry, If True, all non-primary particles will not be included in the output interactions' .particle attribute. volume: int - compute_vertex: bool, default True + get_vertex: bool, default True Returns: - out: List of instances (see particle.Interaction). ''' - - if vertex_mode == None: - vertex_mode = self.vertex_mode - out = [] particles = self.get_particles(entry, only_primaries=drop_nonprimary_particles, volume=volume) - out = group_particles_to_interactions_fn(particles, mode='pred', tag_pi0=tag_pi0) - for ia in out: - if compute_vertex: - ia.vertex, ia.vertex_candidate_count = estimate_vertex( - ia.particles, - use_primaries=use_primaries_for_vertex, - mode=vertex_mode, - prune_candidates=self.prune_vertex, - return_candidate_count=True) - ia.volume = volume - + out = group_particles_to_interactions_fn(particles, mode='pred') + for ia in out: ia.volume = volume + if get_vertex: + vertices = self.result['vertices'][entry] + for ia in out: + mask = vertices[:, BATCH_COL].astype(int) == ia.id + vertex = vertices[mask][:, COORD_COLS[0]:COORD_COLS[-1]+1] + ia.vertex = vertex.squeeze() return out diff --git a/analysis/decorator.py b/analysis/decorator.py index 937e1b0b..ef9a70ed 100644 --- a/analysis/decorator.py +++ b/analysis/decorator.py @@ -12,7 +12,7 @@ from mlreco.iotools.factories import loader_factory from mlreco.iotools.readers import HDF5Reader from mlreco.iotools.writers import CSVWriter - +from mlreco.main_funcs import run_post_processing def evaluate(filenames): ''' @@ -67,13 +67,14 @@ def process_dataset(analysis_config, cfg, profile=True): writers = {} for file_name in filenames: - writers[file_name] = CSVWriter(f'{log_dir}/{file_name}.csv', append) + path = os.path.join(log_dir, file_name+'.csv') + writers[file_name] = CSVWriter(path, append) # Loop over the number of requested iterations iteration = 0 while iteration < max_iteration: - # Load data batch + # 1. Forwarding or Reading HDF5 file if profile: start = time.time() if 'reader' not in analysis_config: @@ -83,8 +84,14 @@ def process_dataset(analysis_config, cfg, profile=True): if profile: print("Forward took %.2f s" % (time.time() - start)) img_indices = data_blob['index'] + + # 2. Run post-processing scripts + stime = time.time() + if 'post_processing' in analysis_config: + run_post_processing(analysis_config, data_blob, res) + - # Build the output dictionary + # 3. Run analysis tools script stime = time.time() fname_to_update_list = defaultdict(list) for batch_index, img_index in enumerate(img_indices): @@ -92,11 +99,15 @@ def process_dataset(analysis_config, cfg, profile=True): for i, analysis_dict in enumerate(dict_list): fname_to_update_list[filenames[i]].extend(analysis_dict) - # Store + # 4. Store information to csv file. for i, fname in enumerate(fname_to_update_list): for row_dict in fname_to_update_list[fname]: writers[fname].append(row_dict) + if profile: + end = time.time() + print("Analysis tools and logging took %.2f s" % (time.time() - stime)) + # Increment iteration count iteration += 1 if profile: diff --git a/analysis/run.py b/analysis/run.py index b49c2bfd..e3542028 100644 --- a/analysis/run.py +++ b/analysis/run.py @@ -34,32 +34,32 @@ def main(analysis_cfg_path, model_cfg_path): raise Exception('Analysis configuration needs to live under `analysis` section.') if 'name' in analysis_config['analysis']: process_func = eval(analysis_config['analysis']['name']) - elif 'scripts' in analysis_config['analysis']: - assert isinstance(analysis_config['analysis']['scripts'], dict) + # elif 'scripts' in analysis_config['analysis']: + # assert isinstance(analysis_config['analysis']['scripts'], dict) - filenames = [] - modes = [] - for name in analysis_config['analysis']['scripts']: - files = eval(name)._filenames - mode = eval(name)._mode + # filenames = [] + # modes = [] + # for name in analysis_config['analysis']['scripts']: + # files = eval(name)._filenames + # mode = eval(name)._mode - filenames.extend(files) - modes.append(mode) - unique_modes, counts = np.unique(modes, return_counts=True) - mode = unique_modes[np.argmax(counts)] # most frequent mode wins + # filenames.extend(files) + # modes.append(mode) + # unique_modes, counts = np.unique(modes, return_counts=True) + # mode = unique_modes[np.argmax(counts)] # most frequent mode wins - @evaluate(filenames, mode=mode) - def process_func(data_blob, res, data_idx, analysis, model_cfg): - outs = [] - for name in analysis_config['analysis']['scripts']: - cfg = analysis.copy() - cfg['analysis']['name'] = name - cfg['analysis']['processor_cfg'] = analysis_config['analysis']['scripts'][name] - func = eval(name).__wrapped__ + # @evaluate(filenames, mode=mode) + # def process_func(data_blob, res, data_idx, analysis, model_cfg): + # outs = [] + # for name in analysis_config['analysis']['scripts']: + # cfg = analysis.copy() + # cfg['analysis']['name'] = name + # cfg['analysis']['processor_cfg'] = analysis_config['analysis']['scripts'][name] + # func = eval(name).__wrapped__ - out = func(copy.deepcopy(data_blob), copy.deepcopy(res), data_idx, cfg, model_cfg) - outs.extend(out) - return outs + # out = func(copy.deepcopy(data_blob), copy.deepcopy(res), data_idx, cfg, model_cfg) + # outs.extend(out) + # return outs else: raise Exception('You need to specify either `name` or `scripts` under `analysis` section.') From a48e30dfb63aff405733e1b21d1f1762980c75f5 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Tue, 4 Apr 2023 23:54:52 -0700 Subject: [PATCH 108/180] Analysis tools now functioning --- .gitignore | 1 + analysis/algorithms/logger.py | 326 ++++++++++++++++++ analysis/algorithms/utils.py | 6 - analysis/decorator.py | 10 +- mlreco/iotools/writers.py | 9 +- mlreco/main_funcs.py | 51 +-- mlreco/post_processing/common.py | 4 +- mlreco/post_processing/reconstruction/pi0.py | 42 +++ .../post_processing/reconstruction/utils.py | 25 +- .../post_processing/reconstruction/vertex.py | 75 ++-- 10 files changed, 454 insertions(+), 95 deletions(-) create mode 100644 analysis/algorithms/logger.py delete mode 100644 analysis/algorithms/utils.py create mode 100644 mlreco/post_processing/reconstruction/pi0.py diff --git a/.gitignore b/.gitignore index d85659d8..b41bc89e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # typical files made in repo log* +!logger.py weights* *.txt *.csv diff --git a/analysis/algorithms/logger.py b/analysis/algorithms/logger.py new file mode 100644 index 00000000..2d408058 --- /dev/null +++ b/analysis/algorithms/logger.py @@ -0,0 +1,326 @@ +from collections import OrderedDict +from functools import partial + +import numpy as np +import sys + +from mlreco.utils.globals import PID_LABEL_TO_PARTICLE, PARTICLE_TO_PID_LABEL +from analysis.classes import TruthInteraction, TruthParticle + +def tag(tag_name): + def tags_decorator(func): + func._tag = tag_name + return func + return tags_decorator + +def attach_prefix(update_dict, prefix): + if prefix is None: + return update_dict + out = OrderedDict({}) + + for key, val in update_dict.items(): + new_key = "{}_".format(prefix) + str(key) + out[new_key] = val + + return out + +class AnalysisLogger: + + def __init__(self, fieldnames: dict): + self.fieldnames = fieldnames + self._data_producers = [] + + def prepare(self): + for fname, args_dict in self.fieldnames.items(): + if args_dict is None: + f = getattr(self, fname) + else: + assert 'args' in args_dict + kwargs = args_dict['args'] + f = partial(getattr(self, fname), **kwargs) + self._data_producers.append(f) + + def produce(self, particle, mode=None): + + out = OrderedDict() + if mode not in ['reco', 'true', None]: + raise ValueError('Logger.produce mode argument must be either \ + "true" or "reco", or None.') + + for f in self._data_producers: + if hasattr(f, '_tag'): + if f._tag is not None and f._tag != mode: + continue + update_dict = f(particle) + out.update(update_dict) + + out = attach_prefix(out, mode) + + return out + + +class ParticleLogger(AnalysisLogger): + + def __init__(self, fieldnames: dict): + super(ParticleLogger, self).__init__(fieldnames) + + @staticmethod + def id(particle): + out = {'particle_id': -1} + if hasattr(particle, 'id'): + out['particle_id'] = particle.id + return out + + @staticmethod + def interaction_id(particle): + out = {'particle_interaction_id': -1} + if hasattr(particle, 'interaction_id'): + out['particle_interaction_id'] = particle.interaction_id + return out + + @staticmethod + def pdg_type(particle): + out = {'particle_type': -1} + if hasattr(particle, 'pid'): + out['particle_type'] = particle.pid + return out + + @staticmethod + def semantic_type(particle): + out = {'particle_semantic_type': -1} + if hasattr(particle, 'semantic_type'): + out['particle_semantic_type'] = particle.semantic_type + return out + + @staticmethod + def size(particle): + out = {'particle_size': -1} + if hasattr(particle, 'size'): + out['particle_size'] = particle.size + return out + + @staticmethod + def is_primary(particle): + out = {'particle_is_primary': -1} + if hasattr(particle, 'is_primary'): + out['particle_is_primary'] = particle.is_primary + return out + + @staticmethod + def startpoint(particle): + out = { + 'particle_has_startpoint': False, + 'particle_startpoint_x': -1, + 'particle_startpoint_y': -1, + 'particle_startpoint_z': -1 + } + if hasattr(particle, 'startpoint') \ + and not (particle.startpoint == -1).all(): + out['particle_has_startpoint'] = True + out['particle_startpoint_x'] = particle.startpoint[0] + out['particle_startpoint_y'] = particle.startpoint[1] + out['particle_startpoint_z'] = particle.startpoint[2] + return out + + @staticmethod + def endpoint(particle): + out = { + 'particle_has_endpoint': False, + 'particle_endpoint_x': -1, + 'particle_endpoint_y': -1, + 'particle_endpoint_z': -1 + } + if hasattr(particle, 'endpoint') \ + and not (particle.endpoint == -1).all(): + out['particle_has_endpoint'] = True + out['particle_endpoint_x'] = particle.endpoint[0] + out['particle_endpoint_y'] = particle.endpoint[1] + out['particle_endpoint_z'] = particle.endpoint[2] + return out + + @staticmethod + def startpoint_is_touching(particle, threshold=5.0): + out = {'particle_startpoint_is_touching': True} + if type(particle) is TruthParticle: + if particle.size > 0: + diff = particle.points - particle.startpoint.reshape(1, -1) + dists = np.linalg.norm(diff, axis=1) + min_dist = np.min(dists) + if min_dist > threshold: + out['particle_startpoint_is_touching'] = False + return out + + @staticmethod + @tag('true') + def creation_process(particle): + out = {'particle_creation_process': 'N/A'} + if type(particle) is TruthParticle: + out['particle_creation_process'] = particle.asis.creation_process() + return out + + @staticmethod + @tag('true') + def momentum(particle): + min_int = -sys.maxsize - 1 + out = { + 'particle_px': min_int, + 'particle_py': min_int, + 'particle_pz': min_int, + } + if type(particle) is TruthParticle: + out['particle_px'] = particle.asis.px() + out['particle_py'] = particle.asis.py() + out['particle_pz'] = particle.asis.pz() + return out + + @staticmethod + def reco_direction(particle, **kwargs): + out = { + 'particle_dir_x': 0, + 'particle_dir_y': 0, + 'particle_dir_z': 0 + } + if particle is not None and hasattr(particle, 'direction'): + v = particle.direction + out['particle_dir_x'] = v[0] + out['particle_dir_y'] = v[1] + out['particle_dir_z'] = v[2] + return out + + # @staticmethod + # def reco_length(particle): + # out = {'particle_length': -1} + # if particle is not None \ + # and particle.semantic_type == 1 \ + # and len(particle.points) > 0: + # out['particle_length'] = compute_track_length(particle.points) + # return out + + @staticmethod + def is_contained(particle, vb, threshold=30): + + out = {'particle_is_contained': False} + if particle is not None and len(particle.points) > 0: + if not isinstance(threshold, np.ndarray): + threshold = threshold * np.ones((3,)) + else: + assert len(threshold) == 3 + assert len(threshold.shape) == 1 + + vb = np.array(vb) + + x = (vb[0, 0] + threshold[0] <= particle.points[:, 0]) \ + & (particle.points[:, 0] <= vb[0, 1] - threshold[0]) + y = (vb[1, 0] + threshold[1] <= particle.points[:, 1]) \ + & (particle.points[:, 1] <= vb[1, 1] - threshold[1]) + z = (vb[2, 0] + threshold[2] <= particle.points[:, 2]) \ + & (particle.points[:, 2] <= vb[2, 1] - threshold[2]) + + out['particle_is_contained'] = (x & y & z).all() + return out + + @staticmethod + def sum_edep(particle): + out = {'particle_sum_edep': -1} + if particle is not None: + out['particle_sum_edep'] = particle.sum_edep + return out + + +class InteractionLogger(AnalysisLogger): + + def __init__(self, fieldnames: dict): + super(InteractionLogger, self).__init__(fieldnames) + + @staticmethod + def id(ia): + out = {'interaction_id': -1} + if hasattr(ia, 'id'): + out['interaction_id'] = ia.id + return out + + @staticmethod + def size(ia): + out = {'interaction_size': -1} + if hasattr(ia, 'size'): + out['interaction_size'] = ia.size + return out + + @staticmethod + def count_primary_particles(ia, ptypes=None): + all_types = sorted(list(PID_LABEL_TO_PARTICLE.keys())) + if ptypes is None: + ptypes = all_types + elif set(ptypes).issubset(set(all_types)): + pass + elif len(ptypes) == 0: + return {} + else: + raise ValueError('"ptypes under count_primary_particles must \ + either be None or a list of particle type ids \ + to be counted.') + + out = OrderedDict({'count_primary_'+name.lower() : 0 \ + for name in PARTICLE_TO_PID_LABEL.keys() \ + if PARTICLE_TO_PID_LABEL[name] in ptypes}) + + if ia is not None and hasattr(ia, 'primary_particle_counts'): + out.update({'count_primary_'+key.lower() : val \ + for key, val in ia.primary_particle_counts.items() \ + if key.upper() != 'OTHER' \ + and PARTICLE_TO_PID_LABEL[key.upper()] in ptypes}) + return out + + + @staticmethod + def is_contained(ia, vb, threshold=30): + + out = {'interaction_is_contained': False} + if ia is not None and len(ia.points) > 0: + if not isinstance(threshold, np.ndarray): + threshold = threshold * np.ones((3,)) + else: + assert len(threshold) == 3 + assert len(threshold.shape) == 1 + + vb = np.array(vb) + + x = (vb[0, 0] + threshold[0] <= ia.points[:, 0]) \ + & (ia.points[:, 0] <= vb[0, 1] - threshold[0]) + y = (vb[1, 0] + threshold[1] <= ia.points[:, 1]) \ + & (ia.points[:, 1] <= vb[1, 1] - threshold[1]) + z = (vb[2, 0] + threshold[2] <= ia.points[:, 2]) \ + & (ia.points[:, 2] <= vb[2, 1] - threshold[2]) + + out['interaction_is_contained'] = (x & y & z).all() + return out + + @staticmethod + def vertex(ia): + out = { + # 'has_vertex': False, + 'vertex_x': -sys.maxsize, + 'vertex_y': -sys.maxsize, + 'vertex_z': -sys.maxsize, + # 'vertex_info': None + } + if ia is not None and hasattr(ia, 'vertex'): + out['vertex_x'] = ia.vertex[0] + out['vertex_y'] = ia.vertex[1] + out['vertex_z'] = ia.vertex[2] + return out + + @staticmethod + @tag('true') + def nu_info(ia): + assert (ia is None) or (type(ia) is TruthInteraction) + out = { + 'nu_interaction_type': 'N/A', + 'nu_interaction_mode': 'N/A', + 'nu_current_type': 'N/A', + 'nu_energy_init': 'N/A' + } + if ia is not None: + if ia.nu_id == 1 and isinstance(ia.nu_info, dict): + out.update(ia.nu_info) + return out \ No newline at end of file diff --git a/analysis/algorithms/utils.py b/analysis/algorithms/utils.py deleted file mode 100644 index 998f5791..00000000 --- a/analysis/algorithms/utils.py +++ /dev/null @@ -1,6 +0,0 @@ -import numpy as np -import numba as nb -from collections import OrderedDict -from turtle import up -from sklearn.decomposition import PCA -from scipy.spatial.distance import cdist \ No newline at end of file diff --git a/analysis/decorator.py b/analysis/decorator.py index ef9a70ed..10bc0eae 100644 --- a/analysis/decorator.py +++ b/analysis/decorator.py @@ -62,8 +62,8 @@ def process_dataset(analysis_config, cfg, profile=True): assert max_iteration <= len(Reader) # Initialize the writer(s) - log_dir = analysis_config['analysis']['log_dir'] - append = analysis_config['analysis'].get('append', True) + log_dir = analysis_config['logger']['log_dir'] + append = analysis_config['logger'].get('append', False) writers = {} for file_name in filenames: @@ -82,6 +82,7 @@ def process_dataset(analysis_config, cfg, profile=True): else: data_blob, res = Reader.get(iteration, nested=True) if profile: + print('------------------------------------------------') print("Forward took %.2f s" % (time.time() - start)) img_indices = data_blob['index'] @@ -89,8 +90,10 @@ def process_dataset(analysis_config, cfg, profile=True): stime = time.time() if 'post_processing' in analysis_config: run_post_processing(analysis_config, data_blob, res) + if profile: + end = time.time() + print("Post-processing took %.2f s" % (time.time() - stime)) - # 3. Run analysis tools script stime = time.time() fname_to_update_list = defaultdict(list) @@ -113,6 +116,7 @@ def process_dataset(analysis_config, cfg, profile=True): if profile: end = time.time() print("Iteration %d (total %.2f s)" % (iteration, end - start)) + print('------------------------------------------------') process_dataset._filenames = filenames return process_dataset diff --git a/mlreco/iotools/writers.py b/mlreco/iotools/writers.py index 5a92dd0b..3a49b061 100644 --- a/mlreco/iotools/writers.py +++ b/mlreco/iotools/writers.py @@ -589,10 +589,15 @@ def __init__(self, self.append_file = append_file self.result_keys = None if self.append_file: + if not os.path.isfile(file_name): + msg = "File not found at path: {}. When using append=True "\ + "in CSVWriter, the file must exist at the prescribed path "\ + "before data is written to it.".format(file_name) + raise FileNotFoundError(msg) with open(self.file_name, 'r') as file: self.result_keys = file.readline().split(', ') - def create(self, result_blob): + def create(self, result_blob: dict): ''' Initialize the header of the CSV file, record the keys to be stored. @@ -610,7 +615,7 @@ def create(self, result_blob): header_str = ', '.join(self.result_keys)+'\n' file.write(header_str) - def append(self, result_blob): + def append(self, result_blob: dict): ''' Append the CSV file with the output diff --git a/mlreco/main_funcs.py b/mlreco/main_funcs.py index 8a3ea95c..1afa9668 100644 --- a/mlreco/main_funcs.py +++ b/mlreco/main_funcs.py @@ -9,7 +9,9 @@ pass from mlreco.iotools.factories import loader_factory, writer_factory +import mlreco.post_processing as post_processing from mlreco.post_processing.common import PostProcessor +from collections import OrderedDict # Important: do not import here anything that might # trigger cuda initialization through PyTorch. # We need to set CUDA_VISIBLE_DEVICES first, which @@ -281,13 +283,26 @@ def log(handlers, tstamp_iteration, #tspent_io, tspent_iteration, if handlers.train_logger: handlers.train_logger.flush() +def run_post_processing(cfg, data_blob, result_blob): + + post_processor_interface = PostProcessor(data_blob, result_blob) + + for processor_name, pcfg in cfg['post_processing'].items(): + priority = pcfg.pop('priority', -1) + processor_name = processor_name.split('+')[0] + processor = getattr(post_processing,str(processor_name)) + post_processor_interface.register_function(processor, + priority, + processor_cfg=pcfg) + + post_processor_interface.process_and_modify() + + def train_loop(handlers): """ Trainval loop. With optional minibatching as determined by the parameters cfg['iotool']['batch_size'] vs cfg['iotool']['minibatch_size']. """ - import mlreco.post_processing as post_processing - cfg=handlers.cfg tsum = 0. epoch_counter = 0 @@ -320,17 +335,7 @@ def train_loop(handlers): # Store output if requested if 'post_processing' in cfg: - - post_processor_interface = PostProcessor(cfg, data_blob, result_blob) - - for processor_name, pcfg in cfg['post_processing'].items(): - processor_name = processor_name.split('+')[0] - processor = getattr(post_processing,str(processor_name)) - post_processor_interface.register_function(processor, - processor_cfg=pcfg) - - post_processor_output_dict = post_processor_interface.process() - print(post_processor_output_dict) + run_post_processing(cfg, data_blob, result_blob) handlers.watch.stop('iteration') tsum += handlers.watch.time('iteration') @@ -393,25 +398,7 @@ def inference_loop(handlers): # Store output if requested if 'post_processing' in handlers.cfg: - - post_processor_interface = PostProcessor(handlers.cfg, data_blob, result_blob) - - for processor_name, pcfg in handlers.cfg['post_processing'].items(): - processor_name = processor_name.split('+')[0] - processor = getattr(post_processing,str(processor_name)) - post_processor_interface.register_function(processor, - processor_cfg=pcfg) - - post_processor_output_dict = post_processor_interface.process() - - for key, val in post_processor_output_dict.items(): - if key in result_blob: - msg = "Post processing script output key {} "\ - "is already in result_dict, you may want"\ - "to rename it.".format(key) - raise RuntimeError(msg) - else: - result_blob[key] = val + run_post_processing(handlers.cfg, data_blob, result_blob) handlers.watch.stop('iteration') tsum += handlers.watch.time('iteration') diff --git a/mlreco/post_processing/common.py b/mlreco/post_processing/common.py index 5f970948..f6cb9a8a 100644 --- a/mlreco/post_processing/common.py +++ b/mlreco/post_processing/common.py @@ -4,9 +4,9 @@ class PostProcessor: - def __init__(self, cfg, data, result, debug=True): + def __init__(self, data, result, debug=True): self._funcs = defaultdict(list) - self._num_batches = cfg['iotool']['batch_size'] + self._num_batches = len(data['index']) self.data = data self.result = result self.debug = debug diff --git a/mlreco/post_processing/reconstruction/pi0.py b/mlreco/post_processing/reconstruction/pi0.py new file mode 100644 index 00000000..ac066502 --- /dev/null +++ b/mlreco/post_processing/reconstruction/pi0.py @@ -0,0 +1,42 @@ +from collections import defaultdict +from itertools import combinations +from mlreco.post_processing.reconstruction.utils import closest_distance_two_lines +from mlreco.utils.gnn.cluster import cluster_direction + +# TODO: Need to refactor according to post processing conventions + +def _tag_neutral_pions_true(particles): + out = [] + tagged = defaultdict(list) + for part in particles: + num_voxels_noghost = part.coords_noghost.shape[0] + p = part.asis + ancestor = p.ancestor_track_id() + if p.pdg_code() == 22 \ + and p.creation_process() == "Decay" \ + and p.parent_creation_process() == "primary" \ + and p.ancestor_pdg_code() == 111 \ + and num_voxels_noghost > 0: + tagged[ancestor].append(p.id()) + for photon_list in tagged.values(): + out.append(tuple(photon_list)) + return out + +def _tag_neutral_pions_reco(particles, threshold=5): + out = [] + photons = [p for p in particles if p.pid == 0] + for entry in combinations(photons, 2): + p1, p2 = entry + v1, v2 = cluster_direction(p1), cluster_direction(p2) + d = closest_distance_two_lines(p1.startpoint, v1, p2.startpoint, v2) + if d < threshold: + out.append((p1.id, p2.id)) + return out + +def tag_neutral_pions(particles, mode): + if mode == 'truth': + return _tag_neutral_pions_true(particles) + elif mode == 'pred': + return _tag_neutral_pions_reco(particles) + else: + raise ValueError \ No newline at end of file diff --git a/mlreco/post_processing/reconstruction/utils.py b/mlreco/post_processing/reconstruction/utils.py index 58627877..1553a60a 100644 --- a/mlreco/post_processing/reconstruction/utils.py +++ b/mlreco/post_processing/reconstruction/utils.py @@ -33,4 +33,27 @@ def reconstruct_direction(data_dict, result_dict, update_dict['particle_dirs'] = particle_dirs else: update_dict['particle_dirs'] = np.array(particle_dirs) - return update_dict \ No newline at end of file + return update_dict + + +@nb.njit +def closest_distance_two_lines(a0, u0, a1, u1): + ''' + a0, u0: point (a0) and unit vector (u0) defining line 1 + a1, u1: point (a1) and unit vector (u1) defining line 2 + ''' + cross = np.cross(u0, u1) + # if the cross product is zero, the lines are parallel + if np.linalg.norm(cross) == 0: + # use any point on line A and project it onto line B + t = np.dot(a1 - a0, u1) + a = a1 + t * u1 # projected point + return np.linalg.norm(a0 - a) + else: + # use the formula from https://en.wikipedia.org/wiki/Skew_lines#Distance + t = np.dot(np.cross(a1 - a0, u1), cross) / np.linalg.norm(cross)**2 + # closest point on line A to line B + p = a0 + t * u0 + # closest point on line B to line A + q = p - cross * np.dot(p - a1, cross) / np.linalg.norm(cross)**2 + return np.linalg.norm(p - q) # distance between p and q \ No newline at end of file diff --git a/mlreco/post_processing/reconstruction/vertex.py b/mlreco/post_processing/reconstruction/vertex.py index ac89bb08..681eab23 100644 --- a/mlreco/post_processing/reconstruction/vertex.py +++ b/mlreco/post_processing/reconstruction/vertex.py @@ -74,31 +74,33 @@ def reconstruct_vertex(data_dict, result_dict, if len(startpoints_int) > 0: startpoints_int = np.vstack(startpoints_int) - - # Gather vertex candidates from each algorithm - vertices_1 = get_centroid_adj_pairs(startpoints_int, r1=r1) - vertices_2 = get_track_shower_poca(startpoints_int, - particles_int, - particle_seg_int, - input_coords, - r2=r2, - particle_dirs=dirs_int) - if len(particles_int) >= 2: - pseudovertex = compute_pseudovertex(particles_int, - startpoints_int, - input_coords, - dim=3, - particle_dirs=dirs_int) + if len(startpoints_int) == 1: + vertex = startpoints_int.squeeze() else: - pseudovertex = np.array([]) - - if vertices_1.shape[0] > 0: - candidates.append(vertices_1) - if vertices_2.shape[0] > 0: - candidates.append(vertices_2) - if len(candidates) > 0: - candidates = np.vstack(candidates) - vertex = np.mean(candidates, axis=0) + # Gather vertex candidates from each algorithm + vertices_1 = get_centroid_adj_pairs(startpoints_int, r1=r1) + vertices_2 = get_track_shower_poca(startpoints_int, + particles_int, + particle_seg_int, + input_coords, + r2=r2, + particle_dirs=dirs_int) + if len(particles_int) >= 2: + pseudovertex = compute_pseudovertex(particles_int, + startpoints_int, + input_coords, + dim=3, + particle_dirs=dirs_int) + else: + pseudovertex = np.array([]) + + if vertices_1.shape[0] > 0: + candidates.append(vertices_1) + if vertices_2.shape[0] > 0: + candidates.append(vertices_2) + if len(candidates) > 0: + candidates = np.vstack(candidates) + vertex = np.mean(candidates, axis=0) vertices.append(vertex) if len(vertices) > 0: @@ -250,31 +252,6 @@ def compute_pseudovertex(particle_clusts, return pseudovtx -def compute_vertex_candidates(particle_clusts, - particle_seg, - input_coords, - particle_start_points, - r1=5.0, - r2=5.0, - particle_dirs=None): - - candidates = [] - - # 1. Select two startpoints within dist r1 - candidates.append(get_centroid_adj_pairs(particle_start_points, - r1=r1)) - # 2. Select a track start point which is close - # to a line defined by shower direction - candidates.append(get_track_shower_poca(particle_start_points, - particle_clusts, - particle_seg, - input_coords, - r2=r2, - particle_dirs=particle_dirs)) - - return candidates - - def prune_vertex_candidates(candidates, pseudovtx, r=30): dist = np.linalg.norm(candidates - pseudovtx.reshape(1, -1), axis=1) pruned = candidates[dist < r] From d4d8b402bb0ac6547e1733b536f438006c41122e Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Wed, 5 Apr 2023 00:10:20 -0700 Subject: [PATCH 109/180] Incremental updates to post_processing to support calorimetric energy reco., particle direction reconstruction and point ordering using local dE/dx --- .../reconstruction/__init__.py | 4 +- .../reconstruction/calorimetry.py | 21 ++++++++- .../reconstruction/geometry.py | 35 +++++++++++++++ .../post_processing/reconstruction/points.py | 45 +++++++++++++++++++ 4 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 mlreco/post_processing/reconstruction/geometry.py create mode 100644 mlreco/post_processing/reconstruction/points.py diff --git a/mlreco/post_processing/reconstruction/__init__.py b/mlreco/post_processing/reconstruction/__init__.py index 86b31465..1e205e58 100644 --- a/mlreco/post_processing/reconstruction/__init__.py +++ b/mlreco/post_processing/reconstruction/__init__.py @@ -1 +1,3 @@ -from .calorimetry import range_based_track_energy \ No newline at end of file +from .points import order_end_points +from .geometry import particle_direction +from .calorimetry import calorimetric_energy, range_based_track_energy diff --git a/mlreco/post_processing/reconstruction/calorimetry.py b/mlreco/post_processing/reconstruction/calorimetry.py index 3a2124a4..605ed6ee 100644 --- a/mlreco/post_processing/reconstruction/calorimetry.py +++ b/mlreco/post_processing/reconstruction/calorimetry.py @@ -11,14 +11,31 @@ from mlreco.post_processing import post_processing from mlreco.utils.globals import * -@post_processing(data_capture=[], result_capture=['particle_clusts', + +@post_processing(data_capture=['input_data'], result_capture=['input_rescaled', + 'particle_clusts']) +def calorimetric_energy(data_dict, + result_dict, + conversion_factor=1.): + + input_data = data_dict['input_data'] if 'input_rescaled' not in result_dict else result_dict['input_rescaled'] + particles = result_dict['particle_clusts'] + + update_dict = { + 'particle_calo_energy': conversion_factor*np.array([np.sum(input_data[p, VALUE_COL]) for p in particles]) + } + + return update_dict + + +@post_processing(data_capture=['input_data'], result_capture=['particle_clusts', 'particle_seg', 'input_rescaled', 'particle_node_pred_type']) def range_based_track_energy(data_dict, result_dict, bin_size=17, include_pids=[2, 3, 4], table_path=''): - input_data = result_dict['input_rescaled'] + input_data = data_dict['input_data'] if 'input_rescaled' not in result_dict else result_dict['input_rescaled'] particles = result_dict['particle_clusts'] particle_seg = result_dict['particle_seg'] particle_types = result_dict['particle_node_pred_type'] diff --git a/mlreco/post_processing/reconstruction/geometry.py b/mlreco/post_processing/reconstruction/geometry.py new file mode 100644 index 00000000..4b2f8f2e --- /dev/null +++ b/mlreco/post_processing/reconstruction/geometry.py @@ -0,0 +1,35 @@ +import numpy as np + +from mlreco.utils.gnn.cluster import get_cluster_directions +from mlreco.post_processing import post_processing +from mlreco.utils.globals import * + + +@post_processing(data_capture=['input_data'], result_capture=['input_rescaled', + 'particle_clusts', + 'particle_start_points', + 'particle_end_points']) +def particle_direction(data_dict, + result_dict, + neighborhood_radius=5, + optimize=False): + + input_data = data_dict['input_data'] if 'input_rescaled' not in result_dict else result_dict['input_rescaled'] + particles = result_dict['particle_clusts'] + start_points = result_dict['particle_start_points'] + end_points = result_dict['particle_end_points'] + + update_dict = { + 'particle_start_directions': get_cluster_directions(input_data[:,COORD_COLS], + start_points[:,COORD_COLS], + particles, + neighborhood_radius, + optimize), + 'particle_end_directions': get_cluster_directions(input_data[:,COORD_COLS], + end_points[:,COORD_COLS], + particles, + neighborhood_radius, + optimize) + } + + return update_dict diff --git a/mlreco/post_processing/reconstruction/points.py b/mlreco/post_processing/reconstruction/points.py new file mode 100644 index 00000000..a1ba45b5 --- /dev/null +++ b/mlreco/post_processing/reconstruction/points.py @@ -0,0 +1,45 @@ +import numpy as np +from copy import deepcopy +from scipy.spatial.distance import cdist + +from mlreco.post_processing import post_processing +from mlreco.utils.globals import * + + +@post_processing(data_capture=['input_data'], result_capture=['input_rescaled', + 'particle_clusts', + 'particle_start_points', + 'particle_end_points']) +def order_end_points(data_dict, + result_dict, + method='local_dedx', + neighborhood_radius=5): + + assert method == 'local_dedx', 'Only method currently supported' + + input_data = data_dict['input_data'] if 'input_rescaled' not in result_dict else result_dict['input_rescaled'] + particles = result_dict['particle_clusts'] + start_points = result_dict['particle_start_points'] + end_points = result_dict['particle_end_points'] + + start_dedxs, end_dedxs = np.empty(len(particles)), np.empty(len(particles)) + for i, p in enumerate(particles): + dist_mat = cdist(start_points[i, COORD_COLS][None,:], input_data[p][:, COORD_COLS]).flatten() + de = np.sum(input_data[p][dist_mat < neighborhood_radius, VALUE_COL]) + start_dedxs[i] = de/neighborhood_radius + + dist_mat = cdist(end_points[i, COORD_COLS][None,:], input_data[p][:, COORD_COLS]).flatten() + de = np.sum(input_data[p][dist_mat < neighborhood_radius, VALUE_COL]) + end_dedxs[i] = de/neighborhood_radius + + switch_mask = start_dedxs > end_dedxs + temp_start_points = deepcopy(start_points) + start_points[switch_mask] = end_points[switch_mask] + end_points[switch_mask] = temp_start_points[switch_mask] + + update_dict = { + 'particle_start_points': start_points, + 'particle_end_points': end_points + } + + return update_dict From eceec915e4f16599c5c336f9d780037f46e9579c Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 5 Apr 2023 00:56:00 -0700 Subject: [PATCH 110/180] Minor fixes --- analysis/algorithms/logger.py | 14 +++-- analysis/classes/predictor.py | 99 ++++++++++++----------------------- 2 files changed, 40 insertions(+), 73 deletions(-) diff --git a/analysis/algorithms/logger.py b/analysis/algorithms/logger.py index 2d408058..c816a015 100644 --- a/analysis/algorithms/logger.py +++ b/analysis/algorithms/logger.py @@ -187,14 +187,12 @@ def reco_direction(particle, **kwargs): out['particle_dir_z'] = v[2] return out - # @staticmethod - # def reco_length(particle): - # out = {'particle_length': -1} - # if particle is not None \ - # and particle.semantic_type == 1 \ - # and len(particle.points) > 0: - # out['particle_length'] = compute_track_length(particle.points) - # return out + @staticmethod + def reco_length(particle): + out = {'particle_length': -1} + if particle is not None and hasattr(particle, 'length'): + out['particle_length'] = particle.length + return out @staticmethod def is_contained(particle, vb, threshold=30): diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index 01c58fc8..4d28012e 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -127,7 +127,6 @@ def __init__(self, data_blob, result, cfg, predictor_cfg={}, self.flash_matches = {} # key is (entry, volume, use_true_tpc_objects), value is tuple (tpc_v, pmt_v, list of matches) # type is (list of Interaction/TruthInteraction, list of larcv::Flash, list of flashmatch::FlashMatch_t) - def __repr__(self): msg = "FullChainEvaluator(num_images={})".format(int(self.num_images/self._num_volumes)) return msg @@ -708,6 +707,22 @@ def get_fragments(self, entry, only_primaries=False, out_fragment_list.extend(out) return out_fragment_list + + def _get_primary_labels(self, node_pred_vtx): + primary_labels = -np.ones(len(node_pred_vtx)).astype(int) + primary_scores = np.zeros(len(node_pred_vtx)).astype(float) + if node_pred_vtx.shape[1] == 5: + primary_scores = node_pred_vtx[:, 3:] + elif node_pred_vtx.shape[1] == 2: + primary_scores = node_pred_vtx + else: + raise ValueError(' must either be (N, 5) or (N, 2)') + primary_scores = softmax(node_pred_vtx, axis=1) + if self.primary_score_threshold is None: + primary_labels = np.argmax(primary_scores, axis=1) + else: + primary_labels = primary_scores[:, 1] > self.primary_score_threshold + return primary_labels def get_particles(self, entry, only_primaries=False, @@ -718,12 +733,6 @@ def get_particles(self, entry, only_primaries=False, ''' Method for retriving particle list for given batch index. - The output particles will have its ppn candidates attached as - attributes in the form of pandas dataframes (same as _fit_predict_ppn) - - Method also performs endpoint prediction for tracks and startpoint - prediction for showers. - 1) If a track has no or only one ppn candidate, the endpoints will be calculated by selecting two voxels that have the largest separation distance. Otherwise, the two ppn candidates with the @@ -759,60 +768,34 @@ def get_particles(self, entry, only_primaries=False, if min_particle_voxel_count < 0: min_particle_voxel_count = self.min_particle_voxel_count - # Loop over images - + # Essential Information volume_labels = self.result['input_rescaled'][entry][:, 0] point_cloud = self.result['input_rescaled'][entry][:, 1:4] depositions = self.result['input_rescaled'][entry][:, 4] particles = self.result['particle_clusts'][entry] - # inter_group_pred = self.result['inter_group_pred'][entry] - #print(point_cloud.shape, depositions.shape, len(particles)) - particle_seg = self.result['particle_seg'][entry] + particle_seg = self.result['particle_seg'][entry] - type_logits = self.result['particle_node_pred_type'][entry] + type_logits = self.result['particle_node_pred_type'][entry] particle_start_points = self.result['particle_start_points'][entry] - particle_end_points = self.result['particle_end_points'][entry] + particle_end_points = self.result['particle_end_points'][entry] + node_pred_vtx = self.result['particle_node_pred_vtx'][entry] + inter_ids = self.result['particle_group_pred'][entry] pids = np.argmax(type_logits, axis=1) out = [] - if point_cloud.shape[0] == 0: + + # Some basic input checks + if point_cloud.shape[0] == 0 or len(particles) == 0: return out assert len(particle_seg) == len(particles) assert len(pids) == len(particles) assert len(particle_end_points) == len(particles) assert len(particle_start_points) == len(particles) assert point_cloud.shape[0] == depositions.shape[0] - - node_pred_vtx = self.result['particle_node_pred_vtx'][entry] - assert node_pred_vtx.shape[0] == len(particles) - primary_labels = -np.ones(len(node_pred_vtx)).astype(int) - primary_scores = np.zeros(len(node_pred_vtx)).astype(float) - if node_pred_vtx.shape[1] == 5: - # primary_labels = np.argmax(node_pred_vtx[:, 3:], axis=1) - primary_scores = node_pred_vtx[:, 3:] - elif node_pred_vtx.shape[1] == 2: - # primary_labels = np.argmax(node_pred_vtx, axis=1) - primary_scores = node_pred_vtx - else: - raise ValueError(' must either be (N, 5) or (N, 2)') - - primary_scores = softmax(node_pred_vtx, axis=1) - - assert primary_labels.shape[0] == len(particles) - - if self.primary_score_threshold is None: - primary_labels = np.argmax(node_pred_vtx, axis=1) - else: - primary_labels = node_pred_vtx[:, 1] > self.primary_score_threshold + assert len(inter_ids) == len(particles) - if ('particle_group_pred' in self.result) and ('particle_clusts' in self.result) and len(particles) > 0: - - assert len(self.result['particle_group_pred'][entry]) == len(particles) - inter_labels = self._fit_predict_interaction_labels(entry) - inter_ids = get_cluster_label(inter_labels.reshape(-1, 1), particles, column=0) - else: - inter_ids = np.ones(len(particles)).astype(int) * -1 + primary_labels = self._get_primary_labels(node_pred_vtx) for i, p in enumerate(particles): voxels = point_cloud[p] @@ -827,7 +810,8 @@ def get_particles(self, entry, only_primaries=False, interaction_id = inter_ids[i] part = Particle(voxels, i, - seg_label, interaction_id, + seg_label, + interaction_id, pid, entry, voxel_indices=p, @@ -836,8 +820,8 @@ def get_particles(self, entry, only_primaries=False, pid_conf=softmax(type_logits[i])[pids[i]], volume=volume_id) - part.startpoint = particle_start_points[i][1:4] - part.endpoint = particle_end_points[i][1:4] + part.startpoint = particle_start_points[i][COORD_COLS[0]:COORD_COLS[-1]+1] + part.endpoint = particle_end_points[i][COORD_COLS[0]:COORD_COLS[-1]+1] out.append(part) @@ -846,26 +830,12 @@ def get_particles(self, entry, only_primaries=False, if len(out) == 0: return out - - ppn_results = self._fit_predict_ppn(entry) - + # Get ppn candidates for particle + ppn_results = self._fit_predict_ppn(entry) match_points_to_particles(ppn_results, out, ppn_distance_threshold=attaching_threshold) - # Attach startpoint and endpoint - # as done in full chain geometric encoder - for p in out: - if p.size < min_particle_voxel_count: - continue - if p.semantic_type == 0: - # Check startpoint is replicated - assert(np.sum( - np.abs(p.startpoint - p.endpoint)) < 1e-12) - p.endpoint = None - else: - continue - if volume is not None: out = [p for p in out if p.volume == volume] @@ -875,8 +845,7 @@ def get_particles(self, entry, only_primaries=False, def get_interactions(self, entry, drop_nonprimary_particles=True, volume=None, - get_vertex=True, - tag_pi0=False) -> List[Interaction]: + get_vertex=True) -> List[Interaction]: ''' Method for retriving interaction list for given batch index. From 38598e2972a547ef4986f3c12b069c72802ceb1a Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 5 Apr 2023 02:19:49 -0700 Subject: [PATCH 111/180] All post-processors checked for analysis tools --- analysis/algorithms/logger.py | 10 +++--- analysis/classes/predictor.py | 61 +++++++++++++++++++++++------------ 2 files changed, 45 insertions(+), 26 deletions(-) diff --git a/analysis/algorithms/logger.py b/analysis/algorithms/logger.py index c816a015..381fbbe9 100644 --- a/analysis/algorithms/logger.py +++ b/analysis/algorithms/logger.py @@ -114,8 +114,8 @@ def startpoint(particle): 'particle_startpoint_y': -1, 'particle_startpoint_z': -1 } - if hasattr(particle, 'startpoint') \ - and not (particle.startpoint == -1).all(): + if (particle is not None) and (particle.startpoint is not None) \ + and (not (particle.startpoint == -1).all()): out['particle_has_startpoint'] = True out['particle_startpoint_x'] = particle.startpoint[0] out['particle_startpoint_y'] = particle.startpoint[1] @@ -130,8 +130,8 @@ def endpoint(particle): 'particle_endpoint_y': -1, 'particle_endpoint_z': -1 } - if hasattr(particle, 'endpoint') \ - and not (particle.endpoint == -1).all(): + if (particle is not None) and (particle.endpoint is not None) \ + and (not (particle.endpoint == -1).all()): out['particle_has_endpoint'] = True out['particle_endpoint_x'] = particle.endpoint[0] out['particle_endpoint_y'] = particle.endpoint[1] @@ -174,7 +174,7 @@ def momentum(particle): return out @staticmethod - def reco_direction(particle, **kwargs): + def reco_direction(particle): out = { 'particle_dir_x': 0, 'particle_dir_y': 0, diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index d405c417..c2f18779 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -2,6 +2,7 @@ import numpy as np import os import time +from collections import OrderedDict from mlreco.utils.cluster.cluster_graph_constructor import ClusterGraphConstructor from mlreco.utils.ppn import uresnet_ppn_type_point_selector @@ -728,8 +729,7 @@ def _get_primary_labels(self, node_pred_vtx): def get_particles(self, entry, only_primaries=False, min_particle_voxel_count=-1, attaching_threshold=2, - volume=None, - particles_cfg=None) -> List[Particle]: + volume=None) -> List[Particle]: ''' Method for retriving particle list for given batch index. @@ -765,19 +765,16 @@ def get_particles(self, entry, only_primaries=False, ''' self._check_volume(volume) - if min_particle_voxel_count < 0: - min_particle_voxel_count = self.min_particle_voxel_count - # Essential Information - volume_labels = self.result['input_rescaled'][entry][:, 0] - point_cloud = self.result['input_rescaled'][entry][:, 1:4] + volume_labels = self.result['input_rescaled'][entry][:, BATCH_COL] + point_cloud = self.result['input_rescaled'][entry][:, COORD_COLS] depositions = self.result['input_rescaled'][entry][:, 4] particles = self.result['particle_clusts'][entry] particle_seg = self.result['particle_seg'][entry] type_logits = self.result['particle_node_pred_type'][entry] - particle_start_points = self.result['particle_start_points'][entry] - particle_end_points = self.result['particle_end_points'][entry] + particle_start_points = self.result['particle_start_points'][entry][:, COORD_COLS] + particle_end_points = self.result['particle_end_points'][entry][:, COORD_COLS] node_pred_vtx = self.result['particle_node_pred_vtx'][entry] inter_ids = self.result['particle_group_pred'][entry] pids = np.argmax(type_logits, axis=1) @@ -801,8 +798,6 @@ def get_particles(self, entry, only_primaries=False, voxels = point_cloud[p] volume_id, cts = np.unique(volume_labels[p], return_counts=True) volume_id = int(volume_id[cts.argmax()]) - if voxels.shape[0] < min_particle_voxel_count: - continue seg_label = particle_seg[i] pid = pids[i] if seg_label == 2 or seg_label == 3: @@ -820,27 +815,51 @@ def get_particles(self, entry, only_primaries=False, pid_conf=softmax(type_logits[i])[pids[i]], volume=volume_id) - part.startpoint = particle_start_points[i][COORD_COLS] - part.endpoint = particle_end_points[i][COORD_COLS] + part.startpoint = particle_start_points[i] + part.endpoint = particle_end_points[i] out.append(part) - if only_primaries: - out = [p for p in out if p.is_primary] + out = self._decorate_particles(entry, out, + only_primaries=only_primaries, + volume=volume) + return out + + + def _decorate_particles(self, entry, particles, **kwargs): + + # Decorate particles + for i, p in enumerate(particles): + if 'particle_length' in self.result: + p.length = self.result['particle_length'][entry][i] + if 'particle_range_based_energy' in self.result: + energy = self.result['particle_range_based_energy'][entry][i] + if energy > 0: p.csda_energy = energy + if 'particle_calo_energy' in self.result: + p.calo_energy = self.result['particle_calo_energy'][entry][i] + if 'particle_start_directions' in self.result: + p.direction = self.result['particle_start_directions'][entry][i] + + out = [p for p in particles] + # Filtering actions on particles + if kwargs.get('only_primaries', False): + out = [p for p in particles if p.is_primary] if len(out) == 0: return out # Get ppn candidates for particle - ppn_results = self._fit_predict_ppn(entry) - match_points_to_particles(ppn_results, out, - ppn_distance_threshold=attaching_threshold) + # ppn_results = self._fit_predict_ppn(entry) + # match_points_to_particles(ppn_results, out, + # ppn_distance_threshold=kwargs['attaching_threshold']) + volume = kwargs.get('volume', None) if volume is not None: out = [p for p in out if p.volume == volume] - return out + def _decorate_interactions(self, interactions, **kwargs): + pass def get_interactions(self, entry, drop_nonprimary_particles=True, @@ -884,7 +903,7 @@ def get_interactions(self, entry, return out - def fit_predict_labels(self, entry, volume=None): + def fit_predict_labels(self, entry): ''' Predict all labels of a given batch index . @@ -940,4 +959,4 @@ def fit_predict(self, **kwargs): self._interactions = list_interactions self._labels = labels - return labels + return labels \ No newline at end of file From 939098d19498f430438350bba62793f0ca3a87f8 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Wed, 5 Apr 2023 11:58:19 -0700 Subject: [PATCH 112/180] Allow overwriting existing data products with post_processors --- mlreco/main_funcs.py | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/mlreco/main_funcs.py b/mlreco/main_funcs.py index 8a3ea95c..e3c561a1 100644 --- a/mlreco/main_funcs.py +++ b/mlreco/main_funcs.py @@ -286,8 +286,6 @@ def train_loop(handlers): Trainval loop. With optional minibatching as determined by the parameters cfg['iotool']['batch_size'] vs cfg['iotool']['minibatch_size']. """ - import mlreco.post_processing as post_processing - cfg=handlers.cfg tsum = 0. epoch_counter = 0 @@ -318,20 +316,6 @@ def train_loop(handlers): if checkpt_step: handlers.trainer.save_state(handlers.iteration) - # Store output if requested - if 'post_processing' in cfg: - - post_processor_interface = PostProcessor(cfg, data_blob, result_blob) - - for processor_name, pcfg in cfg['post_processing'].items(): - processor_name = processor_name.split('+')[0] - processor = getattr(post_processing,str(processor_name)) - post_processor_interface.register_function(processor, - processor_cfg=pcfg) - - post_processor_output_dict = post_processor_interface.process() - print(post_processor_output_dict) - handlers.watch.stop('iteration') tsum += handlers.watch.time('iteration') @@ -407,9 +391,11 @@ def inference_loop(handlers): for key, val in post_processor_output_dict.items(): if key in result_blob: msg = "Post processing script output key {} "\ - "is already in result_dict, you may want"\ - "to rename it.".format(key) - raise RuntimeError(msg) + "is already in result_dict, you are overwriting"\ + "existing keys.".format(key) + print(msg) + #raise RuntimeError(msg) + result_blob[key] = val else: result_blob[key] = val From fa54ff6ce7dedcc059c83fe7a679475b1c08ab08 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 5 Apr 2023 12:23:54 -0700 Subject: [PATCH 113/180] Start moving flash matching to post-processing --- analysis/classes/predictor.py | 5 +-- .../post_processing/pmt}/FlashManager.py | 40 ++++++++----------- mlreco/post_processing/pmt/__init__.py | 0 3 files changed, 18 insertions(+), 27 deletions(-) rename {analysis/classes => mlreco/post_processing/pmt}/FlashManager.py (84%) create mode 100644 mlreco/post_processing/pmt/__init__.py diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index c2f18779..964b16a7 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -726,10 +726,7 @@ def _get_primary_labels(self, node_pred_vtx): return primary_labels - def get_particles(self, entry, only_primaries=False, - min_particle_voxel_count=-1, - attaching_threshold=2, - volume=None) -> List[Particle]: + def get_particles(self, entry, only_primaries=False, volume=None) -> List[Particle]: ''' Method for retriving particle list for given batch index. diff --git a/analysis/classes/FlashManager.py b/mlreco/post_processing/pmt/FlashManager.py similarity index 84% rename from analysis/classes/FlashManager.py rename to mlreco/post_processing/pmt/FlashManager.py index aff39041..3981afbd 100644 --- a/analysis/classes/FlashManager.py +++ b/mlreco/post_processing/pmt/FlashManager.py @@ -41,20 +41,19 @@ def __init__(self, cfg, cfg_fmatch, meta=None, detector_specs=None, reflash_merg # Setup OpT0finder basedir = os.getenv('FMATCH_BASEDIR') if basedir is None: - raise Exception("You need to source OpT0Finder configure.sh first, or set the FMATCH_BASEDIR environment variable.") + msg = "You need to source OpT0Finder configure.sh "\ + "first, or set the FMATCH_BASEDIR environment variable." + raise Exception(msg) sys.path.append(os.path.join(basedir, 'python')) - #print(os.getenv('LD_LIBRARY_PATH'), os.getenv('ROOT_INCLUDE_PATH')) os.environ['LD_LIBRARY_PATH'] = "%s:%s" % (os.path.join(basedir, 'build/lib'), os.environ['LD_LIBRARY_PATH']) #os.environ['ROOT_INCLUDE_PATH'] = os.path.join(basedir, 'build/include') - #print(os.environ['LD_LIBRARY_PATH'], os.environ['ROOT_INCLUDE_PATH']) if 'FMATCH_DATADIR' not in os.environ: # needed for loading detector specs os.environ['FMATCH_DATADIR'] = os.path.join(basedir, 'dat') import ROOT import flashmatch - from flashmatch.visualization import plotly_layout3d, plot_track, plot_flash, plot_qcluster - from flashmatch import flashmatch, geoalgo + from flashmatch import flashmatch # Setup meta self.cfg = cfg @@ -68,15 +67,14 @@ def __init__(self, cfg, cfg_fmatch, meta=None, detector_specs=None, reflash_merg self.size_voxel_x = meta[6] self.size_voxel_y = meta[7] self.size_voxel_z = meta[8] - #print('Meta min = ', self.min_x, self.min_y, self.min_z) - #print('Meta size = ', self.size_voxel_x, self.size_voxel_y, self.size_voxel_z) # Setup flash matching print('Setting up OpT0Finder for flash matching...') self.mgr = flashmatch.FlashMatchManager() cfg = flashmatch.CreatePSetFromFile(cfg_fmatch) if detector_specs is None: - self.det = flashmatch.DetectorSpecs.GetME(os.path.join(basedir, 'dat/detector_specs.cfg')) + self.det = flashmatch.DetectorSpecs.GetME( + os.path.join(basedir, 'dat/detector_specs.cfg')) else: assert isinstance(detector_specs, str) if not os.path.exists(detector_specs): @@ -117,7 +115,8 @@ def get_qcluster(self, tpc_id, array=False): raise Exception("TPC object %d does not exist in self.tpc_v" % tpc_id) - def make_qcluster(self, interactions, use_depositions_MeV=False, ADC_to_MeV=1.): + def make_qcluster(self, interactions, + use_depositions_MeV=False, ADC_to_MeV=1.): """ Make flashmatch::QCluster_t objects from list of interactions. @@ -152,14 +151,10 @@ def make_qcluster(self, interactions, use_depositions_MeV=False, ADC_to_MeV=1.): p.points[i, 1] * self.size_voxel_y + self.min_y, p.points[i, 2] * self.size_voxel_z + self.min_z, p.depositions[i]*ADC_to_MeV*self.det.LightYield() if not use_depositions_MeV else p.depositions_MeV[i]*self.det.LightYield()) - #modified_box_model(p.depositions[i], ADC_to_MeV) * self.det.LightYield() if not use_depositions_MeV else p.depositions_MeV[i]*self.det.LightYield()) - #print("make_qcluster ", p.depositions[i] * ADC_to_MeV, p.depositions_MeV[i], p.depositions[i] * 0.00285714) # Add it to geoalgo::QCluster_t qcluster.push_back(qpoint) tpc_v.append(qcluster) - #if self.tpc_v is not None: - # print("Warning: overwriting internal list of particles.") self.tpc_v = tpc_v print('Made list of %d QCluster_t' % len(tpc_v)) return tpc_v @@ -194,15 +189,11 @@ def make_flash(self, larcv_flashes): flash.x_err, flash.y_err, flash.z_err = 0, 0, 0 # PE distribution over the 360 photodetectors - #flash.pe_v = f.PEPerOpDet() - #for i in range(360): offset = 0 if len(f.PEPerOpDet()) == 180 else 180 for i in range(180): flash.pe_v.push_back(f.PEPerOpDet()[i + offset]) flash.pe_err_v.push_back(0.) pmt_v.append(flash) - #if self.pmt_v is not None: - # print("Warning: overwriting internal list of flashes.") if self.reflash_merging_window is not None and len(pmt_v) > 0: # then proceed to merging close flashes perm = np.argsort(times) @@ -211,7 +202,6 @@ def make_flash(self, larcv_flashes): for idx, flash in enumerate(pmt_v[1:]): if flash.time - final_pmt_v[-1].time < self.reflash_merging_window: new_flash = self.merge_flashes(flash, final_pmt_v[-1]) - # print("Merged reflash", final_pmt_v[-1].time, new_flash.time, flash.time, np.sum(final_pmt_v[-1].pe_v), np.sum(new_flash.pe_v), np.sum(flash.pe_v)) final_pmt_v[-1] = new_flash else: final_pmt_v.append(flash) @@ -243,7 +233,9 @@ def merge_flashes(self, a, b): flash.time = min(a.time, b.time) flash.time_true = min(a.time_true, b.time_true) flash.x, flash.y, flash.z = min(a.x, b.x), min(a.y, b.y), min(a.z, b.z) - flash.x_err, flash.y_err, flash.z_err = min(a.x_err, b.x_err), min(a.y_err, b.y_err), min(a.z_err, b.z_err) + flash.x_err = min(a.x_err, b.x_err) + flash.y_err = min(a.y_err, b.y_err) + flash.z_err = min(a.z_err, b.z_err) for i in range(180): flash.pe_v.push_back(a.pe_v[i] + b.pe_v[i]) flash.pe_err_v.push_back(a.pe_err_v[i] + b.pe_err_v[i]) @@ -252,14 +244,18 @@ def merge_flashes(self, a, b): def run_flash_matching(self, flashes=None, interactions=None, **kwargs): if self.tpc_v is None: if interactions is None: - raise Exception('You need to specify `interactions`, or to run make_qcluster.') + msg = "You need to specify `interactions`, "\ + "or to run make_qcluster." + raise Exception(msg) if interactions is not None: self.make_qcluster(interactions, **kwargs) if self.pmt_v is None: if flashes is None: - raise Exception("PMT objects need to be defined. Either specify `flashes`, or run make_flash.") + msg = "PMT objects need to be defined. "\ + "Either specify `flashes`, or run make_flash." + raise Exception(msg) if flashes is not None: self.make_flash(flashes) @@ -274,8 +270,6 @@ def run_flash_matching(self, flashes=None, interactions=None, **kwargs): self.mgr.Add(x) # Run the matching - #if self.all_matches is not None: - # print("Warning: overwriting internal list of matches.") self.all_matches = self.mgr.Match() return self.all_matches diff --git a/mlreco/post_processing/pmt/__init__.py b/mlreco/post_processing/pmt/__init__.py new file mode 100644 index 00000000..e69de29b From 555cd728b955da07a9f5ccb023a6a6afea48a9a4 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 5 Apr 2023 13:19:50 -0700 Subject: [PATCH 114/180] Resolve stash conflict --- analysis/classes/__init__.py | 2 +- analysis/classes/predictor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/analysis/classes/__init__.py b/analysis/classes/__init__.py index c4fb0f0f..843e9f05 100644 --- a/analysis/classes/__init__.py +++ b/analysis/classes/__init__.py @@ -4,4 +4,4 @@ from .TruthParticleFragment import TruthParticleFragment from .Interaction import Interaction from .TruthInteraction import TruthInteraction -from .FlashManager import FlashManager +# from .FlashManager import FlashManager diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index 964b16a7..b62901e0 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -9,7 +9,7 @@ from mlreco.utils.metrics import unique_label from scipy.special import softmax -from analysis.classes import Particle, ParticleFragment, Interaction, FlashManager +from analysis.classes import Particle, ParticleFragment, Interaction from analysis.classes.particle_utils import group_particles_to_interactions_fn from analysis.algorithms.point_matching import * From 73b58980f0d939c8bd42c040c13e4059b67e0108 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 5 Apr 2023 14:28:33 -0700 Subject: [PATCH 115/180] Small vertexing network which doesn't work great --- mlreco/models/factories.py | 4 +- mlreco/models/vertex.py | 84 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/mlreco/models/factories.py b/mlreco/models/factories.py index a63218a3..d4484a87 100644 --- a/mlreco/models/factories.py +++ b/mlreco/models/factories.py @@ -51,7 +51,9 @@ def model_dict(): # Deep Single Pass Uncertainty Quantification 'duq_singlep': (singlep.DUQParticleClassifier, singlep.MultiLabelCrossEntropy), # Vertex PPN - 'vertex_ppn': (vertex.VertexPPNChain, vertex.UResNetVertexLoss) + 'vertex_ppn': (vertex.VertexPPNChain, vertex.UResNetVertexLoss), + # Vertex Pointnet + 'vertex_pointnet': (vertex.VertexPointNet, vertex.VertexPointNetLoss) } return models diff --git a/mlreco/models/vertex.py b/mlreco/models/vertex.py index d4d616f7..7168a5db 100644 --- a/mlreco/models/vertex.py +++ b/mlreco/models/vertex.py @@ -10,6 +10,12 @@ from collections import defaultdict from mlreco.models.uresnet import UResNet_Chain from mlreco.models.layers.common.vertex_ppn import VertexPPN, VertexPPNLoss +from mlreco.models.experimental.layers.pointnet import PointNetEncoder + +from mlreco.utils.gnn.data import split_clusts +from mlreco.utils.globals import INTER_COL, BATCH_COL, VTX_COLS, NU_COL +from mlreco.utils.gnn.cluster import form_clusters, get_cluster_label +from torch_geometric.data import Batch, Data class VertexPPNChain(nn.Module): """ @@ -79,3 +85,81 @@ def forward(self, outputs, kinematics_label): 'reg_loss': res_vertex['vertex_reg_loss'] } return res + +class VertexPointNet(nn.Module): + + def __init__(self, cfg, name='vertex_pointnet'): + super(VertexPointNet, self).__init__() + self.encoder = PointNetEncoder(cfg) + self.D = cfg[name].get('D', 3) + self.final_layer = nn.Sequential( + nn.Linear(self.encoder.latent_size, self.D), + nn.Softplus()) + + def split_input(self, point_cloud, clusts=None): + point_cloud_cpu = point_cloud.detach().cpu().numpy() + batches, bcounts = np.unique(point_cloud_cpu[:, BATCH_COL], return_counts=True) + if clusts is None: + clusts = form_clusters(point_cloud_cpu, column=INTER_COL) + if not len(clusts): + return Batch() + + data_list = [] + for i, c in enumerate(clusts): + x = point_cloud[c, 4].view(-1, 1) + pos = point_cloud[c, 1:4] + data = Data(x=x, pos=pos) + data_list.append(data) + + split_data = Batch.from_data_list(data_list) + return split_data, clusts + + def forward(self, input, clusts=None): + res = {} + point_cloud, = input + batch, clusts = self.split_input(point_cloud, clusts) + + interactions = torch.unique(batch.batch) + centroids = torch.vstack([batch.pos[batch.batch == b].mean(dim=0) for b in interactions]) + + out = self.encoder(batch) + out = self.final_layer(out) + res['clusts'] = [clusts] + res['vertex_pred'] = [centroids + out] + return res + + +class VertexPointNetLoss(nn.Module): + + def __init__(self, cfg, name='vertex_pointnet_loss'): + super(VertexPointNetLoss, self).__init__() + self.spatial_size = cfg[name].get('spatial_size', 6144) + self.loss_fn = nn.MSELoss(reduction='none') + + def forward(self, res, cluster_label): + + clusts = res['clusts'][0] + vertex_pred = res['vertex_pred'][0] + + device = cluster_label[0].device + + vtx_x = get_cluster_label(cluster_label[0], clusts, column=VTX_COLS[0]) + vtx_y = get_cluster_label(cluster_label[0], clusts, column=VTX_COLS[1]) + vtx_z = get_cluster_label(cluster_label[0], clusts, column=VTX_COLS[2]) + + nu_label = get_cluster_label(cluster_label[0], clusts, column=NU_COL) + nu_mask = torch.Tensor(nu_label == 1).bool().to(device) + + vtx_label = torch.cat([torch.Tensor(vtx_x.reshape(-1, 1)).to(device), + torch.Tensor(vtx_y.reshape(-1, 1)).to(device), + torch.Tensor(vtx_z.reshape(-1, 1)).to(device)], dim=1) + + mask = nu_mask & (vtx_label >= 0).all(dim=1) & (vtx_label < self.spatial_size).all(dim=1) + loss = self.loss_fn(vertex_pred[mask], vtx_label[mask]).sum(dim=1).mean() + + result = { + 'loss': loss, + 'accuracy': loss + } + + return result \ No newline at end of file From 287875c98a5d03327d42c2e3ba24aa62ee224ddb Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 5 Apr 2023 15:04:13 -0700 Subject: [PATCH 116/180] Minor fix --- mlreco/main_funcs.py | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/mlreco/main_funcs.py b/mlreco/main_funcs.py index 587a4b15..c7dc37be 100644 --- a/mlreco/main_funcs.py +++ b/mlreco/main_funcs.py @@ -394,31 +394,7 @@ def inference_loop(handlers): # Store output if requested if 'post_processing' in handlers.cfg: -<<<<<<< HEAD run_post_processing(handlers.cfg, data_blob, result_blob) -======= - - post_processor_interface = PostProcessor(handlers.cfg, data_blob, result_blob) - - for processor_name, pcfg in handlers.cfg['post_processing'].items(): - processor_name = processor_name.split('+')[0] - processor = getattr(post_processing,str(processor_name)) - post_processor_interface.register_function(processor, - processor_cfg=pcfg) - - post_processor_output_dict = post_processor_interface.process() - - for key, val in post_processor_output_dict.items(): - if key in result_blob: - msg = "Post processing script output key {} "\ - "is already in result_dict, you are overwriting"\ - "existing keys.".format(key) - print(msg) - #raise RuntimeError(msg) - result_blob[key] = val - else: - result_blob[key] = val ->>>>>>> 939098d19498f430438350bba62793f0ca3a87f8 handlers.watch.stop('iteration') tsum += handlers.watch.time('iteration') From 870be03bb8806bdc21e14ebd50ac99cefa46db61 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Wed, 5 Apr 2023 21:40:28 -0700 Subject: [PATCH 117/180] Added option to pass data file list as text file --- mlreco/iotools/datasets.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mlreco/iotools/datasets.py b/mlreco/iotools/datasets.py index e3cb1333..3eff5a5c 100644 --- a/mlreco/iotools/datasets.py +++ b/mlreco/iotools/datasets.py @@ -40,6 +40,9 @@ def __init__(self, data_schema, data_keys, limit_num_files=0, limit_num_samples= # Create file list self._files = [] + if isinstance(data_keys, str): + with open(data_keys, 'r') as f: + data_keys = f.read().splitlines() for key in data_keys: fs = sorted(glob.glob(key)) for f in fs: From 9029c688885b33b492b90c27b5a09eca2ba762ea Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Thu, 6 Apr 2023 02:33:26 -0700 Subject: [PATCH 118/180] Make DataProduct Builders, move postprocessing to analysis, other major changes, analysis tools working --- analysis/algorithms/arxiv/example_nue.py | 2 +- analysis/algorithms/arxiv/flash_matching.py | 2 +- analysis/algorithms/arxiv/michel_electrons.py | 2 +- analysis/algorithms/arxiv/muon_decay.py | 2 +- analysis/algorithms/arxiv/particles.py | 2 +- analysis/algorithms/arxiv/statistics.py | 2 +- analysis/algorithms/arxiv/stopping_muons.py | 2 +- .../algorithms/arxiv/through_going_muons.py | 2 +- analysis/algorithms/common.py | 34 + analysis/algorithms/decorator.py | 23 + analysis/algorithms/scripts/benchmark.py | 4 +- analysis/algorithms/scripts/template.py | 32 +- analysis/classes/Particle.py | 50 +- analysis/classes/ParticleFragment.py | 7 +- analysis/classes/__init__.py | 1 + analysis/classes/builders.py | 593 ++++++++++++++++++ analysis/classes/evaluator.py | 342 +--------- analysis/classes/predictor.py | 190 +----- analysis/decorator.py | 123 ---- analysis/manager.py | 191 ++++++ .../post_processing/README.md | 0 .../post_processing/__init__.py | 0 .../arxiv/analysis/__init__.py | 0 .../arxiv/analysis/instance_clustering.py | 0 .../arxiv/analysis/michel_reconstruction.py | 0 .../analysis/michel_reconstruction_2d.py | 0 .../analysis/michel_reconstruction_noghost.py | 0 .../arxiv/analysis/muon_residual_range.py | 0 .../arxiv/analysis/nue_selection.py | 0 .../arxiv/analysis/stopping_muons.py | 0 .../arxiv/analysis/through_muons.py | 0 .../arxiv/analysis/track_clustering.py | 0 .../post_processing/arxiv/metrics/__init__.py | 0 .../arxiv/metrics/bayes_segnet_mcdropout.py | 0 .../arxiv/metrics/cluster_cnn_metrics.py | 0 .../arxiv/metrics/cluster_gnn_metrics.py | 0 .../metrics/cosmic_discriminator_metrics.py | 0 .../arxiv/metrics/deghosting_metrics.py | 0 .../arxiv/metrics/doublet_metrics.py | 0 .../arxiv/metrics/duq_metrics.py | 0 .../arxiv/metrics/evidential_gnn.py | 0 .../arxiv/metrics/evidential_metrics.py | 0 .../arxiv/metrics/evidential_segnet.py | 0 .../arxiv/metrics/graph_spice_metrics.py | 0 .../arxiv/metrics/kinematics_metrics.py | 0 .../arxiv/metrics/multi_particle.py | 0 .../arxiv/metrics/pid_metrics.py | 0 .../arxiv/metrics/ppn_metrics.py | 0 .../arxiv/metrics/ppn_simple.py | 0 .../arxiv/metrics/single_particle.py | 0 .../arxiv/metrics/singlep_mcdropout.py | 0 .../arxiv/metrics/uresnet_metrics.py | 0 .../arxiv/metrics/vertex_metrics.py | 0 .../post_processing/arxiv/store/__init__.py | 0 .../arxiv/store/store_input.py | 0 .../arxiv/store/store_output.py | 0 .../arxiv/store/store_uresnet.py | 0 .../arxiv/store/store_uresnet_ppn.py | 0 .../post_processing/common.py | 9 +- .../post_processing/decorator.py | 8 - .../post_processing/pmt/FlashManager.py | 0 .../post_processing/pmt/__init__.py | 0 .../reconstruction/__init__.py | 0 .../reconstruction/calorimetry.py | 2 +- .../reconstruction/geometry.py | 2 +- .../reconstruction/particle_points.py | 4 +- .../post_processing/reconstruction/pi0.py | 2 +- .../post_processing/reconstruction/points.py | 2 +- .../post_processing/reconstruction/utils.py | 2 +- .../post_processing/reconstruction/vertex.py | 2 +- analysis/run.py | 42 +- mlreco/main_funcs.py | 22 - 72 files changed, 958 insertions(+), 745 deletions(-) create mode 100644 analysis/algorithms/common.py create mode 100644 analysis/algorithms/decorator.py create mode 100644 analysis/classes/builders.py delete mode 100644 analysis/decorator.py create mode 100644 analysis/manager.py rename {mlreco => analysis}/post_processing/README.md (100%) rename {mlreco => analysis}/post_processing/__init__.py (100%) rename {mlreco => analysis}/post_processing/arxiv/analysis/__init__.py (100%) rename {mlreco => analysis}/post_processing/arxiv/analysis/instance_clustering.py (100%) rename {mlreco => analysis}/post_processing/arxiv/analysis/michel_reconstruction.py (100%) rename {mlreco => analysis}/post_processing/arxiv/analysis/michel_reconstruction_2d.py (100%) rename {mlreco => analysis}/post_processing/arxiv/analysis/michel_reconstruction_noghost.py (100%) rename {mlreco => analysis}/post_processing/arxiv/analysis/muon_residual_range.py (100%) rename {mlreco => analysis}/post_processing/arxiv/analysis/nue_selection.py (100%) rename {mlreco => analysis}/post_processing/arxiv/analysis/stopping_muons.py (100%) rename {mlreco => analysis}/post_processing/arxiv/analysis/through_muons.py (100%) rename {mlreco => analysis}/post_processing/arxiv/analysis/track_clustering.py (100%) rename {mlreco => analysis}/post_processing/arxiv/metrics/__init__.py (100%) rename {mlreco => analysis}/post_processing/arxiv/metrics/bayes_segnet_mcdropout.py (100%) rename {mlreco => analysis}/post_processing/arxiv/metrics/cluster_cnn_metrics.py (100%) rename {mlreco => analysis}/post_processing/arxiv/metrics/cluster_gnn_metrics.py (100%) rename {mlreco => analysis}/post_processing/arxiv/metrics/cosmic_discriminator_metrics.py (100%) rename {mlreco => analysis}/post_processing/arxiv/metrics/deghosting_metrics.py (100%) rename {mlreco => analysis}/post_processing/arxiv/metrics/doublet_metrics.py (100%) rename {mlreco => analysis}/post_processing/arxiv/metrics/duq_metrics.py (100%) rename {mlreco => analysis}/post_processing/arxiv/metrics/evidential_gnn.py (100%) rename {mlreco => analysis}/post_processing/arxiv/metrics/evidential_metrics.py (100%) rename {mlreco => analysis}/post_processing/arxiv/metrics/evidential_segnet.py (100%) rename {mlreco => analysis}/post_processing/arxiv/metrics/graph_spice_metrics.py (100%) rename {mlreco => analysis}/post_processing/arxiv/metrics/kinematics_metrics.py (100%) rename {mlreco => analysis}/post_processing/arxiv/metrics/multi_particle.py (100%) rename {mlreco => analysis}/post_processing/arxiv/metrics/pid_metrics.py (100%) rename {mlreco => analysis}/post_processing/arxiv/metrics/ppn_metrics.py (100%) rename {mlreco => analysis}/post_processing/arxiv/metrics/ppn_simple.py (100%) rename {mlreco => analysis}/post_processing/arxiv/metrics/single_particle.py (100%) rename {mlreco => analysis}/post_processing/arxiv/metrics/singlep_mcdropout.py (100%) rename {mlreco => analysis}/post_processing/arxiv/metrics/uresnet_metrics.py (100%) rename {mlreco => analysis}/post_processing/arxiv/metrics/vertex_metrics.py (100%) rename {mlreco => analysis}/post_processing/arxiv/store/__init__.py (100%) rename {mlreco => analysis}/post_processing/arxiv/store/store_input.py (100%) rename {mlreco => analysis}/post_processing/arxiv/store/store_output.py (100%) rename {mlreco => analysis}/post_processing/arxiv/store/store_uresnet.py (100%) rename {mlreco => analysis}/post_processing/arxiv/store/store_uresnet_ppn.py (100%) rename {mlreco => analysis}/post_processing/common.py (92%) rename {mlreco => analysis}/post_processing/decorator.py (83%) rename {mlreco => analysis}/post_processing/pmt/FlashManager.py (100%) rename {mlreco => analysis}/post_processing/pmt/__init__.py (100%) rename {mlreco => analysis}/post_processing/reconstruction/__init__.py (100%) rename {mlreco => analysis}/post_processing/reconstruction/calorimetry.py (98%) rename {mlreco => analysis}/post_processing/reconstruction/geometry.py (97%) rename {mlreco => analysis}/post_processing/reconstruction/particle_points.py (98%) rename {mlreco => analysis}/post_processing/reconstruction/pi0.py (94%) rename {mlreco => analysis}/post_processing/reconstruction/points.py (97%) rename {mlreco => analysis}/post_processing/reconstruction/utils.py (95%) rename {mlreco => analysis}/post_processing/reconstruction/vertex.py (99%) diff --git a/analysis/algorithms/arxiv/example_nue.py b/analysis/algorithms/arxiv/example_nue.py index 8cefb5ee..7e3b32e1 100644 --- a/analysis/algorithms/arxiv/example_nue.py +++ b/analysis/algorithms/arxiv/example_nue.py @@ -2,7 +2,7 @@ from analysis.algorithms.utils import get_interaction_properties, get_particle_properties from analysis.classes.evaluator import FullChainEvaluator -from analysis.decorator import evaluate +from lartpc_mlreco3d.analysis.algorithms.arxiv.decorator import evaluate from lartpc_mlreco3d.analysis.classes.particle_utils import match_particles_fn, matrix_iou, match_particles_optimal from pprint import pprint diff --git a/analysis/algorithms/arxiv/flash_matching.py b/analysis/algorithms/arxiv/flash_matching.py index 27bc2fbb..0b393366 100644 --- a/analysis/algorithms/arxiv/flash_matching.py +++ b/analysis/algorithms/arxiv/flash_matching.py @@ -1,7 +1,7 @@ from collections import OrderedDict from analysis.classes.evaluator import FullChainEvaluator -from analysis.decorator import evaluate +from lartpc_mlreco3d.analysis.algorithms.arxiv.decorator import evaluate from pprint import pprint import time diff --git a/analysis/algorithms/arxiv/michel_electrons.py b/analysis/algorithms/arxiv/michel_electrons.py index 173d133d..9aec19eb 100644 --- a/analysis/algorithms/arxiv/michel_electrons.py +++ b/analysis/algorithms/arxiv/michel_electrons.py @@ -4,7 +4,7 @@ from analysis.classes.predictor import FullChainPredictor from analysis.classes.evaluator import FullChainEvaluator -from analysis.decorator import evaluate +from lartpc_mlreco3d.analysis.algorithms.arxiv.decorator import evaluate from lartpc_mlreco3d.analysis.algorithms.arxiv.calorimetry import compute_track_length from pprint import pprint diff --git a/analysis/algorithms/arxiv/muon_decay.py b/analysis/algorithms/arxiv/muon_decay.py index e9643b4c..948d6072 100644 --- a/analysis/algorithms/arxiv/muon_decay.py +++ b/analysis/algorithms/arxiv/muon_decay.py @@ -3,7 +3,7 @@ from analysis.classes.evaluator import FullChainEvaluator from lartpc_mlreco3d.analysis.algorithms.arxiv.calorimetry import compute_track_length -from analysis.decorator import evaluate +from lartpc_mlreco3d.analysis.algorithms.arxiv.decorator import evaluate from lartpc_mlreco3d.analysis.classes.particle_utils import match_particles_fn, matrix_iou from analysis.algorithms.arxiv.michel_electrons import get_bounding_box, is_attached_at_edge diff --git a/analysis/algorithms/arxiv/particles.py b/analysis/algorithms/arxiv/particles.py index 859584da..4dbd2e24 100644 --- a/analysis/algorithms/arxiv/particles.py +++ b/analysis/algorithms/arxiv/particles.py @@ -5,7 +5,7 @@ sys.path.append('/sdf/group/neutrino/ldomine/OpT0Finder/python') -from analysis.decorator import evaluate +from lartpc_mlreco3d.analysis.algorithms.arxiv.decorator import evaluate from analysis.classes.evaluator import FullChainEvaluator from analysis.classes.TruthInteraction import TruthInteraction from analysis.classes.Interaction import Interaction diff --git a/analysis/algorithms/arxiv/statistics.py b/analysis/algorithms/arxiv/statistics.py index 75524173..cf0d3021 100644 --- a/analysis/algorithms/arxiv/statistics.py +++ b/analysis/algorithms/arxiv/statistics.py @@ -5,7 +5,7 @@ from lartpc_mlreco3d.analysis.algorithms.arxiv.calorimetry import compute_track_length, get_particle_direction from analysis.classes.predictor import FullChainPredictor from analysis.classes.evaluator import FullChainEvaluator -from analysis.decorator import evaluate +from lartpc_mlreco3d.analysis.algorithms.arxiv.decorator import evaluate import numpy as np diff --git a/analysis/algorithms/arxiv/stopping_muons.py b/analysis/algorithms/arxiv/stopping_muons.py index 66492a28..e2a0827e 100644 --- a/analysis/algorithms/arxiv/stopping_muons.py +++ b/analysis/algorithms/arxiv/stopping_muons.py @@ -4,7 +4,7 @@ from analysis.classes.predictor import FullChainPredictor from analysis.classes.evaluator import FullChainEvaluator -from analysis.decorator import evaluate +from lartpc_mlreco3d.analysis.algorithms.arxiv.decorator import evaluate from mlreco.utils.gnn.evaluation import clustering_metrics from mlreco.utils.gnn.cluster import get_cluster_label diff --git a/analysis/algorithms/arxiv/through_going_muons.py b/analysis/algorithms/arxiv/through_going_muons.py index 7495c7e6..1bf065ab 100644 --- a/analysis/algorithms/arxiv/through_going_muons.py +++ b/analysis/algorithms/arxiv/through_going_muons.py @@ -4,7 +4,7 @@ from analysis.classes.predictor import FullChainPredictor from analysis.classes.evaluator import FullChainEvaluator -from analysis.decorator import evaluate +from lartpc_mlreco3d.analysis.algorithms.arxiv.decorator import evaluate from analysis.algorithms.selections.flash_matching import find_true_time, find_true_x from pprint import pprint diff --git a/analysis/algorithms/common.py b/analysis/algorithms/common.py new file mode 100644 index 00000000..c4693a29 --- /dev/null +++ b/analysis/algorithms/common.py @@ -0,0 +1,34 @@ +import numpy as np +from functools import partial +from collections import defaultdict, OrderedDict + +from pprint import pprint + +class ScriptProcessor: + + def __init__(self, data, result, debug=True): + self._funcs = defaultdict(list) + self._num_batches = len(data['index']) + self.data = data + self.index = data['index'] + self.result = result + self.debug = debug + + def register_function(self, f, priority, script_cfg={}): + filenames = f._filenames + pf = partial(f, **script_cfg) + pf._filenames = filenames + self._funcs[priority].append(pf) + + def process(self): + """ + """ + fname_to_update_list = defaultdict(list) + sorted_processors = sorted([x for x in self._funcs.items()], reverse=True) + for priority, f_list in sorted_processors: + for f in f_list: + dict_list = f(self.data, self.result) + filenames = f._filenames + for i, analysis_dict in enumerate(dict_list): + fname_to_update_list[filenames[i]].extend(analysis_dict) + return fname_to_update_list diff --git a/analysis/algorithms/decorator.py b/analysis/algorithms/decorator.py new file mode 100644 index 00000000..606ce9e2 --- /dev/null +++ b/analysis/algorithms/decorator.py @@ -0,0 +1,23 @@ +from functools import wraps + +def write_to(filenames=[]): + """ + Decorator for handling analysis tools script savefiles. + + Parameters + ---------- + filenames: list of output filenames + """ + def decorator(func): + @wraps(func) + def wrapper(data_dict, result_dict, **kwargs): + + # TODO: Handle unwrap/non-unwrap + + out = func(data_dict, result_dict, **kwargs) + return out + + wrapper._filenames = filenames + + return wrapper + return decorator \ No newline at end of file diff --git a/analysis/algorithms/scripts/benchmark.py b/analysis/algorithms/scripts/benchmark.py index 0fea3b73..5af88462 100644 --- a/analysis/algorithms/scripts/benchmark.py +++ b/analysis/algorithms/scripts/benchmark.py @@ -1,13 +1,13 @@ from analysis.classes.evaluator import FullChainEvaluator -from analysis.decorator import evaluate +from analysis.algorithms.decorator import write_to from pprint import pprint import time import numpy as np import os, sys -@evaluate(['test']) +@write_to(['test']) def benchmark(data_blob, res, data_idx, analysis_cfg, cfg): """ Dummy script to see how long FullChainEvaluator initialization takes. diff --git a/analysis/algorithms/scripts/template.py b/analysis/algorithms/scripts/template.py index 34ef294f..e4cd4561 100644 --- a/analysis/algorithms/scripts/template.py +++ b/analysis/algorithms/scripts/template.py @@ -1,13 +1,14 @@ from collections import OrderedDict -from analysis.decorator import evaluate +from analysis.algorithms.decorator import write_to from analysis.classes.evaluator import FullChainEvaluator from analysis.classes.TruthInteraction import TruthInteraction from analysis.classes.Interaction import Interaction from analysis.algorithms.logger import ParticleLogger, InteractionLogger -@evaluate(['interactions', 'particles']) -def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): + +@write_to(['interactions', 'particles']) +def run_inference(data_blob, res, **kwargs): """ Example of analysis script for nue analysis. """ @@ -16,26 +17,30 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): interactions, particles = [], [] # Analysis tools configuration - primaries = analysis_cfg['analysis']['match_primaries'] - enable_flash_matching = analysis_cfg['analysis'].get('enable_flash_matching', False) - ADC_to_MeV = analysis_cfg['analysis'].get('ADC_to_MeV', 1./350.) - matching_mode = analysis_cfg['analysis']['matching_mode'] - flash_matching_cfg = analysis_cfg['analysis'].get('flash_matching_cfg', '') + primaries = kwargs['match_primaries'] + enable_flash_matching = kwargs.get('enable_flash_matching', False) + ADC_to_MeV = kwargs.get('ADC_to_MeV', 1./350.) + matching_mode = kwargs['matching_mode'] + flash_matching_cfg = kwargs.get('flash_matching_cfg', '') + boundaries = kwargs.get('boundaries', [[1376.3], None, None]) # FullChainEvaluator config - processor_cfg = analysis_cfg['analysis'].get('processor_cfg', {}) + evaluator_cfg = kwargs.get('evaluator_cfg', {}) # Particle and Interaction processor names - particle_fieldnames = analysis_cfg['logger'].get('particles', {}) - int_fieldnames = analysis_cfg['logger'].get('interactions', {}) + particle_fieldnames = kwargs['logger'].get('particles', {}) + int_fieldnames = kwargs['logger'].get('interactions', {}) # Load data into evaluator if enable_flash_matching: - predictor = FullChainEvaluator(data_blob, res, cfg, processor_cfg, + predictor = FullChainEvaluator(data_blob, res, + predictor_cfg=evaluator_cfg, enable_flash_matching=enable_flash_matching, flash_matching_cfg=flash_matching_cfg, opflash_keys=['opflash_cryoE', 'opflash_cryoW']) else: - predictor = FullChainEvaluator(data_blob, res, cfg, processor_cfg) + predictor = FullChainEvaluator(data_blob, res, + evaluator_cfg=evaluator_cfg, + boundaries=boundaries) image_idxs = data_blob['index'] spatial_size = predictor.spatial_size @@ -72,6 +77,7 @@ def run_inference(data_blob, res, data_idx, analysis_cfg, cfg): # 2. Process interaction level information interaction_logger = InteractionLogger(int_fieldnames) interaction_logger.prepare() + for i, interaction_pair in enumerate(matches): int_dict = OrderedDict() diff --git a/analysis/classes/Particle.py b/analysis/classes/Particle.py index 47683f76..a9990313 100644 --- a/analysis/classes/Particle.py +++ b/analysis/classes/Particle.py @@ -44,20 +44,44 @@ class Particle: endpoint: (1,3) np.array (1, 3) array of particle's endpoint, if it could be assigned ''' - def __init__(self, coords, group_id, semantic_type, interaction_id, - pid, image_id, nu_id=-1, voxel_indices=None, depositions=None, volume=-1, **kwargs): - self.id = group_id - self.points = coords - self.size = coords.shape[0] - self.depositions = depositions # In rescaled ADC - self.voxel_indices = voxel_indices - self.semantic_type = semantic_type - self.pid = pid - self.pid_conf = kwargs.get('pid_conf', None) + def __init__(self, + coords, + group_id, + semantic_type, + interaction_id, + image_id, + nu_id=-1, + voxel_indices=None, + depositions=None, + volume=-1, **kwargs): + self.id = group_id + self.points = coords + self.size = coords.shape[0] + self.depositions = depositions # In rescaled ADC + self.voxel_indices = voxel_indices + self.semantic_type = semantic_type self.interaction_id = interaction_id - self.nu_id = nu_id - self.image_id = image_id - self.is_primary = kwargs.get('is_primary', False) + self.image_id = image_id + + self.nu_id = nu_id + self.primary_scores = kwargs.get('primary_scores', None) + self.pid_scores = kwargs.get('pid_scores', None) + + assert 'pid' in kwargs or self.pid_scores is not None + assert 'is_primary' in kwargs or self.primary_scores is not None + + # Override argmax pid if pid is explicitly given. + if kwargs.get('pid', None) is not None: + self.pid = kwargs['pid'] + else: + self.pid = np.argmax(self.pid_scores) + + # Overrite argmax primary label if primariness is explicity given. + if 'is_primary' in kwargs: + self.is_primary = kwargs['is_primary'] + else: + self.is_primary = np.argmax(self.primary_scores) + self.match = [] self._match_counts = {} # self.fragments = fragment_ids diff --git a/analysis/classes/ParticleFragment.py b/analysis/classes/ParticleFragment.py index 07cf40d7..db25feac 100644 --- a/analysis/classes/ParticleFragment.py +++ b/analysis/classes/ParticleFragment.py @@ -1,11 +1,6 @@ -import numpy as np -import pandas as pd -from typing import Counter, List, Union -from . import Particle - -class ParticleFragment(Particle): +class ParticleFragment: ''' Data structure for managing fragment-level full chain output information diff --git a/analysis/classes/__init__.py b/analysis/classes/__init__.py index 843e9f05..2aaa1fd7 100644 --- a/analysis/classes/__init__.py +++ b/analysis/classes/__init__.py @@ -4,4 +4,5 @@ from .TruthParticleFragment import TruthParticleFragment from .Interaction import Interaction from .TruthInteraction import TruthInteraction +from .builders import ParticleBuilder, InteractionBuilder, FragmentBuilder # from .FlashManager import FlashManager diff --git a/analysis/classes/builders.py b/analysis/classes/builders.py new file mode 100644 index 00000000..2f6852d0 --- /dev/null +++ b/analysis/classes/builders.py @@ -0,0 +1,593 @@ +from abc import ABC, abstractmethod +from typing import List + +import numpy as np +from scipy.special import softmax +from scipy.spatial.distance import cdist + +from mlreco.utils.globals import (BATCH_COL, + COORD_COLS, + PDG_TO_PID, + VALUE_COL, + VTX_COLS, + INTER_COL, + GROUP_COL, + PSHOW_COL, + CLUST_COL) +from analysis.classes import (Particle, + TruthParticle, + Interaction, + TruthInteraction, + ParticleFragment, + TruthParticleFragment) +from analysis.classes.particle_utils import group_particles_to_interactions_fn +from mlreco.utils.vertex import get_vertex +from mlreco.utils.gnn.cluster import get_cluster_label + +class Builder(ABC): + + def build(self, data: dict, result: dict, mode='reco'): + output = [] + num_batches = len(data['index']) + for bidx in range(num_batches): + entities = self.build_image(bidx, data, result, mode=mode) + output.append(entities) + return output + + def build_image(self, entry: int, data: dict, result: dict, mode='reco'): + + if mode == 'truth': + entities = self._build_true(entry, data, result) + elif mode == 'reco': + entities = self._build_reco(entry, data, result) + else: + raise ValueError(f"Particle builder mode {mode} not supported!") + + return entities + + @abstractmethod + def _build_true(self, entry, data: dict, result: dict): + raise NotImplementedError + + @abstractmethod + def _build_reco(self, entry, data: dict, result: dict): + raise NotImplementedError + + +class ParticleBuilder(Builder): + """ + Eats data, result and makes List of Particles per image. + """ + def __init__(self, builder_cfg={}): + self.cfg = builder_cfg + + def _build_reco(self, + entry: int, + data: dict, + result: dict) -> List[Particle]: + + out = [] + + # Essential Information + volume_labels = result['input_rescaled'][entry][:, BATCH_COL] + point_cloud = result['input_rescaled'][entry][:, COORD_COLS] + depositions = result['input_rescaled'][entry][:, 4] + particles = result['particle_clusts'][entry] + particle_seg = result['particle_seg'][entry] + + particle_start_points = result['particle_start_points'][entry][:, COORD_COLS] + particle_end_points = result['particle_end_points'][entry][:, COORD_COLS] + inter_ids = result['particle_group_pred'][entry] + + type_logits = result['particle_node_pred_type'][entry] + primary_logits = result['particle_node_pred_vtx'][entry] + + pid_scores = softmax(type_logits, axis=1) + primary_scores = softmax(primary_logits, axis=1) + pid = None + + for i, p in enumerate(particles): + voxels = point_cloud[p] + volume_id, cts = np.unique(volume_labels[p], return_counts=True) + volume_id = int(volume_id[cts.argmax()]) + seg_label = particle_seg[i] + if seg_label == 2 or seg_label == 3: + pid = 1 + interaction_id = inter_ids[i] + part = Particle(voxels, + i, + seg_label, + interaction_id, + entry, + pid=pid, + voxel_indices=p, + depositions=depositions[p], + volume=volume_id, + primary_scores=primary_scores[i], + pid_scores=pid_scores[i]) + + part.startpoint = particle_start_points[i] + part.endpoint = particle_end_points[i] + + out.append(part) + + return out + + def _build_true(self, + entry: int, + data: dict, + result: dict) -> List[TruthParticle]: + + out = [] + + labels = result['cluster_label_adapted'][entry] + labels_nonghost = data['cluster_label'][entry] + larcv_particles = data['particles_asis'][entry] + rescaled_charge = result['input_rescaled'][entry][:, 4] + particle_ids = set(list(np.unique(labels[:, 6]).astype(int))) + coordinates = result['input_rescaled'][entry][:, COORD_COLS] + + + for i, lpart in enumerate(larcv_particles): + id = int(lpart.id()) + pid = PDG_TO_PID.get(lpart.pdg_code(), -1) + is_primary = lpart.group_id() == lpart.parent_id() + mask_nonghost = labels_nonghost[:, 6].astype(int) == pid + if np.count_nonzero(mask_nonghost) <= 0: + continue # Skip larcv particles with no true depositions + # 1. Check if current pid is one of the existing group ids + if pid not in particle_ids: + particle = handle_empty_true_particles(labels_nonghost, + mask_nonghost, + lpart, + entry) + out.append(particle) + continue + + # 1. Process voxels + mask = labels[:, 6].astype(int) == pid + # If particle is Michel electron, we have the option to + # only consider the primary ionization. + # Semantic labels only label the primary ionization as Michel. + # Cluster labels will have the entire Michel together. + # if self.michel_primary_ionization_only and 2 in labels[mask][:, -1].astype(int): + # mask = mask & (labels[:, -1].astype(int) == 2) + # mask_noghost = mask_noghost & (labels_nonghost[:, -1].astype(int) == 2) + + coords = coordinates[mask] + voxel_indices = np.where(mask)[0] + # fragments = np.unique(labels[mask][:, 5].astype(int)) + depositions_MeV = labels[mask][:, VALUE_COL] + depositions = rescaled_charge[mask] # Will be in ADC + coords_noghost = labels_nonghost[mask_nonghost][:, COORD_COLS] + depositions_noghost = labels_nonghost[mask_nonghost][:, VALUE_COL].squeeze() + + volume_labels = labels_nonghost[mask_nonghost][:, BATCH_COL] + volume_id, cts = np.unique(volume_labels, return_counts=True) + volume_id = int(volume_id[cts.argmax()]) + + # if lpart.pdg_code() not in PDG_TO_PID: + # continue + # exclude_ids = self._apply_true_voxel_cut(entry) + # if pid in exclude_ids: + # # Skip this particle if its below the voxel minimum requirement + # continue + + # 2. Process particle-level labels + semantic_type, int_id, nu_id = get_true_particle_labels(labels, + mask, + pid=pid) + + particle = TruthParticle(coords, + id, + semantic_type, + int_id, + entry, + particle_asis=lpart, + coords_noghost=coords_noghost, + depositions_noghost=depositions_noghost, + depositions_MeV=depositions_MeV, + nu_id=nu_id, + voxel_indices=voxel_indices, + depositions=depositions, + volume=volume_id, + is_primary=is_primary, + pid=pid,) + + particle.p = np.array([lpart.px(), lpart.py(), lpart.pz()]) + # particle.fragments = fragments + + particle.startpoint = np.array([lpart.first_step().x(), + lpart.first_step().y(), + lpart.first_step().z()]) + + if semantic_type == 1: + particle.endpoint = np.array([lpart.last_step().x(), + lpart.last_step().y(), + lpart.last_step().z()]) + out.append(particle) + + return out + + +class InteractionBuilder(Builder): + + def __init__(self, builder_cfg={}): + self.cfg = builder_cfg + + def _build_reco(self, entry: int, data: dict, result: dict) -> List[Interaction]: + particles = result['Particles'][entry] + out = group_particles_to_interactions_fn(particles, + get_nu_id=True, + mode='pred') + return out + + def _build_true(self, entry: int, data: dict, result: dict) -> List[TruthInteraction]: + particles = result['TruthParticles'][entry] + out = group_particles_to_interactions_fn(particles, + get_nu_id=True, + mode='truth') + + out = self.decorate_true_interactions(entry, data, out) + return out + + def build_true_using_particles(self, entry, data, particles): + out = group_particles_to_interactions_fn(particles, + get_nu_id=True, + mode='truth') + out = self.decorate_true_interactions(entry, data, out) + return out + + def decorate_true_interactions(self, entry, data, interactions): + vertices = self.get_true_vertices(entry, data) + for ia in interactions: + if ia.id in vertices: + ia.vertex = vertices[ia.id] + + if 'neutrino_asis' in data and ia.nu_id == 1: + # assert 'particles_asis' in data_blob + # particles = data_blob['particles_asis'][i] + neutrinos = data['neutrino_asis'][entry] + if len(neutrinos) > 1 or len(neutrinos) == 0: continue + nu = neutrinos[0] + # Get larcv::Particle objects for each + # particle of the true interaction + # true_particles = np.array(particles)[np.array([p.id for p in true_int.particles])] + # true_particles_track_ids = [p.track_id() for p in true_particles] + # for nu in neutrinos: + # if nu.mct_index() not in true_particles_track_ids: continue + ia.nu_info = { + 'nu_interaction_type': nu.interaction_type(), + 'nu_interaction_mode': nu.interaction_mode(), + 'nu_current_type': nu.current_type(), + 'nu_energy_init': nu.energy_init() + } + return interactions + + def get_true_vertices(self, entry, data: dict): + out = {} + inter_idxs = np.unique( + data['cluster_label'][entry][:, INTER_COL].astype(int)) + for inter_idx in inter_idxs: + if inter_idx < 0: + continue + vtx = get_vertex(data['cluster_label'], + data['cluster_label'], + data_idx=entry, + inter_idx=inter_idx, + vtx_col=VTX_COLS[0]) + mask = data['cluster_label'][entry][:, INTER_COL].astype(int) == inter_idx + points = data['cluster_label'][entry][:, COORD_COLS] + new_vtx = points[mask][np.linalg.norm(points[mask] - vtx, axis=1).argmin()] + out[inter_idx] = new_vtx + return out + + +class FragmentBuilder(Builder): + + def __init__(self, builder_cfg={}): + self.cfg = builder_cfg + self.allow_nodes = self.cfg.get('allow_nodes', [0,2,3]) + self.min_particle_voxel_count = self.cfg.get('min_particle_voxel_cut', -1) + self.only_primaries = self.cfg.get('only_primaries', False) + self.include_semantics = self.cfg.get('include_semantics', None) + self.attaching_threshold = self.cfg.get('attaching_threshold', 5.0) + self.verbose = self.cfg.get('verbose', False) + + def _build_reco(self, entry, + data: dict, + result: dict): + + volume_labels = result['input_rescaled'][entry][:, BATCH_COL] + point_cloud = result['input_rescaled'][entry][:, COORD_COLS] + depositions = result['input_rescaled'][entry][:, VALUE_COL] + fragments = result['fragment_clusts'][entry] + fragments_seg = result['fragment_seg'][entry] + + shower_mask = np.isin(fragments_seg, self.allow_nodes) + shower_frag_primary = np.argmax( + result['shower_fragment_node_pred'][entry], axis=1) + + shower_startpoints = result['shower_fragment_start_points'][entry][:, COORD_COLS] + track_startpoints = result['track_fragment_start_points'][entry][:, COORD_COLS] + track_endpoints = result['track_fragment_end_points'][entry][:, COORD_COLS] + + assert len(fragments_seg) == len(fragments) + + temp = [] + + shower_group = result['shower_fragment_group_pred'][entry] + track_group = result['track_fragment_group_pred'][entry] + + group_ids = np.ones(len(fragments)).astype(int) * -1 + inter_ids = np.ones(len(fragments)).astype(int) * -1 + + for i, p in enumerate(fragments): + voxels = point_cloud[p] + seg_label = fragments_seg[i] + volume_id, cts = np.unique(volume_labels[p], return_counts=True) + volume_id = int(volume_id[cts.argmax()]) + + part = ParticleFragment(voxels, + i, seg_label, + group_id=group_ids[i], + interaction_id=inter_ids[i], + image_id=entry, + voxel_indices=p, + depositions=depositions[p], + is_primary=False, + pid_conf=-1, + alias='Fragment', + volume=volume_id) + temp.append(part) + + # Label shower fragments as primaries and attach startpoint + shower_counter = 0 + for p in np.array(temp)[shower_mask]: + is_primary = shower_frag_primary[shower_counter] + p.is_primary = bool(is_primary) + p.startpoint = shower_startpoints[shower_counter] + # p.group_id = int(shower_group_pred[shower_counter]) + shower_counter += 1 + assert shower_counter == shower_frag_primary.shape[0] + + # Attach endpoint to track fragments + track_counter = 0 + for p in temp: + if p.semantic_type == 1: + # p.group_id = int(track_group_pred[track_counter]) + p.startpoint = track_startpoints[track_counter] + p.endpoint = track_endpoints[track_counter] + track_counter += 1 + # assert track_counter == track_group_pred.shape[0] + + # Apply fragment voxel cut + out = [] + for p in temp: + if p.points.shape[0] < self.min_particle_voxel_count: + continue + out.append(p) + + # Check primaries and assign ppn points + if self.only_primaries: + out = [p for p in out if p.is_primary] + + if self.include_semantics is not None: + out = [p for p in out if p.semantic_type in self.include_semantics] + + return out + + def _build_true(self, entry, data: dict, result: dict): + + fragments = [] + + labels = result['cluster_label_adapted'][entry] + rescaled_input_charge = result['input_rescaled'][entry][:, VALUE_COL] + fragment_ids = set(list(np.unique(labels[:, CLUST_COL]).astype(int))) + + for fid in fragment_ids: + mask = labels[:, CLUST_COL] == fid + + semantic_type, counts = np.unique(labels[:, -1][mask].astype(int), + return_counts=True) + if semantic_type.shape[0] > 1: + if self.verbose: + print("Semantic Type of Fragment {} is not "\ + "unique: {}, {}".format(fid, + str(semantic_type), + str(counts))) + perm = counts.argmax() + semantic_type = semantic_type[perm] + else: + semantic_type = semantic_type[0] + + points = labels[mask][:, COORD_COLS] + size = points.shape[0] + depositions = rescaled_input_charge[mask] + depositions_MeV = labels[mask][:, VALUE_COL] + voxel_indices = np.where(mask)[0] + + volume_id, cts = np.unique(labels[:, BATCH_COL][mask].astype(int), + return_counts=True) + volume_id = int(volume_id[cts.argmax()]) + + group_id, counts = np.unique(labels[:, GROUP_COL][mask].astype(int), + return_counts=True) + if group_id.shape[0] > 1: + if self.verbose: + print("Group ID of Fragment {} is not "\ + "unique: {}, {}".format(fid, + str(group_id), + str(counts))) + perm = counts.argmax() + group_id = group_id[perm] + else: + group_id = group_id[0] + + interaction_id, counts = np.unique(labels[:, INTER_COL][mask].astype(int), + return_counts=True) + if interaction_id.shape[0] > 1: + if self.verbose: + print("Interaction ID of Fragment {} is not "\ + "unique: {}, {}".format(fid, + str(interaction_id), + str(counts))) + perm = counts.argmax() + interaction_id = interaction_id[perm] + else: + interaction_id = interaction_id[0] + + + is_primary, counts = np.unique(labels[:, PSHOW_COL][mask].astype(bool), + return_counts=True) + if is_primary.shape[0] > 1: + if self.verbose: + print("Primary label of Fragment {} is not "\ + "unique: {}, {}".format(fid, + str(is_primary), + str(counts))) + perm = counts.argmax() + is_primary = is_primary[perm] + else: + is_primary = is_primary[0] + + part = TruthParticleFragment(points, + fid, + semantic_type, + interaction_id, + group_id, + image_id=entry, + voxel_indices=voxel_indices, + depositions=depositions, + depositions_MeV=depositions_MeV, + is_primary=is_primary, + volume=volume_id, + alias='Fragment') + + fragments.append(part) + return fragments + + +# --------------------------Helper functions--------------------------- + +def handle_empty_true_particles(labels_noghost, + mask_noghost, + p, + entry, + verbose=False): + pid = int(p.id()) + pdg = PDG_TO_PID.get(p.pdg_code(), -1) + is_primary = p.group_id() == p.parent_id() + + semantic_type, interaction_id, nu_id = -1, -1, -1 + coords, depositions, voxel_indices = np.array([]), np.array([]), np.array([]) + coords_noghost, depositions_noghost = np.array([]), np.array([]) + if np.count_nonzero(mask_noghost) > 0: + coords_noghost = labels_noghost[mask_noghost][:, 1:4] + depositions_noghost = labels_noghost[mask_noghost][:, 4].squeeze() + semantic_type, interaction_id, nu_id = get_true_particle_labels(labels_noghost, + mask_noghost, + pid=pid, + verbose=verbose) + particle = TruthParticle(coords, + pid, semantic_type, interaction_id, pdg, + entry, particle_asis=p, + depositions=depositions, + is_primary=is_primary, + coords_noghost=coords_noghost, + depositions_noghost=depositions_noghost, + depositions_MeV=depositions) + particle.p = np.array([p.px(), p.py(), p.pz()]) + particle.fragments = [] + particle.particle_asis = p + particle.nu_id = nu_id + particle.voxel_indices = voxel_indices + + particle.startpoint = np.array([p.first_step().x(), + p.first_step().y(), + p.first_step().z()]) + + if semantic_type == 1: + particle.endpoint = np.array([p.last_step().x(), + p.last_step().y(), + p.last_step().z()]) + return particle + + +def get_true_particle_labels(labels, mask, pid=-1, verbose=False): + semantic_type, sem_counts = np.unique(labels[mask][:, -1].astype(int), + return_counts=True) + if semantic_type.shape[0] > 1: + if verbose: + print("Semantic Type of Particle {} is not "\ + "unique: {}, {}".format(pid, + str(semantic_type), + str(sem_counts))) + perm = sem_counts.argmax() + semantic_type = semantic_type[perm] + else: + semantic_type = semantic_type[0] + + interaction_id, int_counts = np.unique(labels[mask][:, 7].astype(int), + return_counts=True) + if interaction_id.shape[0] > 1: + if verbose: + print("Interaction ID of Particle {} is not "\ + "unique: {}".format(pid, str(interaction_id))) + perm = int_counts.argmax() + interaction_id = interaction_id[perm] + else: + interaction_id = interaction_id[0] + + nu_id, nu_counts = np.unique(labels[mask][:, 8].astype(int), + return_counts=True) + if nu_id.shape[0] > 1: + if verbose: + print("Neutrino ID of Particle {} is not "\ + "unique: {}".format(pid, str(nu_id))) + perm = nu_counts.argmax() + nu_id = nu_id[perm] + else: + nu_id = nu_id[0] + + return semantic_type, interaction_id, nu_id + +def match_points_to_particles(ppn_points : np.ndarray, + particles : List[Particle], + semantic_type=None, ppn_distance_threshold=2): + """Function for matching ppn points to particles. + + For each particle, match ppn_points that have hausdorff distance + less than and inplace update particle.ppn_candidates + + If semantic_type is set to a class integer value, + points will be matched to particles with the same + predicted semantic type. + + Parameters + ---------- + ppn_points : (N x 4 np.array) + PPN point array with (coords, point_type) + particles : list of objects + List of particles for which to match ppn points. + semantic_type: int + If set to an integer, only match ppn points with prescribed + semantic type + ppn_distance_threshold: int or float + Maximum distance required to assign ppn point to particle. + + Returns + ------- + None (operation is in-place) + """ + if semantic_type is not None: + ppn_points_type = ppn_points[ppn_points[:, 5] == semantic_type] + else: + ppn_points_type = ppn_points + # TODO: Fix semantic type ppn selection + + ppn_coords = ppn_points_type[:, :3] + for particle in particles: + dist = cdist(ppn_coords, particle.points) + matches = ppn_points_type[dist.min(axis=1) < ppn_distance_threshold] + particle.ppn_candidates = matches.reshape(-1, 7) \ No newline at end of file diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index 52679f3e..7af9ce1e 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -16,86 +16,6 @@ from analysis.classes.predictor import FullChainPredictor -def get_true_particle_labels(labels, mask, pid=-1, verbose=False): - semantic_type, sem_counts = np.unique(labels[mask][:, -1].astype(int), - return_counts=True) - if semantic_type.shape[0] > 1: - if verbose: - print("Semantic Type of Particle {} is not "\ - "unique: {}, {}".format(pid, - str(semantic_type), - str(sem_counts))) - perm = sem_counts.argmax() - semantic_type = semantic_type[perm] - else: - semantic_type = semantic_type[0] - - interaction_id, int_counts = np.unique(labels[mask][:, 7].astype(int), - return_counts=True) - if interaction_id.shape[0] > 1: - if verbose: - print("Interaction ID of Particle {} is not "\ - "unique: {}".format(pid, str(interaction_id))) - perm = int_counts.argmax() - interaction_id = interaction_id[perm] - else: - interaction_id = interaction_id[0] - - nu_id, nu_counts = np.unique(labels[mask][:, 8].astype(int), - return_counts=True) - if nu_id.shape[0] > 1: - if verbose: - print("Neutrino ID of Particle {} is not "\ - "unique: {}".format(pid, str(nu_id))) - perm = nu_counts.argmax() - nu_id = nu_id[perm] - else: - nu_id = nu_id[0] - - return semantic_type, interaction_id, nu_id - - -def handle_empty_true_particles(labels_noghost, mask_noghost, p, entry, - verbose=False): - pid = int(p.id()) - pdg = PDG_TO_PID.get(p.pdg_code(), -1) - is_primary = p.group_id() == p.parent_id() - - semantic_type, interaction_id, nu_id = -1, -1, -1 - coords, depositions, voxel_indices = np.array([]), np.array([]), np.array([]) - coords_noghost, depositions_noghost = np.array([]), np.array([]) - if np.count_nonzero(mask_noghost) > 0: - coords_noghost = labels_noghost[mask_noghost][:, 1:4] - depositions_noghost = labels_noghost[mask_noghost][:, 4].squeeze() - semantic_type, interaction_id, nu_id = get_true_particle_labels(labels_noghost, - mask_noghost, - pid=pid, - verbose=verbose) - particle = TruthParticle(coords, - pid, semantic_type, interaction_id, pdg, - entry, particle_asis=p, - depositions=depositions, - is_primary=is_primary, - coords_noghost=coords_noghost, - depositions_noghost=depositions_noghost, - depositions_MeV=depositions) - particle.p = np.array([p.px(), p.py(), p.pz()]) - particle.fragments = [] - particle.particle_asis = p - particle.nu_id = nu_id - particle.voxel_indices = voxel_indices - - particle.startpoint = np.array([p.first_step().x(), - p.first_step().y(), - p.first_step().z()]) - - if semantic_type == 1: - particle.endpoint = np.array([p.last_step().x(), - p.last_step().y(), - p.last_step().z()]) - return particle - - class FullChainEvaluator(FullChainPredictor): ''' Helper class for full chain prediction and evaluation. @@ -156,9 +76,9 @@ class FullChainEvaluator(FullChainPredictor): } - def __init__(self, data_blob, result, cfg, processor_cfg={}, **kwargs): - super(FullChainEvaluator, self).__init__(data_blob, result, cfg, processor_cfg, **kwargs) - self.michel_primary_ionization_only = processor_cfg.get('michel_primary_ionization_only', False) + def __init__(self, data_blob, result, evaluator_cfg={}, **kwargs): + super(FullChainEvaluator, self).__init__(data_blob, result, evaluator_cfg, **kwargs) + self.michel_primary_ionization_only = evaluator_cfg.get('michel_primary_ionization_only', False) def get_true_label(self, entry, name, schema='cluster_label_adapted'): """ @@ -230,95 +150,18 @@ def _apply_true_voxel_cut(self, entry): return set(particles_exclude) - def get_true_fragments(self, entry, verbose=False) -> List[TruthParticleFragment]: + def get_true_fragments(self, entry) -> List[TruthParticleFragment]: ''' Get list of instances for given batch id. ''' - fragments = [] - - # Both are "adapted" labels - labels = self.result['cluster_label_adapted'][entry] - rescaled_input_charge = self.result['input_rescaled'][entry][:, 4] - - fragment_ids = set(list(np.unique(labels[:, 5]).astype(int))) - - for fid in fragment_ids: - mask = labels[:, 5] == fid - - semantic_type, counts = np.unique(labels[:, -1][mask], return_counts=True) - if semantic_type.shape[0] > 1: - if verbose: - print("Semantic Type of Fragment {} is not "\ - "unique: {}, {}".format(fid, - str(semantic_type), - str(counts))) - perm = counts.argmax() - semantic_type = semantic_type[perm] - else: - semantic_type = semantic_type[0] - - points = labels[mask][:, 1:4] - size = points.shape[0] - depositions = rescaled_input_charge[mask] - depositions_MeV = labels[mask][:, 4] - voxel_indices = np.where(mask)[0] - - group_id, counts = np.unique(labels[:, 6][mask].astype(int), return_counts=True) - if group_id.shape[0] > 1: - if verbose: - print("Group ID of Fragment {} is not "\ - "unique: {}, {}".format(fid, - str(group_id), - str(counts))) - perm = counts.argmax() - group_id = group_id[perm] - else: - group_id = group_id[0] - - interaction_id, counts = np.unique(labels[:, 7][mask].astype(int), return_counts=True) - if interaction_id.shape[0] > 1: - if verbose: - print("Interaction ID of Fragment {} is not "\ - "unique: {}, {}".format(fid, - str(interaction_id), - str(counts))) - perm = counts.argmax() - interaction_id = interaction_id[perm] - else: - interaction_id = interaction_id[0] - - - is_primary, counts = np.unique(labels[:, -2][mask].astype(bool), return_counts=True) - if is_primary.shape[0] > 1: - if verbose: - print("Primary label of Fragment {} is not "\ - "unique: {}, {}".format(fid, - str(is_primary), - str(counts))) - perm = counts.argmax() - is_primary = is_primary[perm] - else: - is_primary = is_primary[0] - - part = TruthParticleFragment(self._translate(points, volume), - fid, semantic_type, - interaction_id=interaction_id, - group_id=group_id, - image_id=entry, - voxel_indices=voxel_indices, - depositions=depositions, - depositions_MeV=depositions_MeV, - is_primary=is_primary, - alias='Fragment') - - fragments.append(part) - + fragments = self.result['ParticleFragments'][entry] return fragments - def get_true_particles(self, entry, only_primaries=True, - verbose=False, volume=None) -> List[TruthParticle]: + def get_true_particles(self, entry, + only_primaries=True, + volume=None) -> List[TruthParticle]: ''' Get list of instances for given batch id. @@ -337,138 +180,19 @@ def get_true_particles(self, entry, only_primaries=True, p: true momentum vector ''' out_particles_list = [] - - labels = self.result['cluster_label_adapted'][entry] - labels_noghost = self.data_blob['cluster_label'][entry] - particle_ids = set(list(np.unique(labels[:, 6]).astype(int))) - rescaled_input_charge = self.result['input_rescaled'][entry][:, 4] - - particles = [] - exclude_ids = set([]) - - for idx, p in enumerate(self.data_blob['particles_asis'][entry]): - pid = int(p.id()) - pdg = PDG_TO_PID.get(p.pdg_code(), -1) - is_primary = p.group_id() == p.parent_id() - mask_noghost = labels_noghost[:, 6].astype(int) == pid - if np.count_nonzero(mask_noghost) <= 0: - continue - # 1. Check if current pid is one of the existing group ids - if pid not in particle_ids: - particle = handle_empty_true_particles(labels_noghost, mask_noghost, p, entry, - verbose=verbose) - particles.append(particle) - continue - - # 1. Process voxels - mask = labels[:, 6].astype(int) == pid - # If particle is Michel electron, we have the option to - # only consider the primary ionization. - # Semantic labels only label the primary ionization as Michel. - # Cluster labels will have the entire Michel together. - if self.michel_primary_ionization_only and 2 in labels[mask][:, -1].astype(int): - mask = mask & (labels[:, -1].astype(int) == 2) - mask_noghost = mask_noghost & (labels_noghost[:, -1].astype(int) == 2) - - coords = self.result['input_rescaled'][entry][mask][:, 1:4] - voxel_indices = np.where(mask)[0] - fragments = np.unique(labels[mask][:, 5].astype(int)) - depositions_MeV = labels[mask][:, 4] - depositions = rescaled_input_charge[mask] # Will be in ADC - coords_noghost = labels_noghost[mask_noghost][:, 1:4] - depositions_noghost = labels_noghost[mask_noghost][:, 4].squeeze() - - volume_labels = labels_noghost[mask_noghost][:, 0] - volume_id, cts = np.unique(volume_labels, return_counts=True) - volume_id = int(volume_id[cts.argmax()]) - - # 2. Process particle-level labels - if p.pdg_code() not in PDG_TO_PID: - # print("PID {} not in TYPE LABELS".format(pid)) - continue - exclude_ids = self._apply_true_voxel_cut(entry) - if pid in exclude_ids: - # Skip this particle if its below the voxel minimum requirement - continue - - semantic_type, interaction_id, nu_id = get_true_particle_labels(labels, mask, pid=pid, verbose=verbose) - - particle = TruthParticle(coords, - pid, - semantic_type, interaction_id, pdg, entry, - particle_asis=p, - depositions=depositions, - is_primary=is_primary, - coords_noghost=coords_noghost, - depositions_noghost=depositions_noghost, - depositions_MeV=depositions_MeV, - volume=volume_id) - - particle.p = np.array([p.px(), p.py(), p.pz()]) - particle.fragments = fragments - particle.particle_asis = p - particle.nu_id = nu_id - particle.voxel_indices = voxel_indices - - particle.startpoint = np.array([p.first_step().x(), - p.first_step().y(), - p.first_step().z()]) - - if semantic_type == 1: - particle.endpoint = np.array([p.last_step().x(), - p.last_step().y(), - p.last_step().z()]) - - if particle.voxel_indices.shape[0] >= self.min_particle_voxel_count: - particles.append(particle) - - out_particles_list.extend(particles) + particles = self.result['TruthParticles'][entry] if only_primaries: - out_particles_list = [p for p in out_particles_list if p.is_primary] + out_particles_list = [p for p in particles if p.is_primary] if volume is not None: - out_particles_list = [p for p in out_particles_list if p.volume == volume] + out_particles_list = [p for p in particles if p.volume == volume] return out_particles_list - def get_true_interactions(self, entry, drop_nonprimary_particles=True, - min_particle_voxel_count=-1, - volume=None) -> List[Interaction]: - if min_particle_voxel_count < 0: - min_particle_voxel_count = self.min_particle_voxel_count - - out = [] - true_particles = self.get_true_particles(entry, - only_primaries=drop_nonprimary_particles, - volume=volume) - out = group_particles_to_interactions_fn(true_particles, - get_nu_id=True, - mode='truth') - vertices = self.get_true_vertices(entry) - for ia in out: - if ia.id in vertices: - ia.vertex = vertices[ia.id] - - if 'neutrino_asis' in self.data_blob and ia.nu_id == 1: - # assert 'particles_asis' in data_blob - # particles = data_blob['particles_asis'][i] - neutrinos = self.data_blob['neutrino_asis'][entry] - if len(neutrinos) > 1 or len(neutrinos) == 0: continue - nu = neutrinos[0] - # Get larcv::Particle objects for each - # particle of the true interaction - # true_particles = np.array(particles)[np.array([p.id for p in true_int.particles])] - # true_particles_track_ids = [p.track_id() for p in true_particles] - # for nu in neutrinos: - # if nu.mct_index() not in true_particles_track_ids: continue - ia.nu_info = { - 'nu_interaction_type': nu.interaction_type(), - 'nu_interaction_mode': nu.interaction_mode(), - 'nu_current_type': nu.current_type(), - 'nu_energy_init': nu.energy_init() - } - + def get_true_interactions(self, entry) -> List[Interaction]: + + out = self.result['TruthInteractions'][entry] return out @staticmethod @@ -511,38 +235,6 @@ def match_parts_within_ints(int_matches): return matched_particles, np.array(match_counts) - def get_true_vertices(self, entry): - """ - Parameters - ========== - entry: int - volume: int, default None - - Returns - ======= - dict - Keys are true interactions ids, values are np.array of shape (N, 3) - with true vertices coordinates. - """ - out = {} - inter_idxs = np.unique( - self.data_blob['cluster_label'][entry][:, INTER_COL].astype(int)) - for inter_idx in inter_idxs: - if inter_idx < 0: - continue - vtx = get_vertex(self.data_blob['cluster_label'], - self.data_blob['cluster_label'], - data_idx=entry, - inter_idx=inter_idx, - vtx_col=VTX_COLS[0]) - mask = self.data_blob['cluster_label'][entry][:, INTER_COL].astype(int) == inter_idx - points = self.data_blob['cluster_label'][entry][:, COORD_COLS[0]:COORD_COLS[-1]+1] - new_vtx = points[mask][np.linalg.norm(points[mask] - vtx, axis=1).argmin()] - out[inter_idx] = new_vtx - - return out - - def match_particles(self, entry, only_primaries=False, mode='pred_to_true', @@ -620,13 +312,11 @@ def match_interactions(self, entry, mode='pred_to_true', if mode == 'pred_to_true': ints_from = self.get_interactions(entry, drop_nonprimary_particles=drop_nonprimary_particles) - ints_to = self.get_true_interactions(entry, - drop_nonprimary_particles=drop_nonprimary_particles) + ints_to = self.get_true_interactions(entry) elif mode == 'true_to_pred': ints_to = self.get_interactions(entry, drop_nonprimary_particles=drop_nonprimary_particles) - ints_from = self.get_true_interactions(entry, - drop_nonprimary_particles=drop_nonprimary_particles) + ints_from = self.get_true_interactions(entry) else: raise ValueError("Mode {} is not valid. For matching each"\ " prediction to truth, use 'pred_to_true' (and vice versa).".format(mode)) diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index b62901e0..72ec487b 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -9,7 +9,7 @@ from mlreco.utils.metrics import unique_label from scipy.special import softmax -from analysis.classes import Particle, ParticleFragment, Interaction +from analysis.classes import Particle, ParticleFragment, Interaction, ParticleBuilder, InteractionBuilder from analysis.classes.particle_utils import group_particles_to_interactions_fn from analysis.algorithms.point_matching import * @@ -50,12 +50,19 @@ class FullChainPredictor: 3) Some outputs needs to be listed under trainval.concat_result. The predictor will run through a checklist to ensure this condition ''' - def __init__(self, data_blob, result, cfg, predictor_cfg={}, - enable_flash_matching=False, flash_matching_cfg="", opflash_keys=[]): - # self.module_config = cfg['model']['modules'] + def __init__(self, data_blob, result, + predictor_cfg={}, + enable_flash_matching=False, + flash_matching_cfg="", + opflash_keys=[], + boundaries=None): + self.data_blob = data_blob self.result = result + self.particle_builder = ParticleBuilder() + self.interaction_builder = InteractionBuilder() + self.num_images = len(result['input_rescaled']) self.index = self.data_blob['index'] @@ -98,7 +105,7 @@ def __init__(self, data_blob, result, cfg, predictor_cfg={}, # split over "virtual" batch ids # Note this is different from "self.volume_boundaries" above # FIXME rename one or the other to be clearer - boundaries = cfg['iotool'].get('collate', {}).get('boundaries', None) + if boundaries is not None: self.vb = VolumeBoundaries(boundaries) self._num_volumes = self.vb.num_volumes() @@ -605,108 +612,7 @@ def get_fragments(self, entry, only_primaries=False, List of instances (see Particle class definition). ''' self._check_volume(volume) - - if min_particle_voxel_count < 0: - min_particle_voxel_count = self.min_particle_voxel_count - - out_fragment_list = [] - - point_cloud = self.result['input_rescaled'][entry][:, 1:4] - depositions = self.result['input_rescaled'][entry][:, 4] - fragments = self.result['fragment_clusts'][entry] - fragments_seg = self.result['fragment_seg'][entry] - - shower_mask = np.isin(fragments_seg, allow_nodes) - shower_frag_primary = np.argmax(self.result['shower_fragment_node_pred'][entry], axis=1) - - if 'shower_fragment_node_features' in self.result: - shower_node_features = self.result['shower_fragment_node_features'][entry] - if 'track_fragment_node_features' in self.result: - track_node_features = self.result['track_fragment_node_features'][entry] - - assert len(fragments_seg) == len(fragments) - - temp = [] - - if ('particle_group_pred' in self.result) and ('particle_clusts' in self.result) and len(fragments) > 0: - - group_labels = self._fit_predict_groups(entry) - inter_labels = self._fit_predict_interaction_labels(entry) - group_ids = get_cluster_label(group_labels.reshape(-1, 1), fragments, column=0) - inter_ids = get_cluster_label(inter_labels.reshape(-1, 1), fragments, column=0) - - else: - group_ids = np.ones(len(fragments)).astype(int) * -1 - inter_ids = np.ones(len(fragments)).astype(int) * -1 - - if true_id: - true_fragment_labels = self.data_blob['cluster_label'][entry][:, 5] - - - for i, p in enumerate(fragments): - voxels = point_cloud[p] - seg_label = fragments_seg[i] - part = ParticleFragment(voxels, - i, seg_label, - interaction_id=inter_ids[i], - group_id=group_ids[i], - image_id=entry, - voxel_indices=p, - depositions=depositions[p], - is_primary=False, - pid_conf=-1, - alias='Fragment', - volume=volume) - temp.append(part) - if true_id: - fid = true_fragment_labels[p] - fids, counts = np.unique(fid.astype(int), return_counts=True) - part.true_ids = fids - part.true_counts = counts - - # Label shower fragments as primaries and attach startpoint - shower_counter = 0 - for p in np.array(temp)[shower_mask]: - is_primary = shower_frag_primary[shower_counter] - p.is_primary = bool(is_primary) - p.startpoint = shower_node_features[shower_counter][19:22] - # p.group_id = int(shower_group_pred[shower_counter]) - shower_counter += 1 - assert shower_counter == shower_frag_primary.shape[0] - - # Attach endpoint to track fragments - track_counter = 0 - for p in temp: - if p.semantic_type == 1: - # p.group_id = int(track_group_pred[track_counter]) - p.startpoint = track_node_features[track_counter][19:22] - p.endpoint = track_node_features[track_counter][22:25] - track_counter += 1 - # assert track_counter == track_group_pred.shape[0] - - # Apply fragment voxel cut - out = [] - for p in temp: - if p.points.shape[0] < min_particle_voxel_count: - continue - out.append(p) - - # Check primaries and assign ppn points - if only_primaries: - out = [p for p in out if p.is_primary] - - if semantic_type is not None: - out = [p for p in out if p.semantic_type == semantic_type] - - if len(out) == 0: - return out - - ppn_results = self._fit_predict_ppn(entry) - match_points_to_particles(ppn_results, out, - ppn_distance_threshold=attaching_threshold) - - out_fragment_list.extend(out) - + out_fragment_list = self.result['ParticleFragments'][entry] return out_fragment_list def _get_primary_labels(self, node_pred_vtx): @@ -761,62 +667,7 @@ def get_particles(self, entry, only_primaries=False, volume=None) -> List[Partic List of instances (see Particle class definition). ''' self._check_volume(volume) - - # Essential Information - volume_labels = self.result['input_rescaled'][entry][:, BATCH_COL] - point_cloud = self.result['input_rescaled'][entry][:, COORD_COLS] - depositions = self.result['input_rescaled'][entry][:, 4] - particles = self.result['particle_clusts'][entry] - particle_seg = self.result['particle_seg'][entry] - - type_logits = self.result['particle_node_pred_type'][entry] - particle_start_points = self.result['particle_start_points'][entry][:, COORD_COLS] - particle_end_points = self.result['particle_end_points'][entry][:, COORD_COLS] - node_pred_vtx = self.result['particle_node_pred_vtx'][entry] - inter_ids = self.result['particle_group_pred'][entry] - pids = np.argmax(type_logits, axis=1) - - out = [] - - # Some basic input checks - if point_cloud.shape[0] == 0 or len(particles) == 0: - return out - assert len(particle_seg) == len(particles) - assert len(pids) == len(particles) - assert len(particle_end_points) == len(particles) - assert len(particle_start_points) == len(particles) - assert point_cloud.shape[0] == depositions.shape[0] - assert node_pred_vtx.shape[0] == len(particles) - assert len(inter_ids) == len(particles) - - primary_labels = self._get_primary_labels(node_pred_vtx) - - for i, p in enumerate(particles): - voxels = point_cloud[p] - volume_id, cts = np.unique(volume_labels[p], return_counts=True) - volume_id = int(volume_id[cts.argmax()]) - seg_label = particle_seg[i] - pid = pids[i] - if seg_label == 2 or seg_label == 3: - pid = 1 - interaction_id = inter_ids[i] - part = Particle(voxels, - i, - seg_label, - interaction_id, - pid, - entry, - voxel_indices=p, - depositions=depositions[p], - is_primary=primary_labels[i], - pid_conf=softmax(type_logits[i])[pids[i]], - volume=volume_id) - - part.startpoint = particle_start_points[i] - part.endpoint = particle_end_points[i] - - out.append(part) - + out = self.result['Particles'][entry] out = self._decorate_particles(entry, out, only_primaries=only_primaries, volume=volume) @@ -885,18 +736,7 @@ def get_interactions(self, entry, Returns: - out: List of instances (see particle.Interaction). ''' - out = [] - particles = self.get_particles(entry, - only_primaries=drop_nonprimary_particles, - volume=volume) - out = group_particles_to_interactions_fn(particles, mode='pred') - for ia in out: ia.volume = volume - if get_vertex: - vertices = self.result['vertices'][entry] - for ia in out: - mask = vertices[:, BATCH_COL].astype(int) == ia.id - vertex = vertices[mask][:, COORD_COLS[0]:COORD_COLS[-1]+1] - ia.vertex = vertex.squeeze() + out = self.result['Interactions'][entry] return out diff --git a/analysis/decorator.py b/analysis/decorator.py deleted file mode 100644 index 10bc0eae..00000000 --- a/analysis/decorator.py +++ /dev/null @@ -1,123 +0,0 @@ -from collections import defaultdict -from functools import wraps -import os -from tabnanny import verbose -import pandas as pd -from pprint import pprint -import torch -import time - -from mlreco.main_funcs import cycle -from mlreco.trainval import trainval -from mlreco.iotools.factories import loader_factory -from mlreco.iotools.readers import HDF5Reader -from mlreco.iotools.writers import CSVWriter -from mlreco.main_funcs import run_post_processing - -def evaluate(filenames): - ''' - Inputs - ------ - - analysis_function: algorithm that runs on a single image given by - data_blob[data_idx], res - ''' - def decorate(func): - - @wraps(func) - def process_dataset(analysis_config, cfg, profile=True): - - # Total number of iterations to process - max_iteration = analysis_config['analysis']['iteration'] - - # Initialize the process which produces the reconstruction output - if 'reader' not in analysis_config: - # If there is not reader, initialize the full chain - io_cfg = cfg['iotool'] - - module_config = cfg['model']['modules'] - event_list = cfg['iotool']['dataset'].get('event_list', None) - if event_list is not None: - event_list = eval(event_list) - if isinstance(event_list, tuple): - assert event_list[0] < event_list[1] - event_list = list(range(event_list[0], event_list[1])) - - loader = loader_factory(cfg, event_list=event_list) - dataset = iter(cycle(loader)) - Trainer = trainval(cfg) - loaded_iteration = Trainer.initialize() - - if max_iteration == -1: - max_iteration = len(loader.dataset) - assert max_iteration <= len(loader.dataset) - - else: - # If there is a reader, simply load reconstructed data - file_keys = analysis_config['reader']['file_keys'] - entry_list = analysis_config['reader'].get('entry_list', []) - skip_entry_list = analysis_config['reader'].get('skip_entry_list', []) - Reader = HDF5Reader(file_keys, entry_list, skip_entry_list, True) - if max_iteration == -1: - max_iteration = len(Reader) - assert max_iteration <= len(Reader) - - # Initialize the writer(s) - log_dir = analysis_config['logger']['log_dir'] - append = analysis_config['logger'].get('append', False) - - writers = {} - for file_name in filenames: - path = os.path.join(log_dir, file_name+'.csv') - writers[file_name] = CSVWriter(path, append) - - # Loop over the number of requested iterations - iteration = 0 - while iteration < max_iteration: - - # 1. Forwarding or Reading HDF5 file - if profile: - start = time.time() - if 'reader' not in analysis_config: - data_blob, res = Trainer.forward(dataset) - else: - data_blob, res = Reader.get(iteration, nested=True) - if profile: - print('------------------------------------------------') - print("Forward took %.2f s" % (time.time() - start)) - img_indices = data_blob['index'] - - # 2. Run post-processing scripts - stime = time.time() - if 'post_processing' in analysis_config: - run_post_processing(analysis_config, data_blob, res) - if profile: - end = time.time() - print("Post-processing took %.2f s" % (time.time() - stime)) - - # 3. Run analysis tools script - stime = time.time() - fname_to_update_list = defaultdict(list) - for batch_index, img_index in enumerate(img_indices): - dict_list = func(data_blob, res, batch_index, analysis_config, cfg) - for i, analysis_dict in enumerate(dict_list): - fname_to_update_list[filenames[i]].extend(analysis_dict) - - # 4. Store information to csv file. - for i, fname in enumerate(fname_to_update_list): - for row_dict in fname_to_update_list[fname]: - writers[fname].append(row_dict) - - if profile: - end = time.time() - print("Analysis tools and logging took %.2f s" % (time.time() - stime)) - - # Increment iteration count - iteration += 1 - if profile: - end = time.time() - print("Iteration %d (total %.2f s)" % (iteration, end - start)) - print('------------------------------------------------') - - process_dataset._filenames = filenames - return process_dataset - return decorate diff --git a/analysis/manager.py b/analysis/manager.py new file mode 100644 index 00000000..815b39c1 --- /dev/null +++ b/analysis/manager.py @@ -0,0 +1,191 @@ +import time, os, sys +from collections import defaultdict + +from mlreco.iotools.factories import loader_factory +from mlreco.trainval import trainval +from mlreco.main_funcs import cycle +from mlreco.iotools.readers import HDF5Reader +from mlreco.iotools.writers import CSVWriter + +from analysis import post_processing +from analysis.algorithms import scripts +from analysis.post_processing.common import PostProcessor +from analysis.algorithms.common import ScriptProcessor +from analysis.classes.builders import ParticleBuilder, InteractionBuilder, FragmentBuilder + +class AnaToolsManager: + + def __init__(self, cfg, ana_cfg, profile=True): + self.config = cfg + self.ana_config = ana_cfg + self.max_iteration = self.ana_config['analysis']['iteration'] + self.log_dir = self.ana_config['analysis']['log_dir'] + + # Initialize data product builders + self.data_builders = self.ana_config['analysis']['data_builders'] + self.builders = {} + supported_builders = ['ParticleBuilder', 'InteractionBuilder', 'FragmentBuilder'] + for builder_name in self.data_builders: + if builder_name not in supported_builders: + raise ValueError(f"{builder_name} is not a valid data product builder!") + builder = eval(builder_name)() + self.builders[builder_name] = builder + + self._data_reader = None + self._reader_state = None + self.profile = profile + self.writers = {} + + def _set_iteration(self, dataset): + if self.max_iteration == -1: + self.max_iteration = len(dataset) + assert self.max_iteration <= len(dataset) + + def initialize(self): + if 'reader' not in self.ana_config: + event_list = self.config['iotool']['dataset'].get('event_list', None) + if event_list is not None: + event_list = eval(event_list) + if isinstance(event_list, tuple): + assert event_list[0] < event_list[1] + event_list = list(range(event_list[0], event_list[1])) + + loader = loader_factory(self.config, event_list=event_list) + self._dataset = iter(cycle(loader)) + Trainer = trainval(self.config) + loaded_iteration = Trainer.initialize() + self._data_reader = Trainer + self._reader_state = 'trainval' + self._set_iteration(loader.dataset) + else: + # If there is a reader, simply load reconstructed data + file_keys = self.ana_config['reader']['file_keys'] + entry_list = self.ana_config['reader'].get('entry_list', []) + skip_entry_list = self.ana_config['reader'].get('skip_entry_list', []) + Reader = HDF5Reader(file_keys, entry_list, skip_entry_list, True) + self._data_reader = Reader + self._reader_state = 'hdf5' + self._set_iteration(Reader) + + def forward(self, iteration=None): + if self.profile: + start = time.time() + if self._reader_state == 'hdf5': + assert iteration is not None + data, res = self._data_reader.get(iteration, nested=True) + elif self._reader_state == 'trainval': + data, res = self._data_reader.forward(self._dataset) + else: + raise ValueError(f"Data reader {self._reader_state} is not supported!") + if self.profile: + end = time.time() + print("Forwarding data took %.2f s" % (end - start)) + return data, res + + def build_representations(self, data, result): + if self.profile: + start = time.time() + if 'ParticleBuilder' in self.builders: + result['Particles'] = self.builders['ParticleBuilder'].build(data, result, mode='reco') + result['TruthParticles'] = self.builders['ParticleBuilder'].build(data, result, mode='truth') + if 'InteractionBuilder' in self.builders: + result['Interactions'] = self.builders['InteractionBuilder'].build(data, result, mode='reco') + result['TruthInteractions'] = self.builders['InteractionBuilder'].build(data, result, mode='truth') + if 'FragmentBuilder' in self.builders: + result['ParticleFragments'] = self.builders['FragmentBuilder'].build(data, result, mode='reco') + result['TruthParticleFragments'] = self.builders['FragmentBuilder'].build(data, result, mode='truth') + assert len(result['Particles']) == len(result['TruthParticles']) + assert len(result['Interactions']) == len(result['TruthInteractions']) + assert len(result['ParticleFragments']) == len(result['TruthParticleFragments']) + if self.profile: + end = time.time() + print("Data representation change took %.2f s" % (end - start)) + + def run_post_processing(self, data, result): + if self.profile: + start = time.time() + if 'post_processing' in self.ana_config: + post_processor_interface = PostProcessor(data, result) + # Gather post processing functions, register by priority + for processor_name, pcfg in self.ana_config['post_processing'].items(): + priority = pcfg.pop('priority', -1) + processor_name = processor_name.split('+')[0] + processor = getattr(post_processing,str(processor_name)) + post_processor_interface.register_function(processor, + priority, + processor_cfg=pcfg) + + post_processor_interface.process_and_modify() + if self.profile: + end = time.time() + print("Post-processing took %.2f s" % (end - start)) + + def run_ana_scripts(self, data, result): + if self.profile: + start = time.time() + out = {} + + if 'scripts' in self.ana_config: + script_processor = ScriptProcessor(data, result) + for processor_name, pcfg in self.ana_config['scripts'].items(): + priority = pcfg.pop('priority', -1) + processor_name = processor_name.split('+')[0] + processor = getattr(scripts,str(processor_name)) + script_processor.register_function(processor, + priority, + script_cfg=pcfg) + fname_to_update_list = script_processor.process() + out[processor_name] = fname_to_update_list + + if self.profile: + end = time.time() + print("Analysis scripts took %.2f s" % (end - start)) + return out + + def write(self, ana_output): + + if self.profile: + start = time.time() + + if not self.writers: + self.writers = {} + + for script_name, fname_to_update_list in ana_output.items(): + + append = self.ana_config['scripts'][script_name]['logger'].get('append', False) + filenames = list(fname_to_update_list.keys()) + if len(filenames) != len(set(filenames)): + msg = f"Duplicate filenames: {str(filenames)} in {script_name} "\ + "detected. you need to change the output filename for "\ + f"script {script_name} to something else." + raise RuntimeError(msg) + if len(self.writers) == 0: + for fname in filenames: + path = os.path.join(self.log_dir, fname+'.csv') + self.writers[fname] = CSVWriter(path, append) + for i, fname in enumerate(fname_to_update_list): + for row_dict in ana_output[script_name][fname]: + self.writers[fname].append(row_dict) + + if self.profile: + end = time.time() + print("Writing to csvs took %.2f s" % (end - start)) + + def step(self, iteration): + # 1. Run forward + data, res = self.forward(iteration=iteration) + # 2. Build data representations + self.build_representations(data, res) + # 3. Run post-processing, if requested + self.run_post_processing(data, res) + # 4. Run scripts, if requested + ana_output = self.run_ana_scripts(data, res) + if len(ana_output) == 0: + print("No output from analysis scripts.") + self.write(ana_output) + + def run(self): + iteration = 0 + while iteration < self.max_iteration: + self.step(iteration) + \ No newline at end of file diff --git a/mlreco/post_processing/README.md b/analysis/post_processing/README.md similarity index 100% rename from mlreco/post_processing/README.md rename to analysis/post_processing/README.md diff --git a/mlreco/post_processing/__init__.py b/analysis/post_processing/__init__.py similarity index 100% rename from mlreco/post_processing/__init__.py rename to analysis/post_processing/__init__.py diff --git a/mlreco/post_processing/arxiv/analysis/__init__.py b/analysis/post_processing/arxiv/analysis/__init__.py similarity index 100% rename from mlreco/post_processing/arxiv/analysis/__init__.py rename to analysis/post_processing/arxiv/analysis/__init__.py diff --git a/mlreco/post_processing/arxiv/analysis/instance_clustering.py b/analysis/post_processing/arxiv/analysis/instance_clustering.py similarity index 100% rename from mlreco/post_processing/arxiv/analysis/instance_clustering.py rename to analysis/post_processing/arxiv/analysis/instance_clustering.py diff --git a/mlreco/post_processing/arxiv/analysis/michel_reconstruction.py b/analysis/post_processing/arxiv/analysis/michel_reconstruction.py similarity index 100% rename from mlreco/post_processing/arxiv/analysis/michel_reconstruction.py rename to analysis/post_processing/arxiv/analysis/michel_reconstruction.py diff --git a/mlreco/post_processing/arxiv/analysis/michel_reconstruction_2d.py b/analysis/post_processing/arxiv/analysis/michel_reconstruction_2d.py similarity index 100% rename from mlreco/post_processing/arxiv/analysis/michel_reconstruction_2d.py rename to analysis/post_processing/arxiv/analysis/michel_reconstruction_2d.py diff --git a/mlreco/post_processing/arxiv/analysis/michel_reconstruction_noghost.py b/analysis/post_processing/arxiv/analysis/michel_reconstruction_noghost.py similarity index 100% rename from mlreco/post_processing/arxiv/analysis/michel_reconstruction_noghost.py rename to analysis/post_processing/arxiv/analysis/michel_reconstruction_noghost.py diff --git a/mlreco/post_processing/arxiv/analysis/muon_residual_range.py b/analysis/post_processing/arxiv/analysis/muon_residual_range.py similarity index 100% rename from mlreco/post_processing/arxiv/analysis/muon_residual_range.py rename to analysis/post_processing/arxiv/analysis/muon_residual_range.py diff --git a/mlreco/post_processing/arxiv/analysis/nue_selection.py b/analysis/post_processing/arxiv/analysis/nue_selection.py similarity index 100% rename from mlreco/post_processing/arxiv/analysis/nue_selection.py rename to analysis/post_processing/arxiv/analysis/nue_selection.py diff --git a/mlreco/post_processing/arxiv/analysis/stopping_muons.py b/analysis/post_processing/arxiv/analysis/stopping_muons.py similarity index 100% rename from mlreco/post_processing/arxiv/analysis/stopping_muons.py rename to analysis/post_processing/arxiv/analysis/stopping_muons.py diff --git a/mlreco/post_processing/arxiv/analysis/through_muons.py b/analysis/post_processing/arxiv/analysis/through_muons.py similarity index 100% rename from mlreco/post_processing/arxiv/analysis/through_muons.py rename to analysis/post_processing/arxiv/analysis/through_muons.py diff --git a/mlreco/post_processing/arxiv/analysis/track_clustering.py b/analysis/post_processing/arxiv/analysis/track_clustering.py similarity index 100% rename from mlreco/post_processing/arxiv/analysis/track_clustering.py rename to analysis/post_processing/arxiv/analysis/track_clustering.py diff --git a/mlreco/post_processing/arxiv/metrics/__init__.py b/analysis/post_processing/arxiv/metrics/__init__.py similarity index 100% rename from mlreco/post_processing/arxiv/metrics/__init__.py rename to analysis/post_processing/arxiv/metrics/__init__.py diff --git a/mlreco/post_processing/arxiv/metrics/bayes_segnet_mcdropout.py b/analysis/post_processing/arxiv/metrics/bayes_segnet_mcdropout.py similarity index 100% rename from mlreco/post_processing/arxiv/metrics/bayes_segnet_mcdropout.py rename to analysis/post_processing/arxiv/metrics/bayes_segnet_mcdropout.py diff --git a/mlreco/post_processing/arxiv/metrics/cluster_cnn_metrics.py b/analysis/post_processing/arxiv/metrics/cluster_cnn_metrics.py similarity index 100% rename from mlreco/post_processing/arxiv/metrics/cluster_cnn_metrics.py rename to analysis/post_processing/arxiv/metrics/cluster_cnn_metrics.py diff --git a/mlreco/post_processing/arxiv/metrics/cluster_gnn_metrics.py b/analysis/post_processing/arxiv/metrics/cluster_gnn_metrics.py similarity index 100% rename from mlreco/post_processing/arxiv/metrics/cluster_gnn_metrics.py rename to analysis/post_processing/arxiv/metrics/cluster_gnn_metrics.py diff --git a/mlreco/post_processing/arxiv/metrics/cosmic_discriminator_metrics.py b/analysis/post_processing/arxiv/metrics/cosmic_discriminator_metrics.py similarity index 100% rename from mlreco/post_processing/arxiv/metrics/cosmic_discriminator_metrics.py rename to analysis/post_processing/arxiv/metrics/cosmic_discriminator_metrics.py diff --git a/mlreco/post_processing/arxiv/metrics/deghosting_metrics.py b/analysis/post_processing/arxiv/metrics/deghosting_metrics.py similarity index 100% rename from mlreco/post_processing/arxiv/metrics/deghosting_metrics.py rename to analysis/post_processing/arxiv/metrics/deghosting_metrics.py diff --git a/mlreco/post_processing/arxiv/metrics/doublet_metrics.py b/analysis/post_processing/arxiv/metrics/doublet_metrics.py similarity index 100% rename from mlreco/post_processing/arxiv/metrics/doublet_metrics.py rename to analysis/post_processing/arxiv/metrics/doublet_metrics.py diff --git a/mlreco/post_processing/arxiv/metrics/duq_metrics.py b/analysis/post_processing/arxiv/metrics/duq_metrics.py similarity index 100% rename from mlreco/post_processing/arxiv/metrics/duq_metrics.py rename to analysis/post_processing/arxiv/metrics/duq_metrics.py diff --git a/mlreco/post_processing/arxiv/metrics/evidential_gnn.py b/analysis/post_processing/arxiv/metrics/evidential_gnn.py similarity index 100% rename from mlreco/post_processing/arxiv/metrics/evidential_gnn.py rename to analysis/post_processing/arxiv/metrics/evidential_gnn.py diff --git a/mlreco/post_processing/arxiv/metrics/evidential_metrics.py b/analysis/post_processing/arxiv/metrics/evidential_metrics.py similarity index 100% rename from mlreco/post_processing/arxiv/metrics/evidential_metrics.py rename to analysis/post_processing/arxiv/metrics/evidential_metrics.py diff --git a/mlreco/post_processing/arxiv/metrics/evidential_segnet.py b/analysis/post_processing/arxiv/metrics/evidential_segnet.py similarity index 100% rename from mlreco/post_processing/arxiv/metrics/evidential_segnet.py rename to analysis/post_processing/arxiv/metrics/evidential_segnet.py diff --git a/mlreco/post_processing/arxiv/metrics/graph_spice_metrics.py b/analysis/post_processing/arxiv/metrics/graph_spice_metrics.py similarity index 100% rename from mlreco/post_processing/arxiv/metrics/graph_spice_metrics.py rename to analysis/post_processing/arxiv/metrics/graph_spice_metrics.py diff --git a/mlreco/post_processing/arxiv/metrics/kinematics_metrics.py b/analysis/post_processing/arxiv/metrics/kinematics_metrics.py similarity index 100% rename from mlreco/post_processing/arxiv/metrics/kinematics_metrics.py rename to analysis/post_processing/arxiv/metrics/kinematics_metrics.py diff --git a/mlreco/post_processing/arxiv/metrics/multi_particle.py b/analysis/post_processing/arxiv/metrics/multi_particle.py similarity index 100% rename from mlreco/post_processing/arxiv/metrics/multi_particle.py rename to analysis/post_processing/arxiv/metrics/multi_particle.py diff --git a/mlreco/post_processing/arxiv/metrics/pid_metrics.py b/analysis/post_processing/arxiv/metrics/pid_metrics.py similarity index 100% rename from mlreco/post_processing/arxiv/metrics/pid_metrics.py rename to analysis/post_processing/arxiv/metrics/pid_metrics.py diff --git a/mlreco/post_processing/arxiv/metrics/ppn_metrics.py b/analysis/post_processing/arxiv/metrics/ppn_metrics.py similarity index 100% rename from mlreco/post_processing/arxiv/metrics/ppn_metrics.py rename to analysis/post_processing/arxiv/metrics/ppn_metrics.py diff --git a/mlreco/post_processing/arxiv/metrics/ppn_simple.py b/analysis/post_processing/arxiv/metrics/ppn_simple.py similarity index 100% rename from mlreco/post_processing/arxiv/metrics/ppn_simple.py rename to analysis/post_processing/arxiv/metrics/ppn_simple.py diff --git a/mlreco/post_processing/arxiv/metrics/single_particle.py b/analysis/post_processing/arxiv/metrics/single_particle.py similarity index 100% rename from mlreco/post_processing/arxiv/metrics/single_particle.py rename to analysis/post_processing/arxiv/metrics/single_particle.py diff --git a/mlreco/post_processing/arxiv/metrics/singlep_mcdropout.py b/analysis/post_processing/arxiv/metrics/singlep_mcdropout.py similarity index 100% rename from mlreco/post_processing/arxiv/metrics/singlep_mcdropout.py rename to analysis/post_processing/arxiv/metrics/singlep_mcdropout.py diff --git a/mlreco/post_processing/arxiv/metrics/uresnet_metrics.py b/analysis/post_processing/arxiv/metrics/uresnet_metrics.py similarity index 100% rename from mlreco/post_processing/arxiv/metrics/uresnet_metrics.py rename to analysis/post_processing/arxiv/metrics/uresnet_metrics.py diff --git a/mlreco/post_processing/arxiv/metrics/vertex_metrics.py b/analysis/post_processing/arxiv/metrics/vertex_metrics.py similarity index 100% rename from mlreco/post_processing/arxiv/metrics/vertex_metrics.py rename to analysis/post_processing/arxiv/metrics/vertex_metrics.py diff --git a/mlreco/post_processing/arxiv/store/__init__.py b/analysis/post_processing/arxiv/store/__init__.py similarity index 100% rename from mlreco/post_processing/arxiv/store/__init__.py rename to analysis/post_processing/arxiv/store/__init__.py diff --git a/mlreco/post_processing/arxiv/store/store_input.py b/analysis/post_processing/arxiv/store/store_input.py similarity index 100% rename from mlreco/post_processing/arxiv/store/store_input.py rename to analysis/post_processing/arxiv/store/store_input.py diff --git a/mlreco/post_processing/arxiv/store/store_output.py b/analysis/post_processing/arxiv/store/store_output.py similarity index 100% rename from mlreco/post_processing/arxiv/store/store_output.py rename to analysis/post_processing/arxiv/store/store_output.py diff --git a/mlreco/post_processing/arxiv/store/store_uresnet.py b/analysis/post_processing/arxiv/store/store_uresnet.py similarity index 100% rename from mlreco/post_processing/arxiv/store/store_uresnet.py rename to analysis/post_processing/arxiv/store/store_uresnet.py diff --git a/mlreco/post_processing/arxiv/store/store_uresnet_ppn.py b/analysis/post_processing/arxiv/store/store_uresnet_ppn.py similarity index 100% rename from mlreco/post_processing/arxiv/store/store_uresnet_ppn.py rename to analysis/post_processing/arxiv/store/store_uresnet_ppn.py diff --git a/mlreco/post_processing/common.py b/analysis/post_processing/common.py similarity index 92% rename from mlreco/post_processing/common.py rename to analysis/post_processing/common.py index f6cb9a8a..cd6c362b 100644 --- a/mlreco/post_processing/common.py +++ b/analysis/post_processing/common.py @@ -2,6 +2,7 @@ from functools import partial from collections import defaultdict, OrderedDict + class PostProcessor: def __init__(self, data, result, debug=True): @@ -14,10 +15,10 @@ def __init__(self, data, result, debug=True): def register_function(self, f, priority, processor_cfg={}): data_capture, result_capture = f._data_capture, f._result_capture result_capture_optional = f._result_capture_optional - pf = partial(f, **processor_cfg) - pf._data_capture = data_capture - pf._result_capture = result_capture - pf._result_capture_optional = result_capture_optional + pf = partial(f, **processor_cfg) + pf._data_capture = data_capture + pf._result_capture = result_capture + pf._result_capture_optional = result_capture_optional self._funcs[priority].append(pf) def process_event(self, image_id, f_list): diff --git a/mlreco/post_processing/decorator.py b/analysis/post_processing/decorator.py similarity index 83% rename from mlreco/post_processing/decorator.py rename to analysis/post_processing/decorator.py index e34faaae..fa99b8b7 100644 --- a/mlreco/post_processing/decorator.py +++ b/analysis/post_processing/decorator.py @@ -1,10 +1,4 @@ -from mlreco.utils import CSVData -import os -import numpy as np -from mlreco.utils.deghosting import adapt_labels_numpy as adapt_labels - from functools import wraps -from pprint import pprint def post_processing(data_capture, result_capture, result_capture_optional=[]): @@ -21,14 +15,12 @@ def post_processing(data_capture, result_capture, List of result keys needed. """ def decorator(func): - # This mapping is hardcoded for now... @wraps(func) def wrapper(data_dict, result_dict, **kwargs): # TODO: Handle unwrap/non-unwrap out = func(data_dict, result_dict, **kwargs) - return out wrapper._data_capture = data_capture diff --git a/mlreco/post_processing/pmt/FlashManager.py b/analysis/post_processing/pmt/FlashManager.py similarity index 100% rename from mlreco/post_processing/pmt/FlashManager.py rename to analysis/post_processing/pmt/FlashManager.py diff --git a/mlreco/post_processing/pmt/__init__.py b/analysis/post_processing/pmt/__init__.py similarity index 100% rename from mlreco/post_processing/pmt/__init__.py rename to analysis/post_processing/pmt/__init__.py diff --git a/mlreco/post_processing/reconstruction/__init__.py b/analysis/post_processing/reconstruction/__init__.py similarity index 100% rename from mlreco/post_processing/reconstruction/__init__.py rename to analysis/post_processing/reconstruction/__init__.py diff --git a/mlreco/post_processing/reconstruction/calorimetry.py b/analysis/post_processing/reconstruction/calorimetry.py similarity index 98% rename from mlreco/post_processing/reconstruction/calorimetry.py rename to analysis/post_processing/reconstruction/calorimetry.py index 9e4adb04..245b73e7 100644 --- a/mlreco/post_processing/reconstruction/calorimetry.py +++ b/analysis/post_processing/reconstruction/calorimetry.py @@ -7,7 +7,7 @@ from scipy.interpolate import CubicSpline from functools import lru_cache -from mlreco.post_processing import post_processing +from analysis.post_processing import post_processing from mlreco.utils.globals import * @post_processing(data_capture=['input_data'], diff --git a/mlreco/post_processing/reconstruction/geometry.py b/analysis/post_processing/reconstruction/geometry.py similarity index 97% rename from mlreco/post_processing/reconstruction/geometry.py rename to analysis/post_processing/reconstruction/geometry.py index 4b2f8f2e..cc0258c4 100644 --- a/mlreco/post_processing/reconstruction/geometry.py +++ b/analysis/post_processing/reconstruction/geometry.py @@ -1,7 +1,7 @@ import numpy as np from mlreco.utils.gnn.cluster import get_cluster_directions -from mlreco.post_processing import post_processing +from analysis.post_processing import post_processing from mlreco.utils.globals import * diff --git a/mlreco/post_processing/reconstruction/particle_points.py b/analysis/post_processing/reconstruction/particle_points.py similarity index 98% rename from mlreco/post_processing/reconstruction/particle_points.py rename to analysis/post_processing/reconstruction/particle_points.py index 8cb08498..25dbdcb6 100644 --- a/mlreco/post_processing/reconstruction/particle_points.py +++ b/analysis/post_processing/reconstruction/particle_points.py @@ -3,8 +3,8 @@ from scipy.spatial.distance import cdist from sklearn.decomposition import PCA -from mlreco.post_processing import post_processing -from mlreco.post_processing.reconstruction.calorimetry import compute_track_dedx +from analysis.post_processing import post_processing +from analysis.post_processing.reconstruction.calorimetry import compute_track_dedx @post_processing(data_capture=[], result_capture=['particle_start_points', diff --git a/mlreco/post_processing/reconstruction/pi0.py b/analysis/post_processing/reconstruction/pi0.py similarity index 94% rename from mlreco/post_processing/reconstruction/pi0.py rename to analysis/post_processing/reconstruction/pi0.py index ac066502..b776b70b 100644 --- a/mlreco/post_processing/reconstruction/pi0.py +++ b/analysis/post_processing/reconstruction/pi0.py @@ -1,6 +1,6 @@ from collections import defaultdict from itertools import combinations -from mlreco.post_processing.reconstruction.utils import closest_distance_two_lines +from analysis.post_processing.reconstruction.utils import closest_distance_two_lines from mlreco.utils.gnn.cluster import cluster_direction # TODO: Need to refactor according to post processing conventions diff --git a/mlreco/post_processing/reconstruction/points.py b/analysis/post_processing/reconstruction/points.py similarity index 97% rename from mlreco/post_processing/reconstruction/points.py rename to analysis/post_processing/reconstruction/points.py index a1ba45b5..30d217a4 100644 --- a/mlreco/post_processing/reconstruction/points.py +++ b/analysis/post_processing/reconstruction/points.py @@ -2,7 +2,7 @@ from copy import deepcopy from scipy.spatial.distance import cdist -from mlreco.post_processing import post_processing +from analysis.post_processing import post_processing from mlreco.utils.globals import * diff --git a/mlreco/post_processing/reconstruction/utils.py b/analysis/post_processing/reconstruction/utils.py similarity index 95% rename from mlreco/post_processing/reconstruction/utils.py rename to analysis/post_processing/reconstruction/utils.py index ec25d43d..5c733279 100644 --- a/mlreco/post_processing/reconstruction/utils.py +++ b/analysis/post_processing/reconstruction/utils.py @@ -3,7 +3,7 @@ from scipy.spatial.distance import cdist from mlreco.utils.gnn.cluster import cluster_direction -from mlreco.post_processing import post_processing +from analysis.post_processing import post_processing from mlreco.utils.globals import COORD_COLS diff --git a/mlreco/post_processing/reconstruction/vertex.py b/analysis/post_processing/reconstruction/vertex.py similarity index 99% rename from mlreco/post_processing/reconstruction/vertex.py rename to analysis/post_processing/reconstruction/vertex.py index 681eab23..0e790109 100644 --- a/mlreco/post_processing/reconstruction/vertex.py +++ b/analysis/post_processing/reconstruction/vertex.py @@ -5,7 +5,7 @@ from scipy.spatial.distance import cdist from mlreco.utils.gnn.cluster import cluster_direction -from mlreco.post_processing import post_processing +from analysis.post_processing import post_processing from mlreco.utils.globals import COORD_COLS @post_processing(data_capture=[], diff --git a/analysis/run.py b/analysis/run.py index e3542028..49904268 100644 --- a/analysis/run.py +++ b/analysis/run.py @@ -18,9 +18,7 @@ sys.path.insert(0, current_directory) from mlreco.main_funcs import process_config -from analysis.decorator import evaluate -# Folder `selections` contains several scripts -from analysis.algorithms.scripts import * +from analysis.manager import AnaToolsManager def main(analysis_cfg_path, model_cfg_path): @@ -32,40 +30,10 @@ def main(analysis_cfg_path, model_cfg_path): pprint(analysis_config) if 'analysis' not in analysis_config: raise Exception('Analysis configuration needs to live under `analysis` section.') - if 'name' in analysis_config['analysis']: - process_func = eval(analysis_config['analysis']['name']) - # elif 'scripts' in analysis_config['analysis']: - # assert isinstance(analysis_config['analysis']['scripts'], dict) - - # filenames = [] - # modes = [] - # for name in analysis_config['analysis']['scripts']: - # files = eval(name)._filenames - # mode = eval(name)._mode - - # filenames.extend(files) - # modes.append(mode) - # unique_modes, counts = np.unique(modes, return_counts=True) - # mode = unique_modes[np.argmax(counts)] # most frequent mode wins - - # @evaluate(filenames, mode=mode) - # def process_func(data_blob, res, data_idx, analysis, model_cfg): - # outs = [] - # for name in analysis_config['analysis']['scripts']: - # cfg = analysis.copy() - # cfg['analysis']['name'] = name - # cfg['analysis']['processor_cfg'] = analysis_config['analysis']['scripts'][name] - # func = eval(name).__wrapped__ - - # out = func(copy.deepcopy(data_blob), copy.deepcopy(res), data_idx, cfg, model_cfg) - # outs.extend(out) - # return outs - else: - raise Exception('You need to specify either `name` or `scripts` under `analysis` section.') - - # Run Algorithm - process_func(analysis_config, config) - + + manager = AnaToolsManager(config, analysis_config) + manager.initialize() + manager.run() if __name__=="__main__": parser = argparse.ArgumentParser() diff --git a/mlreco/main_funcs.py b/mlreco/main_funcs.py index c7dc37be..3202f6ec 100644 --- a/mlreco/main_funcs.py +++ b/mlreco/main_funcs.py @@ -9,8 +9,6 @@ pass from mlreco.iotools.factories import loader_factory, writer_factory -import mlreco.post_processing as post_processing -from mlreco.post_processing.common import PostProcessor from collections import OrderedDict # Important: do not import here anything that might # trigger cuda initialization through PyTorch. @@ -283,21 +281,6 @@ def log(handlers, tstamp_iteration, #tspent_io, tspent_iteration, if handlers.train_logger: handlers.train_logger.flush() -def run_post_processing(cfg, data_blob, result_blob): - - post_processor_interface = PostProcessor(data_blob, result_blob) - - for processor_name, pcfg in cfg['post_processing'].items(): - priority = pcfg.pop('priority', -1) - processor_name = processor_name.split('+')[0] - processor = getattr(post_processing,str(processor_name)) - post_processor_interface.register_function(processor, - priority, - processor_cfg=pcfg) - - post_processor_interface.process_and_modify() - - def train_loop(handlers): """ Trainval loop. With optional minibatching as determined by the parameters @@ -360,7 +343,6 @@ def inference_loop(handlers): Note: Accuracy/loss will be per batch in the CSV log file, not per event. Write an analysis function to do per-event analysis (TODO). """ - import mlreco.post_processing as post_processing tsum = 0. # Metrics for each event @@ -392,10 +374,6 @@ def inference_loop(handlers): # Run inference data_blob, result_blob = handlers.trainer.forward(handlers.data_io_iter) - # Store output if requested - if 'post_processing' in handlers.cfg: - run_post_processing(handlers.cfg, data_blob, result_blob) - handlers.watch.stop('iteration') tsum += handlers.watch.time('iteration') From e292324348e19c3d87b0e4ce56d851e2d116a814 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Thu, 6 Apr 2023 02:50:42 -0700 Subject: [PATCH 119/180] Temporary solution for vertex post processing --- analysis/algorithms/scripts/template.py | 2 +- analysis/post_processing/reconstruction/vertex.py | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/analysis/algorithms/scripts/template.py b/analysis/algorithms/scripts/template.py index e4cd4561..06e55d7e 100644 --- a/analysis/algorithms/scripts/template.py +++ b/analysis/algorithms/scripts/template.py @@ -5,7 +5,7 @@ from analysis.classes.TruthInteraction import TruthInteraction from analysis.classes.Interaction import Interaction from analysis.algorithms.logger import ParticleLogger, InteractionLogger - +from pprint import pprint @write_to(['interactions', 'particles']) def run_inference(data_blob, res, **kwargs): diff --git a/analysis/post_processing/reconstruction/vertex.py b/analysis/post_processing/reconstruction/vertex.py index 0e790109..7dbe5777 100644 --- a/analysis/post_processing/reconstruction/vertex.py +++ b/analysis/post_processing/reconstruction/vertex.py @@ -14,7 +14,8 @@ 'particle_start_points', 'particle_group_pred', 'particle_node_pred_vtx', - 'input_rescaled'], + 'input_rescaled', + 'Interactions'], result_capture_optional=['particle_dirs']) def reconstruct_vertex(data_dict, result_dict, mode='all', @@ -112,13 +113,13 @@ def reconstruct_vertex(data_dict, result_dict, vertices = np.array([]) interaction_ids = np.array(interaction_ids).reshape(-1, 1) - vertices = np.hstack([interaction_ids, vertices]) - update_dict = { - 'vertices': vertices - } + vertices = {key: val for key, val in zip(interaction_ids.squeeze(), vertices)} - return update_dict + for i, ia in enumerate(result_dict['Interactions']): + ia.vertex = vertices[ia.id] + + return {} @nb.njit(cache=True) def point_to_line_distance_(p1, p2, v2): From 6cd2eb2ee9113a4bbdd7d4f7ee955a130128fc0d Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Thu, 6 Apr 2023 08:26:54 -0700 Subject: [PATCH 120/180] Bug fix in reader when loading multiple files --- mlreco/iotools/readers.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mlreco/iotools/readers.py b/mlreco/iotools/readers.py index c8b388ce..a55ab266 100644 --- a/mlreco/iotools/readers.py +++ b/mlreco/iotools/readers.py @@ -45,9 +45,8 @@ def __init__(self, file_keys, entry_list=[], skip_entry_list=[], to_larcv=False) print('Registered', path) self.file_index = np.concatenate(self.file_index) - # Build an entry list to access - self.entry_list = self.get_entry_list(entry_list, skip_entry_list) - self.file_index = self.file_index[self.entry_list] + # Build an entry index to access, modify file index accordingly + self.entry_index = self.get_entry_list(entry_list, skip_entry_list) # Set whether or not to initialize LArCV objects as such self.to_larcv = to_larcv @@ -100,8 +99,8 @@ def get(self, idx, nested=False): Ditionary of result data products corresponding to one event ''' # Get the appropriate entry index - assert idx < len(self.entry_list) - entry_idx = self.entry_list[idx] + assert idx < len(self.entry_index) + entry_idx = self.entry_index[idx] file_idx = self.file_index[idx] # Use the events tree to find out what needs to be loaded @@ -144,6 +143,7 @@ def get_entry_list(self, entry_list, skip_entry_list): if entry_list: entry_index = entry_index[entry_list] + self.file_index = self.file_index[entry_list] assert len(entry_index), 'Must at least have one entry to load' return entry_index From a5f8ca8487ddfc5439083854e51eae6ac718a4a6 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Thu, 6 Apr 2023 13:33:49 -0700 Subject: [PATCH 121/180] Moving flash matching to post-processing --- analysis/classes/builders.py | 43 +++-- analysis/classes/predictor.py | 4 +- analysis/manager.py | 5 + analysis/post_processing/pmt/FlashManager.py | 162 +++++++++++++++++- analysis/post_processing/pmt/__init__.py | 1 + .../post_processing/pmt/flash_matching.py | 20 +++ .../post_processing/reconstruction/vertex.py | 4 +- 7 files changed, 219 insertions(+), 20 deletions(-) create mode 100644 analysis/post_processing/pmt/flash_matching.py diff --git a/analysis/classes/builders.py b/analysis/classes/builders.py index 2f6852d0..06320059 100644 --- a/analysis/classes/builders.py +++ b/analysis/classes/builders.py @@ -25,7 +25,12 @@ from mlreco.utils.gnn.cluster import get_cluster_label class Builder(ABC): + """Abstract base class for building all data structures + A Builder takes input data and full chain output dictionaries + and processes them into human-readable data structures. + + """ def build(self, data: dict, result: dict, mode='reco'): output = [] num_batches = len(data['index']) @@ -192,7 +197,7 @@ def _build_true(self, depositions=depositions, volume=volume_id, is_primary=is_primary, - pid=pid,) + pid=pid) particle.p = np.array([lpart.px(), lpart.py(), lpart.pz()]) # particle.fragments = fragments @@ -483,25 +488,35 @@ def handle_empty_true_particles(labels_noghost, coords, depositions, voxel_indices = np.array([]), np.array([]), np.array([]) coords_noghost, depositions_noghost = np.array([]), np.array([]) if np.count_nonzero(mask_noghost) > 0: - coords_noghost = labels_noghost[mask_noghost][:, 1:4] - depositions_noghost = labels_noghost[mask_noghost][:, 4].squeeze() + coords_noghost = labels_noghost[mask_noghost][:, COORD_COLS] + depositions_noghost = labels_noghost[mask_noghost][:, VALUE_COL].squeeze() semantic_type, interaction_id, nu_id = get_true_particle_labels(labels_noghost, mask_noghost, pid=pid, verbose=verbose) + volume_id, cts = np.unique(labels_noghost[:, BATCH_COL][mask_noghost].astype(int), + return_counts=True) + volume_id = int(volume_id[cts.argmax()]) particle = TruthParticle(coords, - pid, semantic_type, interaction_id, pdg, - entry, particle_asis=p, - depositions=depositions, - is_primary=is_primary, - coords_noghost=coords_noghost, - depositions_noghost=depositions_noghost, - depositions_MeV=depositions) + id, + semantic_type, + interaction_id, + entry, + particle_asis=p, + coords_noghost=coords_noghost, + depositions_noghost=depositions_noghost, + depositions_MeV=np.array([]), + nu_id=nu_id, + voxel_indices=voxel_indices, + depositions=depositions, + volume=volume_id, + is_primary=is_primary, + pid=pid) particle.p = np.array([p.px(), p.py(), p.pz()]) - particle.fragments = [] - particle.particle_asis = p - particle.nu_id = nu_id - particle.voxel_indices = voxel_indices + # particle.fragments = [] + # particle.particle_asis = p + # particle.nu_id = nu_id + # particle.voxel_indices = voxel_indices particle.startpoint = np.array([p.first_step().x(), p.first_step().y(), diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index 72ec487b..a88843be 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -9,8 +9,7 @@ from mlreco.utils.metrics import unique_label from scipy.special import softmax -from analysis.classes import Particle, ParticleFragment, Interaction, ParticleBuilder, InteractionBuilder -from analysis.classes.particle_utils import group_particles_to_interactions_fn +from analysis.classes import Particle, Interaction, ParticleBuilder, InteractionBuilder from analysis.algorithms.point_matching import * from mlreco.utils.gnn.cluster import get_cluster_label @@ -18,6 +17,7 @@ from mlreco.utils.globals import BATCH_COL, COORD_COLS from scipy.special import softmax +from analysis.post_processing.pmt import FlashManager class FullChainPredictor: diff --git a/analysis/manager.py b/analysis/manager.py index 815b39c1..8d003323 100644 --- a/analysis/manager.py +++ b/analysis/manager.py @@ -171,6 +171,11 @@ def write(self, ana_output): end = time.time() print("Writing to csvs took %.2f s" % (end - start)) + + def write_to_hdf5(self): + raise NotImplementedError + + def step(self, iteration): # 1. Run forward data, res = self.forward(iteration=iteration) diff --git a/analysis/post_processing/pmt/FlashManager.py b/analysis/post_processing/pmt/FlashManager.py index 3981afbd..a727a8cc 100644 --- a/analysis/post_processing/pmt/FlashManager.py +++ b/analysis/post_processing/pmt/FlashManager.py @@ -8,7 +8,162 @@ def modified_box_model(x, constant_calib): beta = 0.212 # kV/cm g/cm^2 /MeV alpha = 0.93 rho = 1.39295 # g.cm^-3 - return (np.exp(x/constant_calib * beta * W_ion / (rho * E)) - alpha) / (beta / (rho * E)) # MeV/cm + return (np.exp(x/constant_calib * beta \ + * W_ion / (rho * E)) - alpha) / (beta / (rho * E)) # MeV/cm + + +class FlashMatcherInterface: + """ + Adapter class between full chain outputs and FlashManager/OpT0Finder + """ + def __init__(self, config, fm_config, **kwargs): + + self.config = config + self.fm_config = fm_config + + self.reflash_merging_window = kwargs.get('reflash_merging_window', None) + self.detector_specs = kwargs.get('detector_specs', None) + self.ADC_to_MeV = kwargs.get('ADC_to_MeV', 1.) + self.use_depositions_MeV = kwargs.get('use_depositions_MeV', False) + + self.flash_matches = {} + + def initialize_flash_manager(self, meta): + self.fm = FlashManager(self.config, self.fm_config, + meta=meta, + reflash_merging_window=self.reflash_merging_window, + detector_specs=None) + + def get_flash_matches(self, + entry, + use_true_tpc_objects=False, + volume=None, + use_depositions_MeV=False, + ADC_to_MeV=1., + interaction_list=[]): + """ + If flash matches has not yet been computed for this volume, then it will + be run as part of this function. Otherwise, flash matching results are + cached in `self.flash_matches` per volume. + + If `interaction_list` is specified, no caching is done. + + Parameters + ========== + entry: int + use_true_tpc_objects: bool, default is False + Whether to use true or predicted interactions. + volume: int, default is None + use_depositions_MeV: bool, default is False + If using true interactions, whether to use true MeV depositions or reconstructed charge. + ADC_to_MEV: double, default is 1. + If using reconstructed interactions, this defines the conversion in OpT0Finder. + OpT0Finder computes the hypothesis flash using light yield and deposited charge in MeV. + interaction_list: list, default is [] + If specified, the interactions to match will be whittle down to this subset of interactions. + Provide list of interaction ids. + + Returns + ======= + list of tuple (Interaction, larcv::Flash, flashmatch::FlashMatch_t) + """ + # No caching done if matching a subset of interactions + if (entry, volume, use_true_tpc_objects) not in self.flash_matches or len(interaction_list): + out = self._run_flash_matching(entry, + use_true_tpc_objects=use_true_tpc_objects, + volume=volume, + use_depositions_MeV=use_depositions_MeV, + ADC_to_MeV=ADC_to_MeV, + interaction_list=interaction_list) + + if len(interaction_list) == 0: + tpc_v, pmt_v, matches = self.flash_matches[(entry, volume, use_true_tpc_objects)] + else: # it wasn't cached, we just computed it + tpc_v, pmt_v, matches = out + return [(tpc_v[m.tpc_id], pmt_v[m.flash_id], m) for m in matches] + + + def _run_flash_matching(self, entry, result, + use_true_tpc_objects=False, + volume=None, + use_depositions_MeV=False, + ADC_to_MeV=1., + interaction_list=[]): + """ + Parameters + ========== + entry: int + use_true_tpc_objects: bool, default is False + Whether to use true or predicted interactions. + volume: int, default is None + """ + if use_true_tpc_objects: + if not hasattr(self, 'get_true_interactions'): + raise Exception('This Predictor does not know about truth info.') + + ints = result['Interactions'][entry] + tpc_v = [ia for ia in ints if ia.volume == volume] + else: + ints = result['TruthInteractions'][entry] + tpc_v = [ia for ia in ints if ia.volume == volume] + + if len(interaction_list) > 0: # by default, use all interactions + tpc_v_select = [] + for interaction in tpc_v: + if interaction.id in interaction_list: + tpc_v_select.append(interaction) + tpc_v = tpc_v_select + + # If we are not running flash matching over the entire volume at once, + # then we need to shift the coordinates that will be used for flash matching + # back to the reference of the first volume. + if volume is not None: + for tpc_object in tpc_v: + tpc_object.points = self._untranslate(tpc_object.points, volume) + input_tpc_v = self.fm.make_qcluster(tpc_v, use_depositions_MeV=use_depositions_MeV, ADC_to_MeV=ADC_to_MeV) + if volume is not None: + for tpc_object in tpc_v: + tpc_object.points = self._translate(tpc_object.points, volume) + + # Now making Flash_t objects + selected_opflash_keys = self.opflash_keys + if volume is not None: + assert isinstance(volume, int) + selected_opflash_keys = [self.opflash_keys[volume]] + pmt_v = [] + for key in selected_opflash_keys: + pmt_v.extend(self.data_blob[key][entry]) + input_pmt_v = self.fm.make_flash([self.data_blob[key][entry] for key in selected_opflash_keys]) + + # input_pmt_v might be a filtered version of pmt_v, + # and we want to store larcv::Flash objects not + # flashmatch::Flash_t objects in self.flash_matches + from larcv import larcv + new_pmt_v = [] + for flash in input_pmt_v: + new_flash = larcv.Flash() + new_flash.time(flash.time) + new_flash.absTime(flash.time_true) # Hijacking this field + new_flash.timeWidth(flash.time_width) + new_flash.xCenter(flash.x) + new_flash.yCenter(flash.y) + new_flash.zCenter(flash.z) + new_flash.xWidth(flash.x_err) + new_flash.yWidth(flash.y_err) + new_flash.zWidth(flash.z_err) + new_flash.PEPerOpDet(flash.pe_v) + new_flash.id(flash.idx) + new_pmt_v.append(new_flash) + + # Running flash matching and caching the results + start = time.time() + matches = self.fm.run_flash_matching() + print('Actual flash matching took %d s' % (time.time() - start)) + if len(interaction_list) == 0: + self.flash_matches[(entry, volume, use_true_tpc_objects)] = (tpc_v, new_pmt_v, matches) + return tpc_v, new_pmt_v, matches + + class FlashManager: """ @@ -16,7 +171,10 @@ class FlashManager: See https://github.com/drinkingkazu/OpT0Finder for more details about it. """ - def __init__(self, cfg, cfg_fmatch, meta=None, detector_specs=None, reflash_merging_window=None): + def __init__(self, cfg, cfg_fmatch, + meta=None, + detector_specs=None, + reflash_merging_window=None): """ Expects that the environment variable `FMATCH_BASEDIR` is set. You can either set it by hand (to the path where one can find diff --git a/analysis/post_processing/pmt/__init__.py b/analysis/post_processing/pmt/__init__.py index e69de29b..e85dd485 100644 --- a/analysis/post_processing/pmt/__init__.py +++ b/analysis/post_processing/pmt/__init__.py @@ -0,0 +1 @@ +from FlashManager import FlashManager \ No newline at end of file diff --git a/analysis/post_processing/pmt/flash_matching.py b/analysis/post_processing/pmt/flash_matching.py new file mode 100644 index 00000000..642a1816 --- /dev/null +++ b/analysis/post_processing/pmt/flash_matching.py @@ -0,0 +1,20 @@ +import numpy as np + +from mlreco.utils.gnn.cluster import get_cluster_directions +from analysis.post_processing import post_processing +from mlreco.utils.globals import * +from . import FlashManager + + +@post_processing(data_capture=['meta'], result_capture=[]) +def run_flash_matching(data_dict, result_dict, + fmatch_config=None, + reflash_merging_window=None, + volume_boundaries=None): + + if fmatch_config is None: + raise ValueError("You need a flash matching config to run flash matching.") + if volume_boundaries is None: + raise ValueError("You need to set volume boundaries to run flash matching.") + + opflash_keys = [] \ No newline at end of file diff --git a/analysis/post_processing/reconstruction/vertex.py b/analysis/post_processing/reconstruction/vertex.py index 7dbe5777..5326b9ea 100644 --- a/analysis/post_processing/reconstruction/vertex.py +++ b/analysis/post_processing/reconstruction/vertex.py @@ -31,8 +31,8 @@ def reconstruct_vertex(data_dict, result_dict, particle_group_pred = result_dict['particle_group_pred'] primary_ids = np.argmax(result_dict['particle_node_pred_vtx'], axis=1) particle_seg = result_dict['particle_seg'] - input_coords = result_dict['input_rescaled'][:, COORD_COLS[0]:COORD_COLS[-1]+1] - startpoints = result_dict['particle_start_points'][:, COORD_COLS[0]:COORD_COLS[-1]+1] + input_coords = result_dict['input_rescaled'][:, COORD_COLS] + startpoints = result_dict['particle_start_points'][:, COORD_COLS] # Optional particle_dirs = result_dict.get('particle_dirs', None) From d2b66a7368db98244eb89dd4cb128d15487d5663 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Thu, 6 Apr 2023 13:36:48 -0700 Subject: [PATCH 122/180] Minor hotfix --- analysis/classes/builders.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/analysis/classes/builders.py b/analysis/classes/builders.py index 06320059..445d86c5 100644 --- a/analysis/classes/builders.py +++ b/analysis/classes/builders.py @@ -498,7 +498,7 @@ def handle_empty_true_particles(labels_noghost, return_counts=True) volume_id = int(volume_id[cts.argmax()]) particle = TruthParticle(coords, - id, + pid, semantic_type, interaction_id, entry, @@ -511,7 +511,7 @@ def handle_empty_true_particles(labels_noghost, depositions=depositions, volume=volume_id, is_primary=is_primary, - pid=pid) + pid=pdg) particle.p = np.array([p.px(), p.py(), p.pz()]) # particle.fragments = [] # particle.particle_asis = p From d61a068dd45b5c06f90de667e6c89b756614380d Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Thu, 6 Apr 2023 13:43:01 -0700 Subject: [PATCH 123/180] Forgot a period --- analysis/post_processing/pmt/FlashManager.py | 56 ++++++++++++++++++-- analysis/post_processing/pmt/__init__.py | 2 +- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/analysis/post_processing/pmt/FlashManager.py b/analysis/post_processing/pmt/FlashManager.py index a727a8cc..39008cb5 100644 --- a/analysis/post_processing/pmt/FlashManager.py +++ b/analysis/post_processing/pmt/FlashManager.py @@ -1,6 +1,8 @@ import os, sys import numpy as np +from mlreco.utils.volumes import VolumeBoundaries + def modified_box_model(x, constant_calib): W_ion = 23.6 * 1e-6 # MeV/electron, work function of argon E = 0.5 # kV/cm, drift electric field @@ -11,7 +13,6 @@ def modified_box_model(x, constant_calib): return (np.exp(x/constant_calib * beta \ * W_ion / (rho * E)) - alpha) / (beta / (rho * E)) # MeV/cm - class FlashMatcherInterface: """ Adapter class between full chain outputs and FlashManager/OpT0Finder @@ -25,9 +26,16 @@ def __init__(self, config, fm_config, **kwargs): self.detector_specs = kwargs.get('detector_specs', None) self.ADC_to_MeV = kwargs.get('ADC_to_MeV', 1.) self.use_depositions_MeV = kwargs.get('use_depositions_MeV', False) + self.boundaries = kwargs.get('boundaries', None) self.flash_matches = {} - + if self.boundaries is not None: + self.vb = VolumeBoundaries(self.boundaries) + self._num_volumes = self.vb.num_volumes() + else: + self.vb = None + self._num_volumes = 1 + def initialize_flash_manager(self, meta): self.fm = FlashManager(self.config, self.fm_config, meta=meta, @@ -102,10 +110,10 @@ def _run_flash_matching(self, entry, result, raise Exception('This Predictor does not know about truth info.') ints = result['Interactions'][entry] - tpc_v = [ia for ia in ints if ia.volume == volume] + tpc_v = [ia for ia in ints if volume is None or ia.volume == volume] else: ints = result['TruthInteractions'][entry] - tpc_v = [ia for ia in ints if ia.volume == volume] + tpc_v = [ia for ia in ints if volume is None or ia.volume == volume] if len(interaction_list) > 0: # by default, use all interactions tpc_v_select = [] @@ -162,6 +170,46 @@ def _run_flash_matching(self, entry, result, if len(interaction_list) == 0: self.flash_matches[(entry, volume, use_true_tpc_objects)] = (tpc_v, new_pmt_v, matches) return tpc_v, new_pmt_v, matches + + def _translate(self, voxels, volume): + """ + Go from 1-volume-only back to full volume coordinates + + Parameters + ========== + voxels: np.ndarray + Shape (N, 3) + volume: int + + Returns + ======= + np.ndarray + Shape (N, 3) + """ + if self.vb is None or volume is None: + return voxels + else: + return self.vb.translate(voxels, volume) + + def _untranslate(self, voxels, volume): + """ + Go from full volume to 1-volume-only coordinates + + Parameters + ========== + voxels: np.ndarray + Shape (N, 3) + volume: int + + Returns + ======= + np.ndarray + Shape (N, 3) + """ + if self.vb is None or volume is None: + return voxels + else: + return self.vb.untranslate(voxels, volume) diff --git a/analysis/post_processing/pmt/__init__.py b/analysis/post_processing/pmt/__init__.py index e85dd485..f42fc76c 100644 --- a/analysis/post_processing/pmt/__init__.py +++ b/analysis/post_processing/pmt/__init__.py @@ -1 +1 @@ -from FlashManager import FlashManager \ No newline at end of file +from .FlashManager import FlashManager \ No newline at end of file From df876d9b0f97ba221038dca9f0b1e79c123aa86e Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Thu, 6 Apr 2023 16:59:18 -0700 Subject: [PATCH 124/180] Flash matching migration and integration done --- analysis/algorithms/logger.py | 27 +- analysis/algorithms/scripts/template.py | 48 ++-- analysis/classes/evaluator.py | 15 ++ analysis/classes/predictor.py | 255 +++--------------- analysis/manager.py | 40 ++- analysis/post_processing/__init__.py | 3 +- analysis/post_processing/common.py | 48 +++- analysis/post_processing/pmt/FlashManager.py | 45 ++-- analysis/post_processing/pmt/__init__.py | 4 +- .../post_processing/pmt/flash_matching.py | 114 +++++++- 10 files changed, 314 insertions(+), 285 deletions(-) diff --git a/analysis/algorithms/logger.py b/analysis/algorithms/logger.py index 381fbbe9..28cd19be 100644 --- a/analysis/algorithms/logger.py +++ b/analysis/algorithms/logger.py @@ -5,15 +5,19 @@ import sys from mlreco.utils.globals import PID_LABEL_TO_PARTICLE, PARTICLE_TO_PID_LABEL -from analysis.classes import TruthInteraction, TruthParticle +from analysis.classes import TruthInteraction, TruthParticle, Interaction def tag(tag_name): + """Tags a function with a str indicator for truth inputs only, + reco inputs only, or both. + """ def tags_decorator(func): func._tag = tag_name return func return tags_decorator def attach_prefix(update_dict, prefix): + """Simple function that adds a prefix to all keys in update_dict""" if prefix is None: return update_dict out = OrderedDict({}) @@ -25,6 +29,9 @@ def attach_prefix(update_dict, prefix): return out class AnalysisLogger: + """ + Base class for analysis tools logger interface. + """ def __init__(self, fieldnames: dict): self.fieldnames = fieldnames @@ -321,4 +328,22 @@ def nu_info(ia): if ia is not None: if ia.nu_id == 1 and isinstance(ia.nu_info, dict): out.update(ia.nu_info) + return out + + @staticmethod + @tag('reco') + def flash_match_info(ia): + assert (ia is None) or (type(ia) is Interaction) + out = { + 'fmatched': False, + 'fmatch_time': -sys.maxsize, + 'fmatch_total_pE': -sys.maxsize, + 'fmatch_id': -sys.maxsize + } + if ia is not None: + if hasattr(ia, 'fmatched'): + out['fmatched'] = ia.fmatched + out['fmatch_time'] = ia.fmatch_time + out['fmatch_total_pE'] = ia.fmatch_total_pE + out['fmatch_id'] = ia.fmatch_id return out \ No newline at end of file diff --git a/analysis/algorithms/scripts/template.py b/analysis/algorithms/scripts/template.py index 06e55d7e..b000b8af 100644 --- a/analysis/algorithms/scripts/template.py +++ b/analysis/algorithms/scripts/template.py @@ -9,8 +9,29 @@ @write_to(['interactions', 'particles']) def run_inference(data_blob, res, **kwargs): - """ - Example of analysis script for nue analysis. + """General logging script for particle and interaction level + information. + + Parameters + ---------- + data_blob: dict + Data dictionary after both model forwarding post-processing + res: dict + Result dictionary after both model forwarding and post-processing + + Returns + ------- + interactions: List[List[dict]] + List of list of dicts, with length batch_size in the top level + and length num_interactions (max between true and reco) in the second + lvel. Each dict corresponds to a row in the generated output file. + + particles: List[List[dict]] + List of list of dicts, with same structure as but with + per-particle information. + + Information in will be saved to $log_dir/interactions.csv + and to $log_dir/particles.csv. """ # List of ordered dictionaries for output logging # Interaction and particle level information @@ -18,10 +39,7 @@ def run_inference(data_blob, res, **kwargs): # Analysis tools configuration primaries = kwargs['match_primaries'] - enable_flash_matching = kwargs.get('enable_flash_matching', False) - ADC_to_MeV = kwargs.get('ADC_to_MeV', 1./350.) matching_mode = kwargs['matching_mode'] - flash_matching_cfg = kwargs.get('flash_matching_cfg', '') boundaries = kwargs.get('boundaries', [[1376.3], None, None]) # FullChainEvaluator config @@ -31,19 +49,10 @@ def run_inference(data_blob, res, **kwargs): int_fieldnames = kwargs['logger'].get('interactions', {}) # Load data into evaluator - if enable_flash_matching: - predictor = FullChainEvaluator(data_blob, res, - predictor_cfg=evaluator_cfg, - enable_flash_matching=enable_flash_matching, - flash_matching_cfg=flash_matching_cfg, - opflash_keys=['opflash_cryoE', 'opflash_cryoW']) - else: - predictor = FullChainEvaluator(data_blob, res, - evaluator_cfg=evaluator_cfg, - boundaries=boundaries) - + predictor = FullChainEvaluator(data_blob, res, + evaluator_cfg=evaluator_cfg, + boundaries=boundaries) image_idxs = data_blob['index'] - spatial_size = predictor.spatial_size # Loop over images for idx, index in enumerate(image_idxs): @@ -53,11 +62,6 @@ def run_inference(data_blob, res, **kwargs): # 'subrun': data_blob['run_info'][idx][1], # 'event': data_blob['run_info'][idx][2] } - if enable_flash_matching: - flash_matches_cryoE = predictor.get_flash_matches(idx, use_true_tpc_objects=False, volume=0, - use_depositions_MeV=False, ADC_to_MeV=ADC_to_MeV) - flash_matches_cryoW = predictor.get_flash_matches(idx, use_true_tpc_objects=False, volume=1, - use_depositions_MeV=False, ADC_to_MeV=ADC_to_MeV) # 1. Match Interactions and log interaction-level information matches, icounts = predictor.match_interactions(idx, diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index 7af9ce1e..da021623 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -78,8 +78,23 @@ class FullChainEvaluator(FullChainPredictor): def __init__(self, data_blob, result, evaluator_cfg={}, **kwargs): super(FullChainEvaluator, self).__init__(data_blob, result, evaluator_cfg, **kwargs) + self.build_representations() self.michel_primary_ionization_only = evaluator_cfg.get('michel_primary_ionization_only', False) + def build_representations(self): + if 'Particles' not in self.result: + self.result['Particles'] = self.particle_builder.build(self.data_blob, self.result, mode='reco') + if 'TruthParticles' not in self.result: + self.result['TruthParticles'] = self.particle_builder.build(self.data_blob, self.result, mode='truth') + if 'Interactions' not in self.result: + self.result['Interactions'] = self.interaction_builder.build(self.data_blob, self.result, mode='reco') + if 'TruthInteractions' not in self.result: + self.result['TruthInteractions'] = self.interaction_builder.build(self.data_blob, self.result, mode='truth') + if 'ParticleFragments' not in self.result: + self.result['ParticleFragments'] = self.fragment_builder.build(self.data_blob, self.result, mode='reco') + if 'TruthParticleFragments' not in self.result: + self.result['TruthParticleFragments'] = self.fragment_builder.build(self.data_blob, self.result, mode='truth') + def get_true_label(self, entry, name, schema='cluster_label_adapted'): """ Retrieve tensor in data blob, labelled with `schema`. diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index a88843be..3143e602 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -9,15 +9,14 @@ from mlreco.utils.metrics import unique_label from scipy.special import softmax -from analysis.classes import Particle, Interaction, ParticleBuilder, InteractionBuilder +from analysis.classes import (Particle, + Interaction, + ParticleBuilder, + InteractionBuilder, + FragmentBuilder) from analysis.algorithms.point_matching import * -from mlreco.utils.gnn.cluster import get_cluster_label -from mlreco.utils.volumes import VolumeBoundaries -from mlreco.utils.globals import BATCH_COL, COORD_COLS - from scipy.special import softmax -from analysis.post_processing.pmt import FlashManager class FullChainPredictor: @@ -28,27 +27,12 @@ class FullChainPredictor: model = Trainer._net.module entry = 0 # batch id - predictor = FullChainPredictor(model, data_blob, res, cfg) - pred_seg = predictor._fit_predict_semantics(entry) + predictor = FullChainPredictor(model, data_blob, res, + predictor_cfg=predictor_cfg) + particles = predictor.get_particles(entry) Instructions ----------------------------------------------------------------------- - - 1) To avoid confusion between different quantities, the label namings under - iotools.schema must be set as follows: - - schema: - input_data: - - parse_sparse3d_scn - - sparse3d_pcluster - - 2) By default, unwrapper must be turned ON under trainval: - - trainval: - unwrapper: unwrap_3d_mink - - 3) Some outputs needs to be listed under trainval.concat_result. - The predictor will run through a checklist to ensure this condition ''' def __init__(self, data_blob, result, predictor_cfg={}, @@ -62,6 +46,9 @@ def __init__(self, data_blob, result, self.particle_builder = ParticleBuilder() self.interaction_builder = InteractionBuilder() + self.fragment_builder = FragmentBuilder() + + self.build_representations() self.num_images = len(result['input_rescaled']) self.index = self.data_blob['index'] @@ -84,182 +71,42 @@ def __init__(self, data_blob, result, self.primary_score_threshold = predictor_cfg.get('primary_score_threshold', None) # This is used to apply fiducial volume cuts. # Min/max boundaries in each dimension haev to be specified. - self.volume_boundaries = predictor_cfg.get('volume_boundaries', None) - if self.volume_boundaries is None: + self.vb = predictor_cfg.get('volume_boundaries', None) + self.set_volume_boundaries() + + + def set_volume_boundaries(self): + if self.vb is None: # Using ICARUS Cryo 0 as a default pass else: - self.volume_boundaries = np.array(self.volume_boundaries, dtype=np.float64) + self.vb = np.array(self.vb, dtype=np.float64) if 'meta' not in self.data_blob: - raise Exception("Cannot use volume boundaries because meta is missing from iotools config.") + msg = "Cannot use volume boundaries because meta is "\ + "missing from iotools config." + raise Exception(msg) else: # convert to voxel units meta = self.data_blob['meta'][0] min_x, min_y, min_z = meta[0:3] size_voxel_x, size_voxel_y, size_voxel_z = meta[6:9] - self.volume_boundaries[0, :] = (self.volume_boundaries[0, :] - min_x) / size_voxel_x - self.volume_boundaries[1, :] = (self.volume_boundaries[1, :] - min_y) / size_voxel_y - self.volume_boundaries[2, :] = (self.volume_boundaries[2, :] - min_z) / size_voxel_z - - # Determine whether we need to account for several distinct volumes - # split over "virtual" batch ids - # Note this is different from "self.volume_boundaries" above - # FIXME rename one or the other to be clearer - - if boundaries is not None: - self.vb = VolumeBoundaries(boundaries) - self._num_volumes = self.vb.num_volumes() - else: - self.vb = None - self._num_volumes = 1 - - # Prepare flash matching if requested - self.enable_flash_matching = enable_flash_matching - self.fm = None + self.vb[0, :] = (self.vb[0, :] - min_x) / size_voxel_x + self.vb[1, :] = (self.vb[1, :] - min_y) / size_voxel_y + self.vb[2, :] = (self.vb[2, :] - min_z) / size_voxel_z - self._num_volumes = len(np.unique(self.result['input_rescaled'][0][:, 0])) - if enable_flash_matching: - reflash_merging_window = predictor_cfg.get('reflash_merging_window', None) - - if 'meta' not in self.data_blob: - raise Exception('Meta unspecified in data_blob. Please add it to your I/O schema.') - #if 'FMATCH_BASEDIR' not in os.environ: - # raise Exception('FMATCH_BASEDIR undefined. Please source `OpT0Finder/configure.sh` or define it manually.') - assert os.path.exists(flash_matching_cfg) - assert len(opflash_keys) == self._num_volumes - - self.fm = FlashManager(cfg, flash_matching_cfg, meta=self.data_blob['meta'][0], reflash_merging_window=reflash_merging_window) - self.opflash_keys = opflash_keys - - self.flash_matches = {} # key is (entry, volume, use_true_tpc_objects), value is tuple (tpc_v, pmt_v, list of matches) - # type is (list of Interaction/TruthInteraction, list of larcv::Flash, list of flashmatch::FlashMatch_t) + def build_representations(self): + if 'Particles' not in self.result: + self.result['Particles'] = self.particle_builder.build(self.data_blob, self.result, mode='reco') + if 'Interactions' not in self.result: + self.result['Interactions'] = self.interaction_builder.build(self.data_blob, self.result, mode='reco') + if 'ParticleFragments' not in self.result: + self.result['ParticleFragments'] = self.fragment_builder.build(self.data_blob, self.result, mode='reco') def __repr__(self): msg = "FullChainEvaluator(num_images={})".format(int(self.num_images/self._num_volumes)) return msg - def get_flash_matches(self, entry, - use_true_tpc_objects=False, - volume=None, - use_depositions_MeV=False, - ADC_to_MeV=1., - interaction_list=[]): - """ - If flash matches has not yet been computed for this volume, then it will - be run as part of this function. Otherwise, flash matching results are - cached in `self.flash_matches` per volume. - - If `interaction_list` is specified, no caching is done. - - Parameters - ========== - entry: int - use_true_tpc_objects: bool, default is False - Whether to use true or predicted interactions. - volume: int, default is None - use_depositions_MeV: bool, default is False - If using true interactions, whether to use true MeV depositions or reconstructed charge. - ADC_to_MEV: double, default is 1. - If using reconstructed interactions, this defines the conversion in OpT0Finder. - OpT0Finder computes the hypothesis flash using light yield and deposited charge in MeV. - interaction_list: list, default is [] - If specified, the interactions to match will be whittle down to this subset of interactions. - Provide list of interaction ids. - - Returns - ======= - list of tuple (Interaction, larcv::Flash, flashmatch::FlashMatch_t) - """ - # No caching done if matching a subset of interactions - if (entry, volume, use_true_tpc_objects) not in self.flash_matches or len(interaction_list): - out = self._run_flash_matching(entry, use_true_tpc_objects=use_true_tpc_objects, volume=volume, - use_depositions_MeV=use_depositions_MeV, ADC_to_MeV=ADC_to_MeV, interaction_list=interaction_list) - - if len(interaction_list) == 0: - tpc_v, pmt_v, matches = self.flash_matches[(entry, volume, use_true_tpc_objects)] - else: # it wasn't cached, we just computed it - tpc_v, pmt_v, matches = out - return [(tpc_v[m.tpc_id], pmt_v[m.flash_id], m) for m in matches] - - def _run_flash_matching(self, entry, - use_true_tpc_objects=False, - volume=None, - use_depositions_MeV=False, - ADC_to_MeV=1., - interaction_list=[]): - """ - Parameters - ========== - entry: int - use_true_tpc_objects: bool, default is False - Whether to use true or predicted interactions. - volume: int, default is None - """ - if use_true_tpc_objects: - if not hasattr(self, 'get_true_interactions'): - raise Exception('This Predictor does not know about truth info.') - - tpc_v = self.get_true_interactions(entry, drop_nonprimary_particles=False, volume=volume, compute_vertex=False) - else: - tpc_v = self.get_interactions(entry, drop_nonprimary_particles=False, volume=volume, compute_vertex=False) - - if len(interaction_list) > 0: # by default, use all interactions - tpc_v_select = [] - for interaction in tpc_v: - if interaction.id in interaction_list: - tpc_v_select.append(interaction) - tpc_v = tpc_v_select - - # If we are not running flash matching over the entire volume at once, - # then we need to shift the coordinates that will be used for flash matching - # back to the reference of the first volume. - if volume is not None: - for tpc_object in tpc_v: - tpc_object.points = self._untranslate(tpc_object.points, volume) - input_tpc_v = self.fm.make_qcluster(tpc_v, use_depositions_MeV=use_depositions_MeV, ADC_to_MeV=ADC_to_MeV) - if volume is not None: - for tpc_object in tpc_v: - tpc_object.points = self._translate(tpc_object.points, volume) - - # Now making Flash_t objects - selected_opflash_keys = self.opflash_keys - if volume is not None: - assert isinstance(volume, int) - selected_opflash_keys = [self.opflash_keys[volume]] - pmt_v = [] - for key in selected_opflash_keys: - pmt_v.extend(self.data_blob[key][entry]) - input_pmt_v = self.fm.make_flash([self.data_blob[key][entry] for key in selected_opflash_keys]) - - # input_pmt_v might be a filtered version of pmt_v, - # and we want to store larcv::Flash objects not - # flashmatch::Flash_t objects in self.flash_matches - from larcv import larcv - new_pmt_v = [] - for flash in input_pmt_v: - new_flash = larcv.Flash() - new_flash.time(flash.time) - new_flash.absTime(flash.time_true) # Hijacking this field - new_flash.timeWidth(flash.time_width) - new_flash.xCenter(flash.x) - new_flash.yCenter(flash.y) - new_flash.zCenter(flash.z) - new_flash.xWidth(flash.x_err) - new_flash.yWidth(flash.y_err) - new_flash.zWidth(flash.z_err) - new_flash.PEPerOpDet(flash.pe_v) - new_flash.id(flash.idx) - new_pmt_v.append(new_flash) - - # Running flash matching and caching the results - start = time.time() - matches = self.fm.run_flash_matching() - print('Actual flash matching took %d s' % (time.time() - start)) - if len(interaction_list) == 0: - self.flash_matches[(entry, volume, use_true_tpc_objects)] = (tpc_v, new_pmt_v, matches) - return tpc_v, new_pmt_v, matches - def _fit_predict_ppn(self, entry): ''' Method for predicting ppn predictions. @@ -537,46 +384,6 @@ def _check_volume(self, volume): if volume is not None: assert isinstance(volume, (int, np.int64, np.int32)) and volume >= 0 - def _translate(self, voxels, volume): - """ - Go from 1-volume-only back to full volume coordinates - - Parameters - ========== - voxels: np.ndarray - Shape (N, 3) - volume: int - - Returns - ======= - np.ndarray - Shape (N, 3) - """ - if self.vb is None or volume is None: - return voxels - else: - return self.vb.translate(voxels, volume) - - def _untranslate(self, voxels, volume): - """ - Go from full volume to 1-volume-only coordinates - - Parameters - ========== - voxels: np.ndarray - Shape (N, 3) - volume: int - - Returns - ======= - np.ndarray - Shape (N, 3) - """ - if self.vb is None or volume is None: - return voxels - else: - return self.vb.untranslate(voxels, volume) - def get_fragments(self, entry, only_primaries=False, min_particle_voxel_count=-1, attaching_threshold=2, diff --git a/analysis/manager.py b/analysis/manager.py index 8d003323..a55e3027 100644 --- a/analysis/manager.py +++ b/analysis/manager.py @@ -1,4 +1,4 @@ -import time, os, sys +import time, os, sys, copy from collections import defaultdict from mlreco.iotools.factories import loader_factory @@ -14,7 +14,31 @@ from analysis.classes.builders import ParticleBuilder, InteractionBuilder, FragmentBuilder class AnaToolsManager: + """ + Chain of responsibility mananger for running analysis related tasks + on full chain output. + AnaToolsManager handles the following procedures + + 1) Forwarding data through the ML Chain + OR reading data from an HDF5 file using the HDF5Reader. + + 2) Build human-readable data representations for full chain output. + + 3) Run (usually non-ML) reconstruction and post-processing algorithms + + 4) Extract attributes from data structures for logging and analysis. + + Parameters + ---------- + cfg : dict + Processed full chain config (after applying process_config) + ana_cfg: dict + Analysis config that specifies configurations for steps 1-4. + profile: bool + Whether to print out execution times. + + """ def __init__(self, cfg, ana_cfg, profile=True): self.config = cfg self.ana_config = ana_cfg @@ -88,15 +112,15 @@ def build_representations(self, data, result): if 'ParticleBuilder' in self.builders: result['Particles'] = self.builders['ParticleBuilder'].build(data, result, mode='reco') result['TruthParticles'] = self.builders['ParticleBuilder'].build(data, result, mode='truth') + assert len(result['Particles']) == len(result['TruthParticles']) if 'InteractionBuilder' in self.builders: result['Interactions'] = self.builders['InteractionBuilder'].build(data, result, mode='reco') result['TruthInteractions'] = self.builders['InteractionBuilder'].build(data, result, mode='truth') + assert len(result['Interactions']) == len(result['TruthInteractions']) if 'FragmentBuilder' in self.builders: result['ParticleFragments'] = self.builders['FragmentBuilder'].build(data, result, mode='reco') result['TruthParticleFragments'] = self.builders['FragmentBuilder'].build(data, result, mode='truth') - assert len(result['Particles']) == len(result['TruthParticles']) - assert len(result['Interactions']) == len(result['TruthInteractions']) - assert len(result['ParticleFragments']) == len(result['TruthParticleFragments']) + assert len(result['ParticleFragments']) == len(result['TruthParticleFragments']) if self.profile: end = time.time() print("Data representation change took %.2f s" % (end - start)) @@ -107,13 +131,17 @@ def run_post_processing(self, data, result): if 'post_processing' in self.ana_config: post_processor_interface = PostProcessor(data, result) # Gather post processing functions, register by priority + for processor_name, pcfg in self.ana_config['post_processing'].items(): - priority = pcfg.pop('priority', -1) + local_pcfg = copy.deepcopy(pcfg) + priority = local_pcfg.pop('priority', -1) + run_on_batch = local_pcfg.pop('run_on_batch', False) processor_name = processor_name.split('+')[0] processor = getattr(post_processing,str(processor_name)) post_processor_interface.register_function(processor, priority, - processor_cfg=pcfg) + processor_cfg=local_pcfg, + run_on_batch=run_on_batch) post_processor_interface.process_and_modify() if self.profile: diff --git a/analysis/post_processing/__init__.py b/analysis/post_processing/__init__.py index 06fcb4cd..89bdfc2f 100644 --- a/analysis/post_processing/__init__.py +++ b/analysis/post_processing/__init__.py @@ -1,2 +1,3 @@ from .decorator import post_processing -from .reconstruction import * \ No newline at end of file +from .reconstruction import * +from .pmt import * \ No newline at end of file diff --git a/analysis/post_processing/common.py b/analysis/post_processing/common.py index cd6c362b..cd37cb50 100644 --- a/analysis/post_processing/common.py +++ b/analysis/post_processing/common.py @@ -7,34 +7,38 @@ class PostProcessor: def __init__(self, data, result, debug=True): self._funcs = defaultdict(list) + self._batch_funcs = defaultdict(list) self._num_batches = len(data['index']) self.data = data self.result = result self.debug = debug - def register_function(self, f, priority, processor_cfg={}): + def register_function(self, f, priority, processor_cfg={}, run_on_batch=False): data_capture, result_capture = f._data_capture, f._result_capture result_capture_optional = f._result_capture_optional pf = partial(f, **processor_cfg) pf._data_capture = data_capture pf._result_capture = result_capture pf._result_capture_optional = result_capture_optional - self._funcs[priority].append(pf) + if run_on_batch: + self._batch_funcs[priority].append(pf) + else: + self._funcs[priority].append(pf) def process_event(self, image_id, f_list): image_dict = {} for f in f_list: - data_dict, result_dict = {}, {} + data_one_event, result_one_event = {}, {} for data_key in f._data_capture: - data_dict[data_key] = self.data[data_key][image_id] + data_one_event[data_key] = self.data[data_key][image_id] for result_key in f._result_capture: - result_dict[result_key] = self.result[result_key][image_id] + result_one_event[result_key] = self.result[result_key][image_id] for result_key in f._result_capture_optional: if result_key in self.result: - result_dict[result_key] = self.result[result_key][image_id] - update_dict = f(data_dict, result_dict) + result_one_event[result_key] = self.result[result_key][image_id] + update_dict = f(data_one_event, result_one_event) for key, val in update_dict.items(): if key in image_dict: msg = 'Output {} in post-processing function {},'\ @@ -46,6 +50,24 @@ def process_event(self, image_id, f_list): return image_dict + def process_batch(self): + out_dict = defaultdict(list) + sorted_processors = sorted([x for x in self._batch_funcs.items()], reverse=True) + for priority, f_list in sorted_processors: + for f in f_list: + + data_batch, result_batch = {}, {} + for data_key in f._data_capture: + data_batch[data_key] = self.data[data_key] + for result_key in f._result_capture: + result_batch[result_key] = self.result[result_key] + for result_key in f._result_capture_optional: + if result_key in self.result: + result_batch[result_key] = self.result[result_key] + update_dict = f(data_batch, result_batch) + out_dict.update(update_dict) + return out_dict + def process_and_modify(self): """ @@ -63,6 +85,7 @@ def process_and_modify(self): assert len(out_dict[key]) == self._num_batches for key, val in out_dict.items(): + assert len(val) == self._num_batches if key in self.result: msg = "Post processing script output key {} "\ "is already in result_dict, you may want"\ @@ -70,6 +93,17 @@ def process_and_modify(self): raise RuntimeError(msg) else: self.result[key] = val + batch_fn_output = self.process_batch() + # Check batch processed output length agrees with batch size + for key, val in batch_fn_output.items(): + assert len(val) == self._num_batches + if key in self.result: + msg = 'Output {} in post-processing function {},'\ + ' caused a dictionary key conflict. You may '\ + 'want to change the output dict key for that function.' + raise ValueError(msg) + else: + self.result[key] = val def extent(voxels): diff --git a/analysis/post_processing/pmt/FlashManager.py b/analysis/post_processing/pmt/FlashManager.py index 39008cb5..0d1f5419 100644 --- a/analysis/post_processing/pmt/FlashManager.py +++ b/analysis/post_processing/pmt/FlashManager.py @@ -1,5 +1,6 @@ import os, sys import numpy as np +import time from mlreco.utils.volumes import VolumeBoundaries @@ -17,10 +18,12 @@ class FlashMatcherInterface: """ Adapter class between full chain outputs and FlashManager/OpT0Finder """ - def __init__(self, config, fm_config, **kwargs): + def __init__(self, config, fm_config, + boundaries=None, opflash_keys=[], **kwargs): self.config = config self.fm_config = fm_config + self.opflash_keys = opflash_keys self.reflash_merging_window = kwargs.get('reflash_merging_window', None) self.detector_specs = kwargs.get('detector_specs', None) @@ -40,21 +43,23 @@ def initialize_flash_manager(self, meta): self.fm = FlashManager(self.config, self.fm_config, meta=meta, reflash_merging_window=self.reflash_merging_window, - detector_specs=None) + detector_specs=self.detector_specs) def get_flash_matches(self, entry, + interactions, + opflashes, use_true_tpc_objects=False, volume=None, use_depositions_MeV=False, ADC_to_MeV=1., - interaction_list=[]): + restrict_interactions=[]): """ If flash matches has not yet been computed for this volume, then it will be run as part of this function. Otherwise, flash matching results are cached in `self.flash_matches` per volume. - If `interaction_list` is specified, no caching is done. + If `restrict_interactions` is specified, no caching is done. Parameters ========== @@ -67,7 +72,7 @@ def get_flash_matches(self, ADC_to_MEV: double, default is 1. If using reconstructed interactions, this defines the conversion in OpT0Finder. OpT0Finder computes the hypothesis flash using light yield and deposited charge in MeV. - interaction_list: list, default is [] + restrict_interactions: list, default is [] If specified, the interactions to match will be whittle down to this subset of interactions. Provide list of interaction ids. @@ -76,27 +81,30 @@ def get_flash_matches(self, list of tuple (Interaction, larcv::Flash, flashmatch::FlashMatch_t) """ # No caching done if matching a subset of interactions - if (entry, volume, use_true_tpc_objects) not in self.flash_matches or len(interaction_list): + if (entry, volume, use_true_tpc_objects) not in self.flash_matches or len(restrict_interactions): out = self._run_flash_matching(entry, + interactions, + opflashes, use_true_tpc_objects=use_true_tpc_objects, volume=volume, use_depositions_MeV=use_depositions_MeV, ADC_to_MeV=ADC_to_MeV, - interaction_list=interaction_list) + restrict_interactions=restrict_interactions) - if len(interaction_list) == 0: + if len(restrict_interactions) == 0: tpc_v, pmt_v, matches = self.flash_matches[(entry, volume, use_true_tpc_objects)] else: # it wasn't cached, we just computed it tpc_v, pmt_v, matches = out return [(tpc_v[m.tpc_id], pmt_v[m.flash_id], m) for m in matches] - def _run_flash_matching(self, entry, result, + def _run_flash_matching(self, entry, interactions, + opflashes, use_true_tpc_objects=False, volume=None, use_depositions_MeV=False, ADC_to_MeV=1., - interaction_list=[]): + restrict_interactions=[]): """ Parameters ========== @@ -109,16 +117,14 @@ def _run_flash_matching(self, entry, result, if not hasattr(self, 'get_true_interactions'): raise Exception('This Predictor does not know about truth info.') - ints = result['Interactions'][entry] - tpc_v = [ia for ia in ints if volume is None or ia.volume == volume] + tpc_v = [ia for ia in interactions if volume is None or ia.volume == volume] else: - ints = result['TruthInteractions'][entry] - tpc_v = [ia for ia in ints if volume is None or ia.volume == volume] + tpc_v = [ia for ia in interactions if volume is None or ia.volume == volume] - if len(interaction_list) > 0: # by default, use all interactions + if len(restrict_interactions) > 0: # by default, use all interactions tpc_v_select = [] for interaction in tpc_v: - if interaction.id in interaction_list: + if interaction.id in restrict_interactions: tpc_v_select.append(interaction) tpc_v = tpc_v_select @@ -140,8 +146,8 @@ def _run_flash_matching(self, entry, result, selected_opflash_keys = [self.opflash_keys[volume]] pmt_v = [] for key in selected_opflash_keys: - pmt_v.extend(self.data_blob[key][entry]) - input_pmt_v = self.fm.make_flash([self.data_blob[key][entry] for key in selected_opflash_keys]) + pmt_v.extend(opflashes[key][entry]) + input_pmt_v = self.fm.make_flash([opflashes[key][entry] for key in selected_opflash_keys]) # input_pmt_v might be a filtered version of pmt_v, # and we want to store larcv::Flash objects not @@ -167,7 +173,7 @@ def _run_flash_matching(self, entry, result, start = time.time() matches = self.fm.run_flash_matching() print('Actual flash matching took %d s' % (time.time() - start)) - if len(interaction_list) == 0: + if len(restrict_interactions) == 0: self.flash_matches[(entry, volume, use_true_tpc_objects)] = (tpc_v, new_pmt_v, matches) return tpc_v, new_pmt_v, matches @@ -266,6 +272,7 @@ def __init__(self, cfg, cfg_fmatch, self.min_x, self.min_y, self.min_z = None, None, None self.size_voxel_x, self.size_voxel_y, self.size_voxel_z = None, None, None + # print(f"META = {meta}") if meta is not None: self.min_x = meta[0] self.min_y = meta[1] diff --git a/analysis/post_processing/pmt/__init__.py b/analysis/post_processing/pmt/__init__.py index f42fc76c..36f0c4bc 100644 --- a/analysis/post_processing/pmt/__init__.py +++ b/analysis/post_processing/pmt/__init__.py @@ -1 +1,3 @@ -from .FlashManager import FlashManager \ No newline at end of file +from .FlashManager import FlashManager +from .FlashManager import FlashMatcherInterface +from .flash_matching import run_flash_matching \ No newline at end of file diff --git a/analysis/post_processing/pmt/flash_matching.py b/analysis/post_processing/pmt/flash_matching.py index 642a1816..da1ad1a1 100644 --- a/analysis/post_processing/pmt/flash_matching.py +++ b/analysis/post_processing/pmt/flash_matching.py @@ -1,20 +1,126 @@ import numpy as np +import yaml +from pprint import pprint +from collections import defaultdict from mlreco.utils.gnn.cluster import get_cluster_directions from analysis.post_processing import post_processing from mlreco.utils.globals import * -from . import FlashManager +from mlreco.main_funcs import process_config +from . import FlashMatcherInterface -@post_processing(data_capture=['meta'], result_capture=[]) +@post_processing(data_capture=['meta', 'index', 'opflash_cryoE', 'opflash_cryoW'], + result_capture=['Interactions']) def run_flash_matching(data_dict, result_dict, + config_path=None, fmatch_config=None, reflash_merging_window=None, - volume_boundaries=None): + volume_boundaries=None, + ADC_to_MeV=1., + opflash_keys=[]): + """ + Post processor for running flash matching using OpT0Finder. + Parameters + ---------- + config_path: str + Path to current model's .cfg file. + fmatch_config: str + Path to flash matching config + reflash_merging_window: float + volume_boundaries: np.ndarray or list + ADC_to_MeV: float + opflash_keys: list of str + + Returns + ------- + update_dict: dict of list + Dictionary of a list of length batch_size, where each entry in + the list is a mapping: + interaction_id : (larcv.Flash, flashmatch.FlashMatch_t) + + NOTE: This post-processor also modifies the list of Interactions + in-place by adding the following attributes: + interaction.fmatched: (bool) + Indicator for whether the given interaction has a flash match + interaction.fmatch_time: float + The flash time in microseconds + interaction.fmatch_total_pE: float + interaction.fmatch_id: int + """ + opflashes = {} + for key in opflash_keys: + opflashes[key] = data_dict[key] + + ADC_to_MeV = ADC_TO_MEV + + if config_path is None: + raise ValueError("You need to give the path to your full chain config.") if fmatch_config is None: raise ValueError("You need a flash matching config to run flash matching.") if volume_boundaries is None: raise ValueError("You need to set volume boundaries to run flash matching.") - opflash_keys = [] \ No newline at end of file + config = yaml.safe_load(open(config_path, 'r')) + process_config(config, verbose=False) + + fm = FlashMatcherInterface(config, fmatch_config, + boundaries=volume_boundaries, + opflash_keys=opflash_keys, + reflash_merging_window=reflash_merging_window) + fm.initialize_flash_manager(data_dict['meta'][0]) + + update_dict = {} + + flash_matches_cryoE = [] + flash_matches_cryoW = [] + + for entry, image_id in enumerate(data_dict['index']): + interactions = result_dict['Interactions'][entry] + + fmatches_E = fm.get_flash_matches(entry, + interactions, + opflashes, + use_true_tpc_objects=False, + volume=0, + use_depositions_MeV=False, + ADC_to_MeV=ADC_to_MeV, + restrict_interactions=[]) + fmatches_W = fm.get_flash_matches(entry, + interactions, + opflashes, + use_true_tpc_objects=False, + volume=1, + use_depositions_MeV=False, + ADC_to_MeV=ADC_to_MeV, + restrict_interactions=[]) + flash_matches_cryoE.append(fmatches_E) + flash_matches_cryoW.append(fmatches_W) + + update_dict = defaultdict(list) + + for tuple_list in flash_matches_cryoE: + flash_dict_E = {} + for ia, flash, match in tuple_list: + flash_dict_E[ia.id] = (flash, match) + ia.fmatched = True + ia.fmatch_time = flash.time() + ia.fmatch_total_pE = flash.TotalPE() + ia.fmatch_id = flash.id() + update_dict['flash_matches_cryoE'].append(flash_dict_E) + + for tuple_list in flash_matches_cryoW: + flash_dict_W = {} + for ia, flash, match in tuple_list: + flash_dict_W[ia.id] = (flash, match) + ia.fmatched = True + ia.fmatch_time = flash.time() + ia.fmatch_total_pE = flash.TotalPE() + ia.fmatch_id = flash.id() + update_dict['flash_matches_cryoW'].append(flash_dict_W) + + assert len(update_dict['flash_matches_cryoE'])\ + == len(update_dict['flash_matches_cryoW']) + + return update_dict \ No newline at end of file From f73911d3634ba2ad214db987675526dc45e8631e Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Fri, 7 Apr 2023 13:54:39 -0700 Subject: [PATCH 125/180] Add option to only use the collection plane to estimate charge --- mlreco/utils/deghosting.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/mlreco/utils/deghosting.py b/mlreco/utils/deghosting.py index 37de8d73..44826ece 100644 --- a/mlreco/utils/deghosting.py +++ b/mlreco/utils/deghosting.py @@ -4,8 +4,10 @@ from sklearn.cluster import DBSCAN from torch_cluster import knn +from .globals import * -def compute_rescaled_charge(input_data, deghost_mask, last_index = 6, batch_col = 0): + +def compute_rescaled_charge(input_data, deghost_mask, last_index = 6, collection_only=False): """ Computes rescaled charge after deghosting @@ -21,7 +23,8 @@ def compute_rescaled_charge(input_data, deghost_mask, last_index = 6, batch_col Shape (N,), N_deghost is the predicted deghosted voxel count last_index: int, default 6 Indexes where hit-related features start @ 4 + deghost_input_features - batch_col: int, default 0 + collection_only : bool, default False + Only use the collection plane to estimate the rescaled charge Returns ------- @@ -38,16 +41,20 @@ def compute_rescaled_charge(input_data, deghost_mask, last_index = 6, batch_col empty = np.empty sum = lambda x: np.sum(x, axis=1) - batches = unique(input_data[:, batch_col]) + batches = unique(input_data[:, BATCH_COL]) hit_charges = input_data[deghost_mask, last_index :last_index+3] hit_ids = input_data[deghost_mask, last_index+3:last_index+6] multiplicity = empty(hit_charges.shape, ) for b in batches: - batch_mask = input_data[deghost_mask, batch_col] == b + batch_mask = input_data[deghost_mask, BATCH_COL] == b _, inverse, counts = unique(hit_ids[batch_mask], return_inverse=True, return_counts=True) multiplicity[batch_mask] = counts[inverse].reshape(-1,3) - pmask = hit_ids > -1 - charges = sum((hit_charges*pmask)/multiplicity)/sum(pmask) # Take average estimate + if not collection_only: + pmask = hit_ids > -1 + charges = sum((hit_charges*pmask)/multiplicity)/sum(pmask) # Take average estimate + else: + charges = hit_charges[:,-1]/multiplicity[:,-1] # Only use the collection plate measurement + return charges From dd5f2f0a9789282763fbcae5f297f87c36fbfdf1 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Fri, 7 Apr 2023 14:17:21 -0700 Subject: [PATCH 126/180] Add documentation, tutorial, and reco/truth mode --- analysis/README.md | 257 +++++++++++++++++++++++++++ analysis/classes/Interaction.py | 11 +- analysis/classes/Particle.py | 3 +- analysis/classes/TruthInteraction.py | 1 - analysis/classes/builders.py | 2 +- analysis/manager.py | 53 +++++- 6 files changed, 309 insertions(+), 18 deletions(-) create mode 100644 analysis/README.md diff --git a/analysis/README.md b/analysis/README.md new file mode 100644 index 00000000..a694d3d9 --- /dev/null +++ b/analysis/README.md @@ -0,0 +1,257 @@ +# LArTPC MLReco3D Analysis Tools Documentation +------ +LArTPC Analysis Tools (`lartpc_mlreco3d.analysis`) is a python interface for using the deep-learning reconstruction chain of `lartpc_mlreco3d` and related LArTPC reconstruction techniques for physics analysis. + +Features described in this documentation are separated by the priority in which each steps are taken during reconstruction. + * `analysis.post_processing`: all algorithms that uses or modifies the ML chain output for reconstruction. + * ex. vertex reconstruction, direction reconstruction, calorimetry, PMT flash-matching, etc. + * `analysis.classes`: data structures and user interface for organizing ML output data into human readable format. + * ex. Particles, Interactions. + * `analysis.algorithms` (will be renamed to `analysis.producers`): all procedures that involve extracting and writing information from reconstruction to files. + +# I. Overview + +Modules under Analysis Tools may be used in two ways. You can import each module separately in a Jupyter notebook, for instance, and use them to examine the ML chain output. Analysis tools also provides a `run.py` main python executable that can run the entire reconstruction inference process, from ML chain forwarding to saving quantities of interest to CSV/HDF5 files. The latter process is divided into three parts: + 1. **DataBuilders**: The ML chain output is organized into human readable representation. + 2. **Post-processing**: post-ML chain reconstruction algorithms are perform on **DataBuilder** products. + 3. **Producers**: Reconstruction information from the ML chain and **post_processing** scripts are aggregated and save to CSV files. + +![Full chain](../images/anatools.png) + +(Example AnalysisTools inference process containing two post-processors for particle direction and interaction vertex reconstruction.) + +# II. Tutorial + +In this tutorial, we introduce the concepts of analysis tools by demonstrating a generic high level analysis workflow using the `lartpc_mlreco3d` reconstruction chain. + +## 1. Accessing ML chain output and/or reading from pre-generated HDF5 files. +------- + +Analysis tools need two configuration files to function: one for the full ML chain configuration (the config used for training and evaluating ML models) and another for analysis tools itself. We can begin by creating `analysis_config.cfg` as follows: +```yaml +analysis: + iteration: -1 + log_dir: /sdf/group/neutrino/koh0207/logs/nu_selection/trash +``` +Here, `iteration: -1` is a shorthand for "iterate over the full dataset", and `log_dir` is the output directory in which all products of analysis tools (if one decides to write something to files) will be saved to. + +First, it's good to understand what the raw ML chain output looks like. +```python +import os, sys +import numpy as np +import torch +import yaml + +# Set lartpc_mlreco3d path +LARTPC_MLRECO_PATH = $PATH_TO_YOUR_COPY_OF_LARTPC_MLRECO3D +sys.path.append(LARTPC_MLRECO_PATH) + +from mlreco.main_funcs import process_config + +# Load config file +cfg_file = $PATH_TO_CFG +cfg = yaml.load(open(cfg_file, 'r'), Loader=yaml.Loader) +process_config(cfg, verbose=False) + +# Load analysis config file +analysis_cfg_path = $PATH_TO_ANALYSIS_CFG +analysis_config = yaml.safe_load(open(analysis_cfg_path, 'r')) + +from analysis.manager import AnaToolsManager +manager = AnaToolsManager(cfg, analysis_config) + +manager.initialize() +``` +One would usually work with analysis tools after training the ML model. The model weights are loaded when the manager is first initialized. If the model weights are successfully loaded, one would see: +```bash +Restoring weights for from /sdf/group/neutrino/drielsma/train/icarus/localized/full_chain/weights/full_chain/grappa_inter_nomlp/snapshot-2999.ckpt... +Done. +``` +The data used by the ML chain and the output returned may be obtained by forwarding the `AnaToolsManager`: +```python +data, result = manager.forward() +``` +All inputs used by the ML model along with all label information are stored in the `data` dictionary, while all outputs from the ML chain are registered in the `result` dictionary. You will see that both `data` and `result` is a long dictionary containing arrays, numbers, `larcv` data formats, etc. + +## 2. Data Structures +---------- + +The contents in `data` and `result` is not much human readable unless one understands the implementation details of the ML chain. To resolve this we organize the ML output into `Particle` and `Interaction` data structures. We can extend `analysis_config.cfg` to command `AnaToolsManager` to build and save `Particle` and `Interaction` objects to the `result` dictionary: + + +---------- +(`analysis_config.cfg`) +```yaml +analysis: + iteration: -1 + log_dir: /sdf/group/neutrino/koh0207/logs/nu_selection/trash + data_builders: + - ParticleBuilder + - InteractionBuilder +``` +(Jupyter) +```python +manager.build_representation(data, result) # This will save 'Particle' and 'Interaction' instances to result dict directly +``` +(or) +```python +from analysis.classes.builders import ParticleBuilder +particle_builder = ParticleBuilder() +result['Particles'] = particle_builder.build(data, result, mode='reco') +result['TruthParticles'] = particle_builder.build(data, result, mode='truth') +``` +We can try printing out the third particle in the first image: +```python +print(result['Particles'][0][3]) +----------------------------- +Particle( Image ID=0 | Particle ID=3 | Semantic_type: Shower Fragment | PID: Electron | Primary: 1 | Interaction ID: 3 | Size: 302 | Volume: 0 ) +``` +Each `Particle` instance corresponds to a reconstructed particle from the ML chain. `TruthParticles` are similar to `Particle` instances, but correspond to "true particles" obtained from simulation truth information. + +We may further organize information by aggregating particles the same interactions: +```python +from analysis.classes.builders import InteractionBuilder +interaction_builder = InteractionBuilder() +result['Interactions'] = interaction_builder.build(data, result, mode='reco') +result['TruthInteractions'] = interaction_builder.build(data, result, mode='truth') +``` +Since `Interactions` are built using `Particle` instances, one has to build `Particles` first to build `Interactions`. +```python +for ia in result['Interactions'][0]: + print(ia) +----------------------------- +Interaction 4, Vertex: x=-1.00, y=-1.00, z=-1.00 +-------------------------------------------------------------------- + * Particle 32: PID = Muon, Size = 4222, Match = [] + - Particle 1: PID = Electron, Size = 69, Match = [] + - Particle 4: PID = Photon, Size = 45, Match = [] + - Particle 20: PID = Electron, Size = 12, Match = [] + - Particle 21: PID = Electron, Size = 37, Match = [] + - Particle 23: PID = Electron, Size = 10, Match = [] + - Particle 24: PID = Electron, Size = 7, Match = [] + +Interaction 22, Vertex: x=-1.00, y=-1.00, z=-1.00 +-------------------------------------------------------------------- + * Particle 31: PID = Muon, Size = 514, Match = [] + * Particle 33: PID = Proton, Size = 22, Match = [] + * Particle 34: PID = Proton, Size = 1264, Match = [] + * Particle 35: PID = Proton, Size = 419, Match = [] + * Particle 36: PID = Pion, Size = 969, Match = [] + * Particle 38: PID = Proton, Size = 1711, Match = [] + - Particle 2: PID = Photon, Size = 14, Match = [] + - Particle 6: PID = Photon, Size = 891, Match = [] + - Particle 22: PID = Electron, Size = 17, Match = [] +...(continuing) +``` +The primaries of an interaction are indicated by the asterisk (*) bullet point. + +## 3. Defining and running post-processing scripts for reconstruction +----- + +You may have noticed that the vertex of interactions have the default placeholder `[-1, -1, -1]` values. This is because vertex reconstruction is not a part of the ML chain but a separate (non-ML) algorithm that uses ML chain outputs. Many other reconstruction tasks lie in this category (range-based track energy estimation, computing particle directions usnig PCA, etc). We group these subroutines under `analysis.post_processing`. Here is an example post-processing function `particle_direction` that estimates the particle's direction with respect to the start and end points: +```python +# geometry.py +import numpy as np + +from mlreco.utils.gnn.cluster import get_cluster_directions +from analysis.post_processing import post_processing +from mlreco.utils.globals import * + + +@post_processing(data_capture=['input_data'], result_capture=['input_rescaled', + 'particle_clusts', + 'particle_start_points', + 'particle_end_points']) +def particle_direction(data_dict, + result_dict, + neighborhood_radius=5, + optimize=False): + + input_data = data_dict['input_data'] if 'input_rescaled' not in result_dict else result_dict['input_rescaled'] + particles = result_dict['particle_clusts'] + start_points = result_dict['particle_start_points'] + end_points = result_dict['particle_end_points'] + + update_dict = { + 'particle_start_directions': get_cluster_directions(input_data[:,COORD_COLS], + start_points[:,COORD_COLS], + particles, + neighborhood_radius, + optimize), + 'particle_end_directions': get_cluster_directions(input_data[:,COORD_COLS], + end_points[:,COORD_COLS], + particles, + neighborhood_radius, + optimize) + } + + return update_dict +``` +Some properties of `post_processing` functions: + * All post-processing functions must have the `@post_processing` decorator on top that lists the keys in the `data` dictionary and `result` dictionary to be fed into the function. + * Each `post_processing` function operates on single images. Hence `data_dict['input_data']` will only contain one entry, representing the 3D coordinates and the voxel energy deposition of that image. + +Once you have written your `post_processing` script, you can integrate it within the Analysis Tools inference chain by adding the file under `analysis.post_processing`: + +```bash +analysis/ + post_processing/ + __init__.py + common.py + decorator.py + reconstruction/ + __init__.py + geometry.py +``` +(Don't forget to include the import commands under each `__init__.py`) + +To run `particle_direction` from `analysis/run.py`, we include the function name and it's additional keyword arguments inside `analysis_config.cfg`: +```yaml +analysis: + iteration: -1 + log_dir: /sdf/group/neutrino/koh0207/logs/nu_selection/trash + data_builders: + - ParticleBuilder + - InteractionBuilder + # - FragmentBuilder +post_processing: + particle_direction: + optimize: True + priority: 1 +``` +**NOTE**: The **priority** argument is an integer that allows `run.py` to execute some post-processing scripts before others (to avoid duplicate computations). By default, all post-processing scripts have `priority=-1`, and will be executed last simultaneously. Each unique priority value is a loop over all images in the current batch, so unless it's absolutely needed to run some processes before others we advise against setting the priority value manually (the example here is for demonstration). + +At this point we are done registering the post-processor to the Analysis Tools chain. We can try running the `AnaToolsManager` with our new `analysis_config.cfg`: +```yaml +analysis: + iteration: -1 + log_dir: /sdf/group/neutrino/koh0207/logs/nu_selection/trash + data_builders: + - ParticleBuilder + - InteractionBuilder +post_processing: + particle_direction: + optimize: True + priority: 1 +``` +(Jupyter): +```python +manager.build_representations(data, result) +manager.run_post_processing(data, result) + +result['particle_start_directions'][0] +-------------------------------------- +array([[-0.45912635, 0.46559292, 0.75658846], + [ 0.50584 , 0.7468423 , 0.43168548], + [-0.89442724, -0.44721362, 0. ], + [-0.4881733 , -0.6689782 , 0.56049526], + ... +``` +which gives all the reconstructed particle directions in image #0 (in order). As usual, the finished `result` dictionary can be saved into a HDF5 file: + +## 4. Evaluating reconstruction and writing outputs CSVs. + +While HDF5 format is suitable for saving large amounts of data to be used in the future, for high level analysis we generally save per-image, per-interaction, or per-particle attributes and features in tabular form (such as CSVs). Also, some operation are needed after post-processing to evaluate the model with respect to truth information. These include: + * Matching reconstructed particles to corresponding true particles. + * Retrieving labels from truth information. + * Evaluating module performance \ No newline at end of file diff --git a/analysis/classes/Interaction.py b/analysis/classes/Interaction.py index 0262d319..201de80f 100644 --- a/analysis/classes/Interaction.py +++ b/analysis/classes/Interaction.py @@ -108,10 +108,11 @@ def particles(self, value): def get_particles_summary(self): + primary_str = {True: '*', False: '-'} self.particles_summary = "" - for p in self.particles: - pmsg = " - Particle {}: PID = {}, Size = {}, Match = {} \n".format( - p.id, self.pid_keys[p.pid], p.points.shape[0], str(p.match)) + for p in sorted(self.particles, key=lambda x: x.is_primary, reverse=True): + pmsg = " {} Particle {}: PID = {}, Size = {}, Match = {} \n".format( + primary_str[p.is_primary], p.id, self.pid_keys[p.pid], p.points.shape[0], str(p.match)) self.particles_summary += pmsg @@ -122,9 +123,9 @@ def __getitem__(self, key): def __str__(self): self.get_particles_summary() - msg = "Interaction {}, Valid: {}, Vertex: x={:.2f}, y={:.2f}, z={:.2f}\n"\ + msg = "Interaction {}, Vertex: x={:.2f}, y={:.2f}, z={:.2f}\n"\ "--------------------------------------------------------------------\n".format( - self.id, self.is_valid, self.vertex[0], self.vertex[1], self.vertex[2]) + self.id, self.vertex[0], self.vertex[1], self.vertex[2]) return msg + self.particles_summary def __repr__(self): diff --git a/analysis/classes/Particle.py b/analysis/classes/Particle.py index a9990313..186adefc 100644 --- a/analysis/classes/Particle.py +++ b/analysis/classes/Particle.py @@ -113,14 +113,13 @@ def __repr__(self): def __str__(self): fmt = "Particle( Image ID={:<3} | Particle ID={:<3} | Semantic_type: {:<15}"\ - " | PID: {:<8} | Primary: {:<2} | Interaction ID: {:<2} | Size: {:<5} | Score = {:.2f}% | Volume: {:<2} )" + " | PID: {:<8} | Primary: {:<2} | Interaction ID: {:<2} | Size: {:<5} | Volume: {:<2} )" msg = fmt.format(self.image_id, self.id, self.semantic_keys[self.semantic_type] if self.semantic_type in self.semantic_keys else "None", self.pid_keys[self.pid] if self.pid in self.pid_keys else "None", self.is_primary, self.interaction_id, self.points.shape[0], - self.pid_conf * 100, self.volume) return msg diff --git a/analysis/classes/TruthInteraction.py b/analysis/classes/TruthInteraction.py index d53b4329..57055c62 100644 --- a/analysis/classes/TruthInteraction.py +++ b/analysis/classes/TruthInteraction.py @@ -20,7 +20,6 @@ def __init__(self, *args, **kwargs): self.depositions_MeV = np.hstack(self.depositions_MeV) self.nu_info = None - @property def particles(self): return list(self._particles.values()) diff --git a/analysis/classes/builders.py b/analysis/classes/builders.py index 445d86c5..a675ba18 100644 --- a/analysis/classes/builders.py +++ b/analysis/classes/builders.py @@ -89,13 +89,13 @@ def _build_reco(self, pid_scores = softmax(type_logits, axis=1) primary_scores = softmax(primary_logits, axis=1) - pid = None for i, p in enumerate(particles): voxels = point_cloud[p] volume_id, cts = np.unique(volume_labels[p], return_counts=True) volume_id = int(volume_id[cts.argmax()]) seg_label = particle_seg[i] + pid = np.argmax(pid_scores[i]) if seg_label == 2 or seg_label == 3: pid = 1 interaction_id = inter_ids[i] diff --git a/analysis/manager.py b/analysis/manager.py index a55e3027..af3d3fc3 100644 --- a/analysis/manager.py +++ b/analysis/manager.py @@ -44,6 +44,7 @@ def __init__(self, cfg, ana_cfg, profile=True): self.ana_config = ana_cfg self.max_iteration = self.ana_config['analysis']['iteration'] self.log_dir = self.ana_config['analysis']['log_dir'] + self.ana_mode = self.ana_config['analysis'].get('run_mode', None) # Initialize data product builders self.data_builders = self.ana_config['analysis']['data_builders'] @@ -105,22 +106,56 @@ def forward(self, iteration=None): end = time.time() print("Forwarding data took %.2f s" % (end - start)) return data, res - - def build_representations(self, data, result): - if self.profile: - start = time.time() + + def _build_reco_reps(self, data, result): + length_check = [] if 'ParticleBuilder' in self.builders: result['Particles'] = self.builders['ParticleBuilder'].build(data, result, mode='reco') - result['TruthParticles'] = self.builders['ParticleBuilder'].build(data, result, mode='truth') - assert len(result['Particles']) == len(result['TruthParticles']) + length_check.append(len(result['Particles'])) if 'InteractionBuilder' in self.builders: result['Interactions'] = self.builders['InteractionBuilder'].build(data, result, mode='reco') - result['TruthInteractions'] = self.builders['InteractionBuilder'].build(data, result, mode='truth') - assert len(result['Interactions']) == len(result['TruthInteractions']) + length_check.append(len(result['Interactions'])) if 'FragmentBuilder' in self.builders: result['ParticleFragments'] = self.builders['FragmentBuilder'].build(data, result, mode='reco') + length_check.append(len(result['ParticleFragments'])) + return length_check + + def _build_truth_reps(self, data, result): + length_check = [] + if 'ParticleBuilder' in self.builders: + result['TruthParticles'] = self.builders['ParticleBuilder'].build(data, result, mode='truth') + length_check.append(len(result['TruthParticles'])) + if 'InteractionBuilder' in self.builders: + result['TruthInteractions'] = self.builders['InteractionBuilder'].build(data, result, mode='truth') + length_check.append(len(result['TruthInteractions'])) + if 'FragmentBuilder' in self.builders: result['TruthParticleFragments'] = self.builders['FragmentBuilder'].build(data, result, mode='truth') - assert len(result['ParticleFragments']) == len(result['TruthParticleFragments']) + length_check.append(len(result['TruthParticleFragments'])) + return length_check + + def build_representations(self, data, result, mode=None): + + num_batches = len(data['index']) + + lcheck_reco, lcheck_truth = [], [] + + if self.ana_mode is not None: + mode = self.ana_mode + if self.profile: + start = time.time() + if mode == 'reco': + lcheck_reco = self._build_reco_reps(data, result) + elif mode == 'truth': + lcheck_truth = self._build_truth_reps(data, result) + elif mode is None: + lcheck_reco = self._build_reco_reps(data, result) + lcheck_truth = self._build_truth_reps(data, result) + else: + raise ValueError(f"DataBuilder mode {mode} is not supported!") + for lreco in lcheck_reco: + assert lreco == num_batches + for ltruth in lcheck_truth: + assert ltruth == num_batches if self.profile: end = time.time() print("Data representation change took %.2f s" % (end - start)) From e5a24602ad7bc651112c459397874dd26fe2eca8 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Fri, 7 Apr 2023 14:18:23 -0700 Subject: [PATCH 127/180] Forgot to include image for anatools docs --- images/anatools.png | Bin 0 -> 453287 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 images/anatools.png diff --git a/images/anatools.png b/images/anatools.png new file mode 100644 index 0000000000000000000000000000000000000000..dbab648c0aab0537164cd08d74d1b0733e143aaa GIT binary patch literal 453287 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

Kq*S2u5R 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 z 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 NYx4tUWhNdM{tb7F39bMD literal 0 HcmV?d00001 From 3807ae76456d785cce1ea69665f1de4df47ac6e7 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Fri, 7 Apr 2023 15:23:30 -0700 Subject: [PATCH 128/180] Hotfix for predictor crash --- analysis/classes/predictor.py | 6 +++--- analysis/manager.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index 3143e602..e573034e 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -53,7 +53,7 @@ def __init__(self, data_blob, result, self.num_images = len(result['input_rescaled']) self.index = self.data_blob['index'] - self.spatial_size = predictor_cfg['spatial_size'] + self.spatial_size = predictor_cfg.get('spatial_size', 6144) # For matching particles and interactions self.min_overlap_count = predictor_cfg.get('min_overlap_count', 0) # Idem, can be 'count' or 'iou' @@ -100,8 +100,8 @@ def build_representations(self): self.result['Particles'] = self.particle_builder.build(self.data_blob, self.result, mode='reco') if 'Interactions' not in self.result: self.result['Interactions'] = self.interaction_builder.build(self.data_blob, self.result, mode='reco') - if 'ParticleFragments' not in self.result: - self.result['ParticleFragments'] = self.fragment_builder.build(self.data_blob, self.result, mode='reco') + # if 'ParticleFragments' not in self.result: + # self.result['ParticleFragments'] = self.fragment_builder.build(self.data_blob, self.result, mode='reco') def __repr__(self): msg = "FullChainEvaluator(num_images={})".format(int(self.num_images/self._num_volumes)) diff --git a/analysis/manager.py b/analysis/manager.py index af3d3fc3..eb8d1706 100644 --- a/analysis/manager.py +++ b/analysis/manager.py @@ -147,7 +147,7 @@ def build_representations(self, data, result, mode=None): lcheck_reco = self._build_reco_reps(data, result) elif mode == 'truth': lcheck_truth = self._build_truth_reps(data, result) - elif mode is None: + elif mode is None or mode == 'all': lcheck_reco = self._build_reco_reps(data, result) lcheck_truth = self._build_truth_reps(data, result) else: From 659cb23c4f410b0d38913c7e2d41901322fad956 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Fri, 7 Apr 2023 15:50:38 -0700 Subject: [PATCH 129/180] Rename algorithms -> producers --- analysis/README.md | 9 +++++---- analysis/classes/evaluator.py | 2 +- analysis/classes/predictor.py | 6 +++--- analysis/manager.py | 4 ++-- analysis/{algorithms => producers}/__init__.py | 0 analysis/{algorithms => producers}/arxiv/calorimetry.py | 0 analysis/{algorithms => producers}/arxiv/example_nue.py | 2 +- .../{algorithms => producers}/arxiv/flash_matching.py | 0 .../{algorithms => producers}/arxiv/michel_electrons.py | 0 analysis/{algorithms => producers}/arxiv/muon_decay.py | 0 analysis/{algorithms => producers}/arxiv/particles.py | 0 analysis/{algorithms => producers}/arxiv/statistics.py | 0 .../{algorithms => producers}/arxiv/stopping_muons.py | 0 .../arxiv/through_going_muons.py | 0 analysis/{algorithms => producers}/common.py | 0 analysis/{algorithms => producers}/decorator.py | 0 analysis/{algorithms => producers}/logger.py | 0 analysis/{algorithms => producers}/point_matching.py | 0 analysis/{algorithms => producers}/scripts/__init__.py | 0 analysis/{algorithms => producers}/scripts/benchmark.py | 2 +- analysis/{algorithms => producers}/scripts/template.py | 4 ++-- 21 files changed, 15 insertions(+), 14 deletions(-) rename analysis/{algorithms => producers}/__init__.py (100%) rename analysis/{algorithms => producers}/arxiv/calorimetry.py (100%) rename analysis/{algorithms => producers}/arxiv/example_nue.py (99%) rename analysis/{algorithms => producers}/arxiv/flash_matching.py (100%) rename analysis/{algorithms => producers}/arxiv/michel_electrons.py (100%) rename analysis/{algorithms => producers}/arxiv/muon_decay.py (100%) rename analysis/{algorithms => producers}/arxiv/particles.py (100%) rename analysis/{algorithms => producers}/arxiv/statistics.py (100%) rename analysis/{algorithms => producers}/arxiv/stopping_muons.py (100%) rename analysis/{algorithms => producers}/arxiv/through_going_muons.py (100%) rename analysis/{algorithms => producers}/common.py (100%) rename analysis/{algorithms => producers}/decorator.py (100%) rename analysis/{algorithms => producers}/logger.py (100%) rename analysis/{algorithms => producers}/point_matching.py (100%) rename analysis/{algorithms => producers}/scripts/__init__.py (100%) rename analysis/{algorithms => producers}/scripts/benchmark.py (95%) rename analysis/{algorithms => producers}/scripts/template.py (97%) diff --git a/analysis/README.md b/analysis/README.md index a694d3d9..5335c1c3 100644 --- a/analysis/README.md +++ b/analysis/README.md @@ -7,7 +7,7 @@ Features described in this documentation are separated by the priority in which * ex. vertex reconstruction, direction reconstruction, calorimetry, PMT flash-matching, etc. * `analysis.classes`: data structures and user interface for organizing ML output data into human readable format. * ex. Particles, Interactions. - * `analysis.algorithms` (will be renamed to `analysis.producers`): all procedures that involve extracting and writing information from reconstruction to files. + * `analysis.producers`: all procedures that involve extracting and writing information from reconstruction to files. # I. Overview @@ -251,7 +251,8 @@ which gives all the reconstructed particle directions in image #0 (in order). As ## 4. Evaluating reconstruction and writing outputs CSVs. -While HDF5 format is suitable for saving large amounts of data to be used in the future, for high level analysis we generally save per-image, per-interaction, or per-particle attributes and features in tabular form (such as CSVs). Also, some operation are needed after post-processing to evaluate the model with respect to truth information. These include: +While HDF5 format is suitable for saving large amounts of data to be used in the future, for high level analysis we generally save per-image, per-interaction, or per-particle attributes and features in tabular form (such as CSVs). Also, there's a need to compute different evaluation metrics once the all the post-processors return their reconstruction outputs. We group all these that happen after post-processing under `analysis.producers.scripts`: * Matching reconstructed particles to corresponding true particles. - * Retrieving labels from truth information. - * Evaluating module performance \ No newline at end of file + * Retrieving properly structured labels from truth information. + * Evaluating module performance against truth labels +Here is an example `template.py` under `analysis.producers.scripts` \ No newline at end of file diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index da021623..e9ac46eb 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -9,7 +9,7 @@ group_particles_to_interactions_fn, match_interactions_optimal, match_particles_optimal) -from analysis.algorithms.point_matching import * +from analysis.producers.point_matching import * from mlreco.utils.vertex import get_vertex diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index e573034e..cccca595 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -14,7 +14,7 @@ ParticleBuilder, InteractionBuilder, FragmentBuilder) -from analysis.algorithms.point_matching import * +from analysis.producers.point_matching import * from scipy.special import softmax @@ -50,7 +50,7 @@ def __init__(self, data_blob, result, self.build_representations() - self.num_images = len(result['input_rescaled']) + self.num_images = len(self.data_blob['index']) self.index = self.data_blob['index'] self.spatial_size = predictor_cfg.get('spatial_size', 6144) @@ -104,7 +104,7 @@ def build_representations(self): # self.result['ParticleFragments'] = self.fragment_builder.build(self.data_blob, self.result, mode='reco') def __repr__(self): - msg = "FullChainEvaluator(num_images={})".format(int(self.num_images/self._num_volumes)) + msg = "FullChainEvaluator(num_images={})".format(int(self.num_images)) return msg def _fit_predict_ppn(self, entry): diff --git a/analysis/manager.py b/analysis/manager.py index eb8d1706..b12d9c93 100644 --- a/analysis/manager.py +++ b/analysis/manager.py @@ -8,9 +8,9 @@ from mlreco.iotools.writers import CSVWriter from analysis import post_processing -from analysis.algorithms import scripts +from analysis.producers import scripts from analysis.post_processing.common import PostProcessor -from analysis.algorithms.common import ScriptProcessor +from analysis.producers.common import ScriptProcessor from analysis.classes.builders import ParticleBuilder, InteractionBuilder, FragmentBuilder class AnaToolsManager: diff --git a/analysis/algorithms/__init__.py b/analysis/producers/__init__.py similarity index 100% rename from analysis/algorithms/__init__.py rename to analysis/producers/__init__.py diff --git a/analysis/algorithms/arxiv/calorimetry.py b/analysis/producers/arxiv/calorimetry.py similarity index 100% rename from analysis/algorithms/arxiv/calorimetry.py rename to analysis/producers/arxiv/calorimetry.py diff --git a/analysis/algorithms/arxiv/example_nue.py b/analysis/producers/arxiv/example_nue.py similarity index 99% rename from analysis/algorithms/arxiv/example_nue.py rename to analysis/producers/arxiv/example_nue.py index 7e3b32e1..08970202 100644 --- a/analysis/algorithms/arxiv/example_nue.py +++ b/analysis/producers/arxiv/example_nue.py @@ -2,7 +2,7 @@ from analysis.algorithms.utils import get_interaction_properties, get_particle_properties from analysis.classes.evaluator import FullChainEvaluator -from lartpc_mlreco3d.analysis.algorithms.arxiv.decorator import evaluate +from lartpc_mlreco3d.analysis.producers.arxiv.decorator import evaluate from lartpc_mlreco3d.analysis.classes.particle_utils import match_particles_fn, matrix_iou, match_particles_optimal from pprint import pprint diff --git a/analysis/algorithms/arxiv/flash_matching.py b/analysis/producers/arxiv/flash_matching.py similarity index 100% rename from analysis/algorithms/arxiv/flash_matching.py rename to analysis/producers/arxiv/flash_matching.py diff --git a/analysis/algorithms/arxiv/michel_electrons.py b/analysis/producers/arxiv/michel_electrons.py similarity index 100% rename from analysis/algorithms/arxiv/michel_electrons.py rename to analysis/producers/arxiv/michel_electrons.py diff --git a/analysis/algorithms/arxiv/muon_decay.py b/analysis/producers/arxiv/muon_decay.py similarity index 100% rename from analysis/algorithms/arxiv/muon_decay.py rename to analysis/producers/arxiv/muon_decay.py diff --git a/analysis/algorithms/arxiv/particles.py b/analysis/producers/arxiv/particles.py similarity index 100% rename from analysis/algorithms/arxiv/particles.py rename to analysis/producers/arxiv/particles.py diff --git a/analysis/algorithms/arxiv/statistics.py b/analysis/producers/arxiv/statistics.py similarity index 100% rename from analysis/algorithms/arxiv/statistics.py rename to analysis/producers/arxiv/statistics.py diff --git a/analysis/algorithms/arxiv/stopping_muons.py b/analysis/producers/arxiv/stopping_muons.py similarity index 100% rename from analysis/algorithms/arxiv/stopping_muons.py rename to analysis/producers/arxiv/stopping_muons.py diff --git a/analysis/algorithms/arxiv/through_going_muons.py b/analysis/producers/arxiv/through_going_muons.py similarity index 100% rename from analysis/algorithms/arxiv/through_going_muons.py rename to analysis/producers/arxiv/through_going_muons.py diff --git a/analysis/algorithms/common.py b/analysis/producers/common.py similarity index 100% rename from analysis/algorithms/common.py rename to analysis/producers/common.py diff --git a/analysis/algorithms/decorator.py b/analysis/producers/decorator.py similarity index 100% rename from analysis/algorithms/decorator.py rename to analysis/producers/decorator.py diff --git a/analysis/algorithms/logger.py b/analysis/producers/logger.py similarity index 100% rename from analysis/algorithms/logger.py rename to analysis/producers/logger.py diff --git a/analysis/algorithms/point_matching.py b/analysis/producers/point_matching.py similarity index 100% rename from analysis/algorithms/point_matching.py rename to analysis/producers/point_matching.py diff --git a/analysis/algorithms/scripts/__init__.py b/analysis/producers/scripts/__init__.py similarity index 100% rename from analysis/algorithms/scripts/__init__.py rename to analysis/producers/scripts/__init__.py diff --git a/analysis/algorithms/scripts/benchmark.py b/analysis/producers/scripts/benchmark.py similarity index 95% rename from analysis/algorithms/scripts/benchmark.py rename to analysis/producers/scripts/benchmark.py index 5af88462..0eb40509 100644 --- a/analysis/algorithms/scripts/benchmark.py +++ b/analysis/producers/scripts/benchmark.py @@ -1,6 +1,6 @@ from analysis.classes.evaluator import FullChainEvaluator -from analysis.algorithms.decorator import write_to +from analysis.producers.decorator import write_to from pprint import pprint import time diff --git a/analysis/algorithms/scripts/template.py b/analysis/producers/scripts/template.py similarity index 97% rename from analysis/algorithms/scripts/template.py rename to analysis/producers/scripts/template.py index b000b8af..d54c3e9a 100644 --- a/analysis/algorithms/scripts/template.py +++ b/analysis/producers/scripts/template.py @@ -1,10 +1,10 @@ from collections import OrderedDict -from analysis.algorithms.decorator import write_to +from analysis.producers.decorator import write_to from analysis.classes.evaluator import FullChainEvaluator from analysis.classes.TruthInteraction import TruthInteraction from analysis.classes.Interaction import Interaction -from analysis.algorithms.logger import ParticleLogger, InteractionLogger +from analysis.producers.logger import ParticleLogger, InteractionLogger from pprint import pprint @write_to(['interactions', 'particles']) From 1ad8d9ba3fb3bc844afabc7fae3d5d16e45e054a Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Mon, 10 Apr 2023 16:04:04 -0700 Subject: [PATCH 130/180] Fix in AnaTools in which truth particles were reconstructed incorrectly --- analysis/README.md | 151 +++++++++++++++++- analysis/classes/Interaction.py | 4 +- analysis/classes/TruthInteraction.py | 4 +- analysis/classes/builders.py | 14 +- analysis/classes/evaluator.py | 29 +++- analysis/classes/predictor.py | 39 +++-- analysis/config/nue_selection.cfg | 98 ------------ analysis/config/template.cfg | 98 ------------ analysis/config/test_icarus.cfg | 94 ----------- analysis/manager.py | 2 +- analysis/post_processing/common.py | 17 +- .../reconstruction/geometry.py | 5 +- .../producers/{scripts => arxiv}/benchmark.py | 2 +- analysis/producers/scripts/__init__.py | 3 +- analysis/producers/scripts/template.py | 13 +- analysis/run.py | 17 +- 16 files changed, 240 insertions(+), 350 deletions(-) delete mode 100644 analysis/config/nue_selection.cfg delete mode 100644 analysis/config/template.cfg delete mode 100644 analysis/config/test_icarus.cfg rename analysis/producers/{scripts => arxiv}/benchmark.py (94%) diff --git a/analysis/README.md b/analysis/README.md index 5335c1c3..9e12ff66 100644 --- a/analysis/README.md +++ b/analysis/README.md @@ -13,7 +13,7 @@ Features described in this documentation are separated by the priority in which Modules under Analysis Tools may be used in two ways. You can import each module separately in a Jupyter notebook, for instance, and use them to examine the ML chain output. Analysis tools also provides a `run.py` main python executable that can run the entire reconstruction inference process, from ML chain forwarding to saving quantities of interest to CSV/HDF5 files. The latter process is divided into three parts: 1. **DataBuilders**: The ML chain output is organized into human readable representation. - 2. **Post-processing**: post-ML chain reconstruction algorithms are perform on **DataBuilder** products. + 2. **Post-processing**: post-ML chain reconstruction algorithms are performed on **DataBuilder** products. 3. **Producers**: Reconstruction information from the ML chain and **post_processing** scripts are aggregated and save to CSV files. ![Full chain](../images/anatools.png) @@ -167,7 +167,10 @@ def particle_direction(data_dict, neighborhood_radius=5, optimize=False): - input_data = data_dict['input_data'] if 'input_rescaled' not in result_dict else result_dict['input_rescaled'] + if 'input_rescaled' not in result_dict: + input_data = data_dict['input_data'] + else: + input_data = result_dict['input_rescaled'] particles = result_dict['particle_clusts'] start_points = result_dict['particle_start_points'] end_points = result_dict['particle_end_points'] @@ -255,4 +258,146 @@ While HDF5 format is suitable for saving large amounts of data to be used in the * Matching reconstructed particles to corresponding true particles. * Retrieving properly structured labels from truth information. * Evaluating module performance against truth labels -Here is an example `template.py` under `analysis.producers.scripts` \ No newline at end of file +As an example, we will write a `script` function called `run_inference` to demonstrate coding conventions: +(`scripts/run_inference.py`) +```python +from analysis.producers.decorator import write_to + +@write_to(['interactions', 'particles']) +def run_inference(data, result, **kwargs): + """General logging script for particle and interaction level + information. + + Parameters + ---------- + data_blob: dict + Data dictionary after both model forwarding post-processing + res: dict + Result dictionary after both model forwarding and post-processing + """ + # List of ordered dictionaries for output logging + # Interaction and particle level information + interactions, particles = [], [] + return [interactions, particles] +``` + +The `@write_to` decorator lists the name of the output files (in this case, will be `interactions.csv` and `particles.csv`) that will be generated in your pre-defined AnaTools log directory: +```yaml +analysis: +... + log_dir: /sdf/group/neutrino/koh0207/logs/nu_selection/trash +``` + +### 4.1 Running inference using the `Evaluator` and `Predictor` interface. + +Each function inside `analysis.producers.scripts` has `data` and `result` dictionary as its input arguments, so all reconstructed quantities from both the ML chain and the post-processing subroutines are accessible through its keys. At this stage of accessing reconstruction outputs, it is generally up to the user to define the evaluation metrics and/or quantities of interest that will be written to output files. Still, analysis tools have additional user interfaces--`FullChainPredictor` and `FullChainEvaluator`--for easy and consistent evaluation of full chain outputs. + * `FullChainPredictor`: user interface class for accessing full chain predictions. This class is reserved for prediction on non-MC data as it does not have any reference to truth labels or MC information. + * `FullChainEvaluator`: user interface class for accessing full chain predictions, truth labels, and prediction to truth matching functions. Has access to label and MC truth information. + +Example in Jupyter: +```python +data, result = manager.forward(iteration=3) + +from analysis.classes.predictor import FullChainEvaluator +evaluator = FullChainEvaluator(data, result, evaluator_cfg={}) +``` +The `evaluator_cfg` is an optional dictionary containing +additional configuration settings for evaluator methods such as +`Particle` to `TruthParticle` matching, and in most cases it is not +necessary to set it manually. More detailed information on all available +methods for both the predictor and the evaluator can be found in +their docstrings +(under `analysis.classes.predictor` and `analysis.classes.evaluator`). + +We first list some auxiliary arguments needed for logging: +```python + # Analysis tools configuration + primaries = kwargs.get('match_primaries', False) + matching_mode = kwargs.get('matching_mode', 'optimal') + # FullChainEvaluator config + evaluator_cfg = kwargs.get('evaluator_cfg', {}) + # Particle and Interaction processor names + particle_fieldnames = kwargs['logger'].get('particles', {}) + int_fieldnames = kwargs['logger'].get('interactions', {}) + # Load data into evaluator + predictor = FullChainEvaluator(data_blob, res, + evaluator_cfg=evaluator_cfg) + image_idxs = data_blob['index'] +``` +Now we loop over the images in the current batch and match reconstructed +interactions against true interactions. +```python + # Loop over images + for idx, index in enumerate(image_idxs): + + # For saving per image information + index_dict = { + 'Index': index, + # 'run': data_blob['run_info'][idx][0], + # 'subrun': data_blob['run_info'][idx][1], + # 'event': data_blob['run_info'][idx][2] + } + + # 1. Match Interactions and log interaction-level information + matches, icounts = predictor.match_interactions(idx, + mode='true_to_pred', + match_particles=True, + drop_nonprimary_particles=primaries, + return_counts=True, + overlap_mode=predictor.overlap_mode, + matching_mode=matching_mode) + + # 1 a) Check outputs from interaction matching + if len(matches) == 0: + continue + + # We access the particle matching information, which is already + # done by called match_interactions. + pmatches = predictor._matched_particles + pcounts = predictor._matched_particles_counts +``` +Here, `matches` contain pairs (`TruthInteraction`, `Interaction`) which +are matched based on +### 4.2 Using Loggers to organize CSV output fields. +```python + # 2. Process interaction level information + interaction_logger = InteractionLogger(int_fieldnames) + interaction_logger.prepare() + + # 2-1 Loop over matched interaction pairs + for i, interaction_pair in enumerate(matches): + + int_dict = OrderedDict() + int_dict.update(index_dict) + int_dict['interaction_match_counts'] = icounts[i] + true_int, pred_int = interaction_pair[0], interaction_pair[1] + + assert (type(true_int) is TruthInteraction) or (true_int is None) + assert (type(pred_int) is Interaction) or (pred_int is None) + + true_int_dict = interaction_logger.produce(true_int, mode='true') + pred_int_dict = interaction_logger.produce(pred_int, mode='reco') + int_dict.update(true_int_dict) + int_dict.update(pred_int_dict) + interactions.append(int_dict) + + # 3. Process particle level information + particle_logger = ParticleLogger(particle_fieldnames) + particle_logger.prepare() + + # Loop over matched particle pairs + for i, mparticles in enumerate(pmatches): + true_p, pred_p = mparticles[0], mparticles[1] + + true_p_dict = particle_logger.produce(true_p, mode='true') + pred_p_dict = particle_logger.produce(pred_p, mode='reco') + + part_dict = OrderedDict() + part_dict.update(index_dict) + part_dict['particle_match_counts'] = pcounts[i] + part_dict.update(true_p_dict) + part_dict.update(pred_p_dict) + particles.append(part_dict) + + return [interactions, particles] +``` diff --git a/analysis/classes/Interaction.py b/analysis/classes/Interaction.py index 201de80f..91108dd6 100644 --- a/analysis/classes/Interaction.py +++ b/analysis/classes/Interaction.py @@ -129,6 +129,6 @@ def __str__(self): return msg + self.particles_summary def __repr__(self): - return "Interaction(id={}, vertex={}, nu_id={}, Particles={})".format( - self.id, str(self.vertex), self.nu_id, str(self.particle_ids)) + return "Interaction(id={}, vertex={}, size={}, nu_id={}, Particles={})".format( + self.id, str(self.vertex), self.size, self.nu_id, str(self.particle_ids)) diff --git a/analysis/classes/TruthInteraction.py b/analysis/classes/TruthInteraction.py index 57055c62..5bc3179b 100644 --- a/analysis/classes/TruthInteraction.py +++ b/analysis/classes/TruthInteraction.py @@ -50,6 +50,6 @@ def __str__(self): return msg + self.particles_summary def __repr__(self): - return "TruthInteraction(id={}, vertex={}, nu_id={}, Particles={})".format( - self.id, str(self.vertex), self.nu_id, str(self.particle_ids)) + return "TruthInteraction(id={}, vertex={}, size={}, nu_id={}, Particles={})".format( + self.id, str(self.vertex), self.size, self.nu_id, str(self.particle_ids)) diff --git a/analysis/classes/builders.py b/analysis/classes/builders.py index a675ba18..0f7c5191 100644 --- a/analysis/classes/builders.py +++ b/analysis/classes/builders.py @@ -135,13 +135,13 @@ def _build_true(self, for i, lpart in enumerate(larcv_particles): id = int(lpart.id()) - pid = PDG_TO_PID.get(lpart.pdg_code(), -1) + pdg = PDG_TO_PID.get(lpart.pdg_code(), -1) is_primary = lpart.group_id() == lpart.parent_id() - mask_nonghost = labels_nonghost[:, 6].astype(int) == pid + mask_nonghost = labels_nonghost[:, 6].astype(int) == id if np.count_nonzero(mask_nonghost) <= 0: continue # Skip larcv particles with no true depositions # 1. Check if current pid is one of the existing group ids - if pid not in particle_ids: + if id not in particle_ids: particle = handle_empty_true_particles(labels_nonghost, mask_nonghost, lpart, @@ -150,7 +150,7 @@ def _build_true(self, continue # 1. Process voxels - mask = labels[:, 6].astype(int) == pid + mask = labels[:, 6].astype(int) == id # If particle is Michel electron, we have the option to # only consider the primary ionization. # Semantic labels only label the primary ionization as Michel. @@ -181,8 +181,8 @@ def _build_true(self, # 2. Process particle-level labels semantic_type, int_id, nu_id = get_true_particle_labels(labels, mask, - pid=pid) - + pid=id) + particle = TruthParticle(coords, id, semantic_type, @@ -197,7 +197,7 @@ def _build_true(self, depositions=depositions, volume=volume_id, is_primary=is_primary, - pid=pid) + pid=pdg) particle.p = np.array([lpart.px(), lpart.py(), lpart.pz()]) # particle.fragments = fragments diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index e9ac46eb..1ba1667c 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -80,6 +80,14 @@ def __init__(self, data_blob, result, evaluator_cfg={}, **kwargs): super(FullChainEvaluator, self).__init__(data_blob, result, evaluator_cfg, **kwargs) self.build_representations() self.michel_primary_ionization_only = evaluator_cfg.get('michel_primary_ionization_only', False) + # For matching particles and interactions + self.min_overlap_count = evaluator_cfg.get('min_overlap_count', 0) + # Idem, can be 'count' or 'iou' + self.overlap_mode = evaluator_cfg.get('overlap_mode', 'iou') + if self.overlap_mode == 'iou': + assert self.min_overlap_count <= 1 and self.min_overlap_count >= 0 + if self.overlap_mode == 'counts': + assert self.min_overlap_count >= 0 def build_representations(self): if 'Particles' not in self.result: @@ -267,9 +275,6 @@ def match_particles(self, entry, Must be either 'pred_to_true' or 'true_to_pred' volume: int, default None ''' - all_matches = [] - all_counts = [] - # print('matching', entries, volume) if mode == 'pred_to_true': # Match each pred to one in true particles_from = self.get_particles(entry, @@ -293,7 +298,9 @@ def match_particles(self, entry, matched_pairs, counts = match_particles_optimal(particles_from, particles_to, **all_kwargs) else: - raise ValueError + raise ValueError(f"Particle matching mode {matching_mode} not suppored!") + self._matched_particles = matched_pairs + self._matched_particles_counts = counts if return_counts: return matched_pairs, counts else: @@ -301,7 +308,7 @@ def match_particles(self, entry, def match_interactions(self, entry, mode='pred_to_true', - drop_nonprimary_particles=True, + drop_nonprimary_particles=False, match_particles=True, return_counts=False, matching_mode='one_way', @@ -312,7 +319,7 @@ def match_interactions(self, entry, mode='pred_to_true', entry: int mode: str, default 'pred_to_true' Must be either 'pred_to_true' or 'true_to_pred'. - drop_nonprimary_particles: bool, default True + drop_nonprimary_particles: bool, default False match_particles: bool, default True return_counts: bool, default False volume: int, default None @@ -368,7 +375,15 @@ def match_interactions(self, entry, mode='pred_to_true', min_overlap=self.min_overlap_count, overlap_mode=self.overlap_mode) else: - raise ValueError + raise ValueError(f"Particle matching mode {matching_mode} is not supported!") + + pmatches, pcounts = self.match_parts_within_ints(matched_interactions) + + self._matched_particles = pmatches + self._matched_particles_counts = pcounts + + self._matched_interactions = matched_interactions + self._matched_interactions_counts = counts if return_counts: return matched_interactions, counts diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index cccca595..502facae 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -34,12 +34,7 @@ class FullChainPredictor: Instructions ----------------------------------------------------------------------- ''' - def __init__(self, data_blob, result, - predictor_cfg={}, - enable_flash_matching=False, - flash_matching_cfg="", - opflash_keys=[], - boundaries=None): + def __init__(self, data_blob, result, predictor_cfg={}): self.data_blob = data_blob self.result = result @@ -48,27 +43,30 @@ def __init__(self, data_blob, result, self.interaction_builder = InteractionBuilder() self.fragment_builder = FragmentBuilder() + build_reps = predictor_cfg.get('build_reps', ['Particles', 'Interactions']) + self.builders = {} + for key in build_reps: + if key == 'Particles': + self.builders[key] = ParticleBuilder() + if key == 'Interactions': + self.builders[key] = InteractionBuilder() + if key == 'Fragments': + self.builders[key] = FragmentBuilder() + + self.build_representations() self.num_images = len(self.data_blob['index']) self.index = self.data_blob['index'] self.spatial_size = predictor_cfg.get('spatial_size', 6144) - # For matching particles and interactions - self.min_overlap_count = predictor_cfg.get('min_overlap_count', 0) - # Idem, can be 'count' or 'iou' - self.overlap_mode = predictor_cfg.get('overlap_mode', 'iou') - if self.overlap_mode == 'iou': - assert self.min_overlap_count <= 1 and self.min_overlap_count >= 0 - if self.overlap_mode == 'counts': - assert self.min_overlap_count >= 0 # Minimum voxel count for a true non-ghost particle to be considered self.min_particle_voxel_count = predictor_cfg.get('min_particle_voxel_count', 20) # We want to count how well we identify interactions with some PDGs # as primary particles self.primary_pdgs = np.unique(predictor_cfg.get('primary_pdgs', [])) - self.primary_score_threshold = predictor_cfg.get('primary_score_threshold', None) + self.primary_score_threshold = predictor_cfg.get('primary_score_threshold', None) # This is used to apply fiducial volume cuts. # Min/max boundaries in each dimension haev to be specified. self.vb = predictor_cfg.get('volume_boundaries', None) @@ -96,12 +94,11 @@ def set_volume_boundaries(self): def build_representations(self): - if 'Particles' not in self.result: - self.result['Particles'] = self.particle_builder.build(self.data_blob, self.result, mode='reco') - if 'Interactions' not in self.result: - self.result['Interactions'] = self.interaction_builder.build(self.data_blob, self.result, mode='reco') - # if 'ParticleFragments' not in self.result: - # self.result['ParticleFragments'] = self.fragment_builder.build(self.data_blob, self.result, mode='reco') + for key in self.builders: + if key not in self.result: + self.result[key] = self.builders[key].build(self.data_blob, + self.result, + mode='reco') def __repr__(self): msg = "FullChainEvaluator(num_images={})".format(int(self.num_images)) diff --git a/analysis/config/nue_selection.cfg b/analysis/config/nue_selection.cfg deleted file mode 100644 index bc90f629..00000000 --- a/analysis/config/nue_selection.cfg +++ /dev/null @@ -1,98 +0,0 @@ -analysis: - name: run_inference - processor_cfg: - spatial_size: 6144 #768 - data: False - min_overlap_count: 0 - overlap_mode: iou - log_dir: /sdf/group/neutrino/koh0207/logs/nu_selection/mpvmpr/track_default - iteration: 2000 - deghosting: True - match_primaries: False - compute_vertex: True - vertex_mode: 'all' - prune_vertex: False - matching_mode: 'optimal' - interaction_dict: { - 'Index': -1, - 'interaction_match_counts': -1, - 'true_interaction_id': -1, - 'true_interaction_size': -1, - 'true_count_primary_leptons': -1, - 'true_count_primary_particles': -1, - 'true_vertex_x': -1, - 'true_vertex_y': -1, - 'true_vertex_z': -1, - 'true_has_vertex': False, - 'true_vertex_valid': 'N/A', - 'true_count_primary_protons': -1, - 'true_interaction_has_match': False, - 'true_nu_id': -1, - 'true_nu_interaction_type': -1, - 'true_nu_current_type': -1, - 'true_nu_interaction_mode': -1, - 'true_nu_energy': -1, - 'pred_interaction_id': -1, - 'pred_interaction_size': -1, - 'pred_count_primary_leptons': -1, - 'pred_count_primary_particles': -1, - 'pred_vertex_x': -1, - 'pred_vertex_y': -1, - 'pred_vertex_z': -1, - 'pred_has_vertex': False, - 'pred_vertex_valid': 'N/A', - 'pred_count_primary_protons': -1, - 'pred_interaction_has_match': False, - 'pred_nu_id': -1, - 'pred_vertex_candidate_count': -1, - } - particle_dict: { - 'Index': -1, - 'particle_match_value': -1, - 'true_particle_id': -1, - 'true_particle_interaction_id': -1, - 'true_particle_type': -1, - 'true_particle_size': -1, - 'true_particle_semantic_type': -1, - 'true_particle_E': -1, - 'true_particle_is_primary': False, - 'true_particle_has_startpoint': False, - 'true_particle_has_endpoint': False, - 'true_particle_length': -1, - 'true_particle_dir_x': -1, - 'true_particle_dir_y': -1, - 'true_particle_dir_z': -1, - 'true_particle_startpoint_x': -1, - 'true_particle_startpoint_y': -1, - 'true_particle_startpoint_z': -1, - 'true_particle_endpoint_x': -1, - 'true_particle_endpoint_y': -1, - 'true_particle_endpoint_z': -1, - 'true_particle_startpoint_is_touching': False, - 'true_particle_energy_deposit': -1, - 'true_particle_energy_init': -1, - 'true_particle_creation_process': -1, - 'true_particle_children_count': -1, - 'true_particle_has_match': False, - 'pred_particle_has_match': False, - 'pred_particle_id': -1, - 'pred_particle_interaction_id': -1, - 'pred_particle_type': -1, - 'pred_particle_semantic_type': -1, - 'pred_particle_size': -1, - 'pred_particle_E': -1, - 'pred_particle_is_primary': False, - 'pred_particle_has_startpoint': False, - 'pred_particle_has_endpoint': False, - 'pred_particle_length': -1, - 'pred_particle_dir_x': -1, - 'pred_particle_dir_y': -1, - 'pred_particle_dir_z': -1, - 'pred_particle_startpoint_x': -1, - 'pred_particle_startpoint_y': -1, - 'pred_particle_startpoint_z': -1, - 'pred_particle_endpoint_x': -1, - 'pred_particle_endpoint_y': -1, - 'pred_particle_endpoint_z': -1, - 'pred_particle_startpoint_is_touching': True - } \ No newline at end of file diff --git a/analysis/config/template.cfg b/analysis/config/template.cfg deleted file mode 100644 index 44bec747..00000000 --- a/analysis/config/template.cfg +++ /dev/null @@ -1,98 +0,0 @@ -analysis: - name: run_inference - processor_cfg: - spatial_size: 6144 #768 - data: False - min_overlap_count: 0 - overlap_mode: iou - log_dir: /sdf/group/neutrino/koh0207/logs/nu_selection/mpvmpr - iteration: 2000 - deghosting: True - match_primaries: False - compute_vertex: True - vertex_mode: 'all' - prune_vertex: True - matching_mode: 'optimal' - interaction_dict: { - 'Index': -1, - 'interaction_match_counts': -1, - 'true_interaction_id': -1, - 'true_count_primary_leptons': -1, - 'true_count_primary_particles': -1, - 'true_vertex_x': -1, - 'true_vertex_y': -1, - 'true_vertex_z': -1, - 'true_has_vertex': False, - 'true_vertex_valid': 'N/A', - 'true_count_primary_protons': -1, - 'true_interaction_matched': False, - 'true_nu_id': -1, - 'true_nu_interaction_type': -1, - 'true_nu_current_type': -1, - 'true_nu_interaction_mode': -1, - 'true_nu_energy': -1, - 'pred_interaction_id': -1, - 'pred_count_primary_leptons': -1, - 'pred_count_primary_particles': -1, - 'pred_vertex_x': -1, - 'pred_vertex_y': -1, - 'pred_vertex_z': -1, - 'pred_has_vertex': False, - 'pred_vertex_valid': 'N/A', - 'pred_count_primary_protons': -1, - 'pred_interaction_matched': False, - 'pred_nu_id': -1, - 'pred_vertex_candidate_count': -1, - 'fmatched': False, - 'fmatch_time': None, - 'fmatch_total_pe': None, - 'fmatch_id': None - } - particle_dict: { - 'Index': -1, - 'particle_match_value': -1, - 'true_particle_id': -1, - 'true_particle_interaction_id': -1, - 'true_particle_type': -1, - 'true_particle_size': -1, - 'true_particle_E': -1, - 'true_particle_is_primary': False, - 'true_particle_has_startpoint': False, - 'true_particle_has_endpoint': False, - 'true_particle_length': -1, - 'true_particle_dir_x': -1, - 'true_particle_dir_y': -1, - 'true_particle_dir_z': -1, - 'true_particle_startpoint_x': -1, - 'true_particle_startpoint_y': -1, - 'true_particle_startpoint_z': -1, - 'true_particle_endpoint_x': -1, - 'true_particle_endpoint_y': -1, - 'true_particle_endpoint_z': -1, - 'true_particle_startpoint_is_touching': False, - 'true_particle_energy_deposit': -1, - 'true_particle_energy_init': -1, - 'true_particle_creation_process': -1, - 'true_particle_children_count': -1, - 'true_particle_is_matched': False, - 'pred_particle_is_matched': False, - 'pred_particle_id': -1, - 'pred_particle_interaction_id': -1, - 'pred_particle_type': -1, - 'pred_particle_size': -1, - 'pred_particle_E': -1, - 'pred_particle_is_primary': False, - 'pred_particle_has_startpoint': False, - 'pred_particle_has_endpoint': False, - 'pred_particle_length': -1, - 'pred_particle_dir_x': -1, - 'pred_particle_dir_y': -1, - 'pred_particle_dir_z': -1, - 'pred_particle_startpoint_x': -1, - 'pred_particle_startpoint_y': -1, - 'pred_particle_startpoint_z': -1, - 'pred_particle_endpoint_x': -1, - 'pred_particle_endpoint_y': -1, - 'pred_particle_endpoint_z': -1, - 'pred_particle_startpoint_is_touching': True - } \ No newline at end of file diff --git a/analysis/config/test_icarus.cfg b/analysis/config/test_icarus.cfg deleted file mode 100644 index b0f95503..00000000 --- a/analysis/config/test_icarus.cfg +++ /dev/null @@ -1,94 +0,0 @@ -analysis: - name: run_inference - processor_cfg: - spatial_size: 6144 #768 - data: False - min_overlap_count: 0 - overlap_mode: iou - log_dir: /sdf/group/neutrino/koh0207/logs/nu_selection/bnb_nue_corsika - iteration: 2000 - deghosting: True - match_primaries: False - compute_vertex: True - vertex_mode: 'all' - prune_vertex: True - matching_mode: 'optimal' - interaction_dict: { - 'Index': -1, - 'interaction_match_counts': -1, - 'true_interaction_id': -1, - 'true_count_primary_leptons': -1, - 'true_count_primary_particles': -1, - 'true_vertex_x': -1, - 'true_vertex_y': -1, - 'true_vertex_z': -1, - 'true_has_vertex': False, - 'true_vertex_valid': 'N/A', - 'true_count_primary_protons': -1, - 'true_interaction_matched': False, - 'true_nu_id': -1, - 'true_nu_interaction_type': -1, - 'true_nu_current_type': -1, - 'true_nu_interaction_mode': -1, - 'true_nu_energy': -1, - 'pred_interaction_id': -1, - 'pred_count_primary_leptons': -1, - 'pred_count_primary_particles': -1, - 'pred_vertex_x': -1, - 'pred_vertex_y': -1, - 'pred_vertex_z': -1, - 'pred_has_vertex': False, - 'pred_vertex_valid': 'N/A', - 'pred_count_primary_protons': -1, - 'pred_interaction_matched': False, - 'pred_nu_id': -1, - 'pred_vertex_candidate_count': -1 - } - particle_dict: { - 'Index': -1, - 'particle_match_value': -1, - 'true_particle_id': -1, - 'true_particle_interaction_id': -1, - 'true_particle_type': -1, - 'true_particle_size': -1, - 'true_particle_E': -1, - 'true_particle_is_primary': False, - 'true_particle_has_startpoint': False, - 'true_particle_has_endpoint': False, - 'true_particle_length': -1, - 'true_particle_dir_x': -1, - 'true_particle_dir_y': -1, - 'true_particle_dir_z': -1, - 'true_particle_startpoint_x': -1, - 'true_particle_startpoint_y': -1, - 'true_particle_startpoint_z': -1, - 'true_particle_endpoint_x': -1, - 'true_particle_endpoint_y': -1, - 'true_particle_endpoint_z': -1, - 'true_particle_startpoint_is_touching': False, - 'true_particle_energy_deposit': -1, - 'true_particle_energy_init': -1, - 'true_particle_creation_process': -1, - 'true_particle_children_count': -1, - 'true_particle_is_matched': False, - 'pred_particle_is_matched': False, - 'pred_particle_id': -1, - 'pred_particle_interaction_id': -1, - 'pred_particle_type': -1, - 'pred_particle_size': -1, - 'pred_particle_E': -1, - 'pred_particle_is_primary': False, - 'pred_particle_has_startpoint': False, - 'pred_particle_has_endpoint': False, - 'pred_particle_length': -1, - 'pred_particle_dir_x': -1, - 'pred_particle_dir_y': -1, - 'pred_particle_dir_z': -1, - 'pred_particle_startpoint_x': -1, - 'pred_particle_startpoint_y': -1, - 'pred_particle_startpoint_z': -1, - 'pred_particle_endpoint_x': -1, - 'pred_particle_endpoint_y': -1, - 'pred_particle_endpoint_z': -1, - 'pred_particle_startpoint_is_touching': True - } \ No newline at end of file diff --git a/analysis/manager.py b/analysis/manager.py index b12d9c93..7d57b370 100644 --- a/analysis/manager.py +++ b/analysis/manager.py @@ -39,7 +39,7 @@ class AnaToolsManager: Whether to print out execution times. """ - def __init__(self, cfg, ana_cfg, profile=True): + def __init__(self, ana_cfg, profile=True, cfg=None): self.config = cfg self.ana_config = ana_cfg self.max_iteration = self.ana_config['analysis']['iteration'] diff --git a/analysis/post_processing/common.py b/analysis/post_processing/common.py index cd37cb50..cde8e495 100644 --- a/analysis/post_processing/common.py +++ b/analysis/post_processing/common.py @@ -1,6 +1,7 @@ import numpy as np from functools import partial from collections import defaultdict, OrderedDict +import warnings class PostProcessor: @@ -17,6 +18,7 @@ def register_function(self, f, priority, processor_cfg={}, run_on_batch=False): data_capture, result_capture = f._data_capture, f._result_capture result_capture_optional = f._result_capture_optional pf = partial(f, **processor_cfg) + pf.__name__ = f.__name__ pf._data_capture = data_capture pf._result_capture = result_capture pf._result_capture_optional = result_capture_optional @@ -24,6 +26,7 @@ def register_function(self, f, priority, processor_cfg={}, run_on_batch=False): self._batch_funcs[priority].append(pf) else: self._funcs[priority].append(pf) + print(f"Registered post-processor {f.__name__}") def process_event(self, image_id, f_list): @@ -32,9 +35,19 @@ def process_event(self, image_id, f_list): for f in f_list: data_one_event, result_one_event = {}, {} for data_key in f._data_capture: - data_one_event[data_key] = self.data[data_key][image_id] + if data_key in self.data: + data_one_event[data_key] = self.data[data_key][image_id] + else: + msg = f"Unable to find {data_key} in data dictionary while "\ + f"running post-processor {f.__name__}." + warnings.warn(msg) for result_key in f._result_capture: - result_one_event[result_key] = self.result[result_key][image_id] + if result_key in self.result: + result_one_event[result_key] = self.result[result_key][image_id] + else: + msg = f"Unable to find {result_key} in result dictionary while "\ + f"running post-processor {f.__name__}." + warnings.warn(msg) for result_key in f._result_capture_optional: if result_key in self.result: result_one_event[result_key] = self.result[result_key][image_id] diff --git a/analysis/post_processing/reconstruction/geometry.py b/analysis/post_processing/reconstruction/geometry.py index cc0258c4..0f78c194 100644 --- a/analysis/post_processing/reconstruction/geometry.py +++ b/analysis/post_processing/reconstruction/geometry.py @@ -14,7 +14,10 @@ def particle_direction(data_dict, neighborhood_radius=5, optimize=False): - input_data = data_dict['input_data'] if 'input_rescaled' not in result_dict else result_dict['input_rescaled'] + if 'input_rescaled' not in result_dict: + input_data = data_dict['input_data'] + else: + input_data = result_dict['input_rescaled'] particles = result_dict['particle_clusts'] start_points = result_dict['particle_start_points'] end_points = result_dict['particle_end_points'] diff --git a/analysis/producers/scripts/benchmark.py b/analysis/producers/arxiv/benchmark.py similarity index 94% rename from analysis/producers/scripts/benchmark.py rename to analysis/producers/arxiv/benchmark.py index 0eb40509..36e9d120 100644 --- a/analysis/producers/scripts/benchmark.py +++ b/analysis/producers/arxiv/benchmark.py @@ -8,7 +8,7 @@ import os, sys @write_to(['test']) -def benchmark(data_blob, res, data_idx, analysis_cfg, cfg): +def benchmark(data_blob, res, **kwargs): """ Dummy script to see how long FullChainEvaluator initialization takes. Feel free to benchmark other things using this as a template. diff --git a/analysis/producers/scripts/__init__.py b/analysis/producers/scripts/__init__.py index fda0f6e8..e2525942 100644 --- a/analysis/producers/scripts/__init__.py +++ b/analysis/producers/scripts/__init__.py @@ -1,2 +1 @@ -from .template import run_inference -from .benchmark import benchmark \ No newline at end of file +from .template import run_inference \ No newline at end of file diff --git a/analysis/producers/scripts/template.py b/analysis/producers/scripts/template.py index d54c3e9a..a05f7c0d 100644 --- a/analysis/producers/scripts/template.py +++ b/analysis/producers/scripts/template.py @@ -50,12 +50,12 @@ def run_inference(data_blob, res, **kwargs): # Load data into evaluator predictor = FullChainEvaluator(data_blob, res, - evaluator_cfg=evaluator_cfg, - boundaries=boundaries) + evaluator_cfg=evaluator_cfg) image_idxs = data_blob['index'] - # Loop over images for idx, index in enumerate(image_idxs): + + # For saving per image information index_dict = { 'Index': index, # 'run': data_blob['run_info'][idx][0], @@ -76,12 +76,16 @@ def run_inference(data_blob, res, **kwargs): if len(matches) == 0: continue - pmatches, pcounts = predictor.match_parts_within_ints(matches) + # We access the particle matching information, which is already + # done by called match_interactions. + pmatches = predictor._matched_particles + pcounts = predictor._matched_particles_counts # 2. Process interaction level information interaction_logger = InteractionLogger(int_fieldnames) interaction_logger.prepare() + # 2-1 Loop over matched interaction pairs for i, interaction_pair in enumerate(matches): int_dict = OrderedDict() @@ -102,6 +106,7 @@ def run_inference(data_blob, res, **kwargs): particle_logger = ParticleLogger(particle_fieldnames) particle_logger.prepare() + # Loop over matched particle pairs for i, mparticles in enumerate(pmatches): true_p, pred_p = mparticles[0], mparticles[1] diff --git a/analysis/run.py b/analysis/run.py index 49904268..83f01be2 100644 --- a/analysis/run.py +++ b/analysis/run.py @@ -21,23 +21,26 @@ from analysis.manager import AnaToolsManager -def main(analysis_cfg_path, model_cfg_path): +def main(analysis_cfg_path, model_cfg_path=None): analysis_config = yaml.safe_load(open(analysis_cfg_path, 'r')) - config = yaml.safe_load(open(model_cfg_path, 'r')) - process_config(config, verbose=False) + config = None + if model_cfg_path is not None: + config = yaml.safe_load(open(model_cfg_path, 'r')) + process_config(config, verbose=False) - pprint(analysis_config) + # pprint(analysis_config) if 'analysis' not in analysis_config: raise Exception('Analysis configuration needs to live under `analysis` section.') - manager = AnaToolsManager(config, analysis_config) + manager = AnaToolsManager(analysis_config, cfg=config) manager.initialize() manager.run() if __name__=="__main__": parser = argparse.ArgumentParser() - parser.add_argument('config') parser.add_argument('analysis_config') + parser.add_argument('--chain_config', nargs='?', default=None, + help='Path to full chain configuration file') args = parser.parse_args() - main(args.analysis_config, args.config) + main(args.analysis_config, model_cfg_path=args.chain_config) From c5917785db86bea2928587eea2e899a0805583d0 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Mon, 10 Apr 2023 18:58:41 -0700 Subject: [PATCH 131/180] Finished readme --- analysis/README.md | 180 ++++++++++++++---- analysis/classes/builders.py | 4 +- .../reconstruction/geometry.py | 6 +- analysis/producers/common.py | 14 +- analysis/producers/scripts/template.py | 3 + 5 files changed, 167 insertions(+), 40 deletions(-) diff --git a/analysis/README.md b/analysis/README.md index 9e12ff66..180cb6e2 100644 --- a/analysis/README.md +++ b/analysis/README.md @@ -357,47 +357,157 @@ interactions against true interactions. pcounts = predictor._matched_particles_counts ``` Here, `matches` contain pairs (`TruthInteraction`, `Interaction`) which -are matched based on +are matched based on the intersection over union between the 3d spacepoints. Note +that the pairs may contain `None` objects (ex. `(None, Interaction)`) if +a given predicted interaction does not have a corresponding matched true interaction (and vice versa). +The same convention holds for matched particle pairs (`TruthParticle`, `Particle`). +> **Warning**: By default, `TruthInteraction` objects use **reconstructed 3D points** +> for identifying `Interactions` objects that share the same 3D coordinates. +> To have `TruthInteractions` use **true nonghost 3D coordinates** (i.e., true +> 3D spacepoints from G4), one must set **`overlap_mode="chamfer"`** to allow the +> evaluator to use the chamfer distance to match non-overlapping 3D coordinates +> between true nonghost and predicted nonghost coordinates. ### 4.2 Using Loggers to organize CSV output fields. +Loggers are objects that take a `DataBuilder` product and returns an `OrderedDict` +instance representing a single row of an output CSV file. For example: +```python +# true_int is an instance of TruthInteraction +true_int_dict = interaction_logger.produce(true_int, mode='true') +pprint(true_int_dict) +--------------------- +OrderedDict([('true_interaction_id', 1), + ('true_interaction_size', 3262), + ('true_count_primary_photon', 0), + ('true_count_primary_electron', 0), + ('true_count_primary_proton', 0), + ('true_vertex_x', 390.0), + ('true_vertex_y', 636.0), + ('true_vertex_z', 5688.0)]) +``` +Each logger's behavior is defined in the analysis configuration file's `logger` +field under each `script`: +```yaml +scripts: + run_inference: + ... + logger: + append: False + interactions: + ... + particles: + ... +``` +For now, only `ParticleLogger` and `InteracionLogger` are implemented +(corresponds to `particles` and `interactions`) in the above configuration field. +Suppose we want to retrive some basic information of each particle, plus an indicator +to see if the particle is contained within the fiducial volume. We modify our +analysis config as follows: +```yaml +scripts: + run_inference: + ... + logger: + particles: + id: + interaction_id: + pdg_type: + size: + semantic_type: + reco_length: + reco_direction: + startpoint: + endpoint: + is_contained: + args: + vb: [[-412.89, -6.4], [-181.86, 134.96], [-894.951, 894.951]] + threshold: 30 +``` +Some particle attributes do not need any arguments for the logger to fetch the +value (ex. `id`, `size`), while some attributes need arguments to further process +information (ex. `is_contained`). In this case, we have: +```python + particle_fieldnames = kwargs['logger'].get('particles', {}) + int_fieldnames = kwargs['logger'].get('interactions', {}) + + pprint(particle_fieldnames) + --------------------------- + {'endpoint': None, + 'id': None, + 'interaction_id': None, + 'is_contained': {'args': {'threshold': 30, + 'vb': [[-412.89, -6.4], + [-181.86, 134.96], + [-894.951, 894.951]]}}, + 'pdg_type': None, + 'reco_direction': None, + 'reco_length': None, + 'semantic_type': None, + 'size': None, + 'startpoint': None, + 'sum_edep': None} +``` +`ParticleLogger` then takes this dictionary and registers all data fetching +methods to its state: ```python - # 2. Process interaction level information - interaction_logger = InteractionLogger(int_fieldnames) - interaction_logger.prepare() - - # 2-1 Loop over matched interaction pairs - for i, interaction_pair in enumerate(matches): - - int_dict = OrderedDict() - int_dict.update(index_dict) - int_dict['interaction_match_counts'] = icounts[i] - true_int, pred_int = interaction_pair[0], interaction_pair[1] - - assert (type(true_int) is TruthInteraction) or (true_int is None) - assert (type(pred_int) is Interaction) or (pred_int is None) - - true_int_dict = interaction_logger.produce(true_int, mode='true') - pred_int_dict = interaction_logger.produce(pred_int, mode='reco') - int_dict.update(true_int_dict) - int_dict.update(pred_int_dict) - interactions.append(int_dict) - - # 3. Process particle level information particle_logger = ParticleLogger(particle_fieldnames) particle_logger.prepare() +``` +Then given a `Particle/TruthParticle` instance, the logger returns a dict +containing the fetched values: +```python +true_p_dict = particle_logger.produce(true_p, mode='true') +pred_p_dict = particle_logger.produce(pred_p, mode='reco') + +pprint(true_p_dict) +------------------- +OrderedDict([('true_particle_id', 49), + ('true_particle_interaction_id', 1), + ('true_particle_type', 1), + ('true_particle_size', 40), + ('true_particle_semantic_type', 3), + ('true_particle_length', -1), + ('true_particle_dir_x', 0.30373622291144525), + ('true_particle_dir_y', -0.6025136296534822), + ('true_particle_dir_z', -0.738052594991221), + ('true_particle_has_startpoint', True), + ('true_particle_startpoint_x', 569.5), + ('true_particle_startpoint_y', 109.5), + ('true_particle_startpoint_z', 5263.499996), + ('true_particle_has_endpoint', False), + ('true_particle_endpoint_x', -1), + ('true_particle_endpoint_y', -1), + ('true_particle_endpoint_z', -1), + ('true_particle_px', 8.127892246220952), + ('true_particle_py', -16.12308802605594), + ('true_particle_pz', -19.75007098801436), + ('true_particle_sum_edep', 2996.6235), + ('true_particle_is_contained', False)]) +``` +> **Note**: some data fetching methods are only reserved for `TruthParticles` +> (ex. (true) momentum) while others are exclusive for `Particles`. For example, +> `particle_logger.produce(true_p, mode='reco')` will not attempt to fetch +> true momentum values. - # Loop over matched particle pairs - for i, mparticles in enumerate(pmatches): - true_p, pred_p = mparticles[0], mparticles[1] +The outputs of `run_inference` is a list of list of `OrderedDicts`: `[interactions, particles]`. +Each dictionary list represents a separate output file to be generated. The keys +of each ordered dictionary will be registered as column names of the output file. - true_p_dict = particle_logger.produce(true_p, mode='true') - pred_p_dict = particle_logger.produce(pred_p, mode='reco') +An example analysis tools configuration file can be found in `analysis/config/example.cfg`, and a full +implementation of `run_inference` is located in `analysis/producers/scripts/template.py`. - part_dict = OrderedDict() - part_dict.update(index_dict) - part_dict['particle_match_counts'] = pcounts[i] - part_dict.update(true_p_dict) - part_dict.update(pred_p_dict) - particles.append(part_dict) +### 4.3 Launching analysis tools job for large statistics inference. - return [interactions, particles] +To run analysis tools on (already generated) full chain output stored as HDF5 files: +```bash +python3 analysis/run.py $PATH_TO_ANALYSIS_CONFIG ``` +This will run all post-processing, producer scripts, and logger data-fetching and place the result CSVs at the output log directory set by `log_dir`. Again, you will need to set the `reader` field in your analysis config. + +> **Note**: It is not necessary for analysis tools to create an output CSV file. In other words, one can +> halt the analysis tools workflow at the post-processing stage and save the full chain output + post-processing +> result to disk (HDF5 format). + +To run analysis tools in tandem with full chain forwarding, you need an additional argument for the full chain config: +```bash +python3 analysis/run.py $PATH_TO_ANALYSIS_CONFIG --chain_config $PATH_TO_FULL_CHAIN_CONFIG +``` \ No newline at end of file diff --git a/analysis/classes/builders.py b/analysis/classes/builders.py index 0f7c5191..19c9ef79 100644 --- a/analysis/classes/builders.py +++ b/analysis/classes/builders.py @@ -200,7 +200,9 @@ def _build_true(self, pid=pdg) particle.p = np.array([lpart.px(), lpart.py(), lpart.pz()]) - # particle.fragments = fragments + particle.pmag = np.linalg.norm(particle.p) + if particle.pmag > 0: + particle.direction = particle.p / particle.pmag particle.startpoint = np.array([lpart.first_step().x(), lpart.first_step().y(), diff --git a/analysis/post_processing/reconstruction/geometry.py b/analysis/post_processing/reconstruction/geometry.py index 0f78c194..d8e6603b 100644 --- a/analysis/post_processing/reconstruction/geometry.py +++ b/analysis/post_processing/reconstruction/geometry.py @@ -8,7 +8,8 @@ @post_processing(data_capture=['input_data'], result_capture=['input_rescaled', 'particle_clusts', 'particle_start_points', - 'particle_end_points']) + 'particle_end_points', + 'Particles']) def particle_direction(data_dict, result_dict, neighborhood_radius=5, @@ -34,5 +35,8 @@ def particle_direction(data_dict, neighborhood_radius, optimize) } + + for i, p in enumerate(result_dict['Particles']): + p.direction = update_dict['particle_start_directions'][i] return update_dict diff --git a/analysis/producers/common.py b/analysis/producers/common.py index c4693a29..66536c11 100644 --- a/analysis/producers/common.py +++ b/analysis/producers/common.py @@ -5,14 +5,22 @@ from pprint import pprint class ScriptProcessor: - - def __init__(self, data, result, debug=True): + """Simple class for handling script functions used to + generate output csv files for high level analysis. + + Parameters + ---------- + data : dict + data dictionary from either model forwarding or HDF5 reading. + result: dict + result dictionary containing ML chain outputs + """ + def __init__(self, data, result): self._funcs = defaultdict(list) self._num_batches = len(data['index']) self.data = data self.index = data['index'] self.result = result - self.debug = debug def register_function(self, f, priority, script_cfg={}): filenames = f._filenames diff --git a/analysis/producers/scripts/template.py b/analysis/producers/scripts/template.py index a05f7c0d..1161083b 100644 --- a/analysis/producers/scripts/template.py +++ b/analysis/producers/scripts/template.py @@ -112,6 +112,9 @@ def run_inference(data_blob, res, **kwargs): true_p_dict = particle_logger.produce(true_p, mode='true') pred_p_dict = particle_logger.produce(pred_p, mode='reco') + + pprint(true_p_dict) + assert False part_dict = OrderedDict() part_dict.update(index_dict) From d68da1ab55c8c4729a0c783296a14290450bc247 Mon Sep 17 00:00:00 2001 From: Dae Heun Date: Tue, 11 Apr 2023 12:09:18 -0700 Subject: [PATCH 132/180] Update readme --- analysis/README.md | 2 +- analysis/classes/builders.py | 31 +++++++++++++++++++++++++------ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/analysis/README.md b/analysis/README.md index 180cb6e2..b08082f2 100644 --- a/analysis/README.md +++ b/analysis/README.md @@ -58,7 +58,7 @@ analysis_cfg_path = $PATH_TO_ANALYSIS_CFG analysis_config = yaml.safe_load(open(analysis_cfg_path, 'r')) from analysis.manager import AnaToolsManager -manager = AnaToolsManager(cfg, analysis_config) +manager = AnaToolsManager(analysis_config, cfg=cfg) manager.initialize() ``` diff --git a/analysis/classes/builders.py b/analysis/classes/builders.py index 19c9ef79..6500159d 100644 --- a/analysis/classes/builders.py +++ b/analysis/classes/builders.py @@ -24,14 +24,27 @@ from mlreco.utils.vertex import get_vertex from mlreco.utils.gnn.cluster import get_cluster_label -class Builder(ABC): +class DataBuilder(ABC): """Abstract base class for building all data structures - A Builder takes input data and full chain output dictionaries + A DataBuilder takes input data and full chain output dictionaries and processes them into human-readable data structures. """ def build(self, data: dict, result: dict, mode='reco'): + """Process all images in the current batch and change representation + into each respective data format. + + Parameters + ---------- + data: dict + result: dict + mode: str + Indicator for building reconstructed vs true data formats. + In other words, mode='reco' will produce and + data formats, while mode='truth' is reserved for + and + """ output = [] num_batches = len(data['index']) for bidx in range(num_batches): @@ -40,7 +53,13 @@ def build(self, data: dict, result: dict, mode='reco'): return output def build_image(self, entry: int, data: dict, result: dict, mode='reco'): - + """Build data format for a single image. + + Parameters + ---------- + entry: int + Batch id number for the image. + """ if mode == 'truth': entities = self._build_true(entry, data, result) elif mode == 'reco': @@ -59,7 +78,7 @@ def _build_reco(self, entry, data: dict, result: dict): raise NotImplementedError -class ParticleBuilder(Builder): +class ParticleBuilder(DataBuilder): """ Eats data, result and makes List of Particles per image. """ @@ -217,7 +236,7 @@ def _build_true(self, return out -class InteractionBuilder(Builder): +class InteractionBuilder(DataBuilder): def __init__(self, builder_cfg={}): self.cfg = builder_cfg @@ -290,7 +309,7 @@ def get_true_vertices(self, entry, data: dict): return out -class FragmentBuilder(Builder): +class FragmentBuilder(DataBuilder): def __init__(self, builder_cfg={}): self.cfg = builder_cfg From fe765fe4d2abec6faeed17eaf406bcb1ead00d22 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Tue, 11 Apr 2023 16:23:32 -0700 Subject: [PATCH 133/180] Bug fix in parse_cluster3d_charge_rescaled --- mlreco/iotools/parsers/cluster.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/mlreco/iotools/parsers/cluster.py b/mlreco/iotools/parsers/cluster.py index 67ae31a9..8cf16aca 100644 --- a/mlreco/iotools/parsers/cluster.py +++ b/mlreco/iotools/parsers/cluster.py @@ -233,9 +233,20 @@ def parse_cluster3d_charge_rescaled(cluster_event, min_size = -1): # Produces cluster3d labels with sparse3d_reco_rescaled on the fly on datasets that do not have it - np_voxels, np_features = parse_cluster3d(cluster_event, particle_event, particle_mpv_event, sparse_semantics_event, None, - add_particle_info, add_kinematics_info, clean_data, - type_include_mpr, type_include_secondary, primary_include_mpr, break_clusters, min_size) + np_voxels, np_features = parse_cluster3d(cluster_event, + particle_event, + particle_mpv_event, + neutrino_event, + sparse_semantics_event, + None, + add_particle_info, + add_kinematics_info, + clean_data, + type_include_mpr, + type_include_secondary, + primary_include_mpr, + break_clusters, + min_size) from .sparse import parse_sparse3d_charge_rescaled _, val_features = parse_sparse3d_charge_rescaled(sparse_value_event_list) From ceb3d7071e0e39956433811d6351d48f66c23bfe Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 12 Apr 2023 01:56:15 -0700 Subject: [PATCH 134/180] Some experimental models, will not affect chain --- .../models/experimental/cluster/criterion.py | 158 ++++++++ mlreco/models/experimental/cluster/mask3d.py | 353 ++++++++++++++++++ mlreco/models/experimental/cluster/matcher.py | 198 ++++++++++ .../models/experimental/layers/pointnext.py | 0 .../transformers/positional_encodings.py | 56 +++ .../experimental/transformers/transformer.py | 311 +++++++++++++++ mlreco/models/factories.py | 5 +- mlreco/models/mask3d.py | 218 +++++++++++ 8 files changed, 1298 insertions(+), 1 deletion(-) create mode 100644 mlreco/models/experimental/cluster/criterion.py create mode 100644 mlreco/models/experimental/cluster/mask3d.py create mode 100644 mlreco/models/experimental/cluster/matcher.py create mode 100644 mlreco/models/experimental/layers/pointnext.py create mode 100644 mlreco/models/experimental/transformers/positional_encodings.py create mode 100644 mlreco/models/mask3d.py diff --git a/mlreco/models/experimental/cluster/criterion.py b/mlreco/models/experimental/cluster/criterion.py new file mode 100644 index 00000000..3c815cf4 --- /dev/null +++ b/mlreco/models/experimental/cluster/criterion.py @@ -0,0 +1,158 @@ +# Copyright (c) Facebook, Inc. and its affiliates. +# Modified by Bowen Cheng from https://github.com/facebookresearch/detr/blob/master/models/detr.py +# Modified for lartpc_mlreco3d + +import torch +import torch.nn.functional as F +from torch import nn +from mlreco.utils.globals import * +from scipy.optimize import linear_sum_assignment +from mlreco.models.layers.cluster_cnn.losses.misc import iou_batch, LovaszHingeLoss + +class LinearSumAssignmentLoss(nn.Module): + + def __init__(self, weight_dice=1.0, weight_ce=1.0, mode='dice'): + super(LinearSumAssignmentLoss, self).__init__() + self.weight_dice = weight_dice + self.weight_ce = weight_ce + + self.lovasz = LovaszHingeLoss() + self.mode = mode + print(f"Setting LinearSumAssignment loss to '{self.mode}'") + + def compute_accuracy(self, masks, targets, indices): + with torch.no_grad(): + valid_masks = masks[:, indices[0]] > 0 + valid_targets = targets[:, indices[1]] > 0.5 + iou = iou_batch(valid_masks, valid_targets, eps=1e-6) + return float(iou) + + def forward(self, masks, targets): + + with torch.no_grad(): + dice_loss = batch_dice_loss(masks.T, targets.T) + ce_loss = batch_sigmoid_ce_loss(masks.T, targets.T) + cost_matrix = self.weight_dice * dice_loss + self.weight_ce * ce_loss + indices = linear_sum_assignment(cost_matrix.detach().cpu()) + + if self.mode == 'log_dice': + dice_loss = batch_log_dice_loss(masks.T[indices[0]], targets.T[indices[1]]) + elif self.mode == 'dice': + dice_loss = batch_dice_loss(masks.T[indices[0]], targets.T[indices[1]]) + elif self.mode == 'lovasz': + dice_loss = self.lovasz(masks[:, indices[0]], targets[:, indices[1]]) + else: + raise ValueError(f"LSA loss mode {self.mode} is not supported!") + ce_loss = batch_sigmoid_ce_loss(masks.T[indices[0]], targets.T[indices[1]]) + cost_matrix = self.weight_dice * dice_loss + self.weight_ce * ce_loss + loss = torch.diag(cost_matrix).mean() + acc = self.compute_accuracy(masks, targets, indices) + + return loss, acc, indices + + +@torch.jit.script +def get_instance_masks(cluster_label : torch.LongTensor, + max_num_instances: int = -1): + """Given integer coded cluster instance labels, construct a + (N x max_num_instances) bool tensor in which each colume is a + binary instance mask. + + """ + groups, counts = torch.unique(cluster_label, return_counts=True) + if max_num_instances < 0: + max_num_instances = groups.shape[0] + instance_masks = torch.zeros((cluster_label.shape[0], + max_num_instances)).to(device=cluster_label.device, + dtype=torch.bool) + perm = torch.argsort(counts, descending=True)[:max_num_instances] + + for i, group_id in enumerate(groups[perm]): + instance_masks[:, i] = (cluster_label == group_id).to(torch.bool) + + return instance_masks + + +def dice_loss(logits, targets): + """ + + Parameters + ---------- + logits: (N x num_queries) + targets: (N x num_queries) + """ + num_masks = logits.shape[1] + scores = torch.sigmoid(logits) + numerator = (2 * scores * targets).sum(dim=0) + denominator = scores.sum(dim=0) + targets.sum(dim=0) + return (1 - (numerator + 1) / (denominator + 1)).sum() / num_masks + + +@torch.jit.script +def batch_dice_loss(inputs: torch.Tensor, targets: torch.Tensor): + """ + Compute the DICE loss, similar to generalized IOU for masks + Args: + inputs: (num_masks, num_points) Tensor + targets: (num_masks, num_points) Tensor + """ + scores = inputs.sigmoid() + scores = scores.flatten(1) + numerator = 2 * torch.einsum("nc,mc->nm", scores, targets) + denominator = scores.sum(-1)[:, None] + targets.sum(-1)[None, :] + loss = 1 - (numerator + 1) / (denominator + 1) + return loss + +@torch.jit.script +def batch_log_dice_loss(inputs: torch.Tensor, targets: torch.Tensor): + """ + Compute the DICE loss, similar to generalized IOU for masks + Args: + inputs: (num_masks, num_points) Tensor + targets: (num_masks, num_points) Tensor + """ + scores = inputs.sigmoid() + scores = scores.flatten(1) + numerator = 2 * torch.einsum("nc,mc->nm", scores, targets) + denominator = scores.sum(-1)[:, None] + targets.sum(-1)[None, :] + loss = -torch.log((numerator + 1) / (denominator + 1)) + return loss + +@torch.jit.script +def batch_sigmoid_ce_loss(inputs: torch.Tensor, targets: torch.Tensor): + """ + Args: + inputs: A float tensor of arbitrary shape. + The predictions for each example. + targets: A float tensor with the same shape as inputs. Stores the binary + classification label for each element in inputs + (0 for the negative class and 1 for the positive class). + Returns: + Loss tensor + """ + hw = inputs.shape[1] + + pos = F.binary_cross_entropy_with_logits( + inputs, torch.ones_like(inputs), reduction="none" + ) + neg = F.binary_cross_entropy_with_logits( + inputs, torch.zeros_like(inputs), reduction="none" + ) + + loss = torch.einsum("nc,mc->nm", pos, targets) + torch.einsum( + "nc,mc->nm", neg, (1 - targets) + ) + + return loss / hw + + +def batch_ce(inputs, targets): + """ + Only for testing purposes (brute force calculation) + """ + num_masks = inputs.shape[0] + out = torch.zeros((num_masks, num_masks)) + for i in range(num_masks): + for j in range(num_masks): + out[i,j] = F.binary_cross_entropy_with_logits(inputs[i], targets[j], reduction='mean') + return out \ No newline at end of file diff --git a/mlreco/models/experimental/cluster/mask3d.py b/mlreco/models/experimental/cluster/mask3d.py new file mode 100644 index 00000000..e198f77e --- /dev/null +++ b/mlreco/models/experimental/cluster/mask3d.py @@ -0,0 +1,353 @@ +import torch +import torch.nn as nn + +import MinkowskiEngine as ME +import MinkowskiEngine.MinkowskiOps as me + +from mlreco.models.layers.common.uresnet_layers import UResNetDecoder, UResNetEncoder +from mlreco.models.experimental.transformers.positional_encodings import FourierEmbeddings +from mlreco.models.experimental.cluster.pointnet2.pointnet2_utils import furthest_point_sample +from mlreco.models.experimental.transformers.positional_encodings import get_normalized_coordinates +from mlreco.models.experimental.transformers.transformer import TransformerDecoder, GenericMLP +from torch_geometric.nn import MLP + + +from mlreco.utils.globals import * + +class QueryModule(nn.Module): + + def __init__(self, cfg, name='query_module'): + super(QueryModule, self).__init__() + + self.model_config = cfg[name] + + # Define instance query modules + self.num_input = self.model_config.get('num_input', 32) + self.num_pos_input = self.model_config.get('num_pos_input', 128) + self.num_queries = self.model_config.get('num_queries', 200) + # self.num_classes = self.model_config.get('num_classes', 5) + self.mask_dim = self.model_config.get('mask_dim', 128) + self.query_type = self.model_config.get('query_type', 'fps') + self.query_proj = None + + if self.query_type == 'fps': + self.query_projection = GenericMLP( + input_dim=self.num_input, + hidden_dims=[self.mask_dim], + output_dim=self.mask_dim, + use_conv=True, + output_use_activation=True, + hidden_use_bias=True + ) + self.query_pos_projection = GenericMLP( + input_dim=self.num_pos_input, + hidden_dims=[self.mask_dim], + output_dim=self.mask_dim, + use_conv=True, + output_use_activation=True, + hidden_use_bias=True + ) + elif self.query_type == 'embedding': + self.query_feat = nn.Embedding(self.num_queries, self.mask_dim) + self.query_pos = nn.Embedding(self.num_queries, self.mask_dim) + else: + raise ValueError("Query type {} is not supported!".format(self.query_type)) + + self.pos_enc = FourierEmbeddings(cfg) + + def forward(self, x, uresnet_features): + ''' + Inputs + ------ + x: Input ME.SparseTensor from UResNet output + ''' + + batch_size = len(x.decomposed_coordinates) + + if self.query_type == 'fps': + # Sample query points via FPS + fps_idx = [furthest_point_sample(x.decomposed_coordinates[i][None, ...].float(), + self.num_queries).squeeze(0).long() \ + for i in range(len(x.decomposed_coordinates))] + # B, nqueries, 3 + sampled_coords = torch.stack([x.decomposed_coordinates[i][fps_idx[i], :] \ + for i in range(len(x.decomposed_coordinates))], axis=0) + query_pos = self.pos_enc(sampled_coords.float()).permute(0, 2, 1) # B, dim, nqueries + query_pos = self.query_pos_projection(query_pos) # B, dim, mask_dim + queries = torch.stack([uresnet_features.decomposed_features[i][fps_idx[i].long(), :] \ + for i in range(len(fps_idx))]) # B, nqueries, num_uresnet_feats + queries = queries.permute(0, 2, 1) # B, num_uresnet_feats, nqueries + queries = self.query_projection(queries) # B, mask_dim, nqueries + elif self.query_type == 'embedding': + queries = self.query_feat.weight.unsqueze(0).repeat(batch_size, 1, 1) + query_pos = self.query_pos.weight.unsqueeze(1).repeat(1, batch_size, 1) + else: + raise ValueError("Query type {} is not supported!".format(self.query_type)) + + return queries.permute((0, 2, 1)), query_pos.permute((0, 2, 1)) + +class Mask3d(nn.Module): + + def __init__(self, cfg, name='mask3d'): + super(Mask3d, self).__init__() + + self.model_config = cfg[name] + + self.encoder = UResNetEncoder(cfg, name='uresnet') + self.decoder = UResNetDecoder(cfg, name='uresnet') + + num_params_backbone = sum(p.numel() for p in self.encoder.parameters()) + num_params_backbone += sum(p.numel() for p in self.decoder.parameters()) + print(f"Number of Backbone Parameters = {num_params_backbone}") + + self.query_module = QueryModule(cfg) + + num_features = self.encoder.num_filters + self.D = self.model_config.get('D', 3) + self.mask_dim = self.model_config.get('mask_dim', 128) + self.num_classes = self.model_config.get('num_classes', 2) + self.num_heads = self.model_config.get('num_heads', 8) + self.dropout = self.model_config.get('dropout', 0.0) + self.normalize_before = self.model_config.get('normalize_before', False) + + self.depth = self.model_config.get('depth', 5) + self.mask_head = ME.MinkowskiConvolution(num_features, self.mask_dim, + kernel_size=1, stride=1, bias=True, dimension=self.D) + + + # self.instance_to_mask = MLP([self.mask_dim] * 3) + self.instance_to_mask = nn.Sequential( + nn.Linear(self.mask_dim, self.mask_dim), + nn.ReLU(), + nn.Linear(self.mask_dim, self.mask_dim) + ) + self.instance_to_class = nn.Sequential( + nn.Linear(self.mask_dim, self.mask_dim), + nn.ReLU(), + nn.Linear(self.mask_dim, self.num_classes) + ) + self.layernorm = nn.LayerNorm(self.mask_dim) + + self.pooling = ME.MinkowskiAvgPooling(kernel_size=2, stride=2, dimension=3) + + # Layerwise Projections + self.linear_squeeze = nn.ModuleList() + for i in range(self.depth-1, 0, -1): + self.linear_squeeze.append(nn.Linear(i * num_features, + self.mask_dim)) + + # Query Refinement Modules + self.num_transformers = self.model_config.get('num_transformers', 3) + self.shared_decoders = self.model_config.get('shared_decoders', True) + + # Transformer Modules + if self.shared_decoders: + num_shared = 1 + else: + num_shared = self.num_decoders + + self.transformers = nn.ModuleList() + + for num_trans in range(num_shared): + self.transformers.append(TransformerDecoder(self.mask_dim, + self.num_heads, + dropout=self.dropout, + normalize_before=self.normalize_before)) + + self.sample_sizes = [200, 800, 3200, 12800, 51200] + + num_params = sum(p.numel() for p in self.parameters()) + print(f"Number of Total Parameters = {num_params}") + + + + def get_positional_encoding(self, x): + pos_encoding = [] + + for i in range(len(x.decomposed_coordinates)): + coords = x.decomposed_coordinates[i] + pos_enc_batch = self.query_module.pos_enc(coords, features=None) + pos_encoding.append(pos_enc_batch) + + pos_encoding = torch.cat(pos_encoding, dim=0) + return pos_encoding + + + def mask_module(self, queries, mask_features, + return_attention_mask=True, + num_pooling_steps=0): + ''' + Inputs + ------ + - queries: [B, num_queries, query_dim] torch.Tensor + - mask_features: ME.SparseTensor from mask head output + ''' + query_feats = self.layernorm(queries) + mask_embed = self.instance_to_mask(query_feats) + output_class = self.instance_to_class(query_feats) + + output_masks = [] + + coords, feats = mask_features.decomposed_coordinates_and_features + batch_size = len(coords) + + assert mask_embed.shape[0] == batch_size + + for i in range(len(mask_features.decomposed_features)): + mask = feats[i] @ mask_embed[i].T + output_masks.append(mask) + + output_masks = torch.cat(output_masks, dim=0) + output_coords = torch.cat(coords, dim=0) + output_mask = me.SparseTensor(features=output_masks, + coordinate_manager=mask_features.coordinate_manager, + coordinate_map_key=mask_features.coordinate_map_key) + if return_attention_mask: + # nn.MultiHeadAttention attn_mask prevents "True" pixels from access + # Hence the < 0.5 in the attn_mask + with torch.no_grad(): + attn_mask = output_mask + for _ in range(num_pooling_steps): + attn_mask = self.pooling(attn_mask.float()) + attn_mask = me.SparseTensor(features=(attn_mask.F.detach().sigmoid() < 0.5), + coordinate_manager=attn_mask.coordinate_manager, + coordinate_map_key=attn_mask.coordinate_map_key) + return output_mask, output_class, attn_mask + else: + return output_mask, output_class + + + def sampling_module(self, decomposed_feats, decomposed_coords, decomposed_attn, depth, + max_sample_size=False, is_eval=False): + + indices, masks = [], [] + + if min([pcd.shape[0] for pcd in decomposed_feats]) == 1: + raise RuntimeError("only a single point gives nans in cross-attention") + + decomposed_pos_encs = [] + + for coords in decomposed_coords: + pos_enc = self.query_module.pos_enc(coords.float()) + decomposed_pos_encs.append(pos_enc) + + device = decomposed_feats[0].device + + curr_sample_size = max([pcd.shape[0] for pcd in decomposed_feats]) + if not (max_sample_size or is_eval): + curr_sample_size = min(curr_sample_size, self.sample_sizes[depth]) + + for bidx in range(len(decomposed_feats)): + num_points = decomposed_feats[bidx].shape[0] + if num_points <= curr_sample_size: + idx = torch.zeros(curr_sample_size, + dtype=torch.long, + device=device) + + midx = torch.ones(curr_sample_size, + dtype=torch.bool, + device=device) + + idx[:num_points] = torch.arange(num_points, + device=device) + + midx[:num_points] = False # attend to first points + else: + # we have more points in pcd as we like to sample + # take a subset (no padding or masking needed) + idx = torch.randperm(decomposed_feats[bidx].shape[0], + device=device)[:curr_sample_size] + midx = torch.zeros(curr_sample_size, + dtype=torch.bool, + device=device) # attend to all + indices.append(idx) + masks.append(midx) + + batched_feats = torch.stack([ + decomposed_feats[b][indices[b], :] for b in range(len(indices)) + ]) + batched_attn = torch.stack([ + decomposed_attn[b][indices[b], :] for b in range(len(indices)) + ]) + batched_pos_enc = torch.stack([ + decomposed_pos_encs[b][indices[b], :] for b in range(len(indices)) + ]) + + # Mask to handle points less than num_sample points + m = torch.stack(masks) + # If sum(1) == nsamples, then this query has no active voxels + batched_attn.permute((0, 2, 1))[batched_attn.sum(1) == indices[0].shape[0]] = False + # Fianl attention map is intersection of attention map and + # valid voxel samples (m). + batched_attn = torch.logical_or(batched_attn, m[..., None]) + + return batched_feats, batched_attn, batched_pos_enc + + + def forward(self, point_cloud): + + coords = point_cloud[:, COORD_COLS].int() + feats = point_cloud[:, VALUE_COL].float().view(-1, 1) + + normed_coords = get_normalized_coordinates(coords) + features = torch.cat([normed_coords, feats], dim=1) + x = ME.SparseTensor(coordinates=point_cloud[:, :VALUE_COL].int(), + features=features) + encoderOutput = self.encoder(x) + decoderOutput = self.decoder(encoderOutput['finalTensor'], + encoderOutput['encoderTensors']) + queries, query_pos = self.query_module(x, decoderOutput[-1]) + + total_num_pooling = len(decoderOutput)-1 + + full_res_fmap = decoderOutput[-1] + + mask_features = self.mask_head(full_res_fmap) + + batch_size = int(torch.unique(x.C[:, 0]).shape[0]) + + predictions_mask = [] + predictions_class = [] + + for tf_index in range(self.num_transformers): + if self.shared_decoders: + transformer_index = 0 + else: + transformer_index = tf_index + for i, fmap in enumerate(decoderOutput): + assert queries.shape == (batch_size, self.query_module.num_queries, self.mask_dim) + num_pooling = total_num_pooling-i + # queries = queries.permute(2, 0, 1) + output_mask, output_class, attn_mask = self.mask_module(queries, + mask_features, + num_pooling_steps=num_pooling) + + predictions_mask.append(output_mask.F) + predictions_class.append(output_class) + + fmaps, attn_masks = fmap.decomposed_features, attn_mask.decomposed_features + decomposed_coords = fmap.decomposed_coordinates + batched_feats, batched_attn, batched_pos_enc = self.sampling_module( + fmaps, decomposed_coords, attn_masks, i) + src_pcd = self.linear_squeeze[i](batched_feats) + output = self.transformers[transformer_index](queries, + query_pos, + src_pcd, + batched_pos_enc, + batched_attn) + + queries = output + + output_mask, output_class, attn_mask = self.mask_module(queries, + mask_features, + return_attention_mask=True, + num_pooling_steps=0) + + res = { + 'pred_masks' : [output_mask.F], + 'pred_logits': [output_class], + 'aux_masks': [predictions_mask], + 'aux_classes': [predictions_class] + } + + return res \ No newline at end of file diff --git a/mlreco/models/experimental/cluster/matcher.py b/mlreco/models/experimental/cluster/matcher.py new file mode 100644 index 00000000..2fb01d17 --- /dev/null +++ b/mlreco/models/experimental/cluster/matcher.py @@ -0,0 +1,198 @@ +# Copyright (c) Facebook, Inc. and its affiliates. +# Modified by Bowen Cheng from https://github.com/facebookresearch/detr/blob/master/models/matcher.py +""" +Modules to compute the matching cost and solve the corresponding LSAP. +""" +import torch +import torch.nn.functional as F +from scipy.optimize import linear_sum_assignment +from torch import nn +from torch.cuda.amp import autocast + + +def batch_dice_loss(inputs: torch.Tensor, targets: torch.Tensor): + """ + Compute the DICE loss, similar to generalized IOU for masks + Args: + inputs: A float tensor of arbitrary shape. + The predictions for each example. + targets: A float tensor with the same shape as inputs. Stores the binary + classification label for each element in inputs + (0 for the negative class and 1 for the positive class). + """ + inputs = inputs.sigmoid() + inputs = inputs.flatten(1) + numerator = 2 * torch.einsum("nc,mc->nm", inputs, targets) + denominator = inputs.sum(-1)[:, None] + targets.sum(-1)[None, :] + loss = 1 - (numerator + 1) / (denominator + 1) + return loss + + +batch_dice_loss_jit = torch.jit.script( + batch_dice_loss +) # type: torch.jit.ScriptModule + + +def batch_sigmoid_ce_loss(inputs: torch.Tensor, targets: torch.Tensor): + """ + Args: + inputs: A float tensor of arbitrary shape. + The predictions for each example. + targets: A float tensor with the same shape as inputs. Stores the binary + classification label for each element in inputs + (0 for the negative class and 1 for the positive class). + Returns: + Loss tensor + """ + hw = inputs.shape[1] + + pos = F.binary_cross_entropy_with_logits( + inputs, torch.ones_like(inputs), reduction="none" + ) + neg = F.binary_cross_entropy_with_logits( + inputs, torch.zeros_like(inputs), reduction="none" + ) + + loss = torch.einsum("nc,mc->nm", pos, targets) + torch.einsum( + "nc,mc->nm", neg, (1 - targets) + ) + + return loss / hw + + +batch_sigmoid_ce_loss_jit = torch.jit.script( + batch_sigmoid_ce_loss +) # type: torch.jit.ScriptModule + + +class HungarianMatcher(nn.Module): + """This class computes an assignment between the targets and the predictions of the network + + For efficiency reasons, the targets don't include the no_object. Because of this, in general, + there are more predictions than targets. In this case, we do a 1-to-1 matching of the best predictions, + while the others are un-matched (and thus treated as non-objects). + """ + + def __init__(self, cost_class: float = 1, cost_mask: float = 1, cost_dice: float = 1, num_points: int = 0): + """Creates the matcher + + Params: + cost_class: This is the relative weight of the classification error in the matching cost + cost_mask: This is the relative weight of the focal loss of the binary mask in the matching cost + cost_dice: This is the relative weight of the dice loss of the binary mask in the matching cost + """ + super().__init__() + self.cost_class = cost_class + self.cost_mask = cost_mask + self.cost_dice = cost_dice + + assert cost_class != 0 or cost_mask != 0 or cost_dice != 0, "all costs cant be 0" + + self.num_points = num_points + + @torch.no_grad() + def memory_efficient_forward(self, outputs, targets, mask_type): + """More memory-friendly matching""" + bs, num_queries = outputs["pred_logits"].shape[:2] + + indices = [] + + # Iterate through batch size + for b in range(bs): + + out_prob = outputs["pred_logits"][b].softmax(-1) # [num_queries, num_classes] + tgt_ids = targets[b]["labels"].clone() + + # Compute the classification cost. Contrary to the loss, we don't use the NLL, + # but approximate it in 1 - proba[target class]. + # The 1 is a constant that doesn't change the matching, it can be ommitted. + filter_ignore = (tgt_ids == 253) + tgt_ids[filter_ignore] = 0 + cost_class = -out_prob[:, tgt_ids] + cost_class[:, filter_ignore] = -1. # for ignore classes pretend perfect match ;) TODO better worst class match? + + out_mask = outputs['pred_masks'][b].T # [num_queries, H_pred, W_pred] + # gt masks are already padded when preparing target + tgt_mask = targets[b][mask_type].to(out_mask) + + if self.num_points != -1: + point_idx = torch.randperm(tgt_mask.shape[1], + device=tgt_mask.device)[:int(self.num_points*tgt_mask.shape[1])] + #point_idx = torch.randint(0, tgt_mask.shape[1], size=(self.num_points,), device=tgt_mask.device) + else: + # sample all points + point_idx = torch.arange(tgt_mask.shape[1], device=tgt_mask.device) + + # out_mask = out_mask[:, None] + # tgt_mask = tgt_mask[:, None] + # all masks share the same set of points for efficient matching! + # point_coords = torch.rand(1, self.num_points, 2, device=out_mask.device) + # get gt labels + # tgt_mask = point_sample( + # tgt_mask, + # point_coords.repeat(tgt_mask.shape[0], 1, 1), + # align_corners=False, + # ).squeeze(1) + + # out_mask = point_sample( + # out_mask, + # point_coords.repeat(out_mask.shape[0], 1, 1), + # align_corners=False, + # ).squeeze(1) + + with autocast(enabled=False): + out_mask = out_mask.float() + tgt_mask = tgt_mask.float() + # Compute the focal loss between masks + cost_mask = batch_sigmoid_ce_loss_jit(out_mask[:, point_idx], tgt_mask[:, point_idx]) + + # Compute the dice loss betwen masks + cost_dice = batch_dice_loss_jit(out_mask[:, point_idx], tgt_mask[:, point_idx]) + + # Final cost matrix + C = ( + self.cost_mask * cost_mask + + self.cost_class * cost_class + + self.cost_dice * cost_dice + ) + C = C.reshape(num_queries, -1).cpu() + + indices.append(linear_sum_assignment(C)) + + return [ + (torch.as_tensor(i, dtype=torch.int64), torch.as_tensor(j, dtype=torch.int64)) + for i, j in indices + ] + + @torch.no_grad() + def forward(self, outputs, targets, mask_type): + """Performs the matching + + Params: + outputs: This is a dict that contains at least these entries: + "pred_logits": Tensor of dim [batch_size, num_queries, num_classes] with the classification logits + "pred_masks": Tensor of dim [batch_size, num_queries, H_pred, W_pred] with the predicted masks + + targets: This is a list of targets (len(targets) = batch_size), where each target is a dict containing: + "labels": Tensor of dim [num_target_boxes] (where num_target_boxes is the number of ground-truth + objects in the target) containing the class labels + "masks": Tensor of dim [num_target_boxes, H_gt, W_gt] containing the target masks + + Returns: + A list of size batch_size, containing tuples of (index_i, index_j) where: + - index_i is the indices of the selected predictions (in order) + - index_j is the indices of the corresponding selected targets (in order) + For each batch element, it holds: + len(index_i) = len(index_j) = min(num_queries, num_target_boxes) + """ + return self.memory_efficient_forward(outputs, targets, mask_type) + + def __repr__(self, _repr_indent=4): + head = "Matcher " + self.__class__.__name__ + body = [ + "cost_class: {}".format(self.cost_class), + "cost_mask: {}".format(self.cost_mask), + "cost_dice: {}".format(self.cost_dice), + ] + lines = [head] + [" " * _repr_indent + line for line in body] + return "\n".join(lines) diff --git a/mlreco/models/experimental/layers/pointnext.py b/mlreco/models/experimental/layers/pointnext.py new file mode 100644 index 00000000..e69de29b diff --git a/mlreco/models/experimental/transformers/positional_encodings.py b/mlreco/models/experimental/transformers/positional_encodings.py new file mode 100644 index 00000000..e4ce7408 --- /dev/null +++ b/mlreco/models/experimental/transformers/positional_encodings.py @@ -0,0 +1,56 @@ +import numpy as np +import torch +import torch.nn as nn + +import MinkowskiEngine as ME + +'''Adapted from https://github.com/JonasSchult/Mask3D with modification.''' + +def get_normalized_coordinates(coords, D=3, spatial_size=6144): + assert len(coords.shape) == 2 + normalized_coords = (coords[:, :D].float() - float(spatial_size) / 2) \ + / (float(spatial_size) / 2) + return normalized_coords + +class FourierEmbeddings(nn.Module): + + def __init__(self, cfg, name='fourier_embeddings'): + super(FourierEmbeddings, self).__init__() + self.model_config = cfg[name] + + self.D = self.model_config.get('D', 3) + self.num_input = self.model_config.get('num_input_features', 3) + self.pos_dim = self.model_config.get('positional_encoding_dim', 32) + self.normalize = self.model_config.get('normalize_coordinates', False) + self.spatial_size = self.model_config.get('spatial_size', 6144) + assert self.pos_dim % 2 == 0 + self.gauss_scale = self.model_config.get('gauss_scale', 1.0) + B = torch.empty((self.num_input, self.pos_dim // 2)).normal_() + B *= self.gauss_scale + self.register_buffer("gauss_B", B) + + def normalize_coordinates(self, coords): + if len(coords.shape) == 2: + return get_normalized_coordinates(coords) + elif len(coords.shape) == 3: + normalized_coords = (coords[:, :, :self.D].float() \ + - float(self.spatial_size) / 2) \ + / (float(self.spatial_size) / 2) + return normalized_coords + else: + raise ValueError("Normalize coordinates saw {}D tensor!".format(len(coords.shape))) + + def forward(self, coords: torch.Tensor, features: torch.Tensor = None): + if self.normalize: + coordinates = self.normalize_coordinates(coords) + else: + coordinates = coords + + coordinates *= 2 * np.pi + freqs = coordinates @ self.gauss_B + if features is not None: + embeddings = torch.cat([freqs.cos(), freqs.sin(), features], dim=-1) + else: + embeddings = torch.cat([freqs.cos(), freqs.sin()], dim=-1) + return embeddings + diff --git a/mlreco/models/experimental/transformers/transformer.py b/mlreco/models/experimental/transformers/transformer.py index b6174add..4f128f49 100644 --- a/mlreco/models/experimental/transformers/transformer.py +++ b/mlreco/models/experimental/transformers/transformer.py @@ -1,6 +1,52 @@ import torch import torch.nn as nn import torch.nn.functional as F +from functools import partial + +class TransformerDecoder(nn.Module): + + def __init__(self, d_model, num_heads, + dim_feedforward=1024, dropout=0.0, normalize_before=False): + super(TransformerDecoder, self).__init__() + + self.num_heads = num_heads + + self.cross_attention = CrossAttentionLayer(d_model, + num_heads, + dropout=dropout, + normalize_before=normalize_before) + self.self_attention = SelfAttentionLayer(d_model, + num_heads, + dropout=dropout, + normalize_before=normalize_before) + self.ffn_layer = FFNLayer(d_model, + dim_feedforward, + dropout=dropout, + normalize_before=normalize_before) + + self.norm = nn.LayerNorm(d_model) + + def forward(self, queries, query_pos, src_pcd, batched_pos_enc, batched_attn): + """ + queries: B, num_queries, d_model + + """ + x = queries.permute((1,0,2)) + memory_mask = batched_attn.repeat_interleave( + self.num_heads, dim=0).permute(0, 2, 1) + pos = batched_pos_enc.permute((1,0,2)) + x = self.cross_attention(x, + src_pcd.permute((1,0,2)), + memory_mask=memory_mask, + memory_key_padding_mask=None, + pos=pos, + query_pos=query_pos.permute((1,0,2))) + x = self.self_attention(x, tgt_mask=None, tgt_key_padding_mask=None, + query_pos=query_pos.permute((1,0,2))) + out_queries = self.ffn_layer(x).permute((1,0,2)) + + return out_queries + class TransformerEncoderLayer(nn.Module): ''' @@ -135,3 +181,268 @@ def forward(self, x): return self.norm(out) +# --------------------------------------------------------------------------- +# From Mask3D/models/mask3d.py by Jonas Schult: +# https://github.com/JonasSchult/Mask3D + +class SelfAttentionLayer(nn.Module): + + def __init__(self, d_model, nhead, dropout=0.0, + activation="relu", normalize_before=False): + super().__init__() + self.self_attn = nn.MultiheadAttention(d_model, nhead, dropout=dropout) + + self.norm = nn.LayerNorm(d_model) + self.dropout = nn.Dropout(dropout) + + self.activation = _get_activation_fn(activation) + self.normalize_before = normalize_before + + self._reset_parameters() + + def _reset_parameters(self): + for p in self.parameters(): + if p.dim() > 1: + nn.init.xavier_uniform_(p) + + def with_pos_embed(self, tensor, pos): + return tensor if pos is None else tensor + pos + + def forward_post(self, tgt, + tgt_mask = None, + tgt_key_padding_mask= None, + query_pos = None): + q = k = self.with_pos_embed(tgt, query_pos) + tgt2 = self.self_attn(q, k, value=tgt, attn_mask=tgt_mask, + key_padding_mask=tgt_key_padding_mask)[0] + tgt = tgt + self.dropout(tgt2) + tgt = self.norm(tgt) + + return tgt + + def forward_pre(self, tgt, + tgt_mask= None, + tgt_key_padding_mask = None, + query_pos = None): + tgt2 = self.norm(tgt) + q = k = self.with_pos_embed(tgt2, query_pos) + tgt2 = self.self_attn(q, k, value=tgt2, attn_mask=tgt_mask, + key_padding_mask=tgt_key_padding_mask)[0] + tgt = tgt + self.dropout(tgt2) + + return tgt + + def forward(self, tgt, + tgt_mask = None, + tgt_key_padding_mask = None, + query_pos = None): + if self.normalize_before: + return self.forward_pre(tgt, tgt_mask, + tgt_key_padding_mask, query_pos) + return self.forward_post(tgt, tgt_mask, + tgt_key_padding_mask, query_pos) + + +class CrossAttentionLayer(nn.Module): + + def __init__(self, d_model, nhead, dropout=0.0, + activation="relu", normalize_before=False): + super().__init__() + self.multihead_attn = nn.MultiheadAttention(d_model, nhead, dropout=dropout) + + self.norm = nn.LayerNorm(d_model) + self.dropout = nn.Dropout(dropout) + + self.activation = _get_activation_fn(activation) + self.normalize_before = normalize_before + + self._reset_parameters() + + def _reset_parameters(self): + for p in self.parameters(): + if p.dim() > 1: + nn.init.xavier_uniform_(p) + + def with_pos_embed(self, tensor, pos): + return tensor if pos is None else tensor + pos + + def forward_post(self, tgt, memory, + memory_mask = None, + memory_key_padding_mask = None, + pos = None, + query_pos = None): + tgt2 = self.multihead_attn(query=self.with_pos_embed(tgt, query_pos), + key=self.with_pos_embed(memory, pos), + value=memory, attn_mask=memory_mask, + key_padding_mask=memory_key_padding_mask)[0] + tgt = tgt + self.dropout(tgt2) + tgt = self.norm(tgt) + + return tgt + + def forward_pre(self, tgt, memory, + memory_mask = None, + memory_key_padding_mask = None, + pos = None, + query_pos = None): + tgt2 = self.norm(tgt) + + tgt2 = self.multihead_attn(query=self.with_pos_embed(tgt2, query_pos), + key=self.with_pos_embed(memory, pos), + value=memory, attn_mask=memory_mask, + key_padding_mask=memory_key_padding_mask)[0] + tgt = tgt + self.dropout(tgt2) + + return tgt + + def forward(self, tgt, memory, + memory_mask = None, + memory_key_padding_mask = None, + pos = None, + query_pos = None): + if self.normalize_before: + return self.forward_pre(tgt, memory, memory_mask, + memory_key_padding_mask, pos, query_pos) + return self.forward_post(tgt, memory, memory_mask, + memory_key_padding_mask, pos, query_pos) + +class FFNLayer(nn.Module): + + def __init__(self, d_model, dim_feedforward=2048, dropout=0.0, + activation="relu", normalize_before=False): + super().__init__() + # Implementation of Feedforward model + self.linear1 = nn.Linear(d_model, dim_feedforward) + self.dropout = nn.Dropout(dropout) + self.linear2 = nn.Linear(dim_feedforward, d_model) + + self.norm = nn.LayerNorm(d_model) + + self.activation = _get_activation_fn(activation) + self.normalize_before = normalize_before + + self._reset_parameters() + + def _reset_parameters(self): + for p in self.parameters(): + if p.dim() > 1: + nn.init.xavier_uniform_(p) + + def with_pos_embed(self, tensor, pos): + return tensor if pos is None else tensor + pos + + def forward_post(self, tgt): + tgt2 = self.linear2(self.dropout(self.activation(self.linear1(tgt)))) + tgt = tgt + self.dropout(tgt2) + tgt = self.norm(tgt) + return tgt + + def forward_pre(self, tgt): + tgt2 = self.norm(tgt) + tgt2 = self.linear2(self.dropout(self.activation(self.linear1(tgt2)))) + tgt = tgt + self.dropout(tgt2) + return tgt + + def forward(self, tgt): + if self.normalize_before: + return self.forward_pre(tgt) + return self.forward_post(tgt) + + +def _get_activation_fn(activation): + """Return an activation function given a string""" + if activation == "relu": + return F.relu + if activation == "gelu": + return F.gelu + if activation == "glu": + return F.glu + raise RuntimeError(F"activation should be relu/gelu, not {activation}.") + + +NORM_DICT = { + # "bn": BatchNormDim1Swap, + "bn1d": nn.BatchNorm1d, + "id": nn.Identity, + "ln": nn.LayerNorm, +} + +ACTIVATION_DICT = { + "relu": nn.ReLU, + "gelu": nn.GELU, + "leakyrelu": partial(nn.LeakyReLU, negative_slope=0.1), +} + +WEIGHT_INIT_DICT = { + "xavier_uniform": nn.init.xavier_uniform_, +} + + +class GenericMLP(nn.Module): + def __init__( + self, + input_dim, + hidden_dims, + output_dim, + norm_fn_name=None, + activation="relu", + use_conv=False, + dropout=None, + hidden_use_bias=False, + output_use_bias=True, + output_use_activation=False, + output_use_norm=False, + weight_init_name=None, + ): + super().__init__() + activation = ACTIVATION_DICT[activation] + norm = None + if norm_fn_name is not None: + norm = NORM_DICT[norm_fn_name] + if norm_fn_name == "ln" and use_conv: + norm = lambda x: nn.GroupNorm(1, x) # easier way to use LayerNorm + + if dropout is not None: + if not isinstance(dropout, list): + dropout = [dropout for _ in range(len(hidden_dims))] + + layers = [] + prev_dim = input_dim + for idx, x in enumerate(hidden_dims): + if use_conv: + layer = nn.Conv1d(prev_dim, x, 1, bias=hidden_use_bias) + else: + layer = nn.Linear(prev_dim, x, bias=hidden_use_bias) + layers.append(layer) + if norm: + layers.append(norm(x)) + layers.append(activation()) + if dropout is not None: + layers.append(nn.Dropout(p=dropout[idx])) + prev_dim = x + if use_conv: + layer = nn.Conv1d(prev_dim, output_dim, 1, bias=output_use_bias) + else: + layer = nn.Linear(prev_dim, output_dim, bias=output_use_bias) + layers.append(layer) + + if output_use_norm: + layers.append(norm(output_dim)) + + if output_use_activation: + layers.append(activation()) + + self.layers = nn.Sequential(*layers) + + if weight_init_name is not None: + self.do_weight_init(weight_init_name) + + def do_weight_init(self, weight_init_name): + func = WEIGHT_INIT_DICT[weight_init_name] + for (_, param) in self.named_parameters(): + if param.dim() > 1: # skips batchnorm/layernorm + func(param) + + def forward(self, x): + output = self.layers(x) + return output \ No newline at end of file diff --git a/mlreco/models/factories.py b/mlreco/models/factories.py index d4484a87..0ae54051 100644 --- a/mlreco/models/factories.py +++ b/mlreco/models/factories.py @@ -19,6 +19,7 @@ def model_dict(): from . import bayes_uresnet from . import vertex + from . import mask3d # Make some models available (not all of them, e.g. PPN is not standalone) models = { @@ -53,7 +54,9 @@ def model_dict(): # Vertex PPN 'vertex_ppn': (vertex.VertexPPNChain, vertex.UResNetVertexLoss), # Vertex Pointnet - 'vertex_pointnet': (vertex.VertexPointNet, vertex.VertexPointNetLoss) + 'vertex_pointnet': (vertex.VertexPointNet, vertex.VertexPointNetLoss), + # Mask3d + 'mask3d': (mask3d.Mask3DModel, mask3d.Mask3dLoss) } return models diff --git a/mlreco/models/mask3d.py b/mlreco/models/mask3d.py new file mode 100644 index 00000000..ca8ad55e --- /dev/null +++ b/mlreco/models/mask3d.py @@ -0,0 +1,218 @@ +import torch +import torch.nn as nn +import numpy as np +import MinkowskiEngine as ME + +from pprint import pprint +from mlreco.models.experimental.cluster.mask3d import Mask3d +from mlreco.models.experimental.cluster.criterion import * +from mlreco.utils.globals import * +from scipy.optimize import linear_sum_assignment +from collections import defaultdict + +class Mask3DModel(nn.Module): + ''' + Transformer-Instance Query based particle clustering + + Configuration + ------------- + skip_classes: list, default [2, 3, 4] + semantic labels for which to skip voxel clustering + (ex. Michel, Delta, and Low Es rarely require neural network clustering) + dimension: int, default 3 + Spatial dimension (2 or 3). + min_points: int, default 0 + If a value > 0 is specified, this will enable the orphans assignment for + any predicted cluster with voxel count < min_points. + ''' + + MODULES = ['mask3d', 'query_module', 'fourier_embeddings', 'transformer_decoder'] + + def __init__(self, cfg, name='mask3d'): + super(Mask3DModel, self).__init__() + self.net = Mask3d(cfg) + self.skip_classes = cfg[name].get('skip_classes') + + def weight_initialization(self): + for m in self.modules(): + if isinstance(m, ME.MinkowskiConvolution): + ME.utils.kaiming_normal_(m.kernel, mode="fan_out", nonlinearity="relu") + + if isinstance(m, ME.MinkowskiBatchNorm): + nn.init.constant_(m.bn.weight, 1) + nn.init.constant_(m.bn.bias, 0) + + def filter_class(self, x): + ''' + Filter classes according to segmentation label. + ''' + mask = ~np.isin(x[:, -1].detach().cpu().numpy(), self.skip_classes) + point_cloud = x[mask] + return point_cloud + + + def forward(self, input): + ''' + + ''' + x = input[0] + point_cloud = self.filter_class(x) + res = self.net(point_cloud) + return res + + +class Mask3dLoss(nn.Module): + """ + Loss function for GraphSpice. + + Configuration + ------------- + name: str, default 'se_lovasz_inter' + Loss function to use. + invert: bool, default True + You want to leave this to True for statistical weighting purpose. + kernel_lossfn: str + edge_loss_cfg: dict + For example + + .. code-block:: yaml + + edge_loss_cfg: + loss_type: 'LogDice' + + eval: bool, default False + Whether we are in inference mode or not. + + .. warning:: + + Currently you need to manually switch ``eval`` to ``True`` + when you want to run the inference, as there is no way (?) + to know from within the loss function whether we are training + or not. + + Output + ------ + To be completed. + + See Also + -------- + MinkGraphSPICE + """ + def __init__(self, cfg, name='mask3d'): + super(Mask3dLoss, self).__init__() + self.model_config = cfg[name] + self.skip_classes = self.model_config.get('skip_classes', [2, 3, 4]) + self.num_queries = self.model_config.get('num_queries', 200) + + + self.weight_class = torch.Tensor([0.1, 5.0]) + self.xentropy = nn.CrossEntropyLoss(weight=self.weight_class, reduction='mean') + self.dice_loss_mode = self.model_config.get('dice_loss_mode', 'log_dice') + + self.loss_fn = LinearSumAssignmentLoss(mode=self.dice_loss_mode) + + def filter_class(self, cluster_label): + ''' + Filter classes according to segmentation label. + ''' + mask = ~np.isin(cluster_label[0][:, -1].cpu().numpy(), self.skip_classes) + clabel = [cluster_label[0][mask]] + return clabel + + def compute_layerwise_loss(self, aux_masks, aux_classes, clabel): + + batch_col = clabel[0][:, BATCH_COL].int() + num_batches = batch_col.unique().shape[0] + + loss = defaultdict(list) + loss_class = defaultdict(list) + + for bidx in range(num_batches): + for layer, mask_layer in enumerate(aux_masks): + batch_mask = batch_col == bidx + + # Compute instance mask loss + targets = get_instance_masks(clabel[0][batch_mask][:, GROUP_COL].long()).float() + loss_batch, acc_batch, indices = self.loss_fn(mask_layer[batch_mask], targets) + loss[bidx].append(loss_batch) + + # Compute instance class loss + logits_batch = aux_classes[layer][bidx] + targets_class = torch.zeros(logits_batch.shape[0]).to( + dtype=torch.long, device=logits_batch.device) + targets_class[indices[0]] = 1 + loss_class_batch = self.xentropy(logits_batch, targets_class) + loss_class[bidx].append(loss_class_batch) + + return loss, loss_class + + + def forward(self, result, cluster_label): + ''' + + ''' + clabel = self.filter_class(cluster_label) + + aux_masks = result['aux_masks'][0] + aux_classes = result['aux_classes'][0] + + batch_col = clabel[0][:, BATCH_COL].int() + num_batches = batch_col.unique().shape[0] + + loss, acc = defaultdict(list), defaultdict(list) + loss_class = defaultdict(list) + + loss_layer, loss_class_layer = self.compute_layerwise_loss(aux_masks, + aux_classes, + clabel) + + loss.update(loss_layer) + loss_class.update(loss_class_layer) + + acc_class = 0 + + for bidx in range(num_batches): + batch_mask = batch_col == bidx + + output_mask = result['pred_masks'][0][batch_mask] + output_class = result['pred_logits'][0][bidx] + + targets = get_instance_masks(clabel[0][batch_mask][:, GROUP_COL].long()).float() + + loss_batch, acc_batch, indices = self.loss_fn(output_mask, targets) + loss[bidx].append(loss_batch) + acc[bidx].append(acc_batch) + + # Compute instance class loss + targets_class = torch.zeros(output_class.shape[0]).to( + dtype=torch.long, device=output_class.device) + targets_class[indices[0]] = 1 + loss_class_batch = self.xentropy(output_class, targets_class) + loss_class[bidx].append(loss_class_batch) + + with torch.no_grad(): + pred = torch.argmax(output_class, dim=1) + obj_acc = (pred == targets_class).sum() / pred.shape[0] + acc_class += obj_acc / num_batches + + loss = [sum(val) / len(val) for val in loss.values()] + acc = [sum(val) / len(val) for val in acc.values()] + loss_class = [sum(val) / len(val) for val in loss_class.values()] + + loss = sum(loss) / len(loss) + loss_class = sum(loss_class) / len(loss_class) + acc = sum(acc) / len(acc) + + print(loss, loss_class) + + res = { + 'loss': loss + loss_class, + 'accuracy': acc, + 'loss_class': float(loss_class), + 'loss_mask': float(loss), + 'acc_class': float(acc_class) + } + + pprint(res) + + return res From 91e222dc565118c99eb2d4390979ebec8e9f556c Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 12 Apr 2023 15:17:35 -0700 Subject: [PATCH 135/180] Remove experimental model from main mlreco/models --- .../models/experimental/cluster/criterion.py | 111 ++++++++-- mlreco/models/experimental/cluster/mask3d.py | 18 +- .../cluster/mask3d_model.py} | 72 ++++--- mlreco/models/experimental/cluster/matcher.py | 198 ------------------ .../experimental/cluster/transformer_spice.py | 20 ++ .../transformers/positional_encodings.py | 19 +- mlreco/models/factories.py | 5 +- 7 files changed, 176 insertions(+), 267 deletions(-) rename mlreco/models/{mask3d.py => experimental/cluster/mask3d_model.py} (72%) delete mode 100644 mlreco/models/experimental/cluster/matcher.py create mode 100644 mlreco/models/experimental/cluster/transformer_spice.py diff --git a/mlreco/models/experimental/cluster/criterion.py b/mlreco/models/experimental/cluster/criterion.py index 3c815cf4..3d9013c1 100644 --- a/mlreco/models/experimental/cluster/criterion.py +++ b/mlreco/models/experimental/cluster/criterion.py @@ -11,7 +11,7 @@ class LinearSumAssignmentLoss(nn.Module): - def __init__(self, weight_dice=1.0, weight_ce=1.0, mode='dice'): + def __init__(self, weight_dice=2.0, weight_ce=5.0, mode='dice'): super(LinearSumAssignmentLoss, self).__init__() self.weight_dice = weight_dice self.weight_ce = weight_ce @@ -35,20 +35,55 @@ def forward(self, masks, targets): cost_matrix = self.weight_dice * dice_loss + self.weight_ce * ce_loss indices = linear_sum_assignment(cost_matrix.detach().cpu()) - if self.mode == 'log_dice': - dice_loss = batch_log_dice_loss(masks.T[indices[0]], targets.T[indices[1]]) - elif self.mode == 'dice': - dice_loss = batch_dice_loss(masks.T[indices[0]], targets.T[indices[1]]) - elif self.mode == 'lovasz': - dice_loss = self.lovasz(masks[:, indices[0]], targets[:, indices[1]]) - else: - raise ValueError(f"LSA loss mode {self.mode} is not supported!") - ce_loss = batch_sigmoid_ce_loss(masks.T[indices[0]], targets.T[indices[1]]) - cost_matrix = self.weight_dice * dice_loss + self.weight_ce * ce_loss - loss = torch.diag(cost_matrix).mean() + dice_loss = dice_loss_flat(masks[:, indices[0]], targets[:, indices[1]]) + # if self.mode == 'log_dice': + # dice_loss = batch_log_dice_loss(masks.T[indices[0]], targets.T[indices[1]]) + # elif self.mode == 'dice': + # dice_loss = batch_dice_loss(masks.T[indices[0]], targets.T[indices[1]]) + # elif self.mode == 'lovasz': + # dice_loss = self.lovasz(masks[:, indices[0]], targets[:, indices[1]]) + # else: + # raise ValueError(f"LSA loss mode {self.mode} is not supported!") + ce_loss = sigmoid_ce_loss(masks.T[indices[0]], targets.T[indices[1]]) + loss = self.weight_dice * dice_loss + self.weight_ce * ce_loss acc = self.compute_accuracy(masks, targets, indices) return loss, acc, indices + + +class CEDiceLoss(nn.Module): + + def __init__(self, weight_dice=1.0, weight_ce=1.0, mode='dice'): + super(CEDiceLoss, self).__init__() + self.weight_dice = weight_dice + self.weight_ce = weight_ce + self.lovasz = LovaszHingeLoss() + self.mode = mode + print(f"Setting LinearSumAssignment loss to '{self.mode}'") + + def compute_accuracy(self, masks, targets): + with torch.no_grad(): + valid_masks = masks > 0 + valid_targets = targets > 0.5 + iou = iou_batch(valid_masks, valid_targets, eps=1e-6) + return float(iou) + + def forward(self, masks, targets): + + dice_loss = self.lovasz(masks, targets) + # if self.mode == 'log_dice': + # dice_loss = batch_log_dice_loss(masks.T[indices[0]], targets.T[indices[1]]) + # elif self.mode == 'dice': + # dice_loss = batch_dice_loss(masks.T[indices[0]], targets.T[indices[1]]) + # elif self.mode == 'lovasz': + # dice_loss = self.lovasz(masks[:, indices[0]], targets[:, indices[1]]) + # else: + # raise ValueError(f"LSA loss mode {self.mode} is not supported!") + ce_loss = sigmoid_ce_loss(masks.T, targets.T) + loss = self.weight_dice * dice_loss + self.weight_ce * ce_loss + acc = self.compute_accuracy(masks, targets) + + return loss, acc @torch.jit.script @@ -73,6 +108,20 @@ def get_instance_masks(cluster_label : torch.LongTensor, return instance_masks +@torch.jit.script +def get_instance_masks_from_queries(cluster_label: torch.LongTensor, + query_index: torch.Tensor): + max_num_instances = query_index.shape[0] + instance_masks = torch.zeros((cluster_label.shape[0], + max_num_instances)).to(device=cluster_label.device, + dtype=torch.bool) + for i, qidx in enumerate(query_index): + instance_masks[:, i] = (cluster_label == cluster_label[qidx]).to(torch.bool) + + return instance_masks + + + def dice_loss(logits, targets): """ @@ -145,14 +194,36 @@ def batch_sigmoid_ce_loss(inputs: torch.Tensor, targets: torch.Tensor): return loss / hw - -def batch_ce(inputs, targets): +@torch.jit.script +def sigmoid_ce_loss( + inputs: torch.Tensor, + targets: torch.Tensor + ): """ - Only for testing purposes (brute force calculation) + Args: + inputs: A float tensor of arbitrary shape. + The predictions for each example. + targets: A float tensor with the same shape as inputs. Stores the binary + classification label for each element in inputs + (0 for the negative class and 1 for the positive class). + Returns: + Loss tensor """ num_masks = inputs.shape[0] - out = torch.zeros((num_masks, num_masks)) - for i in range(num_masks): - for j in range(num_masks): - out[i,j] = F.binary_cross_entropy_with_logits(inputs[i], targets[j], reduction='mean') - return out \ No newline at end of file + loss = F.binary_cross_entropy_with_logits(inputs, targets, reduction="none") + return loss.mean(1).sum() / num_masks + +@torch.jit.script +def dice_loss_flat(logits, targets): + """ + + Parameters + ---------- + logits: (N x num_queries) + targets: (N x num_queries) + """ + num_masks = logits.shape[1] + scores = torch.sigmoid(logits) + numerator = (2 * scores * targets).sum(dim=0) + denominator = scores.sum(dim=0) + targets.sum(dim=0) + return (1 - (numerator + 1) / (denominator + 1)).sum() / num_masks \ No newline at end of file diff --git a/mlreco/models/experimental/cluster/mask3d.py b/mlreco/models/experimental/cluster/mask3d.py index e198f77e..f0bd3175 100644 --- a/mlreco/models/experimental/cluster/mask3d.py +++ b/mlreco/models/experimental/cluster/mask3d.py @@ -84,7 +84,7 @@ def forward(self, x, uresnet_features): else: raise ValueError("Query type {} is not supported!".format(self.query_type)) - return queries.permute((0, 2, 1)), query_pos.permute((0, 2, 1)) + return queries.permute((0, 2, 1)), query_pos.permute((0, 2, 1)), fps_idx class Mask3d(nn.Module): @@ -109,6 +109,11 @@ def __init__(self, cfg, name='mask3d'): self.num_heads = self.model_config.get('num_heads', 8) self.dropout = self.model_config.get('dropout', 0.0) self.normalize_before = self.model_config.get('normalize_before', False) + + device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") + + self.spatial_size = self.model_config.get('spatial_size', [2753, 1056, 5966]) + self.spatial_size = torch.Tensor(self.spatial_size).float().to(device) self.depth = self.model_config.get('depth', 5) self.mask_head = ME.MinkowskiConvolution(num_features, self.mask_dim, @@ -155,6 +160,7 @@ def __init__(self, cfg, name='mask3d'): normalize_before=self.normalize_before)) self.sample_sizes = [200, 800, 3200, 12800, 51200] + self.adc_to_mev = 1./350 num_params = sum(p.numel() for p in self.parameters()) print(f"Number of Total Parameters = {num_params}") @@ -289,14 +295,15 @@ def forward(self, point_cloud): coords = point_cloud[:, COORD_COLS].int() feats = point_cloud[:, VALUE_COL].float().view(-1, 1) - normed_coords = get_normalized_coordinates(coords) - features = torch.cat([normed_coords, feats], dim=1) + normed_coords = get_normalized_coordinates(coords, self.spatial_size) + normed_feats = feats * self.adc_to_mev + features = torch.cat([normed_coords, normed_feats], dim=1) x = ME.SparseTensor(coordinates=point_cloud[:, :VALUE_COL].int(), features=features) encoderOutput = self.encoder(x) decoderOutput = self.decoder(encoderOutput['finalTensor'], encoderOutput['encoderTensors']) - queries, query_pos = self.query_module(x, decoderOutput[-1]) + queries, query_pos, query_index = self.query_module(x, decoderOutput[-1]) total_num_pooling = len(decoderOutput)-1 @@ -347,7 +354,8 @@ def forward(self, point_cloud): 'pred_masks' : [output_mask.F], 'pred_logits': [output_class], 'aux_masks': [predictions_mask], - 'aux_classes': [predictions_class] + 'aux_classes': [predictions_class], + 'query_index': [query_index] } return res \ No newline at end of file diff --git a/mlreco/models/mask3d.py b/mlreco/models/experimental/cluster/mask3d_model.py similarity index 72% rename from mlreco/models/mask3d.py rename to mlreco/models/experimental/cluster/mask3d_model.py index ca8ad55e..d547a4e7 100644 --- a/mlreco/models/mask3d.py +++ b/mlreco/models/experimental/cluster/mask3d_model.py @@ -109,7 +109,8 @@ def __init__(self, cfg, name='mask3d'): self.xentropy = nn.CrossEntropyLoss(weight=self.weight_class, reduction='mean') self.dice_loss_mode = self.model_config.get('dice_loss_mode', 'log_dice') - self.loss_fn = LinearSumAssignmentLoss(mode=self.dice_loss_mode) + # self.loss_fn = LinearSumAssignmentLoss(mode=self.dice_loss_mode) + self.loss_fn = CEDiceLoss(mode=self.dice_loss_mode) def filter_class(self, cluster_label): ''' @@ -119,7 +120,7 @@ def filter_class(self, cluster_label): clabel = [cluster_label[0][mask]] return clabel - def compute_layerwise_loss(self, aux_masks, aux_classes, clabel): + def compute_layerwise_loss(self, aux_masks, aux_classes, clabel, query_index): batch_col = clabel[0][:, BATCH_COL].int() num_batches = batch_col.unique().shape[0] @@ -130,19 +131,20 @@ def compute_layerwise_loss(self, aux_masks, aux_classes, clabel): for bidx in range(num_batches): for layer, mask_layer in enumerate(aux_masks): batch_mask = batch_col == bidx - + labels = clabel[0][batch_mask][:, GROUP_COL].long() + query_idx_batch = query_index[bidx] # Compute instance mask loss - targets = get_instance_masks(clabel[0][batch_mask][:, GROUP_COL].long()).float() - loss_batch, acc_batch, indices = self.loss_fn(mask_layer[batch_mask], targets) + targets = get_instance_masks_from_queries(labels, query_idx_batch).float() + loss_batch, acc_batch = self.loss_fn(mask_layer[batch_mask], targets) loss[bidx].append(loss_batch) # Compute instance class loss - logits_batch = aux_classes[layer][bidx] - targets_class = torch.zeros(logits_batch.shape[0]).to( - dtype=torch.long, device=logits_batch.device) - targets_class[indices[0]] = 1 - loss_class_batch = self.xentropy(logits_batch, targets_class) - loss_class[bidx].append(loss_class_batch) + # logits_batch = aux_classes[layer][bidx] + # targets_class = torch.zeros(logits_batch.shape[0]).to( + # dtype=torch.long, device=logits_batch.device) + # targets_class[indices[0]] = 1 + # loss_class_batch = self.xentropy(logits_batch, targets_class) + # loss_class[bidx].append(loss_class_batch) return loss, loss_class @@ -155,6 +157,7 @@ def forward(self, result, cluster_label): aux_masks = result['aux_masks'][0] aux_classes = result['aux_classes'][0] + query_index = result['query_index'][0] batch_col = clabel[0][:, BATCH_COL].int() num_batches = batch_col.unique().shape[0] @@ -164,10 +167,11 @@ def forward(self, result, cluster_label): loss_layer, loss_class_layer = self.compute_layerwise_loss(aux_masks, aux_classes, - clabel) + clabel, + query_index) loss.update(loss_layer) - loss_class.update(loss_class_layer) + # loss_class.update(loss_class_layer) acc_class = 0 @@ -177,42 +181,44 @@ def forward(self, result, cluster_label): output_mask = result['pred_masks'][0][batch_mask] output_class = result['pred_logits'][0][bidx] - targets = get_instance_masks(clabel[0][batch_mask][:, GROUP_COL].long()).float() + labels = clabel[0][batch_mask][:, GROUP_COL].long() + + # targets = get_instance_masks(labels).float() + query_idx_batch = query_index[bidx] + targets = get_instance_masks_from_queries(labels, query_idx_batch).float() + + # print(output_mask, targets) - loss_batch, acc_batch, indices = self.loss_fn(output_mask, targets) + loss_batch, acc_batch = self.loss_fn(output_mask, targets) loss[bidx].append(loss_batch) acc[bidx].append(acc_batch) # Compute instance class loss - targets_class = torch.zeros(output_class.shape[0]).to( - dtype=torch.long, device=output_class.device) - targets_class[indices[0]] = 1 - loss_class_batch = self.xentropy(output_class, targets_class) - loss_class[bidx].append(loss_class_batch) + # targets_class = torch.zeros(output_class.shape[0]).to( + # dtype=torch.long, device=output_class.device) + # targets_class[indices[0]] = 1 + # loss_class_batch = self.xentropy(output_class, targets_class) + # loss_class[bidx].append(loss_class_batch) - with torch.no_grad(): - pred = torch.argmax(output_class, dim=1) - obj_acc = (pred == targets_class).sum() / pred.shape[0] - acc_class += obj_acc / num_batches + # with torch.no_grad(): + # pred = torch.argmax(output_class, dim=1) + # obj_acc = (pred == targets_class).sum() / pred.shape[0] + # acc_class += obj_acc / num_batches loss = [sum(val) / len(val) for val in loss.values()] acc = [sum(val) / len(val) for val in acc.values()] - loss_class = [sum(val) / len(val) for val in loss_class.values()] + # loss_class = [sum(val) / len(val) for val in loss_class.values()] loss = sum(loss) / len(loss) - loss_class = sum(loss_class) / len(loss_class) + # loss_class = sum(loss_class) / len(loss_class) acc = sum(acc) / len(acc) - - print(loss, loss_class) res = { - 'loss': loss + loss_class, + 'loss': loss, 'accuracy': acc, - 'loss_class': float(loss_class), + # 'loss_class': float(loss_class), 'loss_mask': float(loss), - 'acc_class': float(acc_class) + # 'acc_class': float(acc_class) } - pprint(res) - return res diff --git a/mlreco/models/experimental/cluster/matcher.py b/mlreco/models/experimental/cluster/matcher.py deleted file mode 100644 index 2fb01d17..00000000 --- a/mlreco/models/experimental/cluster/matcher.py +++ /dev/null @@ -1,198 +0,0 @@ -# Copyright (c) Facebook, Inc. and its affiliates. -# Modified by Bowen Cheng from https://github.com/facebookresearch/detr/blob/master/models/matcher.py -""" -Modules to compute the matching cost and solve the corresponding LSAP. -""" -import torch -import torch.nn.functional as F -from scipy.optimize import linear_sum_assignment -from torch import nn -from torch.cuda.amp import autocast - - -def batch_dice_loss(inputs: torch.Tensor, targets: torch.Tensor): - """ - Compute the DICE loss, similar to generalized IOU for masks - Args: - inputs: A float tensor of arbitrary shape. - The predictions for each example. - targets: A float tensor with the same shape as inputs. Stores the binary - classification label for each element in inputs - (0 for the negative class and 1 for the positive class). - """ - inputs = inputs.sigmoid() - inputs = inputs.flatten(1) - numerator = 2 * torch.einsum("nc,mc->nm", inputs, targets) - denominator = inputs.sum(-1)[:, None] + targets.sum(-1)[None, :] - loss = 1 - (numerator + 1) / (denominator + 1) - return loss - - -batch_dice_loss_jit = torch.jit.script( - batch_dice_loss -) # type: torch.jit.ScriptModule - - -def batch_sigmoid_ce_loss(inputs: torch.Tensor, targets: torch.Tensor): - """ - Args: - inputs: A float tensor of arbitrary shape. - The predictions for each example. - targets: A float tensor with the same shape as inputs. Stores the binary - classification label for each element in inputs - (0 for the negative class and 1 for the positive class). - Returns: - Loss tensor - """ - hw = inputs.shape[1] - - pos = F.binary_cross_entropy_with_logits( - inputs, torch.ones_like(inputs), reduction="none" - ) - neg = F.binary_cross_entropy_with_logits( - inputs, torch.zeros_like(inputs), reduction="none" - ) - - loss = torch.einsum("nc,mc->nm", pos, targets) + torch.einsum( - "nc,mc->nm", neg, (1 - targets) - ) - - return loss / hw - - -batch_sigmoid_ce_loss_jit = torch.jit.script( - batch_sigmoid_ce_loss -) # type: torch.jit.ScriptModule - - -class HungarianMatcher(nn.Module): - """This class computes an assignment between the targets and the predictions of the network - - For efficiency reasons, the targets don't include the no_object. Because of this, in general, - there are more predictions than targets. In this case, we do a 1-to-1 matching of the best predictions, - while the others are un-matched (and thus treated as non-objects). - """ - - def __init__(self, cost_class: float = 1, cost_mask: float = 1, cost_dice: float = 1, num_points: int = 0): - """Creates the matcher - - Params: - cost_class: This is the relative weight of the classification error in the matching cost - cost_mask: This is the relative weight of the focal loss of the binary mask in the matching cost - cost_dice: This is the relative weight of the dice loss of the binary mask in the matching cost - """ - super().__init__() - self.cost_class = cost_class - self.cost_mask = cost_mask - self.cost_dice = cost_dice - - assert cost_class != 0 or cost_mask != 0 or cost_dice != 0, "all costs cant be 0" - - self.num_points = num_points - - @torch.no_grad() - def memory_efficient_forward(self, outputs, targets, mask_type): - """More memory-friendly matching""" - bs, num_queries = outputs["pred_logits"].shape[:2] - - indices = [] - - # Iterate through batch size - for b in range(bs): - - out_prob = outputs["pred_logits"][b].softmax(-1) # [num_queries, num_classes] - tgt_ids = targets[b]["labels"].clone() - - # Compute the classification cost. Contrary to the loss, we don't use the NLL, - # but approximate it in 1 - proba[target class]. - # The 1 is a constant that doesn't change the matching, it can be ommitted. - filter_ignore = (tgt_ids == 253) - tgt_ids[filter_ignore] = 0 - cost_class = -out_prob[:, tgt_ids] - cost_class[:, filter_ignore] = -1. # for ignore classes pretend perfect match ;) TODO better worst class match? - - out_mask = outputs['pred_masks'][b].T # [num_queries, H_pred, W_pred] - # gt masks are already padded when preparing target - tgt_mask = targets[b][mask_type].to(out_mask) - - if self.num_points != -1: - point_idx = torch.randperm(tgt_mask.shape[1], - device=tgt_mask.device)[:int(self.num_points*tgt_mask.shape[1])] - #point_idx = torch.randint(0, tgt_mask.shape[1], size=(self.num_points,), device=tgt_mask.device) - else: - # sample all points - point_idx = torch.arange(tgt_mask.shape[1], device=tgt_mask.device) - - # out_mask = out_mask[:, None] - # tgt_mask = tgt_mask[:, None] - # all masks share the same set of points for efficient matching! - # point_coords = torch.rand(1, self.num_points, 2, device=out_mask.device) - # get gt labels - # tgt_mask = point_sample( - # tgt_mask, - # point_coords.repeat(tgt_mask.shape[0], 1, 1), - # align_corners=False, - # ).squeeze(1) - - # out_mask = point_sample( - # out_mask, - # point_coords.repeat(out_mask.shape[0], 1, 1), - # align_corners=False, - # ).squeeze(1) - - with autocast(enabled=False): - out_mask = out_mask.float() - tgt_mask = tgt_mask.float() - # Compute the focal loss between masks - cost_mask = batch_sigmoid_ce_loss_jit(out_mask[:, point_idx], tgt_mask[:, point_idx]) - - # Compute the dice loss betwen masks - cost_dice = batch_dice_loss_jit(out_mask[:, point_idx], tgt_mask[:, point_idx]) - - # Final cost matrix - C = ( - self.cost_mask * cost_mask - + self.cost_class * cost_class - + self.cost_dice * cost_dice - ) - C = C.reshape(num_queries, -1).cpu() - - indices.append(linear_sum_assignment(C)) - - return [ - (torch.as_tensor(i, dtype=torch.int64), torch.as_tensor(j, dtype=torch.int64)) - for i, j in indices - ] - - @torch.no_grad() - def forward(self, outputs, targets, mask_type): - """Performs the matching - - Params: - outputs: This is a dict that contains at least these entries: - "pred_logits": Tensor of dim [batch_size, num_queries, num_classes] with the classification logits - "pred_masks": Tensor of dim [batch_size, num_queries, H_pred, W_pred] with the predicted masks - - targets: This is a list of targets (len(targets) = batch_size), where each target is a dict containing: - "labels": Tensor of dim [num_target_boxes] (where num_target_boxes is the number of ground-truth - objects in the target) containing the class labels - "masks": Tensor of dim [num_target_boxes, H_gt, W_gt] containing the target masks - - Returns: - A list of size batch_size, containing tuples of (index_i, index_j) where: - - index_i is the indices of the selected predictions (in order) - - index_j is the indices of the corresponding selected targets (in order) - For each batch element, it holds: - len(index_i) = len(index_j) = min(num_queries, num_target_boxes) - """ - return self.memory_efficient_forward(outputs, targets, mask_type) - - def __repr__(self, _repr_indent=4): - head = "Matcher " + self.__class__.__name__ - body = [ - "cost_class: {}".format(self.cost_class), - "cost_mask: {}".format(self.cost_mask), - "cost_dice: {}".format(self.cost_dice), - ] - lines = [head] + [" " * _repr_indent + line for line in body] - return "\n".join(lines) diff --git a/mlreco/models/experimental/cluster/transformer_spice.py b/mlreco/models/experimental/cluster/transformer_spice.py new file mode 100644 index 00000000..187291e7 --- /dev/null +++ b/mlreco/models/experimental/cluster/transformer_spice.py @@ -0,0 +1,20 @@ +import torch +import torch.nn as nn + +import MinkowskiEngine as ME +import MinkowskiEngine.MinkowskiOps as me + +from mlreco.models.layers.common.uresnet_layers import UResNetDecoder, UResNetEncoder +from mlreco.models.experimental.transformers.positional_encodings import FourierEmbeddings +from mlreco.models.experimental.cluster.pointnet2.pointnet2_utils import furthest_point_sample +from mlreco.models.experimental.transformers.positional_encodings import get_normalized_coordinates +from mlreco.models.experimental.transformers.transformer import TransformerDecoder, GenericMLP +from torch_geometric.nn import MLP + + +class TransformerSPICE(nn.Module): + + def __init__(self, cfg, name='transformer_spice'): + super(TransformerSPICE, self).__init__() + + self.model_config = cfg[name] \ No newline at end of file diff --git a/mlreco/models/experimental/transformers/positional_encodings.py b/mlreco/models/experimental/transformers/positional_encodings.py index e4ce7408..acf7094d 100644 --- a/mlreco/models/experimental/transformers/positional_encodings.py +++ b/mlreco/models/experimental/transformers/positional_encodings.py @@ -6,10 +6,10 @@ '''Adapted from https://github.com/JonasSchult/Mask3D with modification.''' -def get_normalized_coordinates(coords, D=3, spatial_size=6144): +def get_normalized_coordinates(coords, spatial_size): assert len(coords.shape) == 2 - normalized_coords = (coords[:, :D].float() - float(spatial_size) / 2) \ - / (float(spatial_size) / 2) + normalized_coords = (coords[:, :3].float() - spatial_size / 2) \ + / (spatial_size / 2) return normalized_coords class FourierEmbeddings(nn.Module): @@ -22,7 +22,12 @@ def __init__(self, cfg, name='fourier_embeddings'): self.num_input = self.model_config.get('num_input_features', 3) self.pos_dim = self.model_config.get('positional_encoding_dim', 32) self.normalize = self.model_config.get('normalize_coordinates', False) - self.spatial_size = self.model_config.get('spatial_size', 6144) + + device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") + + self.spatial_size = self.model_config.get('spatial_size', [2753, 1056, 5966]) + self.spatial_size = torch.Tensor(self.spatial_size).float().to(device) + assert self.pos_dim % 2 == 0 self.gauss_scale = self.model_config.get('gauss_scale', 1.0) B = torch.empty((self.num_input, self.pos_dim // 2)).normal_() @@ -31,11 +36,11 @@ def __init__(self, cfg, name='fourier_embeddings'): def normalize_coordinates(self, coords): if len(coords.shape) == 2: - return get_normalized_coordinates(coords) + return get_normalized_coordinates(coords, spatial_size=self.spatial_size) elif len(coords.shape) == 3: normalized_coords = (coords[:, :, :self.D].float() \ - - float(self.spatial_size) / 2) \ - / (float(self.spatial_size) / 2) + - self.spatial_size / 2) \ + / (self.spatial_size / 2) return normalized_coords else: raise ValueError("Normalize coordinates saw {}D tensor!".format(len(coords.shape))) diff --git a/mlreco/models/factories.py b/mlreco/models/factories.py index 0ae54051..d4484a87 100644 --- a/mlreco/models/factories.py +++ b/mlreco/models/factories.py @@ -19,7 +19,6 @@ def model_dict(): from . import bayes_uresnet from . import vertex - from . import mask3d # Make some models available (not all of them, e.g. PPN is not standalone) models = { @@ -54,9 +53,7 @@ def model_dict(): # Vertex PPN 'vertex_ppn': (vertex.VertexPPNChain, vertex.UResNetVertexLoss), # Vertex Pointnet - 'vertex_pointnet': (vertex.VertexPointNet, vertex.VertexPointNetLoss), - # Mask3d - 'mask3d': (mask3d.Mask3DModel, mask3d.Mask3dLoss) + 'vertex_pointnet': (vertex.VertexPointNet, vertex.VertexPointNetLoss) } return models From 9434cd58f97944759bbe426f6e27937828687710 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 19 Apr 2023 13:50:25 -0700 Subject: [PATCH 136/180] Added profiling to analysis tools, along with other fixes --- README.md | 11 +- analysis/classes/Interaction.py | 9 + analysis/classes/builders.py | 109 ++++++++++- analysis/classes/data.py | 6 + analysis/classes/evaluator.py | 261 ++++++++++++++----------- analysis/manager.py | 198 +++++++++++++++---- analysis/post_processing/common.py | 30 ++- analysis/producers/scripts/template.py | 3 - contributing.md | 26 +-- mlreco/iotools/README.md | 4 +- 10 files changed, 458 insertions(+), 199 deletions(-) create mode 100644 analysis/classes/data.py diff --git a/README.md b/README.md index ca485a0e..9c740563 100644 --- a/README.md +++ b/README.md @@ -101,15 +101,7 @@ print(df.columns.values) ``` ### Recording network output or running analysis -The `post_processing` configuration block allows you to run scripts on input data and/or network outputs. -It also supports storing your scripts output in a CSV file for offline analysis. - -```yaml -post_processing: - script_compute_something: - parameter1: True -``` -See the [postprocessing](./mlreco/post_processing/README.md) instructions for more information. +We use [LArTPC MLReco3D Analysis Tools](./analysis/README.md) for all inference and high-level analysis related work. ## Repository Structure * `bin` contains very simple scripts that run the training/inference functions. @@ -117,6 +109,7 @@ See the [postprocessing](./mlreco/post_processing/README.md) instructions for mo * `docs` Documentation (in progress) * `mlreco` the main code lives there! * `test` some testing using Pytest +* `analysis`: [LArTPC MLReco3D Analysis Tools](./analysis/README.md), a pure python interface for inference, high-level analysis, and visualization using the full chain. Please consult the README of each folder respectively for more information. diff --git a/analysis/classes/Interaction.py b/analysis/classes/Interaction.py index 91108dd6..86404049 100644 --- a/analysis/classes/Interaction.py +++ b/analysis/classes/Interaction.py @@ -74,13 +74,22 @@ def __init__(self, interaction_id: int, particles : OrderedDict, vertex=None, nu @property def particles(self): + """ + List of objects that constitute this interaction. + """ return list(self._particles.values()) def check_particle_input(self, x): + """ + Consistency check for particle interaction id and self.id + """ assert isinstance(x, Particle) assert x.interaction_id == self.id def update_info(self): + """ + Method for updating basic interaction particle count information. + """ self.particle_ids = list(self._particles.keys()) self.particle_counts = Counter({ self.pid_keys[i] : 0 for i in list(self.pid_keys.keys())}) self.particle_counts.update([self.pid_keys[p.pid] for p in self._particles.values()]) diff --git a/analysis/classes/builders.py b/analysis/classes/builders.py index 6500159d..1c52b4db 100644 --- a/analysis/classes/builders.py +++ b/analysis/classes/builders.py @@ -79,8 +79,25 @@ def _build_reco(self, entry, data: dict, result: dict): class ParticleBuilder(DataBuilder): - """ - Eats data, result and makes List of Particles per image. + """Builder for constructing Particle and TruthParticle instances + from full chain output dicts. + + Required result keys: + + reco: + - input_rescaled + - particle_clusts + - particle_seg + - particle_start_points + - particle_end_points + - particle_group_pred + - particle_node_pred_type + - particle_node_pred_vtx + truth: + - cluster_label + - cluster_label_adapted + - particles_asis + - input_rescaled """ def __init__(self, builder_cfg={}): self.cfg = builder_cfg @@ -89,7 +106,13 @@ def _build_reco(self, entry: int, data: dict, result: dict) -> List[Particle]: - + """ + Returns + ------- + out : List[Particle] + list of reco Particle instances of length equal to the + batch size. + """ out = [] # Essential Information @@ -141,6 +164,13 @@ def _build_true(self, entry: int, data: dict, result: dict) -> List[TruthParticle]: + """ + Returns + ------- + out : List[TruthParticle] + list of true TruthParticle instances of length equal to the + batch size. + """ out = [] @@ -237,7 +267,17 @@ def _build_true(self, class InteractionBuilder(DataBuilder): - + """Builder for constructing Interaction and TruthInteraction instances. + + Required result keys: + + reco: + - Particles + truth: + - TruthParticles + - cluster_label + - neutrino_asis (optional) + """ def __init__(self, builder_cfg={}): self.cfg = builder_cfg @@ -265,6 +305,10 @@ def build_true_using_particles(self, entry, data, particles): return out def decorate_true_interactions(self, entry, data, interactions): + """ + Helper function for attaching additional information to + TruthInteraction instances. + """ vertices = self.get_true_vertices(entry, data) for ia in interactions: if ia.id in vertices: @@ -291,6 +335,9 @@ def decorate_true_interactions(self, entry, data, interactions): return interactions def get_true_vertices(self, entry, data: dict): + """ + Helper function for retrieving true vertex information. + """ out = {} inter_idxs = np.unique( data['cluster_label'][entry][:, INTER_COL].astype(int)) @@ -310,7 +357,26 @@ def get_true_vertices(self, entry, data: dict): class FragmentBuilder(DataBuilder): - + """Builder for constructing Particle and TruthParticle instances + from full chain output dicts. + + Required result keys: + + reco: + - input_rescaled + - fragment_clusts + - fragment_seg + - shower_fragment_start_points + - track_fragment_start_points + - track_fragment_end_points + - shower_fragment_group_pred + - track_fragment_group_pred + - shower_fragment_node_pred + truth: + - cluster_label + - cluster_label_adapted + - input_rescaled + """ def __init__(self, builder_cfg={}): self.cfg = builder_cfg self.allow_nodes = self.cfg.get('allow_nodes', [0,2,3]) @@ -501,6 +567,26 @@ def handle_empty_true_particles(labels_noghost, p, entry, verbose=False): + """ + Function for handling true larcv::Particle instances with valid + true nonghost voxels but with no predicted nonghost voxels. + + Parameters + ---------- + labels_noghost: np.ndarray + Label information for true nonghost coordinates + mask_noghost: np.ndarray + True nonghost mask for this particle. + p: larcv::Particle + larcv::Particle object from particles_asis, containing truth + information for this particle + entry: int + Image ID of this particle (for consistent TruthParticle attributes) + + Returns + ------- + particle: TruthParticle + """ pid = int(p.id()) pdg = PDG_TO_PID.get(p.pdg_code(), -1) is_primary = p.group_id() == p.parent_id() @@ -551,6 +637,19 @@ def handle_empty_true_particles(labels_noghost, def get_true_particle_labels(labels, mask, pid=-1, verbose=False): + """ + Helper function for fetching true particle labels from + voxel label array. + + Parameters + ---------- + labels: np.ndarray + Predicted nonghost voxel label information + mask: np.ndarray + Voxel index mask + pid: int, optional + Unique id of this particle (for debugging) + """ semantic_type, sem_counts = np.unique(labels[mask][:, -1].astype(int), return_counts=True) if semantic_type.shape[0] > 1: diff --git a/analysis/classes/data.py b/analysis/classes/data.py new file mode 100644 index 00000000..a2cc4b69 --- /dev/null +++ b/analysis/classes/data.py @@ -0,0 +1,6 @@ +from .Particle import Particle +from .ParticleFragment import ParticleFragment +from .TruthParticle import TruthParticle +from .TruthParticleFragment import TruthParticleFragment +from .Interaction import Interaction +from .TruthInteraction import TruthInteraction \ No newline at end of file diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index 1ba1667c..49d6cf80 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -1,78 +1,45 @@ from typing import List import numpy as np -from mlreco.utils.globals import VTX_COLS, INTER_COL, COORD_COLS, PDG_TO_PID - from analysis.classes import TruthParticleFragment, TruthParticle, Interaction from analysis.classes.particle_utils import (match_particles_fn, match_interactions_fn, - group_particles_to_interactions_fn, match_interactions_optimal, match_particles_optimal) -from analysis.producers.point_matching import * - -from mlreco.utils.vertex import get_vertex from analysis.classes.predictor import FullChainPredictor +from mlreco.utils.globals import * +from analysis.classes.data import * class FullChainEvaluator(FullChainPredictor): ''' - Helper class for full chain prediction and evaluation. + User Interface for full chain prediction and evaluation. + + The FullChainEvaluator shares the same methods as FullChainPredictor, + but with additional methods to retrieve ground truth information and + evaluate performance metrics. Usage: - model = Trainer._net.module - entry = 0 # batch id - predictor = FullChainEvaluator(model, data_blob, res, cfg) - pred_seg = predictor.get_true_label(entry, mode='segmentation') - - To avoid confusion between different quantities, the label namings under - iotools.schema must be set as follows: - - schema: - input_data: - - parse_sparse3d_scn - - sparse3d_pcluster - segment_label: - - parse_sparse3d_scn - - sparse3d_pcluster_semantics - cluster_label: - - parse_cluster3d_clean_full - #- parse_cluster3d_full - - cluster3d_pcluster - - particle_pcluster - #- particle_mpv - - sparse3d_pcluster_semantics - particles_label: - - parse_particle_points_with_tagging - - sparse3d_pcluster - - particle_corrected - particle_graph: - - parse_particle_graph_corrected - - particle_corrected - - cluster3d_pcluster - particles_asis: - - parse_particles - - particle_pcluster - - cluster3d_pcluster - - - Instructions - ---------------------------------------------------------------- - - The FullChainEvaluator share the same methods as FullChainPredictor, - with additional methods to retrieve ground truth information for each - abstraction level. + # , are full chain input/output dictionaries. + evaluator = FullChainEvaluator(data, result) + + # Get labels + pred_seg = evaluator.get_true_label(entry, mode='segmentation') + # Get Particle instances + matched_particles = evaluator.match_particles(entry) + # Get Interaction instances + matched_interactions = evaluator.match_interactions(entry) ''' LABEL_TO_COLUMN = { - 'segment': -1, - 'charge': 4, - 'fragment': 5, - 'group': 6, - 'interaction': 7, - 'pdg': 9, - 'nu': 8 + 'segment': SEG_COL, + 'charge': VALUE_COL, + 'fragment': CLUST_COL, + 'group': GROUP_COL, + 'interaction': INTER_COL, + 'pdg': TYPE_COL, + 'nu': NU_COL } @@ -90,6 +57,22 @@ def __init__(self, data_blob, result, evaluator_cfg={}, **kwargs): assert self.min_overlap_count >= 0 def build_representations(self): + """ + Method using DataBuilders to construct high level data structures. + The constructed data structures are stored inside result dict. + + Will not build data structures if the key corresponding to + the data structure class is already contained in the result dictionary. + + For example, if result['Particles'] exists and contains lists of + reconstructed instances, then methods inside the + Evaluator will use the already existing result['Particles'] + rather than building new lists from scratch. + + Returns + ------- + None (operation is in-place) + """ if 'Particles' not in self.result: self.result['Particles'] = self.particle_builder.build(self.data_blob, self.result, mode='reco') if 'TruthParticles' not in self.result: @@ -108,7 +91,7 @@ def get_true_label(self, entry, name, schema='cluster_label_adapted'): Retrieve tensor in data blob, labelled with `schema`. Parameters - ========== + ---------- entry: int name: str Must be a predefined name within `['segment', 'fragment', 'group', @@ -118,7 +101,7 @@ def get_true_label(self, entry, name, schema='cluster_label_adapted'): volume: int, default None Returns - ======= + ------- np.array """ if name not in self.LABEL_TO_COLUMN: @@ -151,34 +134,16 @@ def get_predicted_label(self, entry, name): return pred[name] - def _apply_true_voxel_cut(self, entry): - - labels = self.data_blob['cluster_label'][entry] - - particle_ids = set(list(np.unique(labels[:, 6]).astype(int))) - particles_exclude = [] - - for idx, p in enumerate(self.data_blob['particles_asis'][entry]): - pid = int(p.id()) - if pid not in particle_ids: - continue - is_primary = p.group_id() == p.parent_id() - if p.pdg_code() not in PDG_TO_PID: - continue - mask = labels[:, 6].astype(int) == pid - coords = labels[mask][:, 1:4] - if coords.shape[0] < self.min_particle_voxel_count: - particles_exclude.append(p.id()) - - return set(particles_exclude) - - def get_true_fragments(self, entry) -> List[TruthParticleFragment]: ''' - Get list of instances for given batch id. + Get list of instances for given batch id. + + Returns + ------- + fragments: List[TruthParticleFragment] + All track/shower fragments contained in image #. ''' - - fragments = self.result['ParticleFragments'][entry] + fragments = self.result['TruthParticleFragments'][entry] return fragments @@ -187,20 +152,27 @@ def get_true_particles(self, entry, volume=None) -> List[TruthParticle]: ''' Get list of instances for given batch id. - - The method will return particles only if its id number appears in - the group_id column of cluster_label. - - Each TruthParticle will contain the following information (attributes): - - points: N x 3 coordinate array for particle's full image. - id: group_id - semantic_type: true semantic type - interaction_id: true interaction id - pid: PDG type (photons: 0, electrons: 1, ...) - fragments: list of integers corresponding to constituent fragment - id number - p: true momentum vector + + Can construct TruthParticles with no TruthParticle.points attribute + (predicted nonghost coordinates), if the corresponding larcv::Particle + object has nonzero true nonghost voxel depositions. + + See TruthParticle for more information. + + Parameters + ---------- + entry: int + Image # (batch id) to fetch true particles. + only_primaries: bool, optional + If True, discards non-primary true particles from output. + volume: int, optional + Indicator for fetching TruthParticles only within a given cryostat. + Currently, 0 corresponds to east and 1 to west. + + Returns + ------- + out_particles_list: List[TruthParticle] + List of TruthParticles in image # ''' out_particles_list = [] particles = self.result['TruthParticles'][entry] @@ -214,17 +186,40 @@ def get_true_particles(self, entry, def get_true_interactions(self, entry) -> List[Interaction]: + ''' + Get list of instances for given batch id. + + Can construct TruthInteraction with no TruthInteraction.points + (predicted nonghost coordinates), if all particles that compose the + interaction has no predicted nonghost coordinates and nonzero + true nonghost coordinates. + See TruthInteraction for more information. + + Parameters + ---------- + entry: int + Image # (batch id) to fetch true particles. + + Returns + ------- + out: List[Interaction] + List of TruthInteraction in image # + ''' out = self.result['TruthInteractions'][entry] return out + @staticmethod def match_parts_within_ints(int_matches): ''' - Given list of Tuple[(Truth)Interaction, (Truth)Interaction], + Given list of matches Tuple[(Truth)Interaction, (Truth)Interaction], return list of particle matches Tuple[TruthParticle, Particle]. - If no match, (Truth)Particle is replaced with None. + This means rather than matching all predicted particles againts + all true particles, it has an additional constraint that only + particles within a matched interaction pair can be considered + for matching. ''' matched_particles, match_counts = [], [] @@ -265,15 +260,34 @@ def match_particles(self, entry, return_counts=False, **kwargs): ''' - Returns (, None) if no match was found - + Method for matching reco and true particles by 3D voxel coordinate. + Parameters - ========== + ---------- entry: int - only_primaries: bool, default False - mode: str, default 'pred_to_true' - Must be either 'pred_to_true' or 'true_to_pred' - volume: int, default None + Image # (batch id) + only_primaries: bool (default False) + If true, non-primary particles will be discarded from beginning. + mode: str (default "pred_to_true") + Whether to match reco to true, or true to reco. This + affects the output if matching_mode="one_way". + matching_mode: str (default "one_way") + The algorithm used to establish matches. Currently there are + only two options: + - one_way: loops over true/reco particles, and chooses a + reco/true particle with the highest overlap. + - optimal: finds an optimal assignment between reco/true + particles so that the sum of overlap metric (counts or IoU) + is maximized. + return_counts: bool (default False) + If True, returns the overlap metric (counts or IoU) value for + each match. + + Returns + ------- + matched_pairs: List[Tuple[Particle, TruthParticle]] + counts: np.ndarray + overlap metric values corresponding to each matched pair. ''' if mode == 'pred_to_true': # Match each pred to one in true @@ -314,20 +328,33 @@ def match_interactions(self, entry, mode='pred_to_true', matching_mode='one_way', **kwargs): """ + Method for matching reco and true interactions. + Parameters - ========== + ---------- entry: int - mode: str, default 'pred_to_true' - Must be either 'pred_to_true' or 'true_to_pred'. - drop_nonprimary_particles: bool, default False - match_particles: bool, default True - return_counts: bool, default False - volume: int, default None - + Image # (batch id) + drop_nonprimary_particles: bool (default False) + If true, non-primary particles will be discarded from beginning. + match_particles: bool (default True) + Option to match particles within matched interactions. + matching_mode: str (default "one_way") + The algorithm used to establish matches. Currently there are + only two options: + - one_way: loops over true/reco particles, and chooses a + reco/true particle with the highest overlap. + - optimal: finds an optimal assignment between reco/true + particles so that the sum of overlap metric (counts or IoU) + is maximized. + return_counts: bool (default False) + If True, returns the overlap metric (counts or IoU) value for + each match. + Returns - ======= - List[Tuple[Interaction, Interaction]] - List of tuples, indicating the matched interactions. + ------- + matched_pairs: List[Tuple[Particle, TruthParticle]] + counts: np.ndarray + overlap metric values corresponding to each matched pair. """ all_matches, all_counts = [], [] diff --git a/analysis/manager.py b/analysis/manager.py index 7d57b370..a961039b 100644 --- a/analysis/manager.py +++ b/analysis/manager.py @@ -39,7 +39,7 @@ class AnaToolsManager: Whether to print out execution times. """ - def __init__(self, ana_cfg, profile=True, cfg=None): + def __init__(self, ana_cfg, verbose=True, cfg=None): self.config = cfg self.ana_config = ana_cfg self.max_iteration = self.ana_config['analysis']['iteration'] @@ -58,15 +58,30 @@ def __init__(self, ana_cfg, profile=True, cfg=None): self._data_reader = None self._reader_state = None - self.profile = profile + self.verbose = verbose self.writers = {} + + self.profile = self.ana_config['analysis'].get('profile', False) + self.logger = CSVWriter(os.path.join(self.log_dir, 'log.csv')) + self.logger_dict = {} def _set_iteration(self, dataset): + """Sets maximum number of iteration given dataset + and max_iteration input. + + Parameters + ---------- + dataset : torch.utils.data.Dataset + Torch dataset containing images. + """ if self.max_iteration == -1: self.max_iteration = len(dataset) assert self.max_iteration <= len(dataset) def initialize(self): + """Initializer for setting up inference mode full chain forwarding + or reading data from HDF5. + """ if 'reader' not in self.ana_config: event_list = self.config['iotool']['dataset'].get('event_list', None) if event_list is not None: @@ -93,8 +108,22 @@ def initialize(self): self._set_iteration(Reader) def forward(self, iteration=None): - if self.profile: - start = time.time() + """Read one minibatch worth of image from dataset. + + Parameters + ---------- + iteration : int, optional + Iteration number, needed for reading entries from + HDF5 files, by default None. + + Returns + ------- + data: dict + Data dictionary containing network inputs (and labels if available). + res: dict + Result dictionary containing full chain outputs + + """ if self._reader_state == 'hdf5': assert iteration is not None data, res = self._data_reader.get(iteration, nested=True) @@ -102,12 +131,24 @@ def forward(self, iteration=None): data, res = self._data_reader.forward(self._dataset) else: raise ValueError(f"Data reader {self._reader_state} is not supported!") - if self.profile: - end = time.time() - print("Forwarding data took %.2f s" % (end - start)) return data, res def _build_reco_reps(self, data, result): + """Build representations for reconstructed objects. + + Parameters + ---------- + data : dict + Data dictionary + result : dict + Result dictionary + + Returns + ------- + length_check: List[int] + List of integers representing the length of each data structure + from DataBuilders, used for checking validity. + """ length_check = [] if 'ParticleBuilder' in self.builders: result['Particles'] = self.builders['ParticleBuilder'].build(data, result, mode='reco') @@ -121,6 +162,21 @@ def _build_reco_reps(self, data, result): return length_check def _build_truth_reps(self, data, result): + """Build representations for true objects. + + Parameters + ---------- + data : dict + Data dictionary + result : dict + Result dictionary + + Returns + ------- + length_check: List[int] + List of integers representing the length of each data structure + from DataBuilders, used for checking validity. + """ length_check = [] if 'ParticleBuilder' in self.builders: result['TruthParticles'] = self.builders['ParticleBuilder'].build(data, result, mode='truth') @@ -133,16 +189,25 @@ def _build_truth_reps(self, data, result): length_check.append(len(result['TruthParticleFragments'])) return length_check - def build_representations(self, data, result, mode=None): - + def build_representations(self, data, result, mode='all'): + """Build human readable data structures from full chain output. + + Parameters + ---------- + data : dict + Data dictionary + result : dict + Result dictionary + mode : str, optional + Whether to build only reconstructed or true objects. + 'reco', 'truth', and 'all' are available (by default 'all'). + + """ num_batches = len(data['index']) - lcheck_reco, lcheck_truth = [], [] if self.ana_mode is not None: mode = self.ana_mode - if self.profile: - start = time.time() if mode == 'reco': lcheck_reco = self._build_reco_reps(data, result) elif mode == 'truth': @@ -156,13 +221,17 @@ def build_representations(self, data, result, mode=None): assert lreco == num_batches for ltruth in lcheck_truth: assert ltruth == num_batches - if self.profile: - end = time.time() - print("Data representation change took %.2f s" % (end - start)) def run_post_processing(self, data, result): - if self.profile: - start = time.time() + """Run all registered post-processing scripts. + + Parameters + ---------- + data : dict + Data dictionary + result : dict + Result dictionary + """ if 'post_processing' in self.ana_config: post_processor_interface = PostProcessor(data, result) # Gather post processing functions, register by priority @@ -170,24 +239,36 @@ def run_post_processing(self, data, result): for processor_name, pcfg in self.ana_config['post_processing'].items(): local_pcfg = copy.deepcopy(pcfg) priority = local_pcfg.pop('priority', -1) + profile = local_pcfg.pop('profile', False) run_on_batch = local_pcfg.pop('run_on_batch', False) processor_name = processor_name.split('+')[0] processor = getattr(post_processing,str(processor_name)) post_processor_interface.register_function(processor, priority, processor_cfg=local_pcfg, - run_on_batch=run_on_batch) + run_on_batch=run_on_batch, + profile=profile) post_processor_interface.process_and_modify() - if self.profile: - end = time.time() - print("Post-processing took %.2f s" % (end - start)) + self.logger_dict.update(post_processor_interface._profile) def run_ana_scripts(self, data, result): - if self.profile: - start = time.time() + """Run all registered analysis scripts (under producers/scripts) + + Parameters + ---------- + data : dict + Data dictionary + result : dict + Result dictionary + + Returns + ------- + out: dict + Dictionary of column name : value mapping, which corresponds to + each row in the output csv file. + """ out = {} - if 'scripts' in self.ana_config: script_processor = ScriptProcessor(data, result) for processor_name, pcfg in self.ana_config['scripts'].items(): @@ -199,16 +280,23 @@ def run_ana_scripts(self, data, result): script_cfg=pcfg) fname_to_update_list = script_processor.process() out[processor_name] = fname_to_update_list - - if self.profile: - end = time.time() - print("Analysis scripts took %.2f s" % (end - start)) return out def write(self, ana_output): + """Method to gather logging information from each analysis script + and save to csv files. + + Parameters + ---------- + ana_output : dict + Dictionary of column name : value mapping, which corresponds to + each row in the output csv file. - if self.profile: - start = time.time() + Raises + ------ + RuntimeError + If two filenames specified by the user point to the same path. + """ if not self.writers: self.writers = {} @@ -230,30 +318,70 @@ def write(self, ana_output): for row_dict in ana_output[script_name][fname]: self.writers[fname].append(row_dict) - if self.profile: - end = time.time() - print("Writing to csvs took %.2f s" % (end - start)) - def write_to_hdf5(self): + """Method to write reconstruction outputs (data and result dicts) + to HDF5 files. + + Raises + ------ + NotImplementedError + _description_ + """ raise NotImplementedError def step(self, iteration): + """Run single step of analysis tools workflow. This includes + data forwarding, building data structures, running post-processing, + and appending desired information to each row of output csv files. + + Parameters + ---------- + iteration : int + Iteration number for current step. + """ # 1. Run forward + start = time.time() data, res = self.forward(iteration=iteration) + end = time.time() + self.logger_dict['forward_time'] = end-start + start = end # 2. Build data representations self.build_representations(data, res) + end = time.time() + self.logger_dict['build_reps_time'] = end-start + start = end # 3. Run post-processing, if requested self.run_post_processing(data, res) + end = time.time() + self.logger_dict['post_processing_time'] = end-start + start = end # 4. Run scripts, if requested ana_output = self.run_ana_scripts(data, res) if len(ana_output) == 0: print("No output from analysis scripts.") self.write(ana_output) + end = time.time() + self.logger_dict['write_csv_time'] = end-start + + def log(self, iteration): + """Generate analysis tools iteration log. This is a separate logging + operation from the subroutines in analysis.producers.loggers. + + Parameters + ---------- + iteration : int + Current iteration number + """ + row_dict = {'iteration': iteration} + row_dict.update(self.logger_dict) + self.logger.append(row_dict) def run(self): iteration = 0 while iteration < self.max_iteration: self.step(iteration) - \ No newline at end of file + if self.profile: + self.log(iteration) + iteration += 1 \ No newline at end of file diff --git a/analysis/post_processing/common.py b/analysis/post_processing/common.py index cde8e495..3f12d875 100644 --- a/analysis/post_processing/common.py +++ b/analysis/post_processing/common.py @@ -1,20 +1,40 @@ import numpy as np -from functools import partial +from functools import partial, wraps from collections import defaultdict, OrderedDict import warnings +import time class PostProcessor: - - def __init__(self, data, result, debug=True): + """Manager for handling post-processing scripts. + + """ + def __init__(self, data, result, debug=True, profile=False): self._funcs = defaultdict(list) self._batch_funcs = defaultdict(list) self._num_batches = len(data['index']) self.data = data self.result = result self.debug = debug + + self._profile = defaultdict(float) + + def profile(self, func): + '''Decorator that reports the execution time. ''' + @wraps(func) + def wrapper(*args, **kwargs): + start = time.time() + result = func(*args, **kwargs) + end = time.time() + dt = end - start + self._profile[func.__name__] += dt + return result + return wrapper - def register_function(self, f, priority, processor_cfg={}, run_on_batch=False): + def register_function(self, f, priority, + processor_cfg={}, + run_on_batch=False, + profile=False): data_capture, result_capture = f._data_capture, f._result_capture result_capture_optional = f._result_capture_optional pf = partial(f, **processor_cfg) @@ -22,6 +42,8 @@ def register_function(self, f, priority, processor_cfg={}, run_on_batch=False): pf._data_capture = data_capture pf._result_capture = result_capture pf._result_capture_optional = result_capture_optional + if profile: + pf = self.profile(pf) if run_on_batch: self._batch_funcs[priority].append(pf) else: diff --git a/analysis/producers/scripts/template.py b/analysis/producers/scripts/template.py index 1161083b..a05f7c0d 100644 --- a/analysis/producers/scripts/template.py +++ b/analysis/producers/scripts/template.py @@ -112,9 +112,6 @@ def run_inference(data_blob, res, **kwargs): true_p_dict = particle_logger.produce(true_p, mode='true') pred_p_dict = particle_logger.produce(pred_p, mode='reco') - - pprint(true_p_dict) - assert False part_dict = OrderedDict() part_dict.update(index_dict) diff --git a/contributing.md b/contributing.md index b108c9cd..78cd27d9 100644 --- a/contributing.md +++ b/contributing.md @@ -22,33 +22,11 @@ Use the command `CUDA_VISBLE_DEVICES='' pytest -rxXs` to run all the tests that If you are contributing code, please remember that other people use this repository as well, and that they may want (or need) to understand how to use what you have done. You may also need to understand what you do today 6 months from now. This means that documentation is important. There are three steps to making sure that others (and future you) can easily use and understand your code. -1) Write a [docstring](https://www.python.org/dev/peps/pep-0257/) for every function you write, no matter how simple. There's a [template below](#docstring-template). +1) Write a [docstring](https://www.python.org/dev/peps/pep-0257/) for every function you write, no matter how simple. 2) Comment your code. If you're writing more than a few lines in a function, a docstring will not suffice. Let any reader know what you're doing, especially when you get to a loop or if statement. 3) If appropriate, update a README with your contribution. ### Docstring Template -Writing a docstring allows others to understand your function without opening the code. The following should open the docstring: -```python -?my_function -``` - -```python -def my_function(arg1, arg2): - """ - Brief description of what your function does. - INPUTS: - arg1 - - arg2 - - OUTPUT: - ret1 - - ret2 - - ASSUMES: - Do you assume anything about the inputs? If so, state here. - WARNINGS: - Can something go horribly wrong because you mutate inputs? If so, state here. - EXAMPLES: - If you can provide a basic example, this is a good place. - """ -``` +We use the [numpy](https://numpydoc.readthedocs.io/en/latest/format.html) style for docstrings. Several example docstrings can be viewed [here](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_numpy.html). diff --git a/mlreco/iotools/README.md b/mlreco/iotools/README.md index 07fa2632..1944932e 100644 --- a/mlreco/iotools/README.md +++ b/mlreco/iotools/README.md @@ -10,11 +10,11 @@ You can write your own sampling function in `samplers.py`. ### 1. Writing and Reading HDF5 Files -``` +```yaml iotool: writer: name: HDF5Writer - filename: output.h5 + file_name: output.h5 input_keys: None skip_input_keys: [] result_keys: None From 00e8ec60c8b7eb86a9d28d0bcf05c6b3ff6d88e0 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 19 Apr 2023 16:14:25 -0700 Subject: [PATCH 137/180] Move PPN candidate assignment to post-processing --- analysis/classes/builders.py | 44 +------ analysis/post_processing/README.md | 44 ------- .../reconstruction/__init__.py | 1 + .../post_processing/reconstruction/ppn.py | 118 ++++++++++++++++++ 4 files changed, 121 insertions(+), 86 deletions(-) delete mode 100644 analysis/post_processing/README.md create mode 100644 analysis/post_processing/reconstruction/ppn.py diff --git a/analysis/classes/builders.py b/analysis/classes/builders.py index 1c52b4db..65e50538 100644 --- a/analysis/classes/builders.py +++ b/analysis/classes/builders.py @@ -460,7 +460,7 @@ def _build_reco(self, entry, continue out.append(p) - # Check primaries and assign ppn points + # Check primaries if self.only_primaries: out = [p for p in out if p.is_primary] @@ -685,44 +685,4 @@ def get_true_particle_labels(labels, mask, pid=-1, verbose=False): else: nu_id = nu_id[0] - return semantic_type, interaction_id, nu_id - -def match_points_to_particles(ppn_points : np.ndarray, - particles : List[Particle], - semantic_type=None, ppn_distance_threshold=2): - """Function for matching ppn points to particles. - - For each particle, match ppn_points that have hausdorff distance - less than and inplace update particle.ppn_candidates - - If semantic_type is set to a class integer value, - points will be matched to particles with the same - predicted semantic type. - - Parameters - ---------- - ppn_points : (N x 4 np.array) - PPN point array with (coords, point_type) - particles : list of objects - List of particles for which to match ppn points. - semantic_type: int - If set to an integer, only match ppn points with prescribed - semantic type - ppn_distance_threshold: int or float - Maximum distance required to assign ppn point to particle. - - Returns - ------- - None (operation is in-place) - """ - if semantic_type is not None: - ppn_points_type = ppn_points[ppn_points[:, 5] == semantic_type] - else: - ppn_points_type = ppn_points - # TODO: Fix semantic type ppn selection - - ppn_coords = ppn_points_type[:, :3] - for particle in particles: - dist = cdist(ppn_coords, particle.points) - matches = ppn_points_type[dist.min(axis=1) < ppn_distance_threshold] - particle.ppn_candidates = matches.reshape(-1, 7) \ No newline at end of file + return semantic_type, interaction_id, nu_id \ No newline at end of file diff --git a/analysis/post_processing/README.md b/analysis/post_processing/README.md deleted file mode 100644 index 39f62725..00000000 --- a/analysis/post_processing/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# Postprocessing scripts - -If you want to computer event-based metrics, analysis or store informations, this is the right place to do so. - -## Existing scripts -To be filled in... - -* `analysis` Higher level scripts that take the output of the full chain and do some physics analysis with it. For example, finding Michel electrons. -* `metrics` Reproducible metrics scripts for various stages / models, to check and study their performance. -* `store` If you ever need to store in a CSV raw information/predictions from an event (code may be obsolete, to double check). - -## How to write your own script -The bare minimum for a postprocessing script that feeds on the input data `seg_label` and the network output `segmentation` would look like this: - -```python -from mlreco.post_processing import post_processing - -@post_processing('my-metrics', - ['seg_label'], - ['segmentation']) -def my_metrics(cfg, module_cfg, data_blob, res, logdir, iteration, - data_idx=None, seg_label=None, segmentation=None, **kwargs): - # Do your metrics - row_names = ('metric1',) - row_values = (0.5,) - - return row_names, row_values -``` - -The function `my_metrics` runs on a single event. `seg_label[data_idx]` and `segmentation[data_idx]` contain the requested data and output. This file should be named `my_metrics.py` and placed in the appropriate folder among `store`, `metrics` and `analysis`. If placed in a custom location, manually add it to `post_processing/__init__.py` folder. - -The decorator `@post_processing` takes 3 arguments: filenames, data input capture, network output capture. It performs the necessary boilerplate to create/write into the CSV files, save iteration and event id, fetch the data/output quantities, and applies a deghosting mask in the background if necessary. - - -In the configuration, your script would go under the `post_processing` section: - -```yml -post_processing: - ppn_metrics: - store_method: per-iteration - ghost: True -``` - -This will create in the log folder corresponding CSV files named `my-metrics-*.csv`. diff --git a/analysis/post_processing/reconstruction/__init__.py b/analysis/post_processing/reconstruction/__init__.py index 69768701..74b5c6a1 100644 --- a/analysis/post_processing/reconstruction/__init__.py +++ b/analysis/post_processing/reconstruction/__init__.py @@ -4,3 +4,4 @@ from .points import order_end_points from .geometry import particle_direction from .calorimetry import calorimetric_energy, range_based_track_energy +from .ppn import assign_ppn_candidates \ No newline at end of file diff --git a/analysis/post_processing/reconstruction/ppn.py b/analysis/post_processing/reconstruction/ppn.py new file mode 100644 index 00000000..19065cd9 --- /dev/null +++ b/analysis/post_processing/reconstruction/ppn.py @@ -0,0 +1,118 @@ +import numpy as np +from typing import List +from scipy.spatial.distance import cdist + +from analysis.post_processing import post_processing +from mlreco.utils.globals import * +from mlreco.utils.ppn import uresnet_ppn_type_point_selector +from analysis.classes import Particle + +PPN_COORD_COLS = (0,1,2) +PPN_LOGITS_COLS = (3,4,5,6,7) +PPN_SCORE_COL = (8,9) + +@post_processing(data_capture=[], result_capture=['input_rescaled', + 'Particles', + 'ppn_classify_endpoints', + 'ppn_output_coords', + 'ppn_points', + 'ppn_coords', + 'ppn_masks', + 'segmentation']) +def assign_ppn_candidates(data_dict, result_dict): + """Select ppn candidates and assign them to each particle instance. + + Parameters + ---------- + data_dict : dict + Data dictionary (contains one image-worth of data) + result_dict : dict + Result dictionary (contains one image-worth of full chain outputs) + + Returns + ------- + None + Operation is in-place on Particles. + """ + + result = {} + for key, val in result_dict.items(): + result[key] = [val] + + ppn = uresnet_ppn_type_point_selector(result['input_rescaled'][0], + result, entry=0, + apply_deghosting=False) + + ppn_voxels = ppn[:, 1:4] + ppn_score = ppn[:, 5] + ppn_type = ppn[:, 12] + if 'ppn_classify_endpoints' in result: + ppn_endpoint = ppn[:, 13:] + assert ppn_endpoint.shape[1] == 2 + + ppn_candidates = [] + for i, pred_point in enumerate(ppn_voxels): + pred_point_type, pred_point_score = ppn_type[i], ppn_score[i] + x, y, z = ppn_voxels[i][0], ppn_voxels[i][1], ppn_voxels[i][2] + if 'ppn_classify_endpoints' in result: + ppn_candidates.append(np.array([x, y, z, + pred_point_score, + pred_point_type, + ppn_endpoint[i][0], + ppn_endpoint[i][1]])) + else: + ppn_candidates.append(np.array([x, y, z, + pred_point_score, + pred_point_type])) + + if len(ppn_candidates): + ppn_candidates = np.vstack(ppn_candidates) + else: + enable_classify_endpoints = 'ppn_classify_endpoints' in result + ppn_candidates = np.empty((0, 5 if not enable_classify_endpoints else 7), + dtype=np.float32) + + match_points_to_particles(ppn_candidates, result_dict['Particles']) + + return {} + + +def match_points_to_particles(ppn_points : np.ndarray, + particles : List[Particle], + semantic_type=None, ppn_distance_threshold=2): + """Function for matching ppn points to particles. + + For each particle, match ppn_points that have hausdorff distance + less than and inplace update particle.ppn_candidates + + If semantic_type is set to a class integer value, + points will be matched to particles with the same + predicted semantic type. + + Parameters + ---------- + ppn_points : (N x 4 np.array) + PPN point array with (coords, point_type) + particles : list of objects + List of particles for which to match ppn points. + semantic_type: int + If set to an integer, only match ppn points with prescribed + semantic type + ppn_distance_threshold: int or float + Maximum distance required to assign ppn point to particle. + + Returns + ------- + None (operation is in-place) + """ + if semantic_type is not None: + ppn_points_type = ppn_points[ppn_points[:, 5] == semantic_type] + else: + ppn_points_type = ppn_points + # TODO: Fix semantic type ppn selection + + ppn_coords = ppn_points_type[:, :3] + for particle in particles: + dist = cdist(ppn_coords, particle.points) + matches = ppn_points_type[dist.min(axis=1) < ppn_distance_threshold] + particle.ppn_candidates = matches.reshape(-1, 7) \ No newline at end of file From 9f206188da4bea279111129682f31d843dd1da9e Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 19 Apr 2023 16:27:34 -0700 Subject: [PATCH 138/180] Removed _fit_predict_ppn from predictor --- analysis/classes/predictor.py | 47 ----------------------------------- 1 file changed, 47 deletions(-) diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index 502facae..22aaf372 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -104,48 +104,6 @@ def __repr__(self): msg = "FullChainEvaluator(num_images={})".format(int(self.num_images)) return msg - def _fit_predict_ppn(self, entry): - ''' - Method for predicting ppn predictions. - - Inputs: - - entry: Batch number to retrieve example. - - Returns: - - df (pd.DataFrame): pandas dataframe of ppn points, with - x, y, z, coordinates, Score, Type, and sample index. - ''' - # Deghosting is already applied during initialization - ppn = uresnet_ppn_type_point_selector(self.result['input_rescaled'][entry], - self.result, - entry=entry, apply_deghosting=False) - ppn_voxels = ppn[:, 1:4] - ppn_score = ppn[:, 5] - ppn_type = ppn[:, 12] - if 'ppn_classify_endpoints' in self.result: - ppn_endpoint = ppn[:, 13:] - assert ppn_endpoint.shape[1] == 2 - - ppn_candidates = [] - for i, pred_point in enumerate(ppn_voxels): - pred_point_type, pred_point_score = ppn_type[i], ppn_score[i] - x, y, z = ppn_voxels[i][0], ppn_voxels[i][1], ppn_voxels[i][2] - if 'ppn_classify_endpoints' in self.result: - ppn_candidates.append(np.array([x, y, z, - pred_point_score, - pred_point_type, - ppn_endpoint[i][0], - ppn_endpoint[i][1]])) - else: - ppn_candidates.append(np.array([x, y, z, pred_point_score, pred_point_type])) - - if len(ppn_candidates): - ppn_candidates = np.vstack(ppn_candidates) - else: - enable_classify_endpoints = 'ppn_classify_endpoints' in self.result - ppn_candidates = np.empty((0, 5 if not enable_classify_endpoints else 6), dtype=np.float32) - return ppn_candidates - def _fit_predict_semantics(self, entry): ''' @@ -499,11 +457,6 @@ def _decorate_particles(self, entry, particles, **kwargs): if len(out) == 0: return out - - # Get ppn candidates for particle - # ppn_results = self._fit_predict_ppn(entry) - # match_points_to_particles(ppn_results, out, - # ppn_distance_threshold=kwargs['attaching_threshold']) volume = kwargs.get('volume', None) if volume is not None: From 802733ea300e4095a2ad5c185913597637e7c2d3 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 19 Apr 2023 17:08:55 -0700 Subject: [PATCH 139/180] Added more docstrings --- analysis/README.md | 61 ++++++++++++++++- .../reconstruction/calorimetry.py | 66 +++++++++++++++++-- 2 files changed, 118 insertions(+), 9 deletions(-) diff --git a/analysis/README.md b/analysis/README.md index b08082f2..e8b1d897 100644 --- a/analysis/README.md +++ b/analysis/README.md @@ -31,7 +31,7 @@ Analysis tools need two configuration files to function: one for the full ML cha ```yaml analysis: iteration: -1 - log_dir: /sdf/group/neutrino/koh0207/logs/nu_selection/trash + log_dir: $PATH_TO_LOG_DIR ``` Here, `iteration: -1` is a shorthand for "iterate over the full dataset", and `log_dir` is the output directory in which all products of analysis tools (if one decides to write something to files) will be saved to. @@ -254,6 +254,8 @@ which gives all the reconstructed particle directions in image #0 (in order). As ## 4. Evaluating reconstruction and writing outputs CSVs. +----- + While HDF5 format is suitable for saving large amounts of data to be used in the future, for high level analysis we generally save per-image, per-interaction, or per-particle attributes and features in tabular form (such as CSVs). Also, there's a need to compute different evaluation metrics once the all the post-processors return their reconstruction outputs. We group all these that happen after post-processing under `analysis.producers.scripts`: * Matching reconstructed particles to corresponding true particles. * Retrieving properly structured labels from truth information. @@ -290,6 +292,8 @@ analysis: ### 4.1 Running inference using the `Evaluator` and `Predictor` interface. +------ + Each function inside `analysis.producers.scripts` has `data` and `result` dictionary as its input arguments, so all reconstructed quantities from both the ML chain and the post-processing subroutines are accessible through its keys. At this stage of accessing reconstruction outputs, it is generally up to the user to define the evaluation metrics and/or quantities of interest that will be written to output files. Still, analysis tools have additional user interfaces--`FullChainPredictor` and `FullChainEvaluator`--for easy and consistent evaluation of full chain outputs. * `FullChainPredictor`: user interface class for accessing full chain predictions. This class is reserved for prediction on non-MC data as it does not have any reference to truth labels or MC information. * `FullChainEvaluator`: user interface class for accessing full chain predictions, truth labels, and prediction to truth matching functions. Has access to label and MC truth information. @@ -367,7 +371,12 @@ The same convention holds for matched particle pairs (`TruthParticle`, `Particle > 3D spacepoints from G4), one must set **`overlap_mode="chamfer"`** to allow the > evaluator to use the chamfer distance to match non-overlapping 3D coordinates > between true nonghost and predicted nonghost coordinates. + + ### 4.2 Using Loggers to organize CSV output fields. + +---- + Loggers are objects that take a `DataBuilder` product and returns an `OrderedDict` instance representing a single row of an output CSV file. For example: ```python @@ -497,6 +506,8 @@ implementation of `run_inference` is located in `analysis/producers/scripts/temp ### 4.3 Launching analysis tools job for large statistics inference. +----- + To run analysis tools on (already generated) full chain output stored as HDF5 files: ```bash python3 analysis/run.py $PATH_TO_ANALYSIS_CONFIG @@ -510,4 +521,50 @@ This will run all post-processing, producer scripts, and logger data-fetching an To run analysis tools in tandem with full chain forwarding, you need an additional argument for the full chain config: ```bash python3 analysis/run.py $PATH_TO_ANALYSIS_CONFIG --chain_config $PATH_TO_FULL_CHAIN_CONFIG -``` \ No newline at end of file +``` +--------- + +## 5. Profiling Reconstruction workflow + +Include a `profile=True` field under `analysis` to obtain the wall-clock time for each stage of reconstruction: +```yaml +analysis: + profile: True + iteration: -1 + log_dir: $PATH_TO_LOG_DIR +... +``` +This will generate a `log.csv` file under `log_dir`, which contain timing information (in seconds) for each stage in analysis tools: + +(`log.csv`) +| iteration | forward_time | build_reps_time | post_processing_time | write_csv_time | +| --------- | ------------ | --------------- | -------------------- | -------------- | +| 0 | 8.9698 | 0.19047 | 33.654 | 0.26532 | +| 1 | 3.7952 | 0.78680 | 25.417 | 0.87310 | +| ... | ... | ... | ... | ... | + + +### 5.1 Profiling each post-processing functions separately. +----- + +Include a `profile=True` field under the post-processor name to log the timing information separately. For example: +``` +analysis: + profile: True + iteration: -1 + log_dir: $PATH_TO_LOG_DIR +post_processing: + particle_direction: + profile: True + optimize: True + priority: 1 +``` + +This will add a column "particle_direction" in `log.csv`: + +(`log.csv`) +| iteration | forward_time | build_reps_time | particle_direction | post_processing_time | write_csv_time | +| --------- | ------------ | --------------- | ------------------ | -------------------- | -------------- | +| 0 | 8.9698 | 0.19047 | 0.10811 | 33.654 | 0.26532 | +| 1 | 3.7952 | 0.78680 | 0.23974 | 25.417 | 0.87310 | +| ... | ... | ... | ... | ... | ... | diff --git a/analysis/post_processing/reconstruction/calorimetry.py b/analysis/post_processing/reconstruction/calorimetry.py index 245b73e7..583faca6 100644 --- a/analysis/post_processing/reconstruction/calorimetry.py +++ b/analysis/post_processing/reconstruction/calorimetry.py @@ -16,6 +16,24 @@ def calorimetric_energy(data_dict, result_dict, conversion_factor=1.): + """Compute calorimetric energy by summing the charge depositions and + scaling by the ADC to MeV conversion factor. + + Parameters + ---------- + data_dict : dict + Data dictionary (contains one image-worth of data) + result_dict : dict + Result dictionary (contains one image-worth of data) + conversion_factor : float, optional + ADC to MeV conversion factor (MeV / ADC), by default 1. + + Returns + ------- + update_dict: dict + Dictionary to be included into result dictionary, containing the + computed energy under the key 'particle_calo_energy'. + """ input_data = data_dict['input_data'] if 'input_rescaled' not in result_dict else result_dict['input_rescaled'] particles = result_dict['particle_clusts'] @@ -34,6 +52,30 @@ def calorimetric_energy(data_dict, 'particle_node_pred_type']) def range_based_track_energy(data_dict, result_dict, bin_size=17, include_pids=[2, 3, 4], table_path=''): + """Compute track energy by the CSDA (continuous slowing-down approximation) + range-based method. + + Parameters + ---------- + data_dict : dict + Data dictionary (contains one image-worth of data) + result_dict : dict + Result dictionary (contains one image-worth of data) + bin_size : int, optional + Bin size used to perform local PCA along the track, by default 17 + include_pids : list, optional + Particle PDG codes (converted to 0-5 labels) to include in + computing the energies, by default [2, 3, 4] + table_path : str, optional + Path to muon/proton/pion CSDARange vs. energy table, by default '' + + Returns + ------- + update_dict: dict + Dictionary to be included into result dictionary, containing the + particle's estimated length ('particle_length') and the estimated + CSDA energy ('particle_range_based_energy') using cubic splines. + """ input_data = data_dict['input_data'] if 'input_rescaled' not in result_dict else result_dict['input_rescaled'] particles = result_dict['particle_clusts'] @@ -72,9 +114,21 @@ def range_based_track_energy(data_dict, result_dict, # Helper Functions @lru_cache(maxsize=10) def get_splines(particle_type, table_path): - ''' - Returns CSDARange (g/cm^2) vs. Kinetic E (MeV/c^2) - ''' + """_summary_ + + Parameters + ---------- + particle_type : int + Particle type ID to construct splines. + Only one of [2,3,4] are available. + table_path : str + Path to CSDARange vs Kinetic E table. + + Returns + ------- + f: Callable + Function mapping CSDARange (g/cm^2) vs. Kinetic E (MeV/c^2) + """ if particle_type == PDG_TO_PID[2212]: path = os.path.join(table_path, 'pE_liquid_argon.txt') tab = pd.read_csv(path, @@ -94,10 +148,8 @@ def get_splines(particle_type, table_path): def compute_track_length(points, bin_size=17): - """ - Compute track length by dividing it into segments - and computing a local PCA axis, then summing the - local lengths of the segments. + """Compute track length by dividing it into segments and computing + a local PCA axis, then summing the local lengths of the segments. Parameters ---------- From 01d113800d94558be028cd69fd72999937c293f1 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Thu, 20 Apr 2023 10:57:30 -0700 Subject: [PATCH 140/180] Update globals, add transformer model, update analysis README --- analysis/README.md | 2 +- .../models/experimental/cluster/criterion.py | 39 +- mlreco/models/experimental/cluster/mask3d.py | 361 ------------------ .../experimental/cluster/transformer_spice.py | 339 +++++++++++++++- mlreco/models/factories.py | 5 +- .../mask3d_model.py => transformer.py} | 28 +- mlreco/utils/globals.py | 1 + 7 files changed, 378 insertions(+), 397 deletions(-) delete mode 100644 mlreco/models/experimental/cluster/mask3d.py rename mlreco/models/{experimental/cluster/mask3d_model.py => transformer.py} (88%) diff --git a/analysis/README.md b/analysis/README.md index e8b1d897..03a373f1 100644 --- a/analysis/README.md +++ b/analysis/README.md @@ -548,7 +548,7 @@ This will generate a `log.csv` file under `log_dir`, which contain timing inform ----- Include a `profile=True` field under the post-processor name to log the timing information separately. For example: -``` +```yaml analysis: profile: True iteration: -1 diff --git a/mlreco/models/experimental/cluster/criterion.py b/mlreco/models/experimental/cluster/criterion.py index 3d9013c1..0890650b 100644 --- a/mlreco/models/experimental/cluster/criterion.py +++ b/mlreco/models/experimental/cluster/criterion.py @@ -35,20 +35,19 @@ def forward(self, masks, targets): cost_matrix = self.weight_dice * dice_loss + self.weight_ce * ce_loss indices = linear_sum_assignment(cost_matrix.detach().cpu()) - dice_loss = dice_loss_flat(masks[:, indices[0]], targets[:, indices[1]]) - # if self.mode == 'log_dice': - # dice_loss = batch_log_dice_loss(masks.T[indices[0]], targets.T[indices[1]]) - # elif self.mode == 'dice': - # dice_loss = batch_dice_loss(masks.T[indices[0]], targets.T[indices[1]]) + if self.mode == 'log_dice': + dice_loss = log_dice_loss_flat(masks[:, indices[0]], targets[:, indices[1]]) + elif self.mode == 'dice': + dice_loss = dice_loss_flat(masks[:, indices[0]], targets[:, indices[1]]) # elif self.mode == 'lovasz': # dice_loss = self.lovasz(masks[:, indices[0]], targets[:, indices[1]]) - # else: - # raise ValueError(f"LSA loss mode {self.mode} is not supported!") + else: + raise ValueError(f"LSA loss mode {self.mode} is not supported!") ce_loss = sigmoid_ce_loss(masks.T[indices[0]], targets.T[indices[1]]) loss = self.weight_dice * dice_loss + self.weight_ce * ce_loss acc = self.compute_accuracy(masks, targets, indices) - return loss, acc, indices + return loss, acc class CEDiceLoss(nn.Module): @@ -57,7 +56,6 @@ def __init__(self, weight_dice=1.0, weight_ce=1.0, mode='dice'): super(CEDiceLoss, self).__init__() self.weight_dice = weight_dice self.weight_ce = weight_ce - self.lovasz = LovaszHingeLoss() self.mode = mode print(f"Setting LinearSumAssignment loss to '{self.mode}'") @@ -65,12 +63,16 @@ def compute_accuracy(self, masks, targets): with torch.no_grad(): valid_masks = masks > 0 valid_targets = targets > 0.5 + + print(masks.sum(dim=0)) + print(targets.sum(dim=0)) + iou = iou_batch(valid_masks, valid_targets, eps=1e-6) return float(iou) def forward(self, masks, targets): - dice_loss = self.lovasz(masks, targets) + dice_loss = dice_loss_flat(masks, targets) # if self.mode == 'log_dice': # dice_loss = batch_log_dice_loss(masks.T[indices[0]], targets.T[indices[1]]) # elif self.mode == 'dice': @@ -226,4 +228,19 @@ def dice_loss_flat(logits, targets): scores = torch.sigmoid(logits) numerator = (2 * scores * targets).sum(dim=0) denominator = scores.sum(dim=0) + targets.sum(dim=0) - return (1 - (numerator + 1) / (denominator + 1)).sum() / num_masks \ No newline at end of file + return (1 - (numerator + 1) / (denominator + 1)).sum() / num_masks + +@torch.jit.script +def log_dice_loss_flat(logits, targets): + """ + + Parameters + ---------- + logits: (N x num_queries) + targets: (N x num_queries) + """ + num_masks = logits.shape[1] + scores = torch.sigmoid(logits) + numerator = (2 * scores * targets).sum(dim=0) + denominator = scores.sum(dim=0) + targets.sum(dim=0) + return (-torch.log(1 - (numerator + 1) / (denominator + 1))).sum() / num_masks \ No newline at end of file diff --git a/mlreco/models/experimental/cluster/mask3d.py b/mlreco/models/experimental/cluster/mask3d.py deleted file mode 100644 index f0bd3175..00000000 --- a/mlreco/models/experimental/cluster/mask3d.py +++ /dev/null @@ -1,361 +0,0 @@ -import torch -import torch.nn as nn - -import MinkowskiEngine as ME -import MinkowskiEngine.MinkowskiOps as me - -from mlreco.models.layers.common.uresnet_layers import UResNetDecoder, UResNetEncoder -from mlreco.models.experimental.transformers.positional_encodings import FourierEmbeddings -from mlreco.models.experimental.cluster.pointnet2.pointnet2_utils import furthest_point_sample -from mlreco.models.experimental.transformers.positional_encodings import get_normalized_coordinates -from mlreco.models.experimental.transformers.transformer import TransformerDecoder, GenericMLP -from torch_geometric.nn import MLP - - -from mlreco.utils.globals import * - -class QueryModule(nn.Module): - - def __init__(self, cfg, name='query_module'): - super(QueryModule, self).__init__() - - self.model_config = cfg[name] - - # Define instance query modules - self.num_input = self.model_config.get('num_input', 32) - self.num_pos_input = self.model_config.get('num_pos_input', 128) - self.num_queries = self.model_config.get('num_queries', 200) - # self.num_classes = self.model_config.get('num_classes', 5) - self.mask_dim = self.model_config.get('mask_dim', 128) - self.query_type = self.model_config.get('query_type', 'fps') - self.query_proj = None - - if self.query_type == 'fps': - self.query_projection = GenericMLP( - input_dim=self.num_input, - hidden_dims=[self.mask_dim], - output_dim=self.mask_dim, - use_conv=True, - output_use_activation=True, - hidden_use_bias=True - ) - self.query_pos_projection = GenericMLP( - input_dim=self.num_pos_input, - hidden_dims=[self.mask_dim], - output_dim=self.mask_dim, - use_conv=True, - output_use_activation=True, - hidden_use_bias=True - ) - elif self.query_type == 'embedding': - self.query_feat = nn.Embedding(self.num_queries, self.mask_dim) - self.query_pos = nn.Embedding(self.num_queries, self.mask_dim) - else: - raise ValueError("Query type {} is not supported!".format(self.query_type)) - - self.pos_enc = FourierEmbeddings(cfg) - - def forward(self, x, uresnet_features): - ''' - Inputs - ------ - x: Input ME.SparseTensor from UResNet output - ''' - - batch_size = len(x.decomposed_coordinates) - - if self.query_type == 'fps': - # Sample query points via FPS - fps_idx = [furthest_point_sample(x.decomposed_coordinates[i][None, ...].float(), - self.num_queries).squeeze(0).long() \ - for i in range(len(x.decomposed_coordinates))] - # B, nqueries, 3 - sampled_coords = torch.stack([x.decomposed_coordinates[i][fps_idx[i], :] \ - for i in range(len(x.decomposed_coordinates))], axis=0) - query_pos = self.pos_enc(sampled_coords.float()).permute(0, 2, 1) # B, dim, nqueries - query_pos = self.query_pos_projection(query_pos) # B, dim, mask_dim - queries = torch.stack([uresnet_features.decomposed_features[i][fps_idx[i].long(), :] \ - for i in range(len(fps_idx))]) # B, nqueries, num_uresnet_feats - queries = queries.permute(0, 2, 1) # B, num_uresnet_feats, nqueries - queries = self.query_projection(queries) # B, mask_dim, nqueries - elif self.query_type == 'embedding': - queries = self.query_feat.weight.unsqueze(0).repeat(batch_size, 1, 1) - query_pos = self.query_pos.weight.unsqueeze(1).repeat(1, batch_size, 1) - else: - raise ValueError("Query type {} is not supported!".format(self.query_type)) - - return queries.permute((0, 2, 1)), query_pos.permute((0, 2, 1)), fps_idx - -class Mask3d(nn.Module): - - def __init__(self, cfg, name='mask3d'): - super(Mask3d, self).__init__() - - self.model_config = cfg[name] - - self.encoder = UResNetEncoder(cfg, name='uresnet') - self.decoder = UResNetDecoder(cfg, name='uresnet') - - num_params_backbone = sum(p.numel() for p in self.encoder.parameters()) - num_params_backbone += sum(p.numel() for p in self.decoder.parameters()) - print(f"Number of Backbone Parameters = {num_params_backbone}") - - self.query_module = QueryModule(cfg) - - num_features = self.encoder.num_filters - self.D = self.model_config.get('D', 3) - self.mask_dim = self.model_config.get('mask_dim', 128) - self.num_classes = self.model_config.get('num_classes', 2) - self.num_heads = self.model_config.get('num_heads', 8) - self.dropout = self.model_config.get('dropout', 0.0) - self.normalize_before = self.model_config.get('normalize_before', False) - - device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") - - self.spatial_size = self.model_config.get('spatial_size', [2753, 1056, 5966]) - self.spatial_size = torch.Tensor(self.spatial_size).float().to(device) - - self.depth = self.model_config.get('depth', 5) - self.mask_head = ME.MinkowskiConvolution(num_features, self.mask_dim, - kernel_size=1, stride=1, bias=True, dimension=self.D) - - - # self.instance_to_mask = MLP([self.mask_dim] * 3) - self.instance_to_mask = nn.Sequential( - nn.Linear(self.mask_dim, self.mask_dim), - nn.ReLU(), - nn.Linear(self.mask_dim, self.mask_dim) - ) - self.instance_to_class = nn.Sequential( - nn.Linear(self.mask_dim, self.mask_dim), - nn.ReLU(), - nn.Linear(self.mask_dim, self.num_classes) - ) - self.layernorm = nn.LayerNorm(self.mask_dim) - - self.pooling = ME.MinkowskiAvgPooling(kernel_size=2, stride=2, dimension=3) - - # Layerwise Projections - self.linear_squeeze = nn.ModuleList() - for i in range(self.depth-1, 0, -1): - self.linear_squeeze.append(nn.Linear(i * num_features, - self.mask_dim)) - - # Query Refinement Modules - self.num_transformers = self.model_config.get('num_transformers', 3) - self.shared_decoders = self.model_config.get('shared_decoders', True) - - # Transformer Modules - if self.shared_decoders: - num_shared = 1 - else: - num_shared = self.num_decoders - - self.transformers = nn.ModuleList() - - for num_trans in range(num_shared): - self.transformers.append(TransformerDecoder(self.mask_dim, - self.num_heads, - dropout=self.dropout, - normalize_before=self.normalize_before)) - - self.sample_sizes = [200, 800, 3200, 12800, 51200] - self.adc_to_mev = 1./350 - - num_params = sum(p.numel() for p in self.parameters()) - print(f"Number of Total Parameters = {num_params}") - - - - def get_positional_encoding(self, x): - pos_encoding = [] - - for i in range(len(x.decomposed_coordinates)): - coords = x.decomposed_coordinates[i] - pos_enc_batch = self.query_module.pos_enc(coords, features=None) - pos_encoding.append(pos_enc_batch) - - pos_encoding = torch.cat(pos_encoding, dim=0) - return pos_encoding - - - def mask_module(self, queries, mask_features, - return_attention_mask=True, - num_pooling_steps=0): - ''' - Inputs - ------ - - queries: [B, num_queries, query_dim] torch.Tensor - - mask_features: ME.SparseTensor from mask head output - ''' - query_feats = self.layernorm(queries) - mask_embed = self.instance_to_mask(query_feats) - output_class = self.instance_to_class(query_feats) - - output_masks = [] - - coords, feats = mask_features.decomposed_coordinates_and_features - batch_size = len(coords) - - assert mask_embed.shape[0] == batch_size - - for i in range(len(mask_features.decomposed_features)): - mask = feats[i] @ mask_embed[i].T - output_masks.append(mask) - - output_masks = torch.cat(output_masks, dim=0) - output_coords = torch.cat(coords, dim=0) - output_mask = me.SparseTensor(features=output_masks, - coordinate_manager=mask_features.coordinate_manager, - coordinate_map_key=mask_features.coordinate_map_key) - if return_attention_mask: - # nn.MultiHeadAttention attn_mask prevents "True" pixels from access - # Hence the < 0.5 in the attn_mask - with torch.no_grad(): - attn_mask = output_mask - for _ in range(num_pooling_steps): - attn_mask = self.pooling(attn_mask.float()) - attn_mask = me.SparseTensor(features=(attn_mask.F.detach().sigmoid() < 0.5), - coordinate_manager=attn_mask.coordinate_manager, - coordinate_map_key=attn_mask.coordinate_map_key) - return output_mask, output_class, attn_mask - else: - return output_mask, output_class - - - def sampling_module(self, decomposed_feats, decomposed_coords, decomposed_attn, depth, - max_sample_size=False, is_eval=False): - - indices, masks = [], [] - - if min([pcd.shape[0] for pcd in decomposed_feats]) == 1: - raise RuntimeError("only a single point gives nans in cross-attention") - - decomposed_pos_encs = [] - - for coords in decomposed_coords: - pos_enc = self.query_module.pos_enc(coords.float()) - decomposed_pos_encs.append(pos_enc) - - device = decomposed_feats[0].device - - curr_sample_size = max([pcd.shape[0] for pcd in decomposed_feats]) - if not (max_sample_size or is_eval): - curr_sample_size = min(curr_sample_size, self.sample_sizes[depth]) - - for bidx in range(len(decomposed_feats)): - num_points = decomposed_feats[bidx].shape[0] - if num_points <= curr_sample_size: - idx = torch.zeros(curr_sample_size, - dtype=torch.long, - device=device) - - midx = torch.ones(curr_sample_size, - dtype=torch.bool, - device=device) - - idx[:num_points] = torch.arange(num_points, - device=device) - - midx[:num_points] = False # attend to first points - else: - # we have more points in pcd as we like to sample - # take a subset (no padding or masking needed) - idx = torch.randperm(decomposed_feats[bidx].shape[0], - device=device)[:curr_sample_size] - midx = torch.zeros(curr_sample_size, - dtype=torch.bool, - device=device) # attend to all - indices.append(idx) - masks.append(midx) - - batched_feats = torch.stack([ - decomposed_feats[b][indices[b], :] for b in range(len(indices)) - ]) - batched_attn = torch.stack([ - decomposed_attn[b][indices[b], :] for b in range(len(indices)) - ]) - batched_pos_enc = torch.stack([ - decomposed_pos_encs[b][indices[b], :] for b in range(len(indices)) - ]) - - # Mask to handle points less than num_sample points - m = torch.stack(masks) - # If sum(1) == nsamples, then this query has no active voxels - batched_attn.permute((0, 2, 1))[batched_attn.sum(1) == indices[0].shape[0]] = False - # Fianl attention map is intersection of attention map and - # valid voxel samples (m). - batched_attn = torch.logical_or(batched_attn, m[..., None]) - - return batched_feats, batched_attn, batched_pos_enc - - - def forward(self, point_cloud): - - coords = point_cloud[:, COORD_COLS].int() - feats = point_cloud[:, VALUE_COL].float().view(-1, 1) - - normed_coords = get_normalized_coordinates(coords, self.spatial_size) - normed_feats = feats * self.adc_to_mev - features = torch.cat([normed_coords, normed_feats], dim=1) - x = ME.SparseTensor(coordinates=point_cloud[:, :VALUE_COL].int(), - features=features) - encoderOutput = self.encoder(x) - decoderOutput = self.decoder(encoderOutput['finalTensor'], - encoderOutput['encoderTensors']) - queries, query_pos, query_index = self.query_module(x, decoderOutput[-1]) - - total_num_pooling = len(decoderOutput)-1 - - full_res_fmap = decoderOutput[-1] - - mask_features = self.mask_head(full_res_fmap) - - batch_size = int(torch.unique(x.C[:, 0]).shape[0]) - - predictions_mask = [] - predictions_class = [] - - for tf_index in range(self.num_transformers): - if self.shared_decoders: - transformer_index = 0 - else: - transformer_index = tf_index - for i, fmap in enumerate(decoderOutput): - assert queries.shape == (batch_size, self.query_module.num_queries, self.mask_dim) - num_pooling = total_num_pooling-i - # queries = queries.permute(2, 0, 1) - output_mask, output_class, attn_mask = self.mask_module(queries, - mask_features, - num_pooling_steps=num_pooling) - - predictions_mask.append(output_mask.F) - predictions_class.append(output_class) - - fmaps, attn_masks = fmap.decomposed_features, attn_mask.decomposed_features - decomposed_coords = fmap.decomposed_coordinates - batched_feats, batched_attn, batched_pos_enc = self.sampling_module( - fmaps, decomposed_coords, attn_masks, i) - src_pcd = self.linear_squeeze[i](batched_feats) - output = self.transformers[transformer_index](queries, - query_pos, - src_pcd, - batched_pos_enc, - batched_attn) - - queries = output - - output_mask, output_class, attn_mask = self.mask_module(queries, - mask_features, - return_attention_mask=True, - num_pooling_steps=0) - - res = { - 'pred_masks' : [output_mask.F], - 'pred_logits': [output_class], - 'aux_masks': [predictions_mask], - 'aux_classes': [predictions_class], - 'query_index': [query_index] - } - - return res \ No newline at end of file diff --git a/mlreco/models/experimental/cluster/transformer_spice.py b/mlreco/models/experimental/cluster/transformer_spice.py index 187291e7..9d1eb24f 100644 --- a/mlreco/models/experimental/cluster/transformer_spice.py +++ b/mlreco/models/experimental/cluster/transformer_spice.py @@ -8,13 +8,344 @@ from mlreco.models.experimental.transformers.positional_encodings import FourierEmbeddings from mlreco.models.experimental.cluster.pointnet2.pointnet2_utils import furthest_point_sample from mlreco.models.experimental.transformers.positional_encodings import get_normalized_coordinates -from mlreco.models.experimental.transformers.transformer import TransformerDecoder, GenericMLP -from torch_geometric.nn import MLP +from mlreco.utils.globals import * +from mlreco.models.experimental.transformers.transformer import GenericMLP +class QueryModule(nn.Module): + + def __init__(self, cfg, name='query_module'): + super(QueryModule, self).__init__() + + self.model_config = cfg[name] + + # Define instance query modules + self.num_input = self.model_config.get('num_input', 32) + self.num_pos_input = self.model_config.get('num_pos_input', 128) + self.num_queries = self.model_config.get('num_queries', 200) + # self.num_classes = self.model_config.get('num_classes', 5) + self.mask_dim = self.model_config.get('mask_dim', 128) + self.query_type = self.model_config.get('query_type', 'fps') + self.query_proj = None + + if self.query_type == 'fps': + self.query_projection = GenericMLP( + input_dim=self.num_input, + hidden_dims=[self.mask_dim], + output_dim=self.mask_dim, + norm_fn_name='bn1d', + use_conv=True, + output_use_activation=True, + hidden_use_bias=True + ) + self.query_pos_projection = GenericMLP( + input_dim=self.num_pos_input, + hidden_dims=[self.mask_dim], + output_dim=self.mask_dim, + norm_fn_name='bn1d', + use_conv=True, + output_use_activation=True, + hidden_use_bias=True + ) + elif self.query_type == 'embedding': + self.query_feat = nn.Embedding(self.num_queries, self.mask_dim) + self.query_pos = nn.Embedding(self.num_queries, self.mask_dim) + else: + raise ValueError("Query type {} is not supported!".format(self.query_type)) + + self.pos_enc = FourierEmbeddings(cfg) + + def forward(self, x, uresnet_features): + ''' + Inputs + ------ + x: Input ME.SparseTensor from UResNet output + ''' + + batch_size = len(x.decomposed_coordinates) + + if self.query_type == 'fps': + # Sample query points via FPS + fps_idx = [furthest_point_sample(x.decomposed_coordinates[i][None, ...].float(), + self.num_queries).squeeze(0).long() \ + for i in range(len(x.decomposed_coordinates))] + # B, nqueries, 3 + sampled_coords = torch.stack([x.decomposed_coordinates[i][fps_idx[i], :] \ + for i in range(len(x.decomposed_coordinates))], axis=0) + query_pos = self.pos_enc(sampled_coords.float()).permute(0, 2, 1) # B, dim, nqueries + query_pos = self.query_pos_projection(query_pos) # B, dim, mask_dim + queries = torch.stack([uresnet_features.decomposed_features[i][fps_idx[i].long(), :] \ + for i in range(len(fps_idx))]) # B, nqueries, num_uresnet_feats + queries = queries.permute(0, 2, 1) # B, num_uresnet_feats, nqueries + queries = self.query_projection(queries) # B, mask_dim, nqueries + elif self.query_type == 'embedding': + queries = self.query_feat.weight.unsqueze(0).repeat(batch_size, 1, 1) + query_pos = self.query_pos.weight.unsqueeze(1).repeat(1, batch_size, 1) + else: + raise ValueError("Query type {} is not supported!".format(self.query_type)) + + return queries.permute((0, 2, 1)), query_pos.permute((0, 2, 1)), fps_idx + class TransformerSPICE(nn.Module): + """ + Transformer based model for particle clustering, using Mask3D + as a backbone. + + Mask3D backbone implementation: https://github.com/JonasSchult/Mask3D + + Mask3D: https://arxiv.org/abs/2210.03105 - def __init__(self, cfg, name='transformer_spice'): + """ + + def __init__(self, cfg, name='mask3d'): super(TransformerSPICE, self).__init__() - self.model_config = cfg[name] \ No newline at end of file + self.model_config = cfg[name] + + self.encoder = UResNetEncoder(cfg, name='uresnet') + self.decoder = UResNetDecoder(cfg, name='uresnet') + + num_params_backbone = sum(p.numel() for p in self.encoder.parameters()) + num_params_backbone += sum(p.numel() for p in self.decoder.parameters()) + print(f"Number of Backbone Parameters = {num_params_backbone}") + + self.query_module = QueryModule(cfg) + + num_features = self.encoder.num_filters + self.D = self.model_config.get('D', 3) + self.mask_dim = self.model_config.get('mask_dim', 128) + self.num_classes = self.model_config.get('num_classes', 2) + self.num_heads = self.model_config.get('num_heads', 8) + self.dropout = self.model_config.get('dropout', 0.0) + + device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") + + self.spatial_size = self.model_config.get('spatial_size', [2753, 1056, 5966]) + self.spatial_size = torch.Tensor(self.spatial_size).float().to(device) + + self.depth = self.model_config.get('depth', 5) + self.mask_head = ME.MinkowskiConvolution(num_features, + self.mask_dim, + kernel_size=1, + stride=1, + bias=True, + dimension=self.D) + self.pooling = ME.MinkowskiAvgPooling(kernel_size=2, + stride=2, + dimension=3) + self.adc_to_mev = 1./350. + + # Query Refinement Modules + self.num_transformers = self.model_config.get('num_transformers', 3) + self.shared_decoders = self.model_config.get('shared_decoders', False) + + self.instance_to_mask = nn.Linear(self.mask_dim, self.mask_dim) + self.instance_to_class = nn.Linear(self.mask_dim, self.mask_dim) + + # Layerwise Projections + self.linear_squeeze = nn.ModuleList() + for i in range(self.depth-1, 0, -1): + self.linear_squeeze.append(nn.Linear(i * num_features, + self.mask_dim)) + + # Transformer Modules + if self.shared_decoders: + num_shared = 1 + else: + num_shared = self.num_transformers + + self.transformers = [] + + for num_trans in range(num_shared): + self.transformers.append(nn.TransformerDecoderLayer( + self.mask_dim, self.num_heads, dim_feedforward=1024, batch_first=True)) + + self.transformers = nn.ModuleList(self.transformers) + self.layernorm = nn.LayerNorm(self.mask_dim) + + self.sample_sizes = [200, 800, 1600, 6400, 12800] + + + def mask_module(self, queries, mask_features, + return_attention_mask=True, + num_pooling_steps=0): + ''' + Inputs + ------ + - queries: [B, num_queries, query_dim] torch.Tensor + - mask_features: ME.SparseTensor from mask head output + ''' + query_feats = self.layernorm(queries) + mask_embed = self.instance_to_mask(query_feats) + output_class = self.instance_to_class(query_feats) + + output_masks = [] + + coords, feats = mask_features.decomposed_coordinates_and_features + batch_size = len(coords) + + assert mask_embed.shape[0] == batch_size + + for i in range(len(mask_features.decomposed_features)): + mask = feats[i] @ mask_embed[i].T + output_masks.append(mask) + + output_masks = torch.cat(output_masks, dim=0) + output_coords = torch.cat(coords, dim=0) + output_mask = me.SparseTensor(features=output_masks, + coordinate_manager=mask_features.coordinate_manager, + coordinate_map_key=mask_features.coordinate_map_key) + + if return_attention_mask: + # nn.MultiHeadAttention attn_mask prevents "True" pixels from access + # Hence the < 0.5 in the attn_mask + with torch.no_grad(): + attn_mask = output_mask + for _ in range(num_pooling_steps): + attn_mask = self.pooling(attn_mask.float()) + attn_mask = me.SparseTensor(features=(attn_mask.F.detach().sigmoid() < 0.5), + coordinate_manager=attn_mask.coordinate_manager, + coordinate_map_key=attn_mask.coordinate_map_key) + return output_mask, output_class, attn_mask + else: + return output_mask, output_class + + + def sampling_module(self, decomposed_feats, decomposed_coords, decomposed_attn, depth, + max_sample_size=False, is_eval=False): + + indices, masks = [], [] + + if min([pcd.shape[0] for pcd in decomposed_feats]) == 1: + raise RuntimeError("only a single point gives nans in cross-attention") + + decomposed_pos_encs = [] + + for coords in decomposed_coords: + pos_enc = self.query_module.pos_enc(coords.float()) + decomposed_pos_encs.append(pos_enc) + + device = decomposed_feats[0].device + + curr_sample_size = max([pcd.shape[0] for pcd in decomposed_feats]) + if not (max_sample_size or is_eval): + curr_sample_size = min(curr_sample_size, self.sample_sizes[depth]) + + for bidx in range(len(decomposed_feats)): + num_points = decomposed_feats[bidx].shape[0] + if num_points <= curr_sample_size: + idx = torch.zeros(curr_sample_size, + dtype=torch.long, + device=device) + + midx = torch.ones(curr_sample_size, + dtype=torch.bool, + device=device) + + idx[:num_points] = torch.arange(num_points, + device=device) + + midx[:num_points] = False # attend to first points + else: + # we have more points in pcd as we like to sample + # take a subset (no padding or masking needed) + idx = torch.randperm(decomposed_feats[bidx].shape[0], + device=device)[:curr_sample_size] + midx = torch.zeros(curr_sample_size, + dtype=torch.bool, + device=device) # attend to all + indices.append(idx) + masks.append(midx) + + batched_feats = torch.stack([ + decomposed_feats[b][indices[b], :] for b in range(len(indices)) + ]) + batched_attn = torch.stack([ + decomposed_attn[b][indices[b], :] for b in range(len(indices)) + ]) + batched_pos_enc = torch.stack([ + decomposed_pos_encs[b][indices[b], :] for b in range(len(indices)) + ]) + + # Mask to handle points less than num_sample points + m = torch.stack(masks) + # If sum(1) == nsamples, then this query has no active voxels + batched_attn.permute((0, 2, 1))[batched_attn.sum(1) == indices[0].shape[0]] = False + # Fianl attention map is intersection of attention map and + # valid voxel samples (m). + batched_attn = torch.logical_or(batched_attn, m[..., None]) + + return batched_feats, batched_attn, batched_pos_enc + + + def forward(self, point_cloud): + + coords = point_cloud[:, COORD_COLS].int() + feats = point_cloud[:, VALUE_COL].float().view(-1, 1) + + normed_coords = get_normalized_coordinates(coords, self.spatial_size) + normed_feats = feats * self.adc_to_mev + features = torch.cat([normed_coords, normed_feats], dim=1) + x = ME.SparseTensor(coordinates=point_cloud[:, :VALUE_COL].int(), + features=features) + encoderOutput = self.encoder(x) + decoderOutput = self.decoder(encoderOutput['finalTensor'], + encoderOutput['encoderTensors']) + queries, query_pos, query_index = self.query_module(x, decoderOutput[-1]) + + total_num_pooling = len(decoderOutput)-1 + full_res_fmap = decoderOutput[-1] + mask_features = self.mask_head(full_res_fmap) + batch_size = int(torch.unique(x.C[:, 0]).shape[0]) + + predictions_mask = [] + predictions_class = [] + + for tf_index in range(self.num_transformers): + if self.shared_decoders: + transformer_index = 0 + else: + transformer_index = tf_index + for i, fmap in enumerate(decoderOutput): + assert queries.shape == (batch_size, + self.query_module.num_queries, + self.mask_dim) + num_pooling = total_num_pooling-i + + output_mask, output_class, attn_mask = self.mask_module(queries, + mask_features, + num_pooling_steps=num_pooling) + + predictions_mask.append(output_mask.F) + predictions_class.append(output_class) + + fmaps, attn_masks = fmap.decomposed_features, attn_mask.decomposed_features + decomposed_coords = fmap.decomposed_coordinates + + batched_feats, batched_attn, batched_pos_enc = self.sampling_module( + fmaps, decomposed_coords, attn_masks, i) + + src_pcd = self.linear_squeeze[i](batched_feats) + + batched_attn = torch.repeat_interleave(batched_attn.permute((0, 2, 1)), repeats=8, dim=0) + + output = self.transformers[transformer_index](queries + query_pos, + src_pcd + batched_pos_enc) + # memory_mask=batched_attn) + + queries = output + + output_mask, output_class, attn_mask = self.mask_module(queries, + mask_features, + return_attention_mask=True, + num_pooling_steps=0) + + res = { + 'pred_masks' : [output_mask.F], + 'pred_logits': [output_class], + 'aux_masks': [predictions_mask], + 'aux_classes': [predictions_class], + 'query_index': [query_index] + } + + return res \ No newline at end of file diff --git a/mlreco/models/factories.py b/mlreco/models/factories.py index d4484a87..7810ab5b 100644 --- a/mlreco/models/factories.py +++ b/mlreco/models/factories.py @@ -19,6 +19,7 @@ def model_dict(): from . import bayes_uresnet from . import vertex + from . import transformer # Make some models available (not all of them, e.g. PPN is not standalone) models = { @@ -53,7 +54,9 @@ def model_dict(): # Vertex PPN 'vertex_ppn': (vertex.VertexPPNChain, vertex.UResNetVertexLoss), # Vertex Pointnet - 'vertex_pointnet': (vertex.VertexPointNet, vertex.VertexPointNetLoss) + 'vertex_pointnet': (vertex.VertexPointNet, vertex.VertexPointNetLoss), + # TransformerSPICE + 'mask3d': (transformer.Mask3DModel, transformer.Mask3dLoss) } return models diff --git a/mlreco/models/experimental/cluster/mask3d_model.py b/mlreco/models/transformer.py similarity index 88% rename from mlreco/models/experimental/cluster/mask3d_model.py rename to mlreco/models/transformer.py index d547a4e7..fd94f34f 100644 --- a/mlreco/models/experimental/cluster/mask3d_model.py +++ b/mlreco/models/transformer.py @@ -4,7 +4,7 @@ import MinkowskiEngine as ME from pprint import pprint -from mlreco.models.experimental.cluster.mask3d import Mask3d +from mlreco.models.experimental.cluster.transformer_spice import TransformerSPICE from mlreco.models.experimental.cluster.criterion import * from mlreco.utils.globals import * from scipy.optimize import linear_sum_assignment @@ -30,18 +30,9 @@ class Mask3DModel(nn.Module): def __init__(self, cfg, name='mask3d'): super(Mask3DModel, self).__init__() - self.net = Mask3d(cfg) + self.net = TransformerSPICE(cfg) self.skip_classes = cfg[name].get('skip_classes') - def weight_initialization(self): - for m in self.modules(): - if isinstance(m, ME.MinkowskiConvolution): - ME.utils.kaiming_normal_(m.kernel, mode="fan_out", nonlinearity="relu") - - if isinstance(m, ME.MinkowskiBatchNorm): - nn.init.constant_(m.bn.weight, 1) - nn.init.constant_(m.bn.bias, 0) - def filter_class(self, x): ''' Filter classes according to segmentation label. @@ -109,8 +100,8 @@ def __init__(self, cfg, name='mask3d'): self.xentropy = nn.CrossEntropyLoss(weight=self.weight_class, reduction='mean') self.dice_loss_mode = self.model_config.get('dice_loss_mode', 'log_dice') - # self.loss_fn = LinearSumAssignmentLoss(mode=self.dice_loss_mode) - self.loss_fn = CEDiceLoss(mode=self.dice_loss_mode) + self.loss_fn = LinearSumAssignmentLoss(mode=self.dice_loss_mode) + # self.loss_fn = CEDiceLoss(mode=self.dice_loss_mode) def filter_class(self, cluster_label): ''' @@ -134,7 +125,8 @@ def compute_layerwise_loss(self, aux_masks, aux_classes, clabel, query_index): labels = clabel[0][batch_mask][:, GROUP_COL].long() query_idx_batch = query_index[bidx] # Compute instance mask loss - targets = get_instance_masks_from_queries(labels, query_idx_batch).float() + targets = get_instance_masks(labels).float() + # targets = get_instance_masks_from_queries(labels, query_idx_batch).float() loss_batch, acc_batch = self.loss_fn(mask_layer[batch_mask], targets) loss[bidx].append(loss_batch) @@ -183,11 +175,9 @@ def forward(self, result, cluster_label): labels = clabel[0][batch_mask][:, GROUP_COL].long() - # targets = get_instance_masks(labels).float() + targets = get_instance_masks(labels).float() query_idx_batch = query_index[bidx] - targets = get_instance_masks_from_queries(labels, query_idx_batch).float() - - # print(output_mask, targets) + # targets = get_instance_masks_from_queries(labels, query_idx_batch).float() loss_batch, acc_batch = self.loss_fn(output_mask, targets) loss[bidx].append(loss_batch) @@ -221,4 +211,4 @@ def forward(self, result, cluster_label): # 'acc_class': float(acc_class) } - return res + return res \ No newline at end of file diff --git a/mlreco/utils/globals.py b/mlreco/utils/globals.py index 55367295..f148d9ff 100644 --- a/mlreco/utils/globals.py +++ b/mlreco/utils/globals.py @@ -19,6 +19,7 @@ PGRP_COL = 11 VTX_COLS = (12,13,14) MOM_COL = 15 +SEG_COL = -1 # Colum which specifies the shape ID of a voxel in a sparse tensor SHAPE_COL = -1 From 06161639212b783e258c5f3915926ae59e3eabb9 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Thu, 20 Apr 2023 11:08:00 -0700 Subject: [PATCH 141/180] Start reworking documentation --- .gitignore | 1 + docs/Makefile | 2 +- docs/requirements_rtd.txt | 1 + docs/source/Configuration.rst | 227 ------------------ docs/source/GettingStarted.rst | 99 -------- docs/source/HowTo.rst | 199 --------------- .../analysis.algorithms.calorimetry.rst | 7 - .../analysis.algorithms.point_matching.rst | 7 - docs/source/analysis.algorithms.rst | 26 -- docs/source/analysis.algorithms.selection.rst | 7 - ...algorithms.selections.michel_electrons.rst | 7 - .../source/analysis.algorithms.selections.rst | 17 -- ...s.algorithms.selections.stopping_muons.rst | 7 - ...orithms.selections.through_going_muons.rst | 7 - docs/source/analysis.algorithms.utils.rst | 7 - docs/source/analysis.classes.particle.rst | 7 - docs/source/analysis.classes.rst | 16 -- docs/source/analysis.classes.ui.rst | 7 - docs/source/analysis.decorator.rst | 7 - docs/source/analysis.rst | 25 -- docs/source/analysis.run.rst | 7 - docs/source/conf.py | 17 +- docs/source/index.rst | 62 ++--- docs/source/mlreco.iotools.parsers.rst | 2 + docs/source/mlreco.iotools.rst | 3 + docs/source/mlreco.models.layers.common.rst | 2 + docs/source/mlreco.models.rst | 2 + .../mlreco.post_processing.acpt_muons.rst | 7 - ...rocessing.analysis.instance_clustering.rst | 7 - ...cessing.analysis.michel_reconstruction.rst | 7 - ...sing.analysis.michel_reconstruction_2d.rst | 7 - ...analysis.michel_reconstruction_noghost.rst | 7 - ...rocessing.analysis.muon_residual_range.rst | 7 - ...eco.post_processing.analysis.nu_energy.rst | 7 - ...post_processing.analysis.nue_selection.rst | 7 - .../mlreco.post_processing.analysis.rst | 24 -- ...ost_processing.analysis.stopping_muons.rst | 7 - ...post_processing.analysis.through_muons.rst | 7 - ...t_processing.analysis.track_clustering.rst | 7 - docs/source/mlreco.post_processing.common.rst | 7 - .../mlreco.post_processing.decorator.rst | 7 - ...cessing.metrics.bayes_segnet_mcdropout.rst | 7 - ...processing.metrics.cluster_cnn_metrics.rst | 7 - ...processing.metrics.cluster_gnn_metrics.rst | 7 - ...g.metrics.cosmic_discriminator_metrics.rst | 7 - ..._processing.metrics.deghosting_metrics.rst | 7 - ...co.post_processing.metrics.duq_metrics.rst | 7 - ...post_processing.metrics.evidential_gnn.rst | 7 - ..._processing.metrics.evidential_metrics.rst | 7 - ...t_processing.metrics.evidential_segnet.rst | 7 - ...processing.metrics.graph_spice_metrics.rst | 7 - ..._processing.metrics.kinematics_metrics.rst | 7 - ...co.post_processing.metrics.pid_metrics.rst | 7 - ...co.post_processing.metrics.ppn_metrics.rst | 7 - ...eco.post_processing.metrics.ppn_simple.rst | 7 - .../source/mlreco.post_processing.metrics.rst | 32 --- ...ost_processing.metrics.single_particle.rst | 7 - ...t_processing.metrics.singlep_mcdropout.rst | 7 - ...ost_processing.metrics.uresnet_metrics.rst | 7 - ...post_processing.metrics.vertex_metrics.rst | 7 - .../mlreco.post_processing.michel_shift.rst | 7 - docs/source/mlreco.post_processing.rst | 30 --- docs/source/mlreco.post_processing.store.rst | 18 -- ...reco.post_processing.store.store_input.rst | 7 - ...eco.post_processing.store.store_output.rst | 7 - ...co.post_processing.store.store_uresnet.rst | 7 - ...ost_processing.store.store_uresnet_ppn.rst | 7 - ...reco.post_processing.track_clustering2.rst | 7 - ...o.post_processing.track_clustering_old.rst | 7 - docs/source/mlreco.rst | 1 - docs/source/mlreco.utils.data_parallel.rst | 7 - docs/source/mlreco.utils.groups.rst | 7 - docs/source/mlreco.utils.numba.rst | 7 - docs/source/mlreco.utils.rst | 8 +- docs/source/mlreco.visualization.rst | 1 + docs/source/modules.rst | 7 - 76 files changed, 50 insertions(+), 1136 deletions(-) delete mode 100644 docs/source/Configuration.rst delete mode 100644 docs/source/GettingStarted.rst delete mode 100644 docs/source/HowTo.rst delete mode 100644 docs/source/analysis.algorithms.calorimetry.rst delete mode 100644 docs/source/analysis.algorithms.point_matching.rst delete mode 100644 docs/source/analysis.algorithms.rst delete mode 100644 docs/source/analysis.algorithms.selection.rst delete mode 100644 docs/source/analysis.algorithms.selections.michel_electrons.rst delete mode 100644 docs/source/analysis.algorithms.selections.rst delete mode 100644 docs/source/analysis.algorithms.selections.stopping_muons.rst delete mode 100644 docs/source/analysis.algorithms.selections.through_going_muons.rst delete mode 100644 docs/source/analysis.algorithms.utils.rst delete mode 100644 docs/source/analysis.classes.particle.rst delete mode 100644 docs/source/analysis.classes.rst delete mode 100644 docs/source/analysis.classes.ui.rst delete mode 100644 docs/source/analysis.decorator.rst delete mode 100644 docs/source/analysis.rst delete mode 100644 docs/source/analysis.run.rst delete mode 100644 docs/source/mlreco.post_processing.acpt_muons.rst delete mode 100644 docs/source/mlreco.post_processing.analysis.instance_clustering.rst delete mode 100644 docs/source/mlreco.post_processing.analysis.michel_reconstruction.rst delete mode 100644 docs/source/mlreco.post_processing.analysis.michel_reconstruction_2d.rst delete mode 100644 docs/source/mlreco.post_processing.analysis.michel_reconstruction_noghost.rst delete mode 100644 docs/source/mlreco.post_processing.analysis.muon_residual_range.rst delete mode 100644 docs/source/mlreco.post_processing.analysis.nu_energy.rst delete mode 100644 docs/source/mlreco.post_processing.analysis.nue_selection.rst delete mode 100644 docs/source/mlreco.post_processing.analysis.rst delete mode 100644 docs/source/mlreco.post_processing.analysis.stopping_muons.rst delete mode 100644 docs/source/mlreco.post_processing.analysis.through_muons.rst delete mode 100644 docs/source/mlreco.post_processing.analysis.track_clustering.rst delete mode 100644 docs/source/mlreco.post_processing.common.rst delete mode 100644 docs/source/mlreco.post_processing.decorator.rst delete mode 100644 docs/source/mlreco.post_processing.metrics.bayes_segnet_mcdropout.rst delete mode 100644 docs/source/mlreco.post_processing.metrics.cluster_cnn_metrics.rst delete mode 100644 docs/source/mlreco.post_processing.metrics.cluster_gnn_metrics.rst delete mode 100644 docs/source/mlreco.post_processing.metrics.cosmic_discriminator_metrics.rst delete mode 100644 docs/source/mlreco.post_processing.metrics.deghosting_metrics.rst delete mode 100644 docs/source/mlreco.post_processing.metrics.duq_metrics.rst delete mode 100644 docs/source/mlreco.post_processing.metrics.evidential_gnn.rst delete mode 100644 docs/source/mlreco.post_processing.metrics.evidential_metrics.rst delete mode 100644 docs/source/mlreco.post_processing.metrics.evidential_segnet.rst delete mode 100644 docs/source/mlreco.post_processing.metrics.graph_spice_metrics.rst delete mode 100644 docs/source/mlreco.post_processing.metrics.kinematics_metrics.rst delete mode 100644 docs/source/mlreco.post_processing.metrics.pid_metrics.rst delete mode 100644 docs/source/mlreco.post_processing.metrics.ppn_metrics.rst delete mode 100644 docs/source/mlreco.post_processing.metrics.ppn_simple.rst delete mode 100644 docs/source/mlreco.post_processing.metrics.rst delete mode 100644 docs/source/mlreco.post_processing.metrics.single_particle.rst delete mode 100644 docs/source/mlreco.post_processing.metrics.singlep_mcdropout.rst delete mode 100644 docs/source/mlreco.post_processing.metrics.uresnet_metrics.rst delete mode 100644 docs/source/mlreco.post_processing.metrics.vertex_metrics.rst delete mode 100644 docs/source/mlreco.post_processing.michel_shift.rst delete mode 100644 docs/source/mlreco.post_processing.rst delete mode 100644 docs/source/mlreco.post_processing.store.rst delete mode 100644 docs/source/mlreco.post_processing.store.store_input.rst delete mode 100644 docs/source/mlreco.post_processing.store.store_output.rst delete mode 100644 docs/source/mlreco.post_processing.store.store_uresnet.rst delete mode 100644 docs/source/mlreco.post_processing.store.store_uresnet_ppn.rst delete mode 100644 docs/source/mlreco.post_processing.track_clustering2.rst delete mode 100644 docs/source/mlreco.post_processing.track_clustering_old.rst delete mode 100644 docs/source/mlreco.utils.data_parallel.rst delete mode 100644 docs/source/mlreco.utils.groups.rst delete mode 100644 docs/source/mlreco.utils.numba.rst delete mode 100644 docs/source/modules.rst diff --git a/.gitignore b/.gitignore index b41bc89e..6aec5bca 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ weights* *.txt *.csv *.root +*.rst *.hdf5 *.h5 *.ipynb diff --git a/docs/Makefile b/docs/Makefile index 32bb86b3..ec5df2f0 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -6,7 +6,7 @@ SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = ./source -BUILDDIR = _build +BUILDDIR = ./build # Put it first so that "make" without argument is like "make help". help: diff --git a/docs/requirements_rtd.txt b/docs/requirements_rtd.txt index e8f09403..9bb553bb 100644 --- a/docs/requirements_rtd.txt +++ b/docs/requirements_rtd.txt @@ -22,3 +22,4 @@ sphinxcontrib-htmlhelp==1.0.3 sphinxcontrib-jsmath==1.0.1 sphinxcontrib-qthelp==1.0.3 sphinxcontrib-serializinghtml==1.1.4 +h5py \ No newline at end of file diff --git a/docs/source/Configuration.rst b/docs/source/Configuration.rst deleted file mode 100644 index a26fd97d..00000000 --- a/docs/source/Configuration.rst +++ /dev/null @@ -1,227 +0,0 @@ -Configuration -============= - -High-level overview -------------------- -Configuration files are written in the YAML format. -Some examples are distributed in the `config/` folder. -This page is a reference for the various configuration -keys and options that are generic. Module- or network- -specific configuration can be found in the corresponding -documentation. - -There are up to four top-level sections in a config file: - -- ``iotool`` -- ``model`` -- ``trainval`` -- ``post_processing`` (optional) - -``iotool`` section ------------------- - -.. rubric:: ``batch_size`` (default: 1) - -How many images the network will see at once -during an iteration. - -.. rubric:: ``shuffle`` (default: True) - -Whether to randomize the dataset sampling. - -.. rubric:: ``num_workers`` (default: 1) - -How many workers should be processing the -dataset in parallel. - -.. tip:: - - If you increase your - batch size significantly, you may want to - increase the number of workers. Conversely - if your batch size is small but you have - too many workers, the overhead time of - starting each worker will slow down the - start of your training/inference. - -.. rubric:: ``collate_fn`` (default: None) - -How to collate data from different events -into a single batch. -Can be `None`, `CollateSparse`, `CollateDense`. - -.. rubric:: ``sampler`` (batch_size, name) - -The sampler defines how events are picked in -the dataset. For training it is better to use -something like :any:`RandomSequenceSampler`. For -inference time you can omit this field and it -will fall back to the default, a sequential -sampling of the dataset. Available samplers -are in :any:`mlreco.iotools.samplers`. - -An example of sampler config looks like this: - -.. code-block:: yaml - - sampler: - batch_size: 32 - name: RandomSequenceSampler - -.. note:: The batch size should match the one specified above. - -.. rubric:: ``dataset`` - -Specifies where to find the dataset. It needs several pieces of -information: - -- ``name`` should be ``LArCVDataset`` (only available option at this time) -- ``data_keys`` is a list of paths where the dataset files live. - It accepts a wild card like ``*`` (uses ``glob`` to find files). -- ``limit_num_files`` is how many files to process from all files listed - in ``data_keys``. -- ``schema`` defines how you want to read your file. More on this in - :any:`mlreco.iotools`. - -An example of ``dataset`` config looks like this: - -.. code-block:: yaml - :linenos: - - dataset: - name: LArCVDataset - data_keys: - - /gpfs/slac/staas/fs1/g/neutrino/kterao/data/wire_mpvmpr_2020_04/train_*.root - limit_num_files: 10 - schema: - input_data: - - parse_sparse3d_scn - - sparse3d_reco - -``model`` section ------------------ - -.. rubric:: ``name`` - -Name of the model that you want to run -(typically one of the models under ``mlreco/models``). - -.. rubric:: ``modules`` - -An example of ``modules`` looks like this for the model -``full_chain``: - -.. code-block:: yaml - - modules: - chain: - enable_uresnet: True - enable_ppn: True - enable_cnn_clust: True - enable_gnn_shower: True - enable_gnn_track: True - enable_gnn_particle: False - enable_gnn_inter: True - enable_gnn_kinematics: False - enable_cosmic: False - enable_ghost: True - use_ppn_in_gnn: True - some_module: - ... config of the module ... - -.. rubric:: ``network_input`` - -This is a list of quantities from the input dataset -that should be fed to the network as input. -The names in the list refer to the names specified -in ``iotools.dataset.schema``. - -.. rubric:: ``loss_input`` - -This is a list of quantities from the input dataset -that should be fed to the loss function as input. -The names in the list refer to the names specified -in ``iotools.dataset.schema``. - -``trainval`` section --------------------- - -.. rubric:: ``seed`` (``int``) - -Integer to use as random seed. - -.. rubric:: ``unwrapper`` (default: ``unwrap``, optional) - -For now, can only be ``unwrap``. - -.. rubric:: concat_result (optional, ``list``) - -List of strings. Each string is a key in the output dictionary. -All outputs listed in ``concat_result`` will NOT undergo the -standard unwrapping process. - -.. rubric:: gpus (``string``) - -If empty string, use CPU. Otherwise string -containing one or more GPU ids. - -.. rubric:: weight_prefix - -Path to folder where weights will be saved. -Includes the weights file prefix, e.g. -`/path/to/snapshot-` for weights that will be -named `snapshot-0000.ckpt`, etc. - -.. rubric:: iterations (``int``) - -How many iterations to run for. - -.. rubric:: report_step (``int``) - -How often (in iterations) to print in the console log. - -.. rubric:: checkpoint_step (``int``) - -How often (in iterations) to save the weights in a -checkpoint file. - -.. rubric:: model_path (``str``) - -Can be empty string. Otherwise, path to a -checkpoint file to load for the whole model. - -.. note:: - - This can use wildcards such as ``*`` to load several - checkpoint files. Not to be used for training time, - but for inference time (e.g. for validation purpose). - -.. rubric:: log_dir (``str``) - -Path to a folder where logs will be stored. - -.. rubric:: train (``bool``) - -Boolean, whether to use train or inference mode. - -.. rubric:: debug - -.. rubric:: minibatch_size (default: -1) - -.. rubric:: optimizer - -Can look like this: - -.. code-block:: yaml - - optimizer: - name: Adam - args: - lr: 0.001 - -``post_processing`` section ---------------------------- -Post-processing scripts allow use to measure the performance -of each stage of the chain. - -Coming soon. diff --git a/docs/source/GettingStarted.rst b/docs/source/GettingStarted.rst deleted file mode 100644 index 5a1496b4..00000000 --- a/docs/source/GettingStarted.rst +++ /dev/null @@ -1,99 +0,0 @@ -Getting started -=============== - -``lartpc_mlreco3d`` is a machine learning pipeline for LArTPC data. - -Basic example --------------- - -.. code-block:: python - :linenos: - - # assume that lartpc_mlreco3d folder is on python path - from mlreco.main_funcs import process_config, train - import yaml - # Load configuration file - with open('lartpc_mlreco3d/config/test_uresnet.cfg', 'r') as f: - cfg = yaml.load(f, Loader=yaml.Loader) - process_config(cfg) - # train a model based on configuration - train(cfg) - -Ways to run ``lartpc_mlreco3d`` -------------------------------- -You have two options when it comes to using `lartpc_mlreco3d` -for your work: in Jupyter notebooks (interactively) or via -scripts in console (especially if you want to run more serious -trainings or high statistics inferences). - -Running interactively in Jupyter notebooks -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -You will need to make sure ``lartpc_mlreco3d`` is in your -python path. Typically by doing something like this at the -beginning of your noteboook (assuming the -library lives in your ``$HOME`` folder): - -.. code-block:: python - - import sys, os - # set software directory - software_dir = '%s/lartpc_mlreco3d' % os.environ.get('HOME') - sys.path.insert(0,software_dir) - -If you want to be able to control each iteration interactively, -you will need to process the config yourself like this: - -.. code-block:: python - - # 1. Load the YAML configuration custom.cfg - import yaml - cfg = yaml.load(open('custom.cfg', 'r'), Loader=yaml.Loader) - - # 2. Process configuration (checks + certain non-specified default settings) - from mlreco.main_funcs import process_config - process_config(cfg) - - # 3. Prepare function configures necessary "handlers" - from mlreco.main_funcs import prepare - hs = prepare(cfg) - -The so-called handlers then hold your I/O information (among others). -For example ``hs.data_io_iter`` is an iterator that you can use to -iterate through the dataset. - -.. code-block:: python - - data = next(hs.data_io_iter) - -Now if you are interested in more than visualizing your input data, -you can run the forward of the network like this: - -.. code-block:: python - - # Call forward to run the net - data, output = hs.trainer.forward(hs.data_io_iter) - -If you want to run the full training loop as specified in your config -file, then you can use the pre-defined ``train`` function: - -.. code-block:: python - - from mlreco.main_funcs import train - train(cfg) - -Running in console -~~~~~~~~~~~~~~~~~~ -Once you are confident with your config, you can run longer -trainings or gather higher statistics for your analysis. - -We have pre-defined ``train`` and ``inference`` functions that -will read your configuration and handle it for you. The way to -invoke them is via the ``bin/run.py`` script: - -.. code-block:: bash - - $ cd lartpc_mlreco3d - $ python3 bin/run.py config/custom.cfg - -You can then use ``nohup`` to leave it running in the background, -or submit it to a job batch system. diff --git a/docs/source/HowTo.rst b/docs/source/HowTo.rst deleted file mode 100644 index ce9c5ff1..00000000 --- a/docs/source/HowTo.rst +++ /dev/null @@ -1,199 +0,0 @@ -============================ -Help! I don't know how to X -============================ - -Dataset-related questions -------------------------- - -How to select specific entries -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -The ``iotool`` configuration has an option to select specific event indexes. -Here is an example: - -.. code-block:: yaml - - iotool: - dataset: - event_list: '[18,34,41]' - -How to go back to real-world coordinates -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Coordinates in ``lartpc_mlreco3d`` are assumed to be in the range -0 .. N where N is some integer. This range is in voxel units. -What if you want to identify a region based on its real-world -coordinates in cm, for example the cathode position? - -If you need to go back to absolute detector coordinates, you will -need to retrieve the *meta* information from the file. There is a -parser that can do this for you: - -.. code-block:: yaml - - iotool: - dataset: - schema: - - parse_meta3d - - sparse3d_reco - -then you will be able to access the ``meta`` information from the -data blob: - -.. code-block:: python - - min_x, min_y, min_z = data['meta'][entry][0:3] - max_x, max_y, max_z = data['meta'][entry][3:6] - size_voxel_x, size_voxel_y, size_voxel_z = data['meta'][entry][6:9] - - absolute_coords_x = relative_coords_x * size_voxel_x + min_x - -How to get true particle information -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -You need to use the parser ``parse_particles``. For example: - -.. code-block:: yaml - - iotool: - dataset: - schema: - particles: - - parse_particles - - particle_pcluster - - cluster3d_pcluster - -Then you will be able to access ``data['particles'][entry]`` -which is a list of objects of type ``larcv::Particle``. - -.. code-block:: python - - for p in data['particles'][entry]: - mom = np.array([p.px(), p.py(), p.pz()]) - print(p.id(), p.num_voxels(), mom/np.linalg.norm(mom)) - -You can see the full list of attributes of ``larcv::Particle`` objects -here: -https://github.com/DeepLearnPhysics/larcv2/blob/develop/larcv/core/DataFormat/Particle.h - - -How to get true neutrino information -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. tip:: - - As of now (6/1/22) you need to build your own copy of ``larcv2`` - to have access to the ``larcv::Neutrino`` data structure which - stores all of the true neutrino information. - - .. code-block:: bash - - $ git clone https://github.com/DeepLearnPhysics/larcv2.git - $ cd larcv2 & git checkout develop - $ source configure.sh & make -j4 - - If you use ``lartpc_mlreco3d`` in command line, you just need to - ``source larcv2/configure.sh`` before running ``lartpc_mlreco3d`` code. - - If instead you rely on a notebook, you will need to load the right version - of ``larcv``, the one you just built instead of the default one - from the Singularity container. - - .. code-block:: python - - %env LD_LIBRARY_PATH=/path/to/your/larcv2/build/lib:$LD_LIBRARY_PATH - - Replace the path with the correct one where you just built larcv2. - This cell should be the first one of your notebook (before you import - ``larcv`` or ``lartpc_mlreco3d`` modules). - - -Assuming you are either using a Singularity container that has the right -larcv2 compiled or you followed the note above explaining how to get it -by yourself, you can use the ``parse_neutrinos`` parser of ``lartpc_mlreco3d``. - - -.. code-block:: yaml - - iotool: - dataset: - schema: - neutrinos: - - parse_neutrinos - - neutrino_mpv - - cluster3d_pcluster - - -You can then read ``data['neutrinos'][entry]`` which is a list of -objects of type ``larcv::Neutrino``. You can check out the header -file here for a full list of attributes: -https://github.com/DeepLearnPhysics/larcv2/blob/develop/larcv/core/DataFormat/Neutrino.h - -A quick example could be: - -.. code-block:: python - - for neutrino in data['neutrinos'][entry]: - print(neutrino.pdg_code()) # 12 for nue, 14 for numu - print(neutrino.current_type(), neutrino.interaction_type()) - -If you try this, it will print integers for the current type and interaction type. -The key to interprete them is in the MCNeutrino header: -https://internal.dunescience.org/doxygen/MCNeutrino_8h_source.html - - -How to read true SimEnergyDeposits (true voxels) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -There is a way to retrieve the true voxels and SimEnergyDeposits particle-wise. -Add the following block to your configuration under ``iotool.dataset.schema``: - -.. code-block:: yaml - - iotool: - dataset: - schema: - simenergydeposits: - - parse_cluster3d - - cluster3d_sed - - -Then you can read it as such (e.g. using analysis tools' predictor): - -.. code-block:: python - - predictor.data_blob['simenergydeposits'][entry] - -It will have a shape ``(N, 6)`` where column ``4`` contains the SimEnergyDeposit value -and column ``5`` contains the particle ID. - - -Training-related questions --------------------------- - -How to freeze a model -^^^^^^^^^^^^^^^^^^^^^ -You can freeze the entire model or just a module (subset) of it. -The keyword in the configuration file is ``freeze_weight``. If you -put it under ``trainval`` directly, it will freeze the entire network. -If you put it under a module configuration, it will only freeze that -module. - -How to load partial weights -^^^^^^^^^^^^^^^^^^^^^^^^^^^ -``model_path`` does not have to be specified at the global level -(under ``trainval`` section). If it is, then the weights will be -loaded for the entire network. But if you want to only load the -weights for a submodule of the network, you can also specify -``model_path`` under that module's configuration. It will filter -weights names based on the module's name to make sure to only load -weights related to the module. - -.. tip:: - - If your weights are named differently in your checkpoint file - versus in your network, you can use ``model_name`` to fix it. - - TODO: explain more. - -I have another question! -^^^^^^^^^^^^^^^^^^^^^^^^ -Ping Laura (@Temigo) or someone else in the `lartpc_mlreco3d` team. -We might include your question here if it can be useful to others! diff --git a/docs/source/analysis.algorithms.calorimetry.rst b/docs/source/analysis.algorithms.calorimetry.rst deleted file mode 100644 index 0ed00584..00000000 --- a/docs/source/analysis.algorithms.calorimetry.rst +++ /dev/null @@ -1,7 +0,0 @@ -analysis.algorithms.calorimetry module -====================================== - -.. automodule:: analysis.algorithms.calorimetry - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/analysis.algorithms.point_matching.rst b/docs/source/analysis.algorithms.point_matching.rst deleted file mode 100644 index 2d13b441..00000000 --- a/docs/source/analysis.algorithms.point_matching.rst +++ /dev/null @@ -1,7 +0,0 @@ -analysis.algorithms.point\_matching module -========================================== - -.. automodule:: analysis.algorithms.point_matching - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/analysis.algorithms.rst b/docs/source/analysis.algorithms.rst deleted file mode 100644 index d1357a8f..00000000 --- a/docs/source/analysis.algorithms.rst +++ /dev/null @@ -1,26 +0,0 @@ -analysis.algorithms package -=========================== - -.. automodule:: analysis.algorithms - :members: - :undoc-members: - :show-inheritance: - -Subpackages ------------ - -.. toctree:: - :maxdepth: 4 - - analysis.algorithms.selections - -Submodules ----------- - -.. toctree:: - :maxdepth: 4 - - analysis.algorithms.calorimetry - analysis.algorithms.point_matching - analysis.algorithms.selection - analysis.algorithms.utils diff --git a/docs/source/analysis.algorithms.selection.rst b/docs/source/analysis.algorithms.selection.rst deleted file mode 100644 index c5907fcb..00000000 --- a/docs/source/analysis.algorithms.selection.rst +++ /dev/null @@ -1,7 +0,0 @@ -analysis.algorithms.selection module -==================================== - -.. automodule:: analysis.algorithms.selection - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/analysis.algorithms.selections.michel_electrons.rst b/docs/source/analysis.algorithms.selections.michel_electrons.rst deleted file mode 100644 index b5f2c51a..00000000 --- a/docs/source/analysis.algorithms.selections.michel_electrons.rst +++ /dev/null @@ -1,7 +0,0 @@ -analysis.algorithms.selections.michel\_electrons module -======================================================= - -.. automodule:: analysis.algorithms.selections.michel_electrons - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/analysis.algorithms.selections.rst b/docs/source/analysis.algorithms.selections.rst deleted file mode 100644 index 5f0e8839..00000000 --- a/docs/source/analysis.algorithms.selections.rst +++ /dev/null @@ -1,17 +0,0 @@ -analysis.algorithms.selections package -====================================== - -.. automodule:: analysis.algorithms.selections - :members: - :undoc-members: - :show-inheritance: - -Submodules ----------- - -.. toctree:: - :maxdepth: 4 - - analysis.algorithms.selections.michel_electrons - analysis.algorithms.selections.stopping_muons - analysis.algorithms.selections.through_going_muons diff --git a/docs/source/analysis.algorithms.selections.stopping_muons.rst b/docs/source/analysis.algorithms.selections.stopping_muons.rst deleted file mode 100644 index 145ee112..00000000 --- a/docs/source/analysis.algorithms.selections.stopping_muons.rst +++ /dev/null @@ -1,7 +0,0 @@ -analysis.algorithms.selections.stopping\_muons module -===================================================== - -.. automodule:: analysis.algorithms.selections.stopping_muons - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/analysis.algorithms.selections.through_going_muons.rst b/docs/source/analysis.algorithms.selections.through_going_muons.rst deleted file mode 100644 index c46252e1..00000000 --- a/docs/source/analysis.algorithms.selections.through_going_muons.rst +++ /dev/null @@ -1,7 +0,0 @@ -analysis.algorithms.selections.through\_going\_muons module -=========================================================== - -.. automodule:: analysis.algorithms.selections.through_going_muons - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/analysis.algorithms.utils.rst b/docs/source/analysis.algorithms.utils.rst deleted file mode 100644 index a9984f45..00000000 --- a/docs/source/analysis.algorithms.utils.rst +++ /dev/null @@ -1,7 +0,0 @@ -analysis.algorithms.utils module -================================ - -.. automodule:: analysis.algorithms.utils - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/analysis.classes.particle.rst b/docs/source/analysis.classes.particle.rst deleted file mode 100644 index 49a289ad..00000000 --- a/docs/source/analysis.classes.particle.rst +++ /dev/null @@ -1,7 +0,0 @@ -analysis.classes.particle module -================================ - -.. automodule:: analysis.classes.particle - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/analysis.classes.rst b/docs/source/analysis.classes.rst deleted file mode 100644 index 6f4cf52f..00000000 --- a/docs/source/analysis.classes.rst +++ /dev/null @@ -1,16 +0,0 @@ -analysis.classes package -======================== - -.. automodule:: analysis.classes - :members: - :undoc-members: - :show-inheritance: - -Submodules ----------- - -.. toctree:: - :maxdepth: 4 - - analysis.classes.particle - analysis.classes.ui diff --git a/docs/source/analysis.classes.ui.rst b/docs/source/analysis.classes.ui.rst deleted file mode 100644 index 88bf9dd6..00000000 --- a/docs/source/analysis.classes.ui.rst +++ /dev/null @@ -1,7 +0,0 @@ -analysis.classes.ui module -========================== - -.. automodule:: analysis.classes.ui - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/analysis.decorator.rst b/docs/source/analysis.decorator.rst deleted file mode 100644 index b20a6589..00000000 --- a/docs/source/analysis.decorator.rst +++ /dev/null @@ -1,7 +0,0 @@ -analysis.decorator module -========================= - -.. automodule:: analysis.decorator - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/analysis.rst b/docs/source/analysis.rst deleted file mode 100644 index 0eec894b..00000000 --- a/docs/source/analysis.rst +++ /dev/null @@ -1,25 +0,0 @@ -analysis package -================ - -.. automodule:: analysis - :members: - :undoc-members: - :show-inheritance: - -Subpackages ------------ - -.. toctree:: - :maxdepth: 4 - - analysis.algorithms - analysis.classes - -Submodules ----------- - -.. toctree:: - :maxdepth: 4 - - analysis.decorator - analysis.run diff --git a/docs/source/analysis.run.rst b/docs/source/analysis.run.rst deleted file mode 100644 index 97c331f2..00000000 --- a/docs/source/analysis.run.rst +++ /dev/null @@ -1,7 +0,0 @@ -analysis.run module -=================== - -.. automodule:: analysis.run - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py index 6cfddb77..bfaa8091 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,7 +1,6 @@ # Configuration file for the Sphinx documentation builder. # -# This file only contains a selection of the most common options. For a full -# list see the documentation: +# For the full list of built-in configuration values, see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Path setup -------------------------------------------------------------- @@ -15,13 +14,13 @@ sys.path.insert(0, os.path.abspath('../../')) sys.path.insert(0, os.path.abspath('./')) - # -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information project = 'lartpc_mlreco3d' -copyright = '2021-2022, DeepLearnPhysics collaboration' -author = 'DeepLearnPhysics collaboration' - +copyright = '2023, DeepLearningPhysics Collaboration' +author = 'DeepLearningPhysics Collaboration' +release = '0.1' # -- General configuration --------------------------------------------------- @@ -34,7 +33,7 @@ 'sphinx_rtd_theme', 'sphinx.ext.autodoc', 'sphinx.ext.napoleon', - #'numpydoc', + 'numpydoc', #'sphinx.ext.autosummary', 'sphinx_copybutton', 'sphinx.ext.autosectionlabel', @@ -57,7 +56,7 @@ 'exclude-members': None, } autodoc_mock_imports = [ - "sparseconvnet", + # "sparseconvnet", "larcv", "numba", "torch_geometric", @@ -77,7 +76,7 @@ # # html_theme = 'alabaster' # html_theme = "sphinx_rtd_theme" -html_theme = "sphinx_book_theme" +html_theme = "sphinx_rtd_theme" html_theme_options = { "show_toc_level": 5 } diff --git a/docs/source/index.rst b/docs/source/index.rst index ff208e80..eaa4e3eb 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,56 +1,42 @@ .. lartpc_mlreco3d documentation master file, created by - sphinx-quickstart on Thu Mar 4 20:35:31 2021. + sphinx-quickstart on Wed Apr 12 23:23:15 2023. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. Welcome to lartpc_mlreco3d's documentation! =========================================== -This documentation is meant to host technical details related to ``lartpc_mlreco3d``. -.. seealso:: - - If you are looking for more step-by-step tutorials, please visit - http://deeplearnphysics.org/lartpc_mlreco3d_tutorials/ - - -.. warning:: - - This is a work-in-progress. If you see something - (that needs clarification, that is misleading, - or simply missing), please **do** something! - Feel free to send pull requests on Github to - improve documentation. +This repository contains code used for training and running machine learning models on LArTPC data. .. toctree:: - :hidden: - :caption: Guides - - GettingStarted - Configuration - HowTo - I/O Parsers + :maxdepth: 1 + :caption: Install lartpc_mlreco3d .. toctree:: - :hidden: - :caption: Quick Links to Models + :maxdepth: 1 + :caption: Usage - UResNet - PPN - GraphSpice - Grappa - Full Chain +.. toctree:: + :maxdepth: 1 + :caption: Tutorials .. toctree:: - :hidden: - :caption: Reference + :maxdepth: 2 + :caption: Package Reference + :glob: + + Analysis Tools + mlreco + mlreco.iotools + mlreco.models + mlreco.visualization + mlreco.utils - analysis - mlreco -Indices and tables -~~~~~~~~~~~~~~~~~~ +.. Indices and tables +.. ================== -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` +.. * :ref:`genindex` +.. * :ref:`modindex` +.. * :ref:`search` diff --git a/docs/source/mlreco.iotools.parsers.rst b/docs/source/mlreco.iotools.parsers.rst index b60eeead..a969154e 100644 --- a/docs/source/mlreco.iotools.parsers.rst +++ b/docs/source/mlreco.iotools.parsers.rst @@ -14,6 +14,8 @@ Submodules mlreco.iotools.parsers.clean_data mlreco.iotools.parsers.cluster + mlreco.iotools.parsers.label_data mlreco.iotools.parsers.misc mlreco.iotools.parsers.particles mlreco.iotools.parsers.sparse + mlreco.iotools.parsers.unwrap_rules diff --git a/docs/source/mlreco.iotools.rst b/docs/source/mlreco.iotools.rst index f770b554..c9fe8087 100644 --- a/docs/source/mlreco.iotools.rst +++ b/docs/source/mlreco.iotools.rst @@ -21,6 +21,9 @@ Submodules :maxdepth: 4 mlreco.iotools.collates + mlreco.iotools.data_parallel mlreco.iotools.datasets mlreco.iotools.factories + mlreco.iotools.readers mlreco.iotools.samplers + mlreco.iotools.writers diff --git a/docs/source/mlreco.models.layers.common.rst b/docs/source/mlreco.models.layers.common.rst index dae388d8..db1a088e 100644 --- a/docs/source/mlreco.models.layers.common.rst +++ b/docs/source/mlreco.models.layers.common.rst @@ -21,6 +21,7 @@ Submodules mlreco.models.layers.common.extract_feature_map mlreco.models.layers.common.fpn mlreco.models.layers.common.gnn_full_chain + mlreco.models.layers.common.mlp_factories mlreco.models.layers.common.mobilenet mlreco.models.layers.common.momentum mlreco.models.layers.common.nonlinearities @@ -30,3 +31,4 @@ Submodules mlreco.models.layers.common.sparse_generator mlreco.models.layers.common.uresnet_layers mlreco.models.layers.common.uresnext + mlreco.models.layers.common.vertex_ppn diff --git a/docs/source/mlreco.models.rst b/docs/source/mlreco.models.rst index 327e3cb2..ea4e5917 100644 --- a/docs/source/mlreco.models.rst +++ b/docs/source/mlreco.models.rst @@ -27,5 +27,7 @@ Submodules mlreco.models.grappa mlreco.models.singlep mlreco.models.spice + mlreco.models.transformer mlreco.models.uresnet mlreco.models.uresnet_ppn_chain + mlreco.models.vertex diff --git a/docs/source/mlreco.post_processing.acpt_muons.rst b/docs/source/mlreco.post_processing.acpt_muons.rst deleted file mode 100644 index 815758b5..00000000 --- a/docs/source/mlreco.post_processing.acpt_muons.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.acpt\_muons module -========================================== - -.. automodule:: mlreco.post_processing.acpt_muons - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.analysis.instance_clustering.rst b/docs/source/mlreco.post_processing.analysis.instance_clustering.rst deleted file mode 100644 index cc5d67fa..00000000 --- a/docs/source/mlreco.post_processing.analysis.instance_clustering.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.analysis.instance\_clustering module -============================================================ - -.. automodule:: mlreco.post_processing.analysis.instance_clustering - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.analysis.michel_reconstruction.rst b/docs/source/mlreco.post_processing.analysis.michel_reconstruction.rst deleted file mode 100644 index 4dd26841..00000000 --- a/docs/source/mlreco.post_processing.analysis.michel_reconstruction.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.analysis.michel\_reconstruction module -============================================================== - -.. automodule:: mlreco.post_processing.analysis.michel_reconstruction - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.analysis.michel_reconstruction_2d.rst b/docs/source/mlreco.post_processing.analysis.michel_reconstruction_2d.rst deleted file mode 100644 index 09c31c1e..00000000 --- a/docs/source/mlreco.post_processing.analysis.michel_reconstruction_2d.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.analysis.michel\_reconstruction\_2d module -================================================================== - -.. automodule:: mlreco.post_processing.analysis.michel_reconstruction_2d - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.analysis.michel_reconstruction_noghost.rst b/docs/source/mlreco.post_processing.analysis.michel_reconstruction_noghost.rst deleted file mode 100644 index 308163fb..00000000 --- a/docs/source/mlreco.post_processing.analysis.michel_reconstruction_noghost.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.analysis.michel\_reconstruction\_noghost module -======================================================================= - -.. automodule:: mlreco.post_processing.analysis.michel_reconstruction_noghost - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.analysis.muon_residual_range.rst b/docs/source/mlreco.post_processing.analysis.muon_residual_range.rst deleted file mode 100644 index 337871f2..00000000 --- a/docs/source/mlreco.post_processing.analysis.muon_residual_range.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.analysis.muon\_residual\_range module -============================================================= - -.. automodule:: mlreco.post_processing.analysis.muon_residual_range - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.analysis.nu_energy.rst b/docs/source/mlreco.post_processing.analysis.nu_energy.rst deleted file mode 100644 index 0092476e..00000000 --- a/docs/source/mlreco.post_processing.analysis.nu_energy.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.analysis.nu\_energy module -================================================== - -.. automodule:: mlreco.post_processing.analysis.nu_energy - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.analysis.nue_selection.rst b/docs/source/mlreco.post_processing.analysis.nue_selection.rst deleted file mode 100644 index eb83f588..00000000 --- a/docs/source/mlreco.post_processing.analysis.nue_selection.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.analysis.nue\_selection module -====================================================== - -.. automodule:: mlreco.post_processing.analysis.nue_selection - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.analysis.rst b/docs/source/mlreco.post_processing.analysis.rst deleted file mode 100644 index 00634dd6..00000000 --- a/docs/source/mlreco.post_processing.analysis.rst +++ /dev/null @@ -1,24 +0,0 @@ -mlreco.post\_processing.analysis package -======================================== - -.. automodule:: mlreco.post_processing.analysis - :members: - :undoc-members: - :show-inheritance: - -Submodules ----------- - -.. toctree:: - :maxdepth: 4 - - mlreco.post_processing.analysis.instance_clustering - mlreco.post_processing.analysis.michel_reconstruction - mlreco.post_processing.analysis.michel_reconstruction_2d - mlreco.post_processing.analysis.michel_reconstruction_noghost - mlreco.post_processing.analysis.muon_residual_range - mlreco.post_processing.analysis.nu_energy - mlreco.post_processing.analysis.nue_selection - mlreco.post_processing.analysis.stopping_muons - mlreco.post_processing.analysis.through_muons - mlreco.post_processing.analysis.track_clustering diff --git a/docs/source/mlreco.post_processing.analysis.stopping_muons.rst b/docs/source/mlreco.post_processing.analysis.stopping_muons.rst deleted file mode 100644 index 41bf5e6f..00000000 --- a/docs/source/mlreco.post_processing.analysis.stopping_muons.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.analysis.stopping\_muons module -======================================================= - -.. automodule:: mlreco.post_processing.analysis.stopping_muons - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.analysis.through_muons.rst b/docs/source/mlreco.post_processing.analysis.through_muons.rst deleted file mode 100644 index e05fa929..00000000 --- a/docs/source/mlreco.post_processing.analysis.through_muons.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.analysis.through\_muons module -====================================================== - -.. automodule:: mlreco.post_processing.analysis.through_muons - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.analysis.track_clustering.rst b/docs/source/mlreco.post_processing.analysis.track_clustering.rst deleted file mode 100644 index c24730b9..00000000 --- a/docs/source/mlreco.post_processing.analysis.track_clustering.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.analysis.track\_clustering module -========================================================= - -.. automodule:: mlreco.post_processing.analysis.track_clustering - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.common.rst b/docs/source/mlreco.post_processing.common.rst deleted file mode 100644 index d90f02ce..00000000 --- a/docs/source/mlreco.post_processing.common.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.common module -===================================== - -.. automodule:: mlreco.post_processing.common - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.decorator.rst b/docs/source/mlreco.post_processing.decorator.rst deleted file mode 100644 index 76078b8d..00000000 --- a/docs/source/mlreco.post_processing.decorator.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.decorator module -======================================== - -.. automodule:: mlreco.post_processing.decorator - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.metrics.bayes_segnet_mcdropout.rst b/docs/source/mlreco.post_processing.metrics.bayes_segnet_mcdropout.rst deleted file mode 100644 index d35ce023..00000000 --- a/docs/source/mlreco.post_processing.metrics.bayes_segnet_mcdropout.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.metrics.bayes\_segnet\_mcdropout module -=============================================================== - -.. automodule:: mlreco.post_processing.metrics.bayes_segnet_mcdropout - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.metrics.cluster_cnn_metrics.rst b/docs/source/mlreco.post_processing.metrics.cluster_cnn_metrics.rst deleted file mode 100644 index 958c18e2..00000000 --- a/docs/source/mlreco.post_processing.metrics.cluster_cnn_metrics.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.metrics.cluster\_cnn\_metrics module -============================================================ - -.. automodule:: mlreco.post_processing.metrics.cluster_cnn_metrics - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.metrics.cluster_gnn_metrics.rst b/docs/source/mlreco.post_processing.metrics.cluster_gnn_metrics.rst deleted file mode 100644 index 85e5e824..00000000 --- a/docs/source/mlreco.post_processing.metrics.cluster_gnn_metrics.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.metrics.cluster\_gnn\_metrics module -============================================================ - -.. automodule:: mlreco.post_processing.metrics.cluster_gnn_metrics - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.metrics.cosmic_discriminator_metrics.rst b/docs/source/mlreco.post_processing.metrics.cosmic_discriminator_metrics.rst deleted file mode 100644 index bf74c0aa..00000000 --- a/docs/source/mlreco.post_processing.metrics.cosmic_discriminator_metrics.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.metrics.cosmic\_discriminator\_metrics module -===================================================================== - -.. automodule:: mlreco.post_processing.metrics.cosmic_discriminator_metrics - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.metrics.deghosting_metrics.rst b/docs/source/mlreco.post_processing.metrics.deghosting_metrics.rst deleted file mode 100644 index 787a85b2..00000000 --- a/docs/source/mlreco.post_processing.metrics.deghosting_metrics.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.metrics.deghosting\_metrics module -========================================================== - -.. automodule:: mlreco.post_processing.metrics.deghosting_metrics - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.metrics.duq_metrics.rst b/docs/source/mlreco.post_processing.metrics.duq_metrics.rst deleted file mode 100644 index 1a8dd948..00000000 --- a/docs/source/mlreco.post_processing.metrics.duq_metrics.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.metrics.duq\_metrics module -=================================================== - -.. automodule:: mlreco.post_processing.metrics.duq_metrics - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.metrics.evidential_gnn.rst b/docs/source/mlreco.post_processing.metrics.evidential_gnn.rst deleted file mode 100644 index d4df6f9e..00000000 --- a/docs/source/mlreco.post_processing.metrics.evidential_gnn.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.metrics.evidential\_gnn module -====================================================== - -.. automodule:: mlreco.post_processing.metrics.evidential_gnn - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.metrics.evidential_metrics.rst b/docs/source/mlreco.post_processing.metrics.evidential_metrics.rst deleted file mode 100644 index 5cfc013f..00000000 --- a/docs/source/mlreco.post_processing.metrics.evidential_metrics.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.metrics.evidential\_metrics module -========================================================== - -.. automodule:: mlreco.post_processing.metrics.evidential_metrics - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.metrics.evidential_segnet.rst b/docs/source/mlreco.post_processing.metrics.evidential_segnet.rst deleted file mode 100644 index 0c25984b..00000000 --- a/docs/source/mlreco.post_processing.metrics.evidential_segnet.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.metrics.evidential\_segnet module -========================================================= - -.. automodule:: mlreco.post_processing.metrics.evidential_segnet - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.metrics.graph_spice_metrics.rst b/docs/source/mlreco.post_processing.metrics.graph_spice_metrics.rst deleted file mode 100644 index d5de0953..00000000 --- a/docs/source/mlreco.post_processing.metrics.graph_spice_metrics.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.metrics.graph\_spice\_metrics module -============================================================ - -.. automodule:: mlreco.post_processing.metrics.graph_spice_metrics - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.metrics.kinematics_metrics.rst b/docs/source/mlreco.post_processing.metrics.kinematics_metrics.rst deleted file mode 100644 index 6abb296e..00000000 --- a/docs/source/mlreco.post_processing.metrics.kinematics_metrics.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.metrics.kinematics\_metrics module -========================================================== - -.. automodule:: mlreco.post_processing.metrics.kinematics_metrics - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.metrics.pid_metrics.rst b/docs/source/mlreco.post_processing.metrics.pid_metrics.rst deleted file mode 100644 index 7f40ed28..00000000 --- a/docs/source/mlreco.post_processing.metrics.pid_metrics.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.metrics.pid\_metrics module -=================================================== - -.. automodule:: mlreco.post_processing.metrics.pid_metrics - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.metrics.ppn_metrics.rst b/docs/source/mlreco.post_processing.metrics.ppn_metrics.rst deleted file mode 100644 index 0ba6af29..00000000 --- a/docs/source/mlreco.post_processing.metrics.ppn_metrics.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.metrics.ppn\_metrics module -=================================================== - -.. automodule:: mlreco.post_processing.metrics.ppn_metrics - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.metrics.ppn_simple.rst b/docs/source/mlreco.post_processing.metrics.ppn_simple.rst deleted file mode 100644 index ca3c9dbd..00000000 --- a/docs/source/mlreco.post_processing.metrics.ppn_simple.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.metrics.ppn\_simple module -================================================== - -.. automodule:: mlreco.post_processing.metrics.ppn_simple - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.metrics.rst b/docs/source/mlreco.post_processing.metrics.rst deleted file mode 100644 index a406bf40..00000000 --- a/docs/source/mlreco.post_processing.metrics.rst +++ /dev/null @@ -1,32 +0,0 @@ -mlreco.post\_processing.metrics package -======================================= - -.. automodule:: mlreco.post_processing.metrics - :members: - :undoc-members: - :show-inheritance: - -Submodules ----------- - -.. toctree:: - :maxdepth: 4 - - mlreco.post_processing.metrics.bayes_segnet_mcdropout - mlreco.post_processing.metrics.cluster_cnn_metrics - mlreco.post_processing.metrics.cluster_gnn_metrics - mlreco.post_processing.metrics.cosmic_discriminator_metrics - mlreco.post_processing.metrics.deghosting_metrics - mlreco.post_processing.metrics.duq_metrics - mlreco.post_processing.metrics.evidential_gnn - mlreco.post_processing.metrics.evidential_metrics - mlreco.post_processing.metrics.evidential_segnet - mlreco.post_processing.metrics.graph_spice_metrics - mlreco.post_processing.metrics.kinematics_metrics - mlreco.post_processing.metrics.pid_metrics - mlreco.post_processing.metrics.ppn_metrics - mlreco.post_processing.metrics.ppn_simple - mlreco.post_processing.metrics.single_particle - mlreco.post_processing.metrics.singlep_mcdropout - mlreco.post_processing.metrics.uresnet_metrics - mlreco.post_processing.metrics.vertex_metrics diff --git a/docs/source/mlreco.post_processing.metrics.single_particle.rst b/docs/source/mlreco.post_processing.metrics.single_particle.rst deleted file mode 100644 index a65774a6..00000000 --- a/docs/source/mlreco.post_processing.metrics.single_particle.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.metrics.single\_particle module -======================================================= - -.. automodule:: mlreco.post_processing.metrics.single_particle - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.metrics.singlep_mcdropout.rst b/docs/source/mlreco.post_processing.metrics.singlep_mcdropout.rst deleted file mode 100644 index 600a039a..00000000 --- a/docs/source/mlreco.post_processing.metrics.singlep_mcdropout.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.metrics.singlep\_mcdropout module -========================================================= - -.. automodule:: mlreco.post_processing.metrics.singlep_mcdropout - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.metrics.uresnet_metrics.rst b/docs/source/mlreco.post_processing.metrics.uresnet_metrics.rst deleted file mode 100644 index dd86eed7..00000000 --- a/docs/source/mlreco.post_processing.metrics.uresnet_metrics.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.metrics.uresnet\_metrics module -======================================================= - -.. automodule:: mlreco.post_processing.metrics.uresnet_metrics - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.metrics.vertex_metrics.rst b/docs/source/mlreco.post_processing.metrics.vertex_metrics.rst deleted file mode 100644 index c5be9f92..00000000 --- a/docs/source/mlreco.post_processing.metrics.vertex_metrics.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.metrics.vertex\_metrics module -====================================================== - -.. automodule:: mlreco.post_processing.metrics.vertex_metrics - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.michel_shift.rst b/docs/source/mlreco.post_processing.michel_shift.rst deleted file mode 100644 index ed858204..00000000 --- a/docs/source/mlreco.post_processing.michel_shift.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.michel\_shift module -============================================ - -.. automodule:: mlreco.post_processing.michel_shift - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.rst b/docs/source/mlreco.post_processing.rst deleted file mode 100644 index e1525f21..00000000 --- a/docs/source/mlreco.post_processing.rst +++ /dev/null @@ -1,30 +0,0 @@ -mlreco.post\_processing package -=============================== - -.. automodule:: mlreco.post_processing - :members: - :undoc-members: - :show-inheritance: - -Subpackages ------------ - -.. toctree:: - :maxdepth: 4 - - mlreco.post_processing.analysis - mlreco.post_processing.metrics - mlreco.post_processing.store - -Submodules ----------- - -.. toctree:: - :maxdepth: 4 - - mlreco.post_processing.acpt_muons - mlreco.post_processing.common - mlreco.post_processing.decorator - mlreco.post_processing.michel_shift - mlreco.post_processing.track_clustering2 - mlreco.post_processing.track_clustering_old diff --git a/docs/source/mlreco.post_processing.store.rst b/docs/source/mlreco.post_processing.store.rst deleted file mode 100644 index cb84b874..00000000 --- a/docs/source/mlreco.post_processing.store.rst +++ /dev/null @@ -1,18 +0,0 @@ -mlreco.post\_processing.store package -===================================== - -.. automodule:: mlreco.post_processing.store - :members: - :undoc-members: - :show-inheritance: - -Submodules ----------- - -.. toctree:: - :maxdepth: 4 - - mlreco.post_processing.store.store_input - mlreco.post_processing.store.store_output - mlreco.post_processing.store.store_uresnet - mlreco.post_processing.store.store_uresnet_ppn diff --git a/docs/source/mlreco.post_processing.store.store_input.rst b/docs/source/mlreco.post_processing.store.store_input.rst deleted file mode 100644 index 2b7f7b94..00000000 --- a/docs/source/mlreco.post_processing.store.store_input.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.store.store\_input module -================================================= - -.. automodule:: mlreco.post_processing.store.store_input - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.store.store_output.rst b/docs/source/mlreco.post_processing.store.store_output.rst deleted file mode 100644 index 01ca84a5..00000000 --- a/docs/source/mlreco.post_processing.store.store_output.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.store.store\_output module -================================================== - -.. automodule:: mlreco.post_processing.store.store_output - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.store.store_uresnet.rst b/docs/source/mlreco.post_processing.store.store_uresnet.rst deleted file mode 100644 index f939ef29..00000000 --- a/docs/source/mlreco.post_processing.store.store_uresnet.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.store.store\_uresnet module -=================================================== - -.. automodule:: mlreco.post_processing.store.store_uresnet - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.store.store_uresnet_ppn.rst b/docs/source/mlreco.post_processing.store.store_uresnet_ppn.rst deleted file mode 100644 index c42d4632..00000000 --- a/docs/source/mlreco.post_processing.store.store_uresnet_ppn.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.store.store\_uresnet\_ppn module -======================================================== - -.. automodule:: mlreco.post_processing.store.store_uresnet_ppn - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.track_clustering2.rst b/docs/source/mlreco.post_processing.track_clustering2.rst deleted file mode 100644 index 8d508653..00000000 --- a/docs/source/mlreco.post_processing.track_clustering2.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.track\_clustering2 module -================================================= - -.. automodule:: mlreco.post_processing.track_clustering2 - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.post_processing.track_clustering_old.rst b/docs/source/mlreco.post_processing.track_clustering_old.rst deleted file mode 100644 index 6c5cacf0..00000000 --- a/docs/source/mlreco.post_processing.track_clustering_old.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.post\_processing.track\_clustering\_old module -===================================================== - -.. automodule:: mlreco.post_processing.track_clustering_old - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.rst b/docs/source/mlreco.rst index 2db40df8..f0db7918 100644 --- a/docs/source/mlreco.rst +++ b/docs/source/mlreco.rst @@ -14,7 +14,6 @@ Subpackages mlreco.iotools mlreco.models - mlreco.post_processing mlreco.utils mlreco.visualization diff --git a/docs/source/mlreco.utils.data_parallel.rst b/docs/source/mlreco.utils.data_parallel.rst deleted file mode 100644 index 897dd572..00000000 --- a/docs/source/mlreco.utils.data_parallel.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.utils.data\_parallel module -================================== - -.. automodule:: mlreco.utils.data_parallel - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.utils.groups.rst b/docs/source/mlreco.utils.groups.rst deleted file mode 100644 index 7ca31ee4..00000000 --- a/docs/source/mlreco.utils.groups.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.utils.groups module -========================== - -.. automodule:: mlreco.utils.groups - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.utils.numba.rst b/docs/source/mlreco.utils.numba.rst deleted file mode 100644 index b8bf92d2..00000000 --- a/docs/source/mlreco.utils.numba.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco.utils.numba module -========================= - -.. automodule:: mlreco.utils.numba - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/mlreco.utils.rst b/docs/source/mlreco.utils.rst index 3f4e84ef..1406eb21 100644 --- a/docs/source/mlreco.utils.rst +++ b/docs/source/mlreco.utils.rst @@ -13,14 +13,16 @@ Submodules :maxdepth: 4 mlreco.utils.adabound - mlreco.utils.data_parallel mlreco.utils.dbscan mlreco.utils.deghosting - mlreco.utils.groups + mlreco.utils.globals + mlreco.utils.inference mlreco.utils.metrics - mlreco.utils.numba + mlreco.utils.numba_local mlreco.utils.ppn mlreco.utils.track_clustering mlreco.utils.unwrap mlreco.utils.utils mlreco.utils.vertex + mlreco.utils.volumes + mlreco.utils.wrapper diff --git a/docs/source/mlreco.visualization.rst b/docs/source/mlreco.visualization.rst index 08b54aaa..65bcf801 100644 --- a/docs/source/mlreco.visualization.rst +++ b/docs/source/mlreco.visualization.rst @@ -16,4 +16,5 @@ Submodules mlreco.visualization.gnn mlreco.visualization.plotly_layouts mlreco.visualization.points + mlreco.visualization.training mlreco.visualization.voxels diff --git a/docs/source/modules.rst b/docs/source/modules.rst deleted file mode 100644 index d5d462d3..00000000 --- a/docs/source/modules.rst +++ /dev/null @@ -1,7 +0,0 @@ -mlreco -====== - -.. toctree:: - :maxdepth: 4 - - mlreco From 83fb06d33e74095e8cf0601d431c09a3302793fd Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Thu, 20 Apr 2023 11:10:24 -0700 Subject: [PATCH 142/180] Revert "Update globals, add transformer model, update analysis README" This reverts commit 01d113800d94558be028cd69fd72999937c293f1. --- analysis/README.md | 2 +- .../models/experimental/cluster/criterion.py | 39 +- mlreco/models/experimental/cluster/mask3d.py | 361 ++++++++++++++++++ .../cluster/mask3d_model.py} | 28 +- .../experimental/cluster/transformer_spice.py | 339 +--------------- mlreco/models/factories.py | 5 +- mlreco/utils/globals.py | 1 - 7 files changed, 397 insertions(+), 378 deletions(-) create mode 100644 mlreco/models/experimental/cluster/mask3d.py rename mlreco/models/{transformer.py => experimental/cluster/mask3d_model.py} (88%) diff --git a/analysis/README.md b/analysis/README.md index 03a373f1..e8b1d897 100644 --- a/analysis/README.md +++ b/analysis/README.md @@ -548,7 +548,7 @@ This will generate a `log.csv` file under `log_dir`, which contain timing inform ----- Include a `profile=True` field under the post-processor name to log the timing information separately. For example: -```yaml +``` analysis: profile: True iteration: -1 diff --git a/mlreco/models/experimental/cluster/criterion.py b/mlreco/models/experimental/cluster/criterion.py index 0890650b..3d9013c1 100644 --- a/mlreco/models/experimental/cluster/criterion.py +++ b/mlreco/models/experimental/cluster/criterion.py @@ -35,19 +35,20 @@ def forward(self, masks, targets): cost_matrix = self.weight_dice * dice_loss + self.weight_ce * ce_loss indices = linear_sum_assignment(cost_matrix.detach().cpu()) - if self.mode == 'log_dice': - dice_loss = log_dice_loss_flat(masks[:, indices[0]], targets[:, indices[1]]) - elif self.mode == 'dice': - dice_loss = dice_loss_flat(masks[:, indices[0]], targets[:, indices[1]]) + dice_loss = dice_loss_flat(masks[:, indices[0]], targets[:, indices[1]]) + # if self.mode == 'log_dice': + # dice_loss = batch_log_dice_loss(masks.T[indices[0]], targets.T[indices[1]]) + # elif self.mode == 'dice': + # dice_loss = batch_dice_loss(masks.T[indices[0]], targets.T[indices[1]]) # elif self.mode == 'lovasz': # dice_loss = self.lovasz(masks[:, indices[0]], targets[:, indices[1]]) - else: - raise ValueError(f"LSA loss mode {self.mode} is not supported!") + # else: + # raise ValueError(f"LSA loss mode {self.mode} is not supported!") ce_loss = sigmoid_ce_loss(masks.T[indices[0]], targets.T[indices[1]]) loss = self.weight_dice * dice_loss + self.weight_ce * ce_loss acc = self.compute_accuracy(masks, targets, indices) - return loss, acc + return loss, acc, indices class CEDiceLoss(nn.Module): @@ -56,6 +57,7 @@ def __init__(self, weight_dice=1.0, weight_ce=1.0, mode='dice'): super(CEDiceLoss, self).__init__() self.weight_dice = weight_dice self.weight_ce = weight_ce + self.lovasz = LovaszHingeLoss() self.mode = mode print(f"Setting LinearSumAssignment loss to '{self.mode}'") @@ -63,16 +65,12 @@ def compute_accuracy(self, masks, targets): with torch.no_grad(): valid_masks = masks > 0 valid_targets = targets > 0.5 - - print(masks.sum(dim=0)) - print(targets.sum(dim=0)) - iou = iou_batch(valid_masks, valid_targets, eps=1e-6) return float(iou) def forward(self, masks, targets): - dice_loss = dice_loss_flat(masks, targets) + dice_loss = self.lovasz(masks, targets) # if self.mode == 'log_dice': # dice_loss = batch_log_dice_loss(masks.T[indices[0]], targets.T[indices[1]]) # elif self.mode == 'dice': @@ -228,19 +226,4 @@ def dice_loss_flat(logits, targets): scores = torch.sigmoid(logits) numerator = (2 * scores * targets).sum(dim=0) denominator = scores.sum(dim=0) + targets.sum(dim=0) - return (1 - (numerator + 1) / (denominator + 1)).sum() / num_masks - -@torch.jit.script -def log_dice_loss_flat(logits, targets): - """ - - Parameters - ---------- - logits: (N x num_queries) - targets: (N x num_queries) - """ - num_masks = logits.shape[1] - scores = torch.sigmoid(logits) - numerator = (2 * scores * targets).sum(dim=0) - denominator = scores.sum(dim=0) + targets.sum(dim=0) - return (-torch.log(1 - (numerator + 1) / (denominator + 1))).sum() / num_masks \ No newline at end of file + return (1 - (numerator + 1) / (denominator + 1)).sum() / num_masks \ No newline at end of file diff --git a/mlreco/models/experimental/cluster/mask3d.py b/mlreco/models/experimental/cluster/mask3d.py new file mode 100644 index 00000000..f0bd3175 --- /dev/null +++ b/mlreco/models/experimental/cluster/mask3d.py @@ -0,0 +1,361 @@ +import torch +import torch.nn as nn + +import MinkowskiEngine as ME +import MinkowskiEngine.MinkowskiOps as me + +from mlreco.models.layers.common.uresnet_layers import UResNetDecoder, UResNetEncoder +from mlreco.models.experimental.transformers.positional_encodings import FourierEmbeddings +from mlreco.models.experimental.cluster.pointnet2.pointnet2_utils import furthest_point_sample +from mlreco.models.experimental.transformers.positional_encodings import get_normalized_coordinates +from mlreco.models.experimental.transformers.transformer import TransformerDecoder, GenericMLP +from torch_geometric.nn import MLP + + +from mlreco.utils.globals import * + +class QueryModule(nn.Module): + + def __init__(self, cfg, name='query_module'): + super(QueryModule, self).__init__() + + self.model_config = cfg[name] + + # Define instance query modules + self.num_input = self.model_config.get('num_input', 32) + self.num_pos_input = self.model_config.get('num_pos_input', 128) + self.num_queries = self.model_config.get('num_queries', 200) + # self.num_classes = self.model_config.get('num_classes', 5) + self.mask_dim = self.model_config.get('mask_dim', 128) + self.query_type = self.model_config.get('query_type', 'fps') + self.query_proj = None + + if self.query_type == 'fps': + self.query_projection = GenericMLP( + input_dim=self.num_input, + hidden_dims=[self.mask_dim], + output_dim=self.mask_dim, + use_conv=True, + output_use_activation=True, + hidden_use_bias=True + ) + self.query_pos_projection = GenericMLP( + input_dim=self.num_pos_input, + hidden_dims=[self.mask_dim], + output_dim=self.mask_dim, + use_conv=True, + output_use_activation=True, + hidden_use_bias=True + ) + elif self.query_type == 'embedding': + self.query_feat = nn.Embedding(self.num_queries, self.mask_dim) + self.query_pos = nn.Embedding(self.num_queries, self.mask_dim) + else: + raise ValueError("Query type {} is not supported!".format(self.query_type)) + + self.pos_enc = FourierEmbeddings(cfg) + + def forward(self, x, uresnet_features): + ''' + Inputs + ------ + x: Input ME.SparseTensor from UResNet output + ''' + + batch_size = len(x.decomposed_coordinates) + + if self.query_type == 'fps': + # Sample query points via FPS + fps_idx = [furthest_point_sample(x.decomposed_coordinates[i][None, ...].float(), + self.num_queries).squeeze(0).long() \ + for i in range(len(x.decomposed_coordinates))] + # B, nqueries, 3 + sampled_coords = torch.stack([x.decomposed_coordinates[i][fps_idx[i], :] \ + for i in range(len(x.decomposed_coordinates))], axis=0) + query_pos = self.pos_enc(sampled_coords.float()).permute(0, 2, 1) # B, dim, nqueries + query_pos = self.query_pos_projection(query_pos) # B, dim, mask_dim + queries = torch.stack([uresnet_features.decomposed_features[i][fps_idx[i].long(), :] \ + for i in range(len(fps_idx))]) # B, nqueries, num_uresnet_feats + queries = queries.permute(0, 2, 1) # B, num_uresnet_feats, nqueries + queries = self.query_projection(queries) # B, mask_dim, nqueries + elif self.query_type == 'embedding': + queries = self.query_feat.weight.unsqueze(0).repeat(batch_size, 1, 1) + query_pos = self.query_pos.weight.unsqueeze(1).repeat(1, batch_size, 1) + else: + raise ValueError("Query type {} is not supported!".format(self.query_type)) + + return queries.permute((0, 2, 1)), query_pos.permute((0, 2, 1)), fps_idx + +class Mask3d(nn.Module): + + def __init__(self, cfg, name='mask3d'): + super(Mask3d, self).__init__() + + self.model_config = cfg[name] + + self.encoder = UResNetEncoder(cfg, name='uresnet') + self.decoder = UResNetDecoder(cfg, name='uresnet') + + num_params_backbone = sum(p.numel() for p in self.encoder.parameters()) + num_params_backbone += sum(p.numel() for p in self.decoder.parameters()) + print(f"Number of Backbone Parameters = {num_params_backbone}") + + self.query_module = QueryModule(cfg) + + num_features = self.encoder.num_filters + self.D = self.model_config.get('D', 3) + self.mask_dim = self.model_config.get('mask_dim', 128) + self.num_classes = self.model_config.get('num_classes', 2) + self.num_heads = self.model_config.get('num_heads', 8) + self.dropout = self.model_config.get('dropout', 0.0) + self.normalize_before = self.model_config.get('normalize_before', False) + + device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") + + self.spatial_size = self.model_config.get('spatial_size', [2753, 1056, 5966]) + self.spatial_size = torch.Tensor(self.spatial_size).float().to(device) + + self.depth = self.model_config.get('depth', 5) + self.mask_head = ME.MinkowskiConvolution(num_features, self.mask_dim, + kernel_size=1, stride=1, bias=True, dimension=self.D) + + + # self.instance_to_mask = MLP([self.mask_dim] * 3) + self.instance_to_mask = nn.Sequential( + nn.Linear(self.mask_dim, self.mask_dim), + nn.ReLU(), + nn.Linear(self.mask_dim, self.mask_dim) + ) + self.instance_to_class = nn.Sequential( + nn.Linear(self.mask_dim, self.mask_dim), + nn.ReLU(), + nn.Linear(self.mask_dim, self.num_classes) + ) + self.layernorm = nn.LayerNorm(self.mask_dim) + + self.pooling = ME.MinkowskiAvgPooling(kernel_size=2, stride=2, dimension=3) + + # Layerwise Projections + self.linear_squeeze = nn.ModuleList() + for i in range(self.depth-1, 0, -1): + self.linear_squeeze.append(nn.Linear(i * num_features, + self.mask_dim)) + + # Query Refinement Modules + self.num_transformers = self.model_config.get('num_transformers', 3) + self.shared_decoders = self.model_config.get('shared_decoders', True) + + # Transformer Modules + if self.shared_decoders: + num_shared = 1 + else: + num_shared = self.num_decoders + + self.transformers = nn.ModuleList() + + for num_trans in range(num_shared): + self.transformers.append(TransformerDecoder(self.mask_dim, + self.num_heads, + dropout=self.dropout, + normalize_before=self.normalize_before)) + + self.sample_sizes = [200, 800, 3200, 12800, 51200] + self.adc_to_mev = 1./350 + + num_params = sum(p.numel() for p in self.parameters()) + print(f"Number of Total Parameters = {num_params}") + + + + def get_positional_encoding(self, x): + pos_encoding = [] + + for i in range(len(x.decomposed_coordinates)): + coords = x.decomposed_coordinates[i] + pos_enc_batch = self.query_module.pos_enc(coords, features=None) + pos_encoding.append(pos_enc_batch) + + pos_encoding = torch.cat(pos_encoding, dim=0) + return pos_encoding + + + def mask_module(self, queries, mask_features, + return_attention_mask=True, + num_pooling_steps=0): + ''' + Inputs + ------ + - queries: [B, num_queries, query_dim] torch.Tensor + - mask_features: ME.SparseTensor from mask head output + ''' + query_feats = self.layernorm(queries) + mask_embed = self.instance_to_mask(query_feats) + output_class = self.instance_to_class(query_feats) + + output_masks = [] + + coords, feats = mask_features.decomposed_coordinates_and_features + batch_size = len(coords) + + assert mask_embed.shape[0] == batch_size + + for i in range(len(mask_features.decomposed_features)): + mask = feats[i] @ mask_embed[i].T + output_masks.append(mask) + + output_masks = torch.cat(output_masks, dim=0) + output_coords = torch.cat(coords, dim=0) + output_mask = me.SparseTensor(features=output_masks, + coordinate_manager=mask_features.coordinate_manager, + coordinate_map_key=mask_features.coordinate_map_key) + if return_attention_mask: + # nn.MultiHeadAttention attn_mask prevents "True" pixels from access + # Hence the < 0.5 in the attn_mask + with torch.no_grad(): + attn_mask = output_mask + for _ in range(num_pooling_steps): + attn_mask = self.pooling(attn_mask.float()) + attn_mask = me.SparseTensor(features=(attn_mask.F.detach().sigmoid() < 0.5), + coordinate_manager=attn_mask.coordinate_manager, + coordinate_map_key=attn_mask.coordinate_map_key) + return output_mask, output_class, attn_mask + else: + return output_mask, output_class + + + def sampling_module(self, decomposed_feats, decomposed_coords, decomposed_attn, depth, + max_sample_size=False, is_eval=False): + + indices, masks = [], [] + + if min([pcd.shape[0] for pcd in decomposed_feats]) == 1: + raise RuntimeError("only a single point gives nans in cross-attention") + + decomposed_pos_encs = [] + + for coords in decomposed_coords: + pos_enc = self.query_module.pos_enc(coords.float()) + decomposed_pos_encs.append(pos_enc) + + device = decomposed_feats[0].device + + curr_sample_size = max([pcd.shape[0] for pcd in decomposed_feats]) + if not (max_sample_size or is_eval): + curr_sample_size = min(curr_sample_size, self.sample_sizes[depth]) + + for bidx in range(len(decomposed_feats)): + num_points = decomposed_feats[bidx].shape[0] + if num_points <= curr_sample_size: + idx = torch.zeros(curr_sample_size, + dtype=torch.long, + device=device) + + midx = torch.ones(curr_sample_size, + dtype=torch.bool, + device=device) + + idx[:num_points] = torch.arange(num_points, + device=device) + + midx[:num_points] = False # attend to first points + else: + # we have more points in pcd as we like to sample + # take a subset (no padding or masking needed) + idx = torch.randperm(decomposed_feats[bidx].shape[0], + device=device)[:curr_sample_size] + midx = torch.zeros(curr_sample_size, + dtype=torch.bool, + device=device) # attend to all + indices.append(idx) + masks.append(midx) + + batched_feats = torch.stack([ + decomposed_feats[b][indices[b], :] for b in range(len(indices)) + ]) + batched_attn = torch.stack([ + decomposed_attn[b][indices[b], :] for b in range(len(indices)) + ]) + batched_pos_enc = torch.stack([ + decomposed_pos_encs[b][indices[b], :] for b in range(len(indices)) + ]) + + # Mask to handle points less than num_sample points + m = torch.stack(masks) + # If sum(1) == nsamples, then this query has no active voxels + batched_attn.permute((0, 2, 1))[batched_attn.sum(1) == indices[0].shape[0]] = False + # Fianl attention map is intersection of attention map and + # valid voxel samples (m). + batched_attn = torch.logical_or(batched_attn, m[..., None]) + + return batched_feats, batched_attn, batched_pos_enc + + + def forward(self, point_cloud): + + coords = point_cloud[:, COORD_COLS].int() + feats = point_cloud[:, VALUE_COL].float().view(-1, 1) + + normed_coords = get_normalized_coordinates(coords, self.spatial_size) + normed_feats = feats * self.adc_to_mev + features = torch.cat([normed_coords, normed_feats], dim=1) + x = ME.SparseTensor(coordinates=point_cloud[:, :VALUE_COL].int(), + features=features) + encoderOutput = self.encoder(x) + decoderOutput = self.decoder(encoderOutput['finalTensor'], + encoderOutput['encoderTensors']) + queries, query_pos, query_index = self.query_module(x, decoderOutput[-1]) + + total_num_pooling = len(decoderOutput)-1 + + full_res_fmap = decoderOutput[-1] + + mask_features = self.mask_head(full_res_fmap) + + batch_size = int(torch.unique(x.C[:, 0]).shape[0]) + + predictions_mask = [] + predictions_class = [] + + for tf_index in range(self.num_transformers): + if self.shared_decoders: + transformer_index = 0 + else: + transformer_index = tf_index + for i, fmap in enumerate(decoderOutput): + assert queries.shape == (batch_size, self.query_module.num_queries, self.mask_dim) + num_pooling = total_num_pooling-i + # queries = queries.permute(2, 0, 1) + output_mask, output_class, attn_mask = self.mask_module(queries, + mask_features, + num_pooling_steps=num_pooling) + + predictions_mask.append(output_mask.F) + predictions_class.append(output_class) + + fmaps, attn_masks = fmap.decomposed_features, attn_mask.decomposed_features + decomposed_coords = fmap.decomposed_coordinates + batched_feats, batched_attn, batched_pos_enc = self.sampling_module( + fmaps, decomposed_coords, attn_masks, i) + src_pcd = self.linear_squeeze[i](batched_feats) + output = self.transformers[transformer_index](queries, + query_pos, + src_pcd, + batched_pos_enc, + batched_attn) + + queries = output + + output_mask, output_class, attn_mask = self.mask_module(queries, + mask_features, + return_attention_mask=True, + num_pooling_steps=0) + + res = { + 'pred_masks' : [output_mask.F], + 'pred_logits': [output_class], + 'aux_masks': [predictions_mask], + 'aux_classes': [predictions_class], + 'query_index': [query_index] + } + + return res \ No newline at end of file diff --git a/mlreco/models/transformer.py b/mlreco/models/experimental/cluster/mask3d_model.py similarity index 88% rename from mlreco/models/transformer.py rename to mlreco/models/experimental/cluster/mask3d_model.py index fd94f34f..d547a4e7 100644 --- a/mlreco/models/transformer.py +++ b/mlreco/models/experimental/cluster/mask3d_model.py @@ -4,7 +4,7 @@ import MinkowskiEngine as ME from pprint import pprint -from mlreco.models.experimental.cluster.transformer_spice import TransformerSPICE +from mlreco.models.experimental.cluster.mask3d import Mask3d from mlreco.models.experimental.cluster.criterion import * from mlreco.utils.globals import * from scipy.optimize import linear_sum_assignment @@ -30,9 +30,18 @@ class Mask3DModel(nn.Module): def __init__(self, cfg, name='mask3d'): super(Mask3DModel, self).__init__() - self.net = TransformerSPICE(cfg) + self.net = Mask3d(cfg) self.skip_classes = cfg[name].get('skip_classes') + def weight_initialization(self): + for m in self.modules(): + if isinstance(m, ME.MinkowskiConvolution): + ME.utils.kaiming_normal_(m.kernel, mode="fan_out", nonlinearity="relu") + + if isinstance(m, ME.MinkowskiBatchNorm): + nn.init.constant_(m.bn.weight, 1) + nn.init.constant_(m.bn.bias, 0) + def filter_class(self, x): ''' Filter classes according to segmentation label. @@ -100,8 +109,8 @@ def __init__(self, cfg, name='mask3d'): self.xentropy = nn.CrossEntropyLoss(weight=self.weight_class, reduction='mean') self.dice_loss_mode = self.model_config.get('dice_loss_mode', 'log_dice') - self.loss_fn = LinearSumAssignmentLoss(mode=self.dice_loss_mode) - # self.loss_fn = CEDiceLoss(mode=self.dice_loss_mode) + # self.loss_fn = LinearSumAssignmentLoss(mode=self.dice_loss_mode) + self.loss_fn = CEDiceLoss(mode=self.dice_loss_mode) def filter_class(self, cluster_label): ''' @@ -125,8 +134,7 @@ def compute_layerwise_loss(self, aux_masks, aux_classes, clabel, query_index): labels = clabel[0][batch_mask][:, GROUP_COL].long() query_idx_batch = query_index[bidx] # Compute instance mask loss - targets = get_instance_masks(labels).float() - # targets = get_instance_masks_from_queries(labels, query_idx_batch).float() + targets = get_instance_masks_from_queries(labels, query_idx_batch).float() loss_batch, acc_batch = self.loss_fn(mask_layer[batch_mask], targets) loss[bidx].append(loss_batch) @@ -175,9 +183,11 @@ def forward(self, result, cluster_label): labels = clabel[0][batch_mask][:, GROUP_COL].long() - targets = get_instance_masks(labels).float() + # targets = get_instance_masks(labels).float() query_idx_batch = query_index[bidx] - # targets = get_instance_masks_from_queries(labels, query_idx_batch).float() + targets = get_instance_masks_from_queries(labels, query_idx_batch).float() + + # print(output_mask, targets) loss_batch, acc_batch = self.loss_fn(output_mask, targets) loss[bidx].append(loss_batch) @@ -211,4 +221,4 @@ def forward(self, result, cluster_label): # 'acc_class': float(acc_class) } - return res \ No newline at end of file + return res diff --git a/mlreco/models/experimental/cluster/transformer_spice.py b/mlreco/models/experimental/cluster/transformer_spice.py index 9d1eb24f..187291e7 100644 --- a/mlreco/models/experimental/cluster/transformer_spice.py +++ b/mlreco/models/experimental/cluster/transformer_spice.py @@ -8,344 +8,13 @@ from mlreco.models.experimental.transformers.positional_encodings import FourierEmbeddings from mlreco.models.experimental.cluster.pointnet2.pointnet2_utils import furthest_point_sample from mlreco.models.experimental.transformers.positional_encodings import get_normalized_coordinates -from mlreco.utils.globals import * -from mlreco.models.experimental.transformers.transformer import GenericMLP +from mlreco.models.experimental.transformers.transformer import TransformerDecoder, GenericMLP +from torch_geometric.nn import MLP -class QueryModule(nn.Module): - - def __init__(self, cfg, name='query_module'): - super(QueryModule, self).__init__() - - self.model_config = cfg[name] - - # Define instance query modules - self.num_input = self.model_config.get('num_input', 32) - self.num_pos_input = self.model_config.get('num_pos_input', 128) - self.num_queries = self.model_config.get('num_queries', 200) - # self.num_classes = self.model_config.get('num_classes', 5) - self.mask_dim = self.model_config.get('mask_dim', 128) - self.query_type = self.model_config.get('query_type', 'fps') - self.query_proj = None - - if self.query_type == 'fps': - self.query_projection = GenericMLP( - input_dim=self.num_input, - hidden_dims=[self.mask_dim], - output_dim=self.mask_dim, - norm_fn_name='bn1d', - use_conv=True, - output_use_activation=True, - hidden_use_bias=True - ) - self.query_pos_projection = GenericMLP( - input_dim=self.num_pos_input, - hidden_dims=[self.mask_dim], - output_dim=self.mask_dim, - norm_fn_name='bn1d', - use_conv=True, - output_use_activation=True, - hidden_use_bias=True - ) - elif self.query_type == 'embedding': - self.query_feat = nn.Embedding(self.num_queries, self.mask_dim) - self.query_pos = nn.Embedding(self.num_queries, self.mask_dim) - else: - raise ValueError("Query type {} is not supported!".format(self.query_type)) - - self.pos_enc = FourierEmbeddings(cfg) - - def forward(self, x, uresnet_features): - ''' - Inputs - ------ - x: Input ME.SparseTensor from UResNet output - ''' - - batch_size = len(x.decomposed_coordinates) - - if self.query_type == 'fps': - # Sample query points via FPS - fps_idx = [furthest_point_sample(x.decomposed_coordinates[i][None, ...].float(), - self.num_queries).squeeze(0).long() \ - for i in range(len(x.decomposed_coordinates))] - # B, nqueries, 3 - sampled_coords = torch.stack([x.decomposed_coordinates[i][fps_idx[i], :] \ - for i in range(len(x.decomposed_coordinates))], axis=0) - query_pos = self.pos_enc(sampled_coords.float()).permute(0, 2, 1) # B, dim, nqueries - query_pos = self.query_pos_projection(query_pos) # B, dim, mask_dim - queries = torch.stack([uresnet_features.decomposed_features[i][fps_idx[i].long(), :] \ - for i in range(len(fps_idx))]) # B, nqueries, num_uresnet_feats - queries = queries.permute(0, 2, 1) # B, num_uresnet_feats, nqueries - queries = self.query_projection(queries) # B, mask_dim, nqueries - elif self.query_type == 'embedding': - queries = self.query_feat.weight.unsqueze(0).repeat(batch_size, 1, 1) - query_pos = self.query_pos.weight.unsqueeze(1).repeat(1, batch_size, 1) - else: - raise ValueError("Query type {} is not supported!".format(self.query_type)) - - return queries.permute((0, 2, 1)), query_pos.permute((0, 2, 1)), fps_idx - class TransformerSPICE(nn.Module): - """ - Transformer based model for particle clustering, using Mask3D - as a backbone. - - Mask3D backbone implementation: https://github.com/JonasSchult/Mask3D - - Mask3D: https://arxiv.org/abs/2210.03105 - """ - - def __init__(self, cfg, name='mask3d'): + def __init__(self, cfg, name='transformer_spice'): super(TransformerSPICE, self).__init__() - self.model_config = cfg[name] - - self.encoder = UResNetEncoder(cfg, name='uresnet') - self.decoder = UResNetDecoder(cfg, name='uresnet') - - num_params_backbone = sum(p.numel() for p in self.encoder.parameters()) - num_params_backbone += sum(p.numel() for p in self.decoder.parameters()) - print(f"Number of Backbone Parameters = {num_params_backbone}") - - self.query_module = QueryModule(cfg) - - num_features = self.encoder.num_filters - self.D = self.model_config.get('D', 3) - self.mask_dim = self.model_config.get('mask_dim', 128) - self.num_classes = self.model_config.get('num_classes', 2) - self.num_heads = self.model_config.get('num_heads', 8) - self.dropout = self.model_config.get('dropout', 0.0) - - device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") - - self.spatial_size = self.model_config.get('spatial_size', [2753, 1056, 5966]) - self.spatial_size = torch.Tensor(self.spatial_size).float().to(device) - - self.depth = self.model_config.get('depth', 5) - self.mask_head = ME.MinkowskiConvolution(num_features, - self.mask_dim, - kernel_size=1, - stride=1, - bias=True, - dimension=self.D) - self.pooling = ME.MinkowskiAvgPooling(kernel_size=2, - stride=2, - dimension=3) - self.adc_to_mev = 1./350. - - # Query Refinement Modules - self.num_transformers = self.model_config.get('num_transformers', 3) - self.shared_decoders = self.model_config.get('shared_decoders', False) - - self.instance_to_mask = nn.Linear(self.mask_dim, self.mask_dim) - self.instance_to_class = nn.Linear(self.mask_dim, self.mask_dim) - - # Layerwise Projections - self.linear_squeeze = nn.ModuleList() - for i in range(self.depth-1, 0, -1): - self.linear_squeeze.append(nn.Linear(i * num_features, - self.mask_dim)) - - # Transformer Modules - if self.shared_decoders: - num_shared = 1 - else: - num_shared = self.num_transformers - - self.transformers = [] - - for num_trans in range(num_shared): - self.transformers.append(nn.TransformerDecoderLayer( - self.mask_dim, self.num_heads, dim_feedforward=1024, batch_first=True)) - - self.transformers = nn.ModuleList(self.transformers) - self.layernorm = nn.LayerNorm(self.mask_dim) - - self.sample_sizes = [200, 800, 1600, 6400, 12800] - - - def mask_module(self, queries, mask_features, - return_attention_mask=True, - num_pooling_steps=0): - ''' - Inputs - ------ - - queries: [B, num_queries, query_dim] torch.Tensor - - mask_features: ME.SparseTensor from mask head output - ''' - query_feats = self.layernorm(queries) - mask_embed = self.instance_to_mask(query_feats) - output_class = self.instance_to_class(query_feats) - - output_masks = [] - - coords, feats = mask_features.decomposed_coordinates_and_features - batch_size = len(coords) - - assert mask_embed.shape[0] == batch_size - - for i in range(len(mask_features.decomposed_features)): - mask = feats[i] @ mask_embed[i].T - output_masks.append(mask) - - output_masks = torch.cat(output_masks, dim=0) - output_coords = torch.cat(coords, dim=0) - output_mask = me.SparseTensor(features=output_masks, - coordinate_manager=mask_features.coordinate_manager, - coordinate_map_key=mask_features.coordinate_map_key) - - if return_attention_mask: - # nn.MultiHeadAttention attn_mask prevents "True" pixels from access - # Hence the < 0.5 in the attn_mask - with torch.no_grad(): - attn_mask = output_mask - for _ in range(num_pooling_steps): - attn_mask = self.pooling(attn_mask.float()) - attn_mask = me.SparseTensor(features=(attn_mask.F.detach().sigmoid() < 0.5), - coordinate_manager=attn_mask.coordinate_manager, - coordinate_map_key=attn_mask.coordinate_map_key) - return output_mask, output_class, attn_mask - else: - return output_mask, output_class - - - def sampling_module(self, decomposed_feats, decomposed_coords, decomposed_attn, depth, - max_sample_size=False, is_eval=False): - - indices, masks = [], [] - - if min([pcd.shape[0] for pcd in decomposed_feats]) == 1: - raise RuntimeError("only a single point gives nans in cross-attention") - - decomposed_pos_encs = [] - - for coords in decomposed_coords: - pos_enc = self.query_module.pos_enc(coords.float()) - decomposed_pos_encs.append(pos_enc) - - device = decomposed_feats[0].device - - curr_sample_size = max([pcd.shape[0] for pcd in decomposed_feats]) - if not (max_sample_size or is_eval): - curr_sample_size = min(curr_sample_size, self.sample_sizes[depth]) - - for bidx in range(len(decomposed_feats)): - num_points = decomposed_feats[bidx].shape[0] - if num_points <= curr_sample_size: - idx = torch.zeros(curr_sample_size, - dtype=torch.long, - device=device) - - midx = torch.ones(curr_sample_size, - dtype=torch.bool, - device=device) - - idx[:num_points] = torch.arange(num_points, - device=device) - - midx[:num_points] = False # attend to first points - else: - # we have more points in pcd as we like to sample - # take a subset (no padding or masking needed) - idx = torch.randperm(decomposed_feats[bidx].shape[0], - device=device)[:curr_sample_size] - midx = torch.zeros(curr_sample_size, - dtype=torch.bool, - device=device) # attend to all - indices.append(idx) - masks.append(midx) - - batched_feats = torch.stack([ - decomposed_feats[b][indices[b], :] for b in range(len(indices)) - ]) - batched_attn = torch.stack([ - decomposed_attn[b][indices[b], :] for b in range(len(indices)) - ]) - batched_pos_enc = torch.stack([ - decomposed_pos_encs[b][indices[b], :] for b in range(len(indices)) - ]) - - # Mask to handle points less than num_sample points - m = torch.stack(masks) - # If sum(1) == nsamples, then this query has no active voxels - batched_attn.permute((0, 2, 1))[batched_attn.sum(1) == indices[0].shape[0]] = False - # Fianl attention map is intersection of attention map and - # valid voxel samples (m). - batched_attn = torch.logical_or(batched_attn, m[..., None]) - - return batched_feats, batched_attn, batched_pos_enc - - - def forward(self, point_cloud): - - coords = point_cloud[:, COORD_COLS].int() - feats = point_cloud[:, VALUE_COL].float().view(-1, 1) - - normed_coords = get_normalized_coordinates(coords, self.spatial_size) - normed_feats = feats * self.adc_to_mev - features = torch.cat([normed_coords, normed_feats], dim=1) - x = ME.SparseTensor(coordinates=point_cloud[:, :VALUE_COL].int(), - features=features) - encoderOutput = self.encoder(x) - decoderOutput = self.decoder(encoderOutput['finalTensor'], - encoderOutput['encoderTensors']) - queries, query_pos, query_index = self.query_module(x, decoderOutput[-1]) - - total_num_pooling = len(decoderOutput)-1 - full_res_fmap = decoderOutput[-1] - mask_features = self.mask_head(full_res_fmap) - batch_size = int(torch.unique(x.C[:, 0]).shape[0]) - - predictions_mask = [] - predictions_class = [] - - for tf_index in range(self.num_transformers): - if self.shared_decoders: - transformer_index = 0 - else: - transformer_index = tf_index - for i, fmap in enumerate(decoderOutput): - assert queries.shape == (batch_size, - self.query_module.num_queries, - self.mask_dim) - num_pooling = total_num_pooling-i - - output_mask, output_class, attn_mask = self.mask_module(queries, - mask_features, - num_pooling_steps=num_pooling) - - predictions_mask.append(output_mask.F) - predictions_class.append(output_class) - - fmaps, attn_masks = fmap.decomposed_features, attn_mask.decomposed_features - decomposed_coords = fmap.decomposed_coordinates - - batched_feats, batched_attn, batched_pos_enc = self.sampling_module( - fmaps, decomposed_coords, attn_masks, i) - - src_pcd = self.linear_squeeze[i](batched_feats) - - batched_attn = torch.repeat_interleave(batched_attn.permute((0, 2, 1)), repeats=8, dim=0) - - output = self.transformers[transformer_index](queries + query_pos, - src_pcd + batched_pos_enc) - # memory_mask=batched_attn) - - queries = output - - output_mask, output_class, attn_mask = self.mask_module(queries, - mask_features, - return_attention_mask=True, - num_pooling_steps=0) - - res = { - 'pred_masks' : [output_mask.F], - 'pred_logits': [output_class], - 'aux_masks': [predictions_mask], - 'aux_classes': [predictions_class], - 'query_index': [query_index] - } - - return res \ No newline at end of file + self.model_config = cfg[name] \ No newline at end of file diff --git a/mlreco/models/factories.py b/mlreco/models/factories.py index 7810ab5b..d4484a87 100644 --- a/mlreco/models/factories.py +++ b/mlreco/models/factories.py @@ -19,7 +19,6 @@ def model_dict(): from . import bayes_uresnet from . import vertex - from . import transformer # Make some models available (not all of them, e.g. PPN is not standalone) models = { @@ -54,9 +53,7 @@ def model_dict(): # Vertex PPN 'vertex_ppn': (vertex.VertexPPNChain, vertex.UResNetVertexLoss), # Vertex Pointnet - 'vertex_pointnet': (vertex.VertexPointNet, vertex.VertexPointNetLoss), - # TransformerSPICE - 'mask3d': (transformer.Mask3DModel, transformer.Mask3dLoss) + 'vertex_pointnet': (vertex.VertexPointNet, vertex.VertexPointNetLoss) } return models diff --git a/mlreco/utils/globals.py b/mlreco/utils/globals.py index f148d9ff..55367295 100644 --- a/mlreco/utils/globals.py +++ b/mlreco/utils/globals.py @@ -19,7 +19,6 @@ PGRP_COL = 11 VTX_COLS = (12,13,14) MOM_COL = 15 -SEG_COL = -1 # Colum which specifies the shape ID of a voxel in a sparse tensor SHAPE_COL = -1 From f4c2036c921307e69c699f88cc1397675d7e5cf8 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Thu, 20 Apr 2023 11:15:17 -0700 Subject: [PATCH 143/180] Revert "Revert "Update globals, add transformer model, update analysis README"" This reverts commit 83fb06d33e74095e8cf0601d431c09a3302793fd. --- analysis/README.md | 2 +- .../models/experimental/cluster/criterion.py | 39 +- mlreco/models/experimental/cluster/mask3d.py | 361 ------------------ .../experimental/cluster/transformer_spice.py | 339 +++++++++++++++- mlreco/models/factories.py | 5 +- .../mask3d_model.py => transformer.py} | 28 +- mlreco/utils/globals.py | 1 + 7 files changed, 378 insertions(+), 397 deletions(-) delete mode 100644 mlreco/models/experimental/cluster/mask3d.py rename mlreco/models/{experimental/cluster/mask3d_model.py => transformer.py} (88%) diff --git a/analysis/README.md b/analysis/README.md index e8b1d897..03a373f1 100644 --- a/analysis/README.md +++ b/analysis/README.md @@ -548,7 +548,7 @@ This will generate a `log.csv` file under `log_dir`, which contain timing inform ----- Include a `profile=True` field under the post-processor name to log the timing information separately. For example: -``` +```yaml analysis: profile: True iteration: -1 diff --git a/mlreco/models/experimental/cluster/criterion.py b/mlreco/models/experimental/cluster/criterion.py index 3d9013c1..0890650b 100644 --- a/mlreco/models/experimental/cluster/criterion.py +++ b/mlreco/models/experimental/cluster/criterion.py @@ -35,20 +35,19 @@ def forward(self, masks, targets): cost_matrix = self.weight_dice * dice_loss + self.weight_ce * ce_loss indices = linear_sum_assignment(cost_matrix.detach().cpu()) - dice_loss = dice_loss_flat(masks[:, indices[0]], targets[:, indices[1]]) - # if self.mode == 'log_dice': - # dice_loss = batch_log_dice_loss(masks.T[indices[0]], targets.T[indices[1]]) - # elif self.mode == 'dice': - # dice_loss = batch_dice_loss(masks.T[indices[0]], targets.T[indices[1]]) + if self.mode == 'log_dice': + dice_loss = log_dice_loss_flat(masks[:, indices[0]], targets[:, indices[1]]) + elif self.mode == 'dice': + dice_loss = dice_loss_flat(masks[:, indices[0]], targets[:, indices[1]]) # elif self.mode == 'lovasz': # dice_loss = self.lovasz(masks[:, indices[0]], targets[:, indices[1]]) - # else: - # raise ValueError(f"LSA loss mode {self.mode} is not supported!") + else: + raise ValueError(f"LSA loss mode {self.mode} is not supported!") ce_loss = sigmoid_ce_loss(masks.T[indices[0]], targets.T[indices[1]]) loss = self.weight_dice * dice_loss + self.weight_ce * ce_loss acc = self.compute_accuracy(masks, targets, indices) - return loss, acc, indices + return loss, acc class CEDiceLoss(nn.Module): @@ -57,7 +56,6 @@ def __init__(self, weight_dice=1.0, weight_ce=1.0, mode='dice'): super(CEDiceLoss, self).__init__() self.weight_dice = weight_dice self.weight_ce = weight_ce - self.lovasz = LovaszHingeLoss() self.mode = mode print(f"Setting LinearSumAssignment loss to '{self.mode}'") @@ -65,12 +63,16 @@ def compute_accuracy(self, masks, targets): with torch.no_grad(): valid_masks = masks > 0 valid_targets = targets > 0.5 + + print(masks.sum(dim=0)) + print(targets.sum(dim=0)) + iou = iou_batch(valid_masks, valid_targets, eps=1e-6) return float(iou) def forward(self, masks, targets): - dice_loss = self.lovasz(masks, targets) + dice_loss = dice_loss_flat(masks, targets) # if self.mode == 'log_dice': # dice_loss = batch_log_dice_loss(masks.T[indices[0]], targets.T[indices[1]]) # elif self.mode == 'dice': @@ -226,4 +228,19 @@ def dice_loss_flat(logits, targets): scores = torch.sigmoid(logits) numerator = (2 * scores * targets).sum(dim=0) denominator = scores.sum(dim=0) + targets.sum(dim=0) - return (1 - (numerator + 1) / (denominator + 1)).sum() / num_masks \ No newline at end of file + return (1 - (numerator + 1) / (denominator + 1)).sum() / num_masks + +@torch.jit.script +def log_dice_loss_flat(logits, targets): + """ + + Parameters + ---------- + logits: (N x num_queries) + targets: (N x num_queries) + """ + num_masks = logits.shape[1] + scores = torch.sigmoid(logits) + numerator = (2 * scores * targets).sum(dim=0) + denominator = scores.sum(dim=0) + targets.sum(dim=0) + return (-torch.log(1 - (numerator + 1) / (denominator + 1))).sum() / num_masks \ No newline at end of file diff --git a/mlreco/models/experimental/cluster/mask3d.py b/mlreco/models/experimental/cluster/mask3d.py deleted file mode 100644 index f0bd3175..00000000 --- a/mlreco/models/experimental/cluster/mask3d.py +++ /dev/null @@ -1,361 +0,0 @@ -import torch -import torch.nn as nn - -import MinkowskiEngine as ME -import MinkowskiEngine.MinkowskiOps as me - -from mlreco.models.layers.common.uresnet_layers import UResNetDecoder, UResNetEncoder -from mlreco.models.experimental.transformers.positional_encodings import FourierEmbeddings -from mlreco.models.experimental.cluster.pointnet2.pointnet2_utils import furthest_point_sample -from mlreco.models.experimental.transformers.positional_encodings import get_normalized_coordinates -from mlreco.models.experimental.transformers.transformer import TransformerDecoder, GenericMLP -from torch_geometric.nn import MLP - - -from mlreco.utils.globals import * - -class QueryModule(nn.Module): - - def __init__(self, cfg, name='query_module'): - super(QueryModule, self).__init__() - - self.model_config = cfg[name] - - # Define instance query modules - self.num_input = self.model_config.get('num_input', 32) - self.num_pos_input = self.model_config.get('num_pos_input', 128) - self.num_queries = self.model_config.get('num_queries', 200) - # self.num_classes = self.model_config.get('num_classes', 5) - self.mask_dim = self.model_config.get('mask_dim', 128) - self.query_type = self.model_config.get('query_type', 'fps') - self.query_proj = None - - if self.query_type == 'fps': - self.query_projection = GenericMLP( - input_dim=self.num_input, - hidden_dims=[self.mask_dim], - output_dim=self.mask_dim, - use_conv=True, - output_use_activation=True, - hidden_use_bias=True - ) - self.query_pos_projection = GenericMLP( - input_dim=self.num_pos_input, - hidden_dims=[self.mask_dim], - output_dim=self.mask_dim, - use_conv=True, - output_use_activation=True, - hidden_use_bias=True - ) - elif self.query_type == 'embedding': - self.query_feat = nn.Embedding(self.num_queries, self.mask_dim) - self.query_pos = nn.Embedding(self.num_queries, self.mask_dim) - else: - raise ValueError("Query type {} is not supported!".format(self.query_type)) - - self.pos_enc = FourierEmbeddings(cfg) - - def forward(self, x, uresnet_features): - ''' - Inputs - ------ - x: Input ME.SparseTensor from UResNet output - ''' - - batch_size = len(x.decomposed_coordinates) - - if self.query_type == 'fps': - # Sample query points via FPS - fps_idx = [furthest_point_sample(x.decomposed_coordinates[i][None, ...].float(), - self.num_queries).squeeze(0).long() \ - for i in range(len(x.decomposed_coordinates))] - # B, nqueries, 3 - sampled_coords = torch.stack([x.decomposed_coordinates[i][fps_idx[i], :] \ - for i in range(len(x.decomposed_coordinates))], axis=0) - query_pos = self.pos_enc(sampled_coords.float()).permute(0, 2, 1) # B, dim, nqueries - query_pos = self.query_pos_projection(query_pos) # B, dim, mask_dim - queries = torch.stack([uresnet_features.decomposed_features[i][fps_idx[i].long(), :] \ - for i in range(len(fps_idx))]) # B, nqueries, num_uresnet_feats - queries = queries.permute(0, 2, 1) # B, num_uresnet_feats, nqueries - queries = self.query_projection(queries) # B, mask_dim, nqueries - elif self.query_type == 'embedding': - queries = self.query_feat.weight.unsqueze(0).repeat(batch_size, 1, 1) - query_pos = self.query_pos.weight.unsqueeze(1).repeat(1, batch_size, 1) - else: - raise ValueError("Query type {} is not supported!".format(self.query_type)) - - return queries.permute((0, 2, 1)), query_pos.permute((0, 2, 1)), fps_idx - -class Mask3d(nn.Module): - - def __init__(self, cfg, name='mask3d'): - super(Mask3d, self).__init__() - - self.model_config = cfg[name] - - self.encoder = UResNetEncoder(cfg, name='uresnet') - self.decoder = UResNetDecoder(cfg, name='uresnet') - - num_params_backbone = sum(p.numel() for p in self.encoder.parameters()) - num_params_backbone += sum(p.numel() for p in self.decoder.parameters()) - print(f"Number of Backbone Parameters = {num_params_backbone}") - - self.query_module = QueryModule(cfg) - - num_features = self.encoder.num_filters - self.D = self.model_config.get('D', 3) - self.mask_dim = self.model_config.get('mask_dim', 128) - self.num_classes = self.model_config.get('num_classes', 2) - self.num_heads = self.model_config.get('num_heads', 8) - self.dropout = self.model_config.get('dropout', 0.0) - self.normalize_before = self.model_config.get('normalize_before', False) - - device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") - - self.spatial_size = self.model_config.get('spatial_size', [2753, 1056, 5966]) - self.spatial_size = torch.Tensor(self.spatial_size).float().to(device) - - self.depth = self.model_config.get('depth', 5) - self.mask_head = ME.MinkowskiConvolution(num_features, self.mask_dim, - kernel_size=1, stride=1, bias=True, dimension=self.D) - - - # self.instance_to_mask = MLP([self.mask_dim] * 3) - self.instance_to_mask = nn.Sequential( - nn.Linear(self.mask_dim, self.mask_dim), - nn.ReLU(), - nn.Linear(self.mask_dim, self.mask_dim) - ) - self.instance_to_class = nn.Sequential( - nn.Linear(self.mask_dim, self.mask_dim), - nn.ReLU(), - nn.Linear(self.mask_dim, self.num_classes) - ) - self.layernorm = nn.LayerNorm(self.mask_dim) - - self.pooling = ME.MinkowskiAvgPooling(kernel_size=2, stride=2, dimension=3) - - # Layerwise Projections - self.linear_squeeze = nn.ModuleList() - for i in range(self.depth-1, 0, -1): - self.linear_squeeze.append(nn.Linear(i * num_features, - self.mask_dim)) - - # Query Refinement Modules - self.num_transformers = self.model_config.get('num_transformers', 3) - self.shared_decoders = self.model_config.get('shared_decoders', True) - - # Transformer Modules - if self.shared_decoders: - num_shared = 1 - else: - num_shared = self.num_decoders - - self.transformers = nn.ModuleList() - - for num_trans in range(num_shared): - self.transformers.append(TransformerDecoder(self.mask_dim, - self.num_heads, - dropout=self.dropout, - normalize_before=self.normalize_before)) - - self.sample_sizes = [200, 800, 3200, 12800, 51200] - self.adc_to_mev = 1./350 - - num_params = sum(p.numel() for p in self.parameters()) - print(f"Number of Total Parameters = {num_params}") - - - - def get_positional_encoding(self, x): - pos_encoding = [] - - for i in range(len(x.decomposed_coordinates)): - coords = x.decomposed_coordinates[i] - pos_enc_batch = self.query_module.pos_enc(coords, features=None) - pos_encoding.append(pos_enc_batch) - - pos_encoding = torch.cat(pos_encoding, dim=0) - return pos_encoding - - - def mask_module(self, queries, mask_features, - return_attention_mask=True, - num_pooling_steps=0): - ''' - Inputs - ------ - - queries: [B, num_queries, query_dim] torch.Tensor - - mask_features: ME.SparseTensor from mask head output - ''' - query_feats = self.layernorm(queries) - mask_embed = self.instance_to_mask(query_feats) - output_class = self.instance_to_class(query_feats) - - output_masks = [] - - coords, feats = mask_features.decomposed_coordinates_and_features - batch_size = len(coords) - - assert mask_embed.shape[0] == batch_size - - for i in range(len(mask_features.decomposed_features)): - mask = feats[i] @ mask_embed[i].T - output_masks.append(mask) - - output_masks = torch.cat(output_masks, dim=0) - output_coords = torch.cat(coords, dim=0) - output_mask = me.SparseTensor(features=output_masks, - coordinate_manager=mask_features.coordinate_manager, - coordinate_map_key=mask_features.coordinate_map_key) - if return_attention_mask: - # nn.MultiHeadAttention attn_mask prevents "True" pixels from access - # Hence the < 0.5 in the attn_mask - with torch.no_grad(): - attn_mask = output_mask - for _ in range(num_pooling_steps): - attn_mask = self.pooling(attn_mask.float()) - attn_mask = me.SparseTensor(features=(attn_mask.F.detach().sigmoid() < 0.5), - coordinate_manager=attn_mask.coordinate_manager, - coordinate_map_key=attn_mask.coordinate_map_key) - return output_mask, output_class, attn_mask - else: - return output_mask, output_class - - - def sampling_module(self, decomposed_feats, decomposed_coords, decomposed_attn, depth, - max_sample_size=False, is_eval=False): - - indices, masks = [], [] - - if min([pcd.shape[0] for pcd in decomposed_feats]) == 1: - raise RuntimeError("only a single point gives nans in cross-attention") - - decomposed_pos_encs = [] - - for coords in decomposed_coords: - pos_enc = self.query_module.pos_enc(coords.float()) - decomposed_pos_encs.append(pos_enc) - - device = decomposed_feats[0].device - - curr_sample_size = max([pcd.shape[0] for pcd in decomposed_feats]) - if not (max_sample_size or is_eval): - curr_sample_size = min(curr_sample_size, self.sample_sizes[depth]) - - for bidx in range(len(decomposed_feats)): - num_points = decomposed_feats[bidx].shape[0] - if num_points <= curr_sample_size: - idx = torch.zeros(curr_sample_size, - dtype=torch.long, - device=device) - - midx = torch.ones(curr_sample_size, - dtype=torch.bool, - device=device) - - idx[:num_points] = torch.arange(num_points, - device=device) - - midx[:num_points] = False # attend to first points - else: - # we have more points in pcd as we like to sample - # take a subset (no padding or masking needed) - idx = torch.randperm(decomposed_feats[bidx].shape[0], - device=device)[:curr_sample_size] - midx = torch.zeros(curr_sample_size, - dtype=torch.bool, - device=device) # attend to all - indices.append(idx) - masks.append(midx) - - batched_feats = torch.stack([ - decomposed_feats[b][indices[b], :] for b in range(len(indices)) - ]) - batched_attn = torch.stack([ - decomposed_attn[b][indices[b], :] for b in range(len(indices)) - ]) - batched_pos_enc = torch.stack([ - decomposed_pos_encs[b][indices[b], :] for b in range(len(indices)) - ]) - - # Mask to handle points less than num_sample points - m = torch.stack(masks) - # If sum(1) == nsamples, then this query has no active voxels - batched_attn.permute((0, 2, 1))[batched_attn.sum(1) == indices[0].shape[0]] = False - # Fianl attention map is intersection of attention map and - # valid voxel samples (m). - batched_attn = torch.logical_or(batched_attn, m[..., None]) - - return batched_feats, batched_attn, batched_pos_enc - - - def forward(self, point_cloud): - - coords = point_cloud[:, COORD_COLS].int() - feats = point_cloud[:, VALUE_COL].float().view(-1, 1) - - normed_coords = get_normalized_coordinates(coords, self.spatial_size) - normed_feats = feats * self.adc_to_mev - features = torch.cat([normed_coords, normed_feats], dim=1) - x = ME.SparseTensor(coordinates=point_cloud[:, :VALUE_COL].int(), - features=features) - encoderOutput = self.encoder(x) - decoderOutput = self.decoder(encoderOutput['finalTensor'], - encoderOutput['encoderTensors']) - queries, query_pos, query_index = self.query_module(x, decoderOutput[-1]) - - total_num_pooling = len(decoderOutput)-1 - - full_res_fmap = decoderOutput[-1] - - mask_features = self.mask_head(full_res_fmap) - - batch_size = int(torch.unique(x.C[:, 0]).shape[0]) - - predictions_mask = [] - predictions_class = [] - - for tf_index in range(self.num_transformers): - if self.shared_decoders: - transformer_index = 0 - else: - transformer_index = tf_index - for i, fmap in enumerate(decoderOutput): - assert queries.shape == (batch_size, self.query_module.num_queries, self.mask_dim) - num_pooling = total_num_pooling-i - # queries = queries.permute(2, 0, 1) - output_mask, output_class, attn_mask = self.mask_module(queries, - mask_features, - num_pooling_steps=num_pooling) - - predictions_mask.append(output_mask.F) - predictions_class.append(output_class) - - fmaps, attn_masks = fmap.decomposed_features, attn_mask.decomposed_features - decomposed_coords = fmap.decomposed_coordinates - batched_feats, batched_attn, batched_pos_enc = self.sampling_module( - fmaps, decomposed_coords, attn_masks, i) - src_pcd = self.linear_squeeze[i](batched_feats) - output = self.transformers[transformer_index](queries, - query_pos, - src_pcd, - batched_pos_enc, - batched_attn) - - queries = output - - output_mask, output_class, attn_mask = self.mask_module(queries, - mask_features, - return_attention_mask=True, - num_pooling_steps=0) - - res = { - 'pred_masks' : [output_mask.F], - 'pred_logits': [output_class], - 'aux_masks': [predictions_mask], - 'aux_classes': [predictions_class], - 'query_index': [query_index] - } - - return res \ No newline at end of file diff --git a/mlreco/models/experimental/cluster/transformer_spice.py b/mlreco/models/experimental/cluster/transformer_spice.py index 187291e7..9d1eb24f 100644 --- a/mlreco/models/experimental/cluster/transformer_spice.py +++ b/mlreco/models/experimental/cluster/transformer_spice.py @@ -8,13 +8,344 @@ from mlreco.models.experimental.transformers.positional_encodings import FourierEmbeddings from mlreco.models.experimental.cluster.pointnet2.pointnet2_utils import furthest_point_sample from mlreco.models.experimental.transformers.positional_encodings import get_normalized_coordinates -from mlreco.models.experimental.transformers.transformer import TransformerDecoder, GenericMLP -from torch_geometric.nn import MLP +from mlreco.utils.globals import * +from mlreco.models.experimental.transformers.transformer import GenericMLP +class QueryModule(nn.Module): + + def __init__(self, cfg, name='query_module'): + super(QueryModule, self).__init__() + + self.model_config = cfg[name] + + # Define instance query modules + self.num_input = self.model_config.get('num_input', 32) + self.num_pos_input = self.model_config.get('num_pos_input', 128) + self.num_queries = self.model_config.get('num_queries', 200) + # self.num_classes = self.model_config.get('num_classes', 5) + self.mask_dim = self.model_config.get('mask_dim', 128) + self.query_type = self.model_config.get('query_type', 'fps') + self.query_proj = None + + if self.query_type == 'fps': + self.query_projection = GenericMLP( + input_dim=self.num_input, + hidden_dims=[self.mask_dim], + output_dim=self.mask_dim, + norm_fn_name='bn1d', + use_conv=True, + output_use_activation=True, + hidden_use_bias=True + ) + self.query_pos_projection = GenericMLP( + input_dim=self.num_pos_input, + hidden_dims=[self.mask_dim], + output_dim=self.mask_dim, + norm_fn_name='bn1d', + use_conv=True, + output_use_activation=True, + hidden_use_bias=True + ) + elif self.query_type == 'embedding': + self.query_feat = nn.Embedding(self.num_queries, self.mask_dim) + self.query_pos = nn.Embedding(self.num_queries, self.mask_dim) + else: + raise ValueError("Query type {} is not supported!".format(self.query_type)) + + self.pos_enc = FourierEmbeddings(cfg) + + def forward(self, x, uresnet_features): + ''' + Inputs + ------ + x: Input ME.SparseTensor from UResNet output + ''' + + batch_size = len(x.decomposed_coordinates) + + if self.query_type == 'fps': + # Sample query points via FPS + fps_idx = [furthest_point_sample(x.decomposed_coordinates[i][None, ...].float(), + self.num_queries).squeeze(0).long() \ + for i in range(len(x.decomposed_coordinates))] + # B, nqueries, 3 + sampled_coords = torch.stack([x.decomposed_coordinates[i][fps_idx[i], :] \ + for i in range(len(x.decomposed_coordinates))], axis=0) + query_pos = self.pos_enc(sampled_coords.float()).permute(0, 2, 1) # B, dim, nqueries + query_pos = self.query_pos_projection(query_pos) # B, dim, mask_dim + queries = torch.stack([uresnet_features.decomposed_features[i][fps_idx[i].long(), :] \ + for i in range(len(fps_idx))]) # B, nqueries, num_uresnet_feats + queries = queries.permute(0, 2, 1) # B, num_uresnet_feats, nqueries + queries = self.query_projection(queries) # B, mask_dim, nqueries + elif self.query_type == 'embedding': + queries = self.query_feat.weight.unsqueze(0).repeat(batch_size, 1, 1) + query_pos = self.query_pos.weight.unsqueeze(1).repeat(1, batch_size, 1) + else: + raise ValueError("Query type {} is not supported!".format(self.query_type)) + + return queries.permute((0, 2, 1)), query_pos.permute((0, 2, 1)), fps_idx + class TransformerSPICE(nn.Module): + """ + Transformer based model for particle clustering, using Mask3D + as a backbone. + + Mask3D backbone implementation: https://github.com/JonasSchult/Mask3D + + Mask3D: https://arxiv.org/abs/2210.03105 - def __init__(self, cfg, name='transformer_spice'): + """ + + def __init__(self, cfg, name='mask3d'): super(TransformerSPICE, self).__init__() - self.model_config = cfg[name] \ No newline at end of file + self.model_config = cfg[name] + + self.encoder = UResNetEncoder(cfg, name='uresnet') + self.decoder = UResNetDecoder(cfg, name='uresnet') + + num_params_backbone = sum(p.numel() for p in self.encoder.parameters()) + num_params_backbone += sum(p.numel() for p in self.decoder.parameters()) + print(f"Number of Backbone Parameters = {num_params_backbone}") + + self.query_module = QueryModule(cfg) + + num_features = self.encoder.num_filters + self.D = self.model_config.get('D', 3) + self.mask_dim = self.model_config.get('mask_dim', 128) + self.num_classes = self.model_config.get('num_classes', 2) + self.num_heads = self.model_config.get('num_heads', 8) + self.dropout = self.model_config.get('dropout', 0.0) + + device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") + + self.spatial_size = self.model_config.get('spatial_size', [2753, 1056, 5966]) + self.spatial_size = torch.Tensor(self.spatial_size).float().to(device) + + self.depth = self.model_config.get('depth', 5) + self.mask_head = ME.MinkowskiConvolution(num_features, + self.mask_dim, + kernel_size=1, + stride=1, + bias=True, + dimension=self.D) + self.pooling = ME.MinkowskiAvgPooling(kernel_size=2, + stride=2, + dimension=3) + self.adc_to_mev = 1./350. + + # Query Refinement Modules + self.num_transformers = self.model_config.get('num_transformers', 3) + self.shared_decoders = self.model_config.get('shared_decoders', False) + + self.instance_to_mask = nn.Linear(self.mask_dim, self.mask_dim) + self.instance_to_class = nn.Linear(self.mask_dim, self.mask_dim) + + # Layerwise Projections + self.linear_squeeze = nn.ModuleList() + for i in range(self.depth-1, 0, -1): + self.linear_squeeze.append(nn.Linear(i * num_features, + self.mask_dim)) + + # Transformer Modules + if self.shared_decoders: + num_shared = 1 + else: + num_shared = self.num_transformers + + self.transformers = [] + + for num_trans in range(num_shared): + self.transformers.append(nn.TransformerDecoderLayer( + self.mask_dim, self.num_heads, dim_feedforward=1024, batch_first=True)) + + self.transformers = nn.ModuleList(self.transformers) + self.layernorm = nn.LayerNorm(self.mask_dim) + + self.sample_sizes = [200, 800, 1600, 6400, 12800] + + + def mask_module(self, queries, mask_features, + return_attention_mask=True, + num_pooling_steps=0): + ''' + Inputs + ------ + - queries: [B, num_queries, query_dim] torch.Tensor + - mask_features: ME.SparseTensor from mask head output + ''' + query_feats = self.layernorm(queries) + mask_embed = self.instance_to_mask(query_feats) + output_class = self.instance_to_class(query_feats) + + output_masks = [] + + coords, feats = mask_features.decomposed_coordinates_and_features + batch_size = len(coords) + + assert mask_embed.shape[0] == batch_size + + for i in range(len(mask_features.decomposed_features)): + mask = feats[i] @ mask_embed[i].T + output_masks.append(mask) + + output_masks = torch.cat(output_masks, dim=0) + output_coords = torch.cat(coords, dim=0) + output_mask = me.SparseTensor(features=output_masks, + coordinate_manager=mask_features.coordinate_manager, + coordinate_map_key=mask_features.coordinate_map_key) + + if return_attention_mask: + # nn.MultiHeadAttention attn_mask prevents "True" pixels from access + # Hence the < 0.5 in the attn_mask + with torch.no_grad(): + attn_mask = output_mask + for _ in range(num_pooling_steps): + attn_mask = self.pooling(attn_mask.float()) + attn_mask = me.SparseTensor(features=(attn_mask.F.detach().sigmoid() < 0.5), + coordinate_manager=attn_mask.coordinate_manager, + coordinate_map_key=attn_mask.coordinate_map_key) + return output_mask, output_class, attn_mask + else: + return output_mask, output_class + + + def sampling_module(self, decomposed_feats, decomposed_coords, decomposed_attn, depth, + max_sample_size=False, is_eval=False): + + indices, masks = [], [] + + if min([pcd.shape[0] for pcd in decomposed_feats]) == 1: + raise RuntimeError("only a single point gives nans in cross-attention") + + decomposed_pos_encs = [] + + for coords in decomposed_coords: + pos_enc = self.query_module.pos_enc(coords.float()) + decomposed_pos_encs.append(pos_enc) + + device = decomposed_feats[0].device + + curr_sample_size = max([pcd.shape[0] for pcd in decomposed_feats]) + if not (max_sample_size or is_eval): + curr_sample_size = min(curr_sample_size, self.sample_sizes[depth]) + + for bidx in range(len(decomposed_feats)): + num_points = decomposed_feats[bidx].shape[0] + if num_points <= curr_sample_size: + idx = torch.zeros(curr_sample_size, + dtype=torch.long, + device=device) + + midx = torch.ones(curr_sample_size, + dtype=torch.bool, + device=device) + + idx[:num_points] = torch.arange(num_points, + device=device) + + midx[:num_points] = False # attend to first points + else: + # we have more points in pcd as we like to sample + # take a subset (no padding or masking needed) + idx = torch.randperm(decomposed_feats[bidx].shape[0], + device=device)[:curr_sample_size] + midx = torch.zeros(curr_sample_size, + dtype=torch.bool, + device=device) # attend to all + indices.append(idx) + masks.append(midx) + + batched_feats = torch.stack([ + decomposed_feats[b][indices[b], :] for b in range(len(indices)) + ]) + batched_attn = torch.stack([ + decomposed_attn[b][indices[b], :] for b in range(len(indices)) + ]) + batched_pos_enc = torch.stack([ + decomposed_pos_encs[b][indices[b], :] for b in range(len(indices)) + ]) + + # Mask to handle points less than num_sample points + m = torch.stack(masks) + # If sum(1) == nsamples, then this query has no active voxels + batched_attn.permute((0, 2, 1))[batched_attn.sum(1) == indices[0].shape[0]] = False + # Fianl attention map is intersection of attention map and + # valid voxel samples (m). + batched_attn = torch.logical_or(batched_attn, m[..., None]) + + return batched_feats, batched_attn, batched_pos_enc + + + def forward(self, point_cloud): + + coords = point_cloud[:, COORD_COLS].int() + feats = point_cloud[:, VALUE_COL].float().view(-1, 1) + + normed_coords = get_normalized_coordinates(coords, self.spatial_size) + normed_feats = feats * self.adc_to_mev + features = torch.cat([normed_coords, normed_feats], dim=1) + x = ME.SparseTensor(coordinates=point_cloud[:, :VALUE_COL].int(), + features=features) + encoderOutput = self.encoder(x) + decoderOutput = self.decoder(encoderOutput['finalTensor'], + encoderOutput['encoderTensors']) + queries, query_pos, query_index = self.query_module(x, decoderOutput[-1]) + + total_num_pooling = len(decoderOutput)-1 + full_res_fmap = decoderOutput[-1] + mask_features = self.mask_head(full_res_fmap) + batch_size = int(torch.unique(x.C[:, 0]).shape[0]) + + predictions_mask = [] + predictions_class = [] + + for tf_index in range(self.num_transformers): + if self.shared_decoders: + transformer_index = 0 + else: + transformer_index = tf_index + for i, fmap in enumerate(decoderOutput): + assert queries.shape == (batch_size, + self.query_module.num_queries, + self.mask_dim) + num_pooling = total_num_pooling-i + + output_mask, output_class, attn_mask = self.mask_module(queries, + mask_features, + num_pooling_steps=num_pooling) + + predictions_mask.append(output_mask.F) + predictions_class.append(output_class) + + fmaps, attn_masks = fmap.decomposed_features, attn_mask.decomposed_features + decomposed_coords = fmap.decomposed_coordinates + + batched_feats, batched_attn, batched_pos_enc = self.sampling_module( + fmaps, decomposed_coords, attn_masks, i) + + src_pcd = self.linear_squeeze[i](batched_feats) + + batched_attn = torch.repeat_interleave(batched_attn.permute((0, 2, 1)), repeats=8, dim=0) + + output = self.transformers[transformer_index](queries + query_pos, + src_pcd + batched_pos_enc) + # memory_mask=batched_attn) + + queries = output + + output_mask, output_class, attn_mask = self.mask_module(queries, + mask_features, + return_attention_mask=True, + num_pooling_steps=0) + + res = { + 'pred_masks' : [output_mask.F], + 'pred_logits': [output_class], + 'aux_masks': [predictions_mask], + 'aux_classes': [predictions_class], + 'query_index': [query_index] + } + + return res \ No newline at end of file diff --git a/mlreco/models/factories.py b/mlreco/models/factories.py index d4484a87..7810ab5b 100644 --- a/mlreco/models/factories.py +++ b/mlreco/models/factories.py @@ -19,6 +19,7 @@ def model_dict(): from . import bayes_uresnet from . import vertex + from . import transformer # Make some models available (not all of them, e.g. PPN is not standalone) models = { @@ -53,7 +54,9 @@ def model_dict(): # Vertex PPN 'vertex_ppn': (vertex.VertexPPNChain, vertex.UResNetVertexLoss), # Vertex Pointnet - 'vertex_pointnet': (vertex.VertexPointNet, vertex.VertexPointNetLoss) + 'vertex_pointnet': (vertex.VertexPointNet, vertex.VertexPointNetLoss), + # TransformerSPICE + 'mask3d': (transformer.Mask3DModel, transformer.Mask3dLoss) } return models diff --git a/mlreco/models/experimental/cluster/mask3d_model.py b/mlreco/models/transformer.py similarity index 88% rename from mlreco/models/experimental/cluster/mask3d_model.py rename to mlreco/models/transformer.py index d547a4e7..fd94f34f 100644 --- a/mlreco/models/experimental/cluster/mask3d_model.py +++ b/mlreco/models/transformer.py @@ -4,7 +4,7 @@ import MinkowskiEngine as ME from pprint import pprint -from mlreco.models.experimental.cluster.mask3d import Mask3d +from mlreco.models.experimental.cluster.transformer_spice import TransformerSPICE from mlreco.models.experimental.cluster.criterion import * from mlreco.utils.globals import * from scipy.optimize import linear_sum_assignment @@ -30,18 +30,9 @@ class Mask3DModel(nn.Module): def __init__(self, cfg, name='mask3d'): super(Mask3DModel, self).__init__() - self.net = Mask3d(cfg) + self.net = TransformerSPICE(cfg) self.skip_classes = cfg[name].get('skip_classes') - def weight_initialization(self): - for m in self.modules(): - if isinstance(m, ME.MinkowskiConvolution): - ME.utils.kaiming_normal_(m.kernel, mode="fan_out", nonlinearity="relu") - - if isinstance(m, ME.MinkowskiBatchNorm): - nn.init.constant_(m.bn.weight, 1) - nn.init.constant_(m.bn.bias, 0) - def filter_class(self, x): ''' Filter classes according to segmentation label. @@ -109,8 +100,8 @@ def __init__(self, cfg, name='mask3d'): self.xentropy = nn.CrossEntropyLoss(weight=self.weight_class, reduction='mean') self.dice_loss_mode = self.model_config.get('dice_loss_mode', 'log_dice') - # self.loss_fn = LinearSumAssignmentLoss(mode=self.dice_loss_mode) - self.loss_fn = CEDiceLoss(mode=self.dice_loss_mode) + self.loss_fn = LinearSumAssignmentLoss(mode=self.dice_loss_mode) + # self.loss_fn = CEDiceLoss(mode=self.dice_loss_mode) def filter_class(self, cluster_label): ''' @@ -134,7 +125,8 @@ def compute_layerwise_loss(self, aux_masks, aux_classes, clabel, query_index): labels = clabel[0][batch_mask][:, GROUP_COL].long() query_idx_batch = query_index[bidx] # Compute instance mask loss - targets = get_instance_masks_from_queries(labels, query_idx_batch).float() + targets = get_instance_masks(labels).float() + # targets = get_instance_masks_from_queries(labels, query_idx_batch).float() loss_batch, acc_batch = self.loss_fn(mask_layer[batch_mask], targets) loss[bidx].append(loss_batch) @@ -183,11 +175,9 @@ def forward(self, result, cluster_label): labels = clabel[0][batch_mask][:, GROUP_COL].long() - # targets = get_instance_masks(labels).float() + targets = get_instance_masks(labels).float() query_idx_batch = query_index[bidx] - targets = get_instance_masks_from_queries(labels, query_idx_batch).float() - - # print(output_mask, targets) + # targets = get_instance_masks_from_queries(labels, query_idx_batch).float() loss_batch, acc_batch = self.loss_fn(output_mask, targets) loss[bidx].append(loss_batch) @@ -221,4 +211,4 @@ def forward(self, result, cluster_label): # 'acc_class': float(acc_class) } - return res + return res \ No newline at end of file diff --git a/mlreco/utils/globals.py b/mlreco/utils/globals.py index 55367295..f148d9ff 100644 --- a/mlreco/utils/globals.py +++ b/mlreco/utils/globals.py @@ -19,6 +19,7 @@ PGRP_COL = 11 VTX_COLS = (12,13,14) MOM_COL = 15 +SEG_COL = -1 # Colum which specifies the shape ID of a voxel in a sparse tensor SHAPE_COL = -1 From b7b317b714c5e250b838f12fc22ffcb63ab4786f Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Thu, 20 Apr 2023 11:18:25 -0700 Subject: [PATCH 144/180] Remove furthest point sampling, only temporary --- mlreco/models/experimental/cluster/transformer_spice.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/mlreco/models/experimental/cluster/transformer_spice.py b/mlreco/models/experimental/cluster/transformer_spice.py index 9d1eb24f..27dedd64 100644 --- a/mlreco/models/experimental/cluster/transformer_spice.py +++ b/mlreco/models/experimental/cluster/transformer_spice.py @@ -6,7 +6,7 @@ from mlreco.models.layers.common.uresnet_layers import UResNetDecoder, UResNetEncoder from mlreco.models.experimental.transformers.positional_encodings import FourierEmbeddings -from mlreco.models.experimental.cluster.pointnet2.pointnet2_utils import furthest_point_sample +# from mlreco.models.experimental.cluster.pointnet2.pointnet2_utils import furthest_point_sample from mlreco.models.experimental.transformers.positional_encodings import get_normalized_coordinates from mlreco.utils.globals import * from mlreco.models.experimental.transformers.transformer import GenericMLP @@ -65,9 +65,10 @@ def forward(self, x, uresnet_features): if self.query_type == 'fps': # Sample query points via FPS - fps_idx = [furthest_point_sample(x.decomposed_coordinates[i][None, ...].float(), - self.num_queries).squeeze(0).long() \ - for i in range(len(x.decomposed_coordinates))] + fps_idx = None + # fps_idx = [furthest_point_sample(x.decomposed_coordinates[i][None, ...].float(), + # self.num_queries).squeeze(0).long() \ + # for i in range(len(x.decomposed_coordinates))] # B, nqueries, 3 sampled_coords = torch.stack([x.decomposed_coordinates[i][fps_idx[i], :] \ for i in range(len(x.decomposed_coordinates))], axis=0) From 19d01dee6b720b59404d3e3a77a345299bb8d09c Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Thu, 20 Apr 2023 12:12:29 -0700 Subject: [PATCH 145/180] Temporary fix for meta information unwrap error for flash matching --- analysis/post_processing/pmt/flash_matching.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/analysis/post_processing/pmt/flash_matching.py b/analysis/post_processing/pmt/flash_matching.py index da1ad1a1..4795e8f3 100644 --- a/analysis/post_processing/pmt/flash_matching.py +++ b/analysis/post_processing/pmt/flash_matching.py @@ -69,7 +69,13 @@ def run_flash_matching(data_dict, result_dict, boundaries=volume_boundaries, opflash_keys=opflash_keys, reflash_merging_window=reflash_merging_window) - fm.initialize_flash_manager(data_dict['meta'][0]) + if isinstance(data_dict['meta'][0], float): + fm.initialize_flash_manager(data_dict['meta']) + elif isinstance(data_dict['meta'][0], list): + fm.initialize_flash_manager(data_dict['meta'][0]) + else: + print(type(data_dict['meta'][0])) + raise AssertionError update_dict = {} From 87e6094fa0f2bc0431b7914b55b876c5467286f0 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Thu, 20 Apr 2023 13:56:32 -0700 Subject: [PATCH 146/180] Add mu and p CSDARange tables --- .../tables/muE_liquid_argon.txt | 146 ++++++++++++++++++ .../reconstruction/tables/pE_liquid_argon.txt | 133 ++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 analysis/post_processing/reconstruction/tables/muE_liquid_argon.txt create mode 100644 analysis/post_processing/reconstruction/tables/pE_liquid_argon.txt diff --git a/analysis/post_processing/reconstruction/tables/muE_liquid_argon.txt b/analysis/post_processing/reconstruction/tables/muE_liquid_argon.txt new file mode 100644 index 00000000..4b5fb082 --- /dev/null +++ b/analysis/post_processing/reconstruction/tables/muE_liquid_argon.txt @@ -0,0 +1,146 @@ + T p Ionization brems pair photonuc Radloss dE/dx CSDARange delta beta dE/dx_R + 1.000E+00 1.457E+01 2.404E+00 0.000E+00 0.000E+00 4.526E-05 4.526E-05 4.808E+00 2.831E-03 0.0000 0.13661 3.355E+01 + 1.200E+00 1.597E+01 2.920E+01 0.000E+00 0.000E+00 4.534E-05 4.534E-05 2.920E+01 9.238E-03 0.0000 0.14944 2.920E+01 + 1.400E+00 1.726E+01 2.595E+01 0.000E+00 0.000E+00 4.542E-05 4.542E-05 2.595E+01 1.652E-02 0.0000 0.16119 2.595E+01 + 1.700E+00 1.903E+01 2.234E+01 0.000E+00 0.000E+00 4.555E-05 4.555E-05 2.234E+01 2.902E-02 0.0000 0.17725 2.234E+01 + 2.000E+00 2.066E+01 1.970E+01 0.000E+00 0.000E+00 4.568E-05 4.568E-05 1.970E+01 4.335E-02 0.0000 0.19186 1.970E+01 + 2.500E+00 2.312E+01 1.655E+01 0.000E+00 0.000E+00 4.589E-05 4.589E-05 1.655E+01 7.117E-02 0.0000 0.21376 1.655E+01 + 3.000E+00 2.536E+01 1.435E+01 0.000E+00 0.000E+00 4.610E-05 4.610E-05 1.435E+01 1.037E-01 0.0000 0.23336 1.417E+01 + 3.500E+00 2.742E+01 1.272E+01 0.000E+00 0.000E+00 4.632E-05 4.632E-05 1.272E+01 1.408E-01 0.0000 0.25120 1.240E+01 + 4.000E+00 2.935E+01 1.146E+01 0.000E+00 0.000E+00 4.653E-05 4.653E-05 1.146E+01 1.822E-01 0.0000 0.26763 1.106E+01 + 4.500E+00 3.116E+01 1.046E+01 0.000E+00 0.000E+00 4.674E-05 4.674E-05 1.046E+01 2.280E-01 0.0000 0.28290 9.998E+00 + 5.000E+00 3.289E+01 9.635E+00 0.000E+00 0.000E+00 4.695E-05 4.695E-05 9.635E+00 2.778E-01 0.0000 0.29720 9.141E+00 + 5.500E+00 3.453E+01 8.949E+00 0.000E+00 0.000E+00 4.716E-05 4.716E-05 8.949E+00 3.317E-01 0.0000 0.31066 8.434E+00 + 6.000E+00 3.611E+01 8.368E+00 0.000E+00 0.000E+00 4.738E-05 4.738E-05 8.368E+00 3.895E-01 0.0000 0.32339 7.839E+00 + 7.000E+00 3.909E+01 7.435E+00 0.000E+00 0.000E+00 4.780E-05 4.780E-05 7.435E+00 5.166E-01 0.0000 0.34700 6.894E+00 + 8.000E+00 4.189E+01 6.719E+00 0.000E+00 0.000E+00 4.823E-05 4.823E-05 6.719E+00 6.583E-01 0.0000 0.36854 6.177E+00 + 9.000E+00 4.453E+01 6.150E+00 0.000E+00 0.000E+00 4.865E-05 4.865E-05 6.150E+00 8.141E-01 0.0000 0.38836 5.613E+00 + 1.000E+01 4.704E+01 5.687E+00 0.000E+00 0.000E+00 4.907E-05 4.907E-05 5.687E+00 9.833E-01 0.0000 0.40675 5.159E+00 + 1.200E+01 5.177E+01 4.979E+00 0.000E+00 0.000E+00 4.992E-05 4.992E-05 4.979E+00 1.360E+00 0.0000 0.43998 4.469E+00 + 1.400E+01 5.616E+01 4.461E+00 0.000E+00 0.000E+00 5.077E-05 5.077E-05 4.461E+00 1.786E+00 0.0000 0.46937 3.971E+00 + 1.700E+01 6.230E+01 3.901E+00 0.000E+00 0.000E+00 5.204E-05 5.204E-05 3.902E+00 2.507E+00 0.0000 0.50792 3.438E+00 + 2.000E+01 6.802E+01 3.502E+00 0.000E+00 0.000E+00 5.332E-05 5.332E-05 3.502E+00 3.321E+00 0.0000 0.54129 3.061E+00 + 2.500E+01 7.686E+01 3.042E+00 0.000E+00 0.000E+00 5.544E-05 5.544E-05 3.042E+00 4.859E+00 0.0000 0.58827 2.631E+00 + 3.000E+01 8.509E+01 2.731E+00 0.000E+00 0.000E+00 5.756E-05 5.756E-05 2.731E+00 6.598E+00 0.0000 0.62720 2.343E+00 + 3.500E+01 9.285E+01 2.508E+00 0.000E+00 0.000E+00 5.968E-05 5.968E-05 2.508E+00 8.512E+00 0.0000 0.66011 2.136E+00 + 4.000E+01 1.003E+02 2.340E+00 0.000E+00 0.000E+00 6.180E-05 6.180E-05 2.340E+00 1.058E+01 0.0000 0.68834 1.982E+00 + 4.500E+01 1.074E+02 2.210E+00 0.000E+00 0.000E+00 6.392E-05 6.392E-05 2.210E+00 1.278E+01 0.0000 0.71286 1.862E+00 + 5.000E+01 1.143E+02 2.107E+00 0.000E+00 0.000E+00 6.605E-05 6.605E-05 2.107E+00 1.510E+01 0.0000 0.73434 1.767E+00 + 5.500E+01 1.210E+02 2.023E+00 3.229E-07 0.000E+00 6.817E-05 6.849E-05 2.023E+00 1.752E+01 0.0000 0.75332 1.690E+00 + 6.000E+01 1.276E+02 1.954E+00 1.490E-06 0.000E+00 7.029E-05 7.178E-05 1.954E+00 2.004E+01 0.0000 0.77019 1.626E+00 + 7.000E+01 1.403E+02 1.848E+00 3.928E-06 0.000E+00 7.453E-05 7.846E-05 1.848E+00 2.531E+01 0.0000 0.79887 1.528E+00 + 8.000E+01 1.527E+02 1.771E+00 6.495E-06 0.000E+00 7.877E-05 8.527E-05 1.771E+00 3.084E+01 0.0000 0.82227 1.456E+00 + 9.000E+01 1.647E+02 1.713E+00 9.185E-06 0.000E+00 8.302E-05 9.220E-05 1.713E+00 3.659E+01 0.0000 0.84166 1.401E+00 + 1.000E+02 1.764E+02 1.669E+00 1.199E-05 0.000E+00 8.726E-05 9.925E-05 1.670E+00 4.250E+01 0.0010 0.85794 1.359E+00 + 1.200E+02 1.994E+02 1.608E+00 1.793E-05 0.000E+00 9.575E-05 1.137E-04 1.609E+00 5.473E+01 0.0098 0.88361 1.298E+00 + 1.400E+02 2.218E+02 1.570E+00 2.428E-05 0.000E+00 1.042E-04 1.285E-04 1.570E+00 6.732E+01 0.0247 0.90278 1.258E+00 + 1.700E+02 2.546E+02 1.536E+00 3.448E-05 0.000E+00 1.170E-04 1.514E-04 1.536E+00 8.666E+01 0.0541 0.92363 1.219E+00 + 2.000E+02 2.868E+02 1.518E+00 4.544E-05 0.000E+00 1.297E-04 1.751E-04 1.519E+00 1.063E+02 0.0884 0.93835 1.195E+00 + 2.500E+02 3.396E+02 1.508E+00 6.515E-05 0.000E+00 1.509E-04 2.161E-04 1.508E+00 1.394E+02 0.1508 0.95485 1.172E+00 + 3.000E+02 3.917E+02 1.509E+00 8.648E-05 0.000E+00 1.721E-04 2.586E-04 1.510E+00 1.725E+02 0.2157 0.96548 1.162E+00 + 3.500E+02 4.432E+02 1.516E+00 1.092E-04 0.000E+00 1.933E-04 3.025E-04 1.517E+00 2.056E+02 0.2809 0.97274 1.157E+00 + 4.000E+02 4.945E+02 1.526E+00 1.332E-04 0.000E+00 2.146E-04 3.477E-04 1.526E+00 2.385E+02 0.3453 0.97793 1.155E+00 + 4.500E+02 5.455E+02 1.536E+00 1.583E-04 0.000E+00 2.358E-04 3.941E-04 1.537E+00 2.711E+02 0.4084 0.98176 1.155E+00 + 5.000E+02 5.964E+02 1.547E+00 1.845E-04 0.000E+00 2.570E-04 4.414E-04 1.548E+00 3.035E+02 0.4698 0.98467 1.156E+00 + 5.500E+02 6.471E+02 1.558E+00 2.115E-04 0.000E+00 2.782E-04 4.897E-04 1.559E+00 3.357E+02 0.5296 0.98693 1.158E+00 + 6.000E+02 6.977E+02 1.569E+00 2.395E-04 0.000E+00 2.994E-04 5.389E-04 1.570E+00 3.677E+02 0.5876 0.98873 1.160E+00 + 7.000E+02 7.987E+02 1.590E+00 2.978E-04 0.000E+00 3.418E-04 6.396E-04 1.591E+00 4.310E+02 0.6986 0.99136 1.165E+00 + 8.000E+02 8.995E+02 1.610E+00 3.589E-04 0.000E+00 3.843E-04 7.432E-04 1.610E+00 4.934E+02 0.8032 0.99317 1.170E+00 + 9.000E+02 1.000E+03 1.627E+00 4.225E-04 0.000E+00 4.267E-04 8.492E-04 1.628E+00 5.552E+02 0.9021 0.99447 1.174E+00 + 1.000E+03 1.101E+03 1.644E+00 4.884E-04 1.833E-05 4.691E-04 9.759E-04 1.645E+00 6.163E+02 0.9957 0.99542 1.179E+00 + 1.200E+03 1.301E+03 1.673E+00 6.263E-04 1.159E-04 5.540E-04 1.296E-03 1.675E+00 7.368E+02 1.1691 0.99672 1.187E+00 + 1.400E+03 1.502E+03 1.699E+00 7.712E-04 2.268E-04 6.389E-04 1.637E-03 1.700E+00 8.552E+02 1.3269 0.99753 1.194E+00 + 1.700E+03 1.803E+03 1.731E+00 9.996E-04 4.144E-04 7.661E-04 2.180E-03 1.733E+00 1.030E+03 1.5399 0.99829 1.202E+00 + 2.000E+03 2.103E+03 1.758E+00 1.239E-03 6.238E-04 8.967E-04 2.760E-03 1.761E+00 1.202E+03 1.7300 0.99874 1.209E+00 + 2.500E+03 2.604E+03 1.795E+00 1.660E-03 1.013E-03 1.126E-03 3.800E-03 1.799E+00 1.482E+03 2.0079 0.99918 1.219E+00 + 3.000E+03 3.104E+03 1.825E+00 2.103E-03 1.444E-03 1.359E-03 4.906E-03 1.829E+00 1.758E+03 2.2491 0.99942 1.226E+00 + 3.500E+03 3.604E+03 1.849E+00 2.565E-03 1.910E-03 1.594E-03 6.068E-03 1.855E+00 2.029E+03 2.4623 0.99957 1.231E+00 + 4.000E+03 4.104E+03 1.870E+00 3.042E-03 2.407E-03 1.831E-03 7.279E-03 1.877E+00 2.297E+03 2.6536 0.99967 1.236E+00 + 4.500E+03 4.604E+03 1.888E+00 3.533E-03 2.929E-03 2.069E-03 8.532E-03 1.897E+00 2.562E+03 2.8273 0.99974 1.239E+00 + 5.000E+03 5.105E+03 1.904E+00 4.038E-03 3.478E-03 2.305E-03 9.821E-03 1.914E+00 2.825E+03 2.9865 0.99979 1.243E+00 + 5.500E+03 5.605E+03 1.919E+00 4.562E-03 4.058E-03 2.523E-03 1.114E-02 1.930E+00 3.085E+03 3.1334 0.99982 1.245E+00 + 6.000E+03 6.105E+03 1.932E+00 5.097E-03 4.658E-03 2.740E-03 1.249E-02 1.944E+00 3.343E+03 3.2699 0.99985 1.248E+00 + 7.000E+03 7.105E+03 1.954E+00 6.196E-03 5.912E-03 3.171E-03 1.528E-02 1.969E+00 3.854E+03 3.5172 0.99989 1.251E+00 + 8.000E+03 8.105E+03 1.973E+00 7.329E-03 7.232E-03 3.601E-03 1.816E-02 1.991E+00 4.359E+03 3.7367 0.99992 1.254E+00 + 9.000E+03 9.105E+03 1.989E+00 8.493E-03 8.607E-03 4.029E-03 2.113E-02 2.010E+00 4.859E+03 3.9342 0.99993 1.257E+00 + 1.000E+04 1.011E+04 2.003E+00 9.685E-03 1.004E-02 4.454E-03 2.417E-02 2.028E+00 5.354E+03 4.1137 0.99995 1.259E+00 + 1.200E+04 1.211E+04 2.027E+00 1.216E-02 1.307E-02 5.279E-03 3.050E-02 2.058E+00 6.333E+03 4.4307 0.99996 1.262E+00 + 1.400E+04 1.411E+04 2.047E+00 1.471E-02 1.627E-02 6.095E-03 3.707E-02 2.084E+00 7.298E+03 4.7044 0.99997 1.264E+00 + 1.700E+04 1.711E+04 2.071E+00 1.867E-02 2.131E-02 7.306E-03 4.729E-02 2.119E+00 8.726E+03 5.0560 0.99998 1.266E+00 + 2.000E+04 2.011E+04 2.091E+00 2.277E-02 2.661E-02 8.504E-03 5.788E-02 2.149E+00 1.013E+04 5.3556 0.99999 1.268E+00 + 2.500E+04 2.511E+04 2.116E+00 2.985E-02 3.611E-02 1.050E-02 7.646E-02 2.193E+00 1.243E+04 5.7742 0.99999 1.270E+00 + 3.000E+04 3.011E+04 2.137E+00 3.719E-02 4.614E-02 1.247E-02 9.580E-02 2.232E+00 1.469E+04 6.1216 0.99999 1.271E+00 + 3.500E+04 3.511E+04 2.153E+00 4.473E-02 5.660E-02 1.443E-02 1.158E-01 2.269E+00 1.692E+04 6.4186 1.00000 1.271E+00 + 4.000E+04 4.011E+04 2.167E+00 5.246E-02 6.743E-02 1.637E-02 1.363E-01 2.304E+00 1.910E+04 6.6781 1.00000 1.272E+00 + 4.500E+04 4.511E+04 2.179E+00 6.035E-02 7.858E-02 1.829E-02 1.572E-01 2.337E+00 2.126E+04 6.9084 1.00000 1.272E+00 + 5.000E+04 5.011E+04 2.190E+00 6.837E-02 9.001E-02 2.021E-02 1.786E-01 2.369E+00 2.338E+04 7.1154 1.00000 1.272E+00 + 5.500E+04 5.511E+04 2.200E+00 7.648E-02 1.015E-01 2.215E-02 2.001E-01 2.400E+00 2.548E+04 7.3034 1.00000 1.273E+00 + 6.000E+04 6.011E+04 2.208E+00 8.469E-02 1.132E-01 2.409E-02 2.219E-01 2.430E+00 2.755E+04 7.4756 1.00000 1.273E+00 + 7.000E+04 7.011E+04 2.223E+00 1.014E-01 1.371E-01 2.795E-02 2.664E-01 2.490E+00 3.161E+04 7.7816 1.00000 1.273E+00 + 8.000E+04 8.011E+04 2.236E+00 1.185E-01 1.617E-01 3.178E-02 3.119E-01 2.548E+00 3.558E+04 8.0475 1.00000 1.273E+00 + 9.000E+04 9.011E+04 2.248E+00 1.359E-01 1.869E-01 3.560E-02 3.583E-01 2.606E+00 3.946E+04 8.2825 1.00000 1.273E+00 + 1.000E+05 1.001E+05 2.258E+00 1.535E-01 2.126E-01 3.941E-02 4.055E-01 2.663E+00 4.326E+04 8.4929 1.00000 1.273E+00 + 1.200E+05 1.201E+05 2.275E+00 1.891E-01 2.646E-01 4.714E-02 5.009E-01 2.776E+00 5.062E+04 8.8572 1.00000 1.273E+00 + 1.400E+05 1.401E+05 2.289E+00 2.256E-01 3.181E-01 5.484E-02 5.985E-01 2.888E+00 5.768E+04 9.1653 1.00000 1.273E+00 + 1.700E+05 1.701E+05 2.307E+00 2.814E-01 4.006E-01 6.636E-02 7.483E-01 3.055E+00 6.778E+04 9.5533 1.00000 1.273E+00 + 2.000E+05 2.001E+05 2.322E+00 3.384E-01 4.853E-01 7.784E-02 9.016E-01 3.224E+00 7.734E+04 9.8782 1.00000 1.273E+00 + 2.500E+05 2.501E+05 2.343E+00 4.341E-01 6.243E-01 9.727E-02 1.156E+00 3.498E+00 9.222E+04 10.3243 1.00000 1.273E+00 + 3.000E+05 3.001E+05 2.360E+00 5.318E-01 7.663E-01 1.167E-01 1.415E+00 3.774E+00 1.060E+05 10.6888 1.00000 1.273E+00 + 3.500E+05 3.501E+05 2.374E+00 6.312E-01 9.111E-01 1.361E-01 1.678E+00 4.052E+00 1.188E+05 10.9970 1.00000 1.273E+00 + 4.000E+05 4.001E+05 2.386E+00 7.320E-01 1.058E+00 1.556E-01 1.946E+00 4.332E+00 1.307E+05 11.2639 1.00000 1.273E+00 + 4.500E+05 4.501E+05 2.397E+00 8.341E-01 1.207E+00 1.750E-01 2.216E+00 4.613E+00 1.419E+05 11.4995 1.00000 1.273E+00 + 5.000E+05 5.001E+05 2.407E+00 9.373E-01 1.358E+00 1.944E-01 2.489E+00 4.896E+00 1.524E+05 11.7101 1.00000 1.273E+00 + 5.500E+05 5.501E+05 2.416E+00 1.040E+00 1.506E+00 2.143E-01 2.759E+00 5.175E+00 1.623E+05 11.9007 1.00000 1.273E+00 + 6.000E+05 6.001E+05 2.424E+00 1.143E+00 1.654E+00 2.343E-01 3.031E+00 5.455E+00 1.717E+05 12.0747 1.00000 1.273E+00 + 7.000E+05 7.001E+05 2.438E+00 1.351E+00 1.955E+00 2.743E-01 3.580E+00 6.018E+00 1.892E+05 12.3830 1.00000 1.273E+00 + 8.000E+05 8.001E+05 2.451E+00 1.562E+00 2.259E+00 3.144E-01 4.135E+00 6.585E+00 2.051E+05 12.6500 1.00000 1.273E+00 + 9.000E+05 9.001E+05 2.462E+00 1.774E+00 2.565E+00 3.547E-01 4.694E+00 7.156E+00 2.196E+05 12.8855 1.00000 1.273E+00 + 1.000E+06 1.000E+06 2.472E+00 1.989E+00 2.875E+00 3.950E-01 5.258E+00 7.730E+00 2.331E+05 13.0962 1.00000 1.273E+00 + 1.200E+06 1.200E+06 2.489E+00 2.415E+00 3.487E+00 4.773E-01 6.380E+00 8.868E+00 2.572E+05 13.4608 1.00000 1.273E+00 + 1.400E+06 1.400E+06 2.503E+00 2.847E+00 4.105E+00 5.600E-01 7.511E+00 1.001E+01 2.784E+05 13.7691 1.00000 1.273E+00 + 1.700E+06 1.700E+06 2.522E+00 3.501E+00 5.040E+00 6.848E-01 9.226E+00 1.175E+01 3.061E+05 14.1574 1.00000 1.273E+00 + 2.000E+06 2.000E+06 2.538E+00 4.161E+00 5.985E+00 8.104E-01 1.096E+01 1.349E+01 3.299E+05 14.4824 1.00000 1.273E+00 + 2.500E+06 2.500E+06 2.559E+00 5.256E+00 7.542E+00 1.024E+00 1.382E+01 1.638E+01 3.634E+05 14.9287 1.00000 1.273E+00 + 3.000E+06 3.000E+06 2.577E+00 6.360E+00 9.110E+00 1.240E+00 1.671E+01 1.929E+01 3.915E+05 15.2933 1.00000 1.273E+00 + 3.500E+06 3.500E+06 2.592E+00 7.473E+00 1.069E+01 1.458E+00 1.962E+01 2.221E+01 4.157E+05 15.6016 1.00000 1.273E+00 + 4.000E+06 4.000E+06 2.606E+00 8.592E+00 1.227E+01 1.677E+00 2.254E+01 2.515E+01 4.368E+05 15.8686 1.00000 1.273E+00 + 4.500E+06 4.500E+06 2.617E+00 9.717E+00 1.386E+01 1.898E+00 2.548E+01 2.810E+01 4.556E+05 16.1042 1.00000 1.273E+00 + 5.000E+06 5.000E+06 2.628E+00 1.085E+01 1.546E+01 2.120E+00 2.843E+01 3.106E+01 4.725E+05 16.3149 1.00000 1.273E+00 + 5.500E+06 5.500E+06 2.637E+00 1.197E+01 1.704E+01 2.346E+00 3.136E+01 3.400E+01 4.879E+05 16.5055 1.00000 1.273E+00 + 6.000E+06 6.000E+06 2.646E+00 1.309E+01 1.863E+01 2.573E+00 3.429E+01 3.694E+01 5.020E+05 16.6796 1.00000 1.273E+00 + 7.000E+06 7.000E+06 2.662E+00 1.534E+01 2.181E+01 3.031E+00 4.018E+01 4.284E+01 5.272E+05 16.9879 1.00000 1.273E+00 + 8.000E+06 8.000E+06 2.676E+00 1.761E+01 2.500E+01 3.493E+00 4.609E+01 4.877E+01 5.490E+05 17.2549 1.00000 1.273E+00 + 9.000E+06 9.000E+06 2.688E+00 1.988E+01 2.819E+01 3.959E+00 5.203E+01 5.471E+01 5.684E+05 17.4905 1.00000 1.273E+00 + 1.000E+07 1.000E+07 2.698E+00 2.215E+01 3.140E+01 4.427E+00 5.798E+01 6.068E+01 5.857E+05 17.7012 1.00000 1.273E+00 + 1.200E+07 1.200E+07 2.717E+00 2.669E+01 3.777E+01 5.382E+00 6.984E+01 7.255E+01 6.158E+05 18.0658 1.00000 1.273E+00 + 1.400E+07 1.400E+07 2.734E+00 3.124E+01 4.416E+01 6.347E+00 8.174E+01 8.447E+01 6.413E+05 18.3741 1.00000 1.273E+00 + 1.700E+07 1.700E+07 2.754E+00 3.808E+01 5.376E+01 7.811E+00 9.965E+01 1.024E+02 6.735E+05 18.7624 1.00000 1.273E+00 + 2.000E+07 2.000E+07 2.771E+00 4.496E+01 6.339E+01 9.292E+00 1.176E+02 1.204E+02 7.005E+05 19.0875 1.00000 1.273E+00 + 2.500E+07 2.500E+07 2.795E+00 5.635E+01 7.938E+01 1.182E+01 1.476E+02 1.503E+02 7.376E+05 19.5338 1.00000 1.273E+00 + 3.000E+07 3.000E+07 2.815E+00 6.777E+01 9.540E+01 1.439E+01 1.776E+02 1.804E+02 7.679E+05 19.8984 1.00000 1.273E+00 + 3.500E+07 3.500E+07 2.832E+00 7.921E+01 1.114E+02 1.699E+01 2.076E+02 2.105E+02 7.936E+05 20.2067 1.00000 1.273E+00 + 4.000E+07 4.000E+07 2.847E+00 9.067E+01 1.275E+02 1.962E+01 2.378E+02 2.406E+02 8.158E+05 20.4738 1.00000 1.273E+00 + 4.500E+07 4.500E+07 2.860E+00 1.022E+02 1.436E+02 2.227E+01 2.680E+02 2.709E+02 8.354E+05 20.7093 1.00000 1.273E+00 + 5.000E+07 5.000E+07 2.871E+00 1.137E+02 1.597E+02 2.494E+01 2.983E+02 3.011E+02 8.529E+05 20.9200 1.00000 1.273E+00 + 5.500E+07 5.500E+07 2.882E+00 1.251E+02 1.757E+02 2.765E+01 3.285E+02 3.314E+02 8.687E+05 21.1107 1.00000 1.273E+00 + 6.000E+07 6.000E+07 2.892E+00 1.366E+02 1.918E+02 3.038E+01 3.587E+02 3.616E+02 8.831E+05 21.2847 1.00000 1.273E+00 + 7.000E+07 7.000E+07 2.909E+00 1.595E+02 2.239E+02 3.590E+01 4.193E+02 4.222E+02 9.087E+05 21.5930 1.00000 1.273E+00 + 8.000E+07 8.000E+07 2.924E+00 1.825E+02 2.560E+02 4.148E+01 4.800E+02 4.829E+02 9.308E+05 21.8601 1.00000 1.273E+00 + 9.000E+07 9.000E+07 2.938E+00 2.055E+02 2.882E+02 4.711E+01 5.408E+02 5.437E+02 9.503E+05 22.0956 1.00000 1.273E+00 + 1.000E+08 1.000E+08 2.950E+00 2.285E+02 3.203E+02 5.279E+01 6.016E+02 6.046E+02 9.678E+05 22.3063 1.00000 1.273E+00 + 1.200E+08 1.200E+08 2.971E+00 2.742E+02 3.844E+02 6.335E+01 7.220E+02 7.249E+02 9.979E+05 22.6710 1.00000 1.273E+00 + 1.400E+08 1.400E+08 2.989E+00 3.199E+02 4.485E+02 7.391E+01 8.423E+02 8.453E+02 1.023E+06 22.9793 1.00000 1.273E+00 + 1.700E+08 1.700E+08 3.012E+00 3.885E+02 5.446E+02 8.974E+01 1.023E+03 1.026E+03 1.056E+06 23.3676 1.00000 1.273E+00 + 2.000E+08 2.000E+08 3.031E+00 4.570E+02 6.407E+02 1.056E+02 1.203E+03 1.206E+03 1.083E+06 23.6926 1.00000 1.273E+00 + 2.500E+08 2.500E+08 3.058E+00 5.713E+02 8.008E+02 1.320E+02 1.504E+03 1.507E+03 1.120E+06 24.1389 1.00000 1.273E+00 + 3.000E+08 3.000E+08 3.080E+00 6.856E+02 9.610E+02 1.584E+02 1.805E+03 1.808E+03 1.150E+06 24.5036 1.00000 1.273E+00 + 3.500E+08 3.500E+08 3.098E+00 7.998E+02 1.121E+03 1.848E+02 2.106E+03 2.109E+03 1.175E+06 24.8119 1.00000 1.273E+00 + 4.000E+08 4.000E+08 3.115E+00 9.141E+02 1.281E+03 2.112E+02 2.407E+03 2.410E+03 1.198E+06 25.0789 1.00000 1.273E+00 + 4.500E+08 4.500E+08 3.129E+00 1.028E+03 1.441E+03 2.376E+02 2.707E+03 2.711E+03 1.217E+06 25.3145 1.00000 1.273E+00 + 5.000E+08 5.000E+08 3.142E+00 1.143E+03 1.602E+03 2.640E+02 3.008E+03 3.011E+03 1.235E+06 25.5252 1.00000 1.273E+00 + 5.500E+08 5.500E+08 3.154E+00 1.257E+03 1.762E+03 2.903E+02 3.309E+03 3.312E+03 1.250E+06 25.7158 1.00000 1.273E+00 + 6.000E+08 6.000E+08 3.165E+00 1.371E+03 1.922E+03 3.167E+02 3.610E+03 3.613E+03 1.265E+06 25.8899 1.00000 1.273E+00 + 7.000E+08 7.000E+08 3.185E+00 1.600E+03 2.242E+03 3.695E+02 4.211E+03 4.215E+03 1.290E+06 26.1982 1.00000 1.273E+00 + 8.000E+08 8.000E+08 3.202E+00 1.828E+03 2.563E+03 4.223E+02 4.813E+03 4.816E+03 1.313E+06 26.4652 1.00000 1.273E+00 + 9.000E+08 9.000E+08 3.217E+00 2.057E+03 2.883E+03 4.751E+02 5.415E+03 5.418E+03 1.332E+06 26.7008 1.00000 1.273E+00 + 1.000E+09 1.000E+09 3.230E+00 2.285E+03 3.203E+03 5.279E+02 6.016E+03 6.020E+03 1.350E+06 26.9115 1.00000 1.273E+00 diff --git a/analysis/post_processing/reconstruction/tables/pE_liquid_argon.txt b/analysis/post_processing/reconstruction/tables/pE_liquid_argon.txt new file mode 100644 index 00000000..9469da28 --- /dev/null +++ b/analysis/post_processing/reconstruction/tables/pE_liquid_argon.txt @@ -0,0 +1,133 @@ +T eStoppingPower nucStoppingPower dE/dx CSDARange ProjectedRange Detour +1.000E-03 8.608E+01 7.470E+00 9.355E+01 1.741E-05 4.206E-06 0.2416 +1.500E-03 1.054E+02 6.891E+00 1.123E+02 2.223E-05 6.141E-06 0.2762 +2.000E-03 1.217E+02 6.398E+00 1.281E+02 2.639E-05 8.047E-06 0.3049 +2.500E-03 1.361E+02 5.980E+00 1.421E+02 3.009E-05 9.911E-06 0.3294 +3.000E-03 1.491E+02 5.623E+00 1.547E+02 3.346E-05 1.174E-05 0.3507 +4.000E-03 1.722E+02 5.045E+00 1.772E+02 3.949E-05 1.527E-05 0.3867 +5.000E-03 1.925E+02 4.594E+00 1.971E+02 4.483E-05 1.867E-05 0.4164 +6.000E-03 2.109E+02 4.231E+00 2.151E+02 4.968E-05 2.194E-05 0.4415 +7.000E-03 2.277E+02 3.931E+00 2.317E+02 5.416E-05 2.509E-05 0.4632 +8.000E-03 2.435E+02 3.678E+00 2.472E+02 5.834E-05 2.813E-05 0.4823 +9.000E-03 2.582E+02 3.460E+00 2.617E+02 6.227E-05 3.109E-05 0.4992 +1.000E-02 2.722E+02 3.271E+00 2.755E+02 6.599E-05 3.395E-05 0.5145 +1.250E-02 2.997E+02 2.890E+00 3.026E+02 7.463E-05 4.082E-05 0.5470 +1.500E-02 3.235E+02 2.599E+00 3.261E+02 8.258E-05 4.737E-05 0.5736 +1.750E-02 3.445E+02 2.368E+00 3.469E+02 9.001E-05 5.365E-05 0.5961 +2.000E-02 3.633E+02 2.180E+00 3.655E+02 9.703E-05 5.971E-05 0.6154 +2.250E-02 3.802E+02 2.023E+00 3.822E+02 1.037E-04 6.556E-05 0.6322 +2.500E-02 3.953E+02 1.890E+00 3.972E+02 1.101E-04 7.126E-05 0.6470 +2.750E-02 4.090E+02 1.775E+00 4.108E+02 1.163E-04 7.680E-05 0.6603 +3.000E-02 4.214E+02 1.675E+00 4.230E+02 1.223E-04 8.223E-05 0.6723 +3.500E-02 4.425E+02 1.509E+00 4.440E+02 1.338E-04 9.277E-05 0.6932 +4.000E-02 4.594E+02 1.376E+00 4.608E+02 1.449E-04 1.030E-04 0.7108 +4.500E-02 4.728E+02 1.266E+00 4.741E+02 1.556E-04 1.130E-04 0.7261 +5.000E-02 4.831E+02 1.175E+00 4.843E+02 1.660E-04 1.228E-04 0.7395 +5.500E-02 4.907E+02 1.097E+00 4.918E+02 1.762E-04 1.324E-04 0.7514 +6.000E-02 4.960E+02 1.030E+00 4.970E+02 1.864E-04 1.420E-04 0.7622 +6.500E-02 4.992E+02 9.711E-01 5.002E+02 1.964E-04 1.516E-04 0.7719 +7.000E-02 5.007E+02 9.194E-01 5.017E+02 2.064E-04 1.611E-04 0.7809 +7.500E-02 5.008E+02 8.734E-01 5.016E+02 2.163E-04 1.707E-04 0.7891 +8.000E-02 4.995E+02 8.323E-01 5.003E+02 2.263E-04 1.803E-04 0.7967 +8.500E-02 4.972E+02 7.952E-01 4.980E+02 2.363E-04 1.899E-04 0.8037 +9.000E-02 4.940E+02 7.616E-01 4.947E+02 2.464E-04 1.997E-04 0.8104 +9.500E-02 4.900E+02 7.310E-01 4.907E+02 2.565E-04 2.095E-04 0.8166 +1.000E-01 4.855E+02 7.029E-01 4.862E+02 2.668E-04 2.194E-04 0.8224 +1.250E-01 4.574E+02 5.920E-01 4.580E+02 3.197E-04 2.708E-04 0.8472 +1.500E-01 4.267E+02 5.134E-01 4.272E+02 3.762E-04 3.260E-04 0.8666 +1.750E-01 3.977E+02 4.546E-01 3.982E+02 4.368E-04 3.854E-04 0.8822 +2.000E-01 3.719E+02 4.088E-01 3.724E+02 5.018E-04 4.491E-04 0.8949 +2.250E-01 3.495E+02 3.719E-01 3.499E+02 5.711E-04 5.172E-04 0.9056 +2.500E-01 3.301E+02 3.417E-01 3.304E+02 6.447E-04 5.895E-04 0.9144 +2.750E-01 3.132E+02 3.163E-01 3.135E+02 7.224E-04 6.660E-04 0.9220 +3.000E-01 2.985E+02 2.947E-01 2.988E+02 8.041E-04 7.465E-04 0.9284 +3.500E-01 2.742E+02 2.598E-01 2.745E+02 9.789E-04 9.189E-04 0.9387 +4.000E-01 2.549E+02 2.328E-01 2.551E+02 1.168E-03 1.106E-03 0.9465 +4.500E-01 2.390E+02 2.112E-01 2.392E+02 1.371E-03 1.306E-03 0.9525 +5.000E-01 2.256E+02 1.935E-01 2.258E+02 1.586E-03 1.518E-03 0.9574 +5.500E-01 2.144E+02 1.787E-01 2.146E+02 1.813E-03 1.743E-03 0.9613 +6.000E-01 2.047E+02 1.662E-01 2.048E+02 2.052E-03 1.979E-03 0.9645 +6.500E-01 1.961E+02 1.554E-01 1.962E+02 2.301E-03 2.226E-03 0.9672 +7.000E-01 1.884E+02 1.460E-01 1.885E+02 2.561E-03 2.483E-03 0.9694 +7.500E-01 1.813E+02 1.378E-01 1.815E+02 2.832E-03 2.751E-03 0.9714 +8.000E-01 1.749E+02 1.304E-01 1.750E+02 3.112E-03 3.029E-03 0.9731 +8.500E-01 1.689E+02 1.239E-01 1.691E+02 3.403E-03 3.316E-03 0.9745 +9.000E-01 1.634E+02 1.181E-01 1.635E+02 3.704E-03 3.614E-03 0.9758 +9.500E-01 1.582E+02 1.128E-01 1.583E+02 4.015E-03 3.922E-03 0.9770 +1.000E+00 1.533E+02 1.080E-01 1.534E+02 4.336E-03 4.240E-03 0.9780 +1.250E+00 1.330E+02 8.925E-02 1.331E+02 6.090E-03 5.979E-03 0.9818 +1.500E+00 1.182E+02 7.633E-02 1.183E+02 8.087E-03 7.960E-03 0.9843 +1.750E+00 1.068E+02 6.682E-02 1.069E+02 1.031E-02 1.017E-02 0.9860 +2.000E+00 9.772E+01 5.950E-02 9.778E+01 1.276E-02 1.260E-02 0.9872 +2.250E+00 9.027E+01 5.370E-02 9.032E+01 1.543E-02 1.524E-02 0.9881 +2.500E+00 8.401E+01 4.898E-02 8.406E+01 1.830E-02 1.809E-02 0.9889 +2.750E+00 7.867E+01 4.505E-02 7.872E+01 2.137E-02 2.115E-02 0.9895 +3.000E+00 7.405E+01 4.174E-02 7.409E+01 2.465E-02 2.440E-02 0.9900 +3.500E+00 6.643E+01 3.645E-02 6.647E+01 3.179E-02 3.149E-02 0.9907 +4.000E+00 6.039E+01 3.239E-02 6.043E+01 3.969E-02 3.934E-02 0.9913 +4.500E+00 5.547E+01 2.919E-02 5.550E+01 4.833E-02 4.793E-02 0.9917 +5.000E+00 5.138E+01 2.658E-02 5.141E+01 5.770E-02 5.724E-02 0.9921 +5.500E+00 4.791E+01 2.442E-02 4.793E+01 6.778E-02 6.726E-02 0.9924 +6.000E+00 4.493E+01 2.259E-02 4.495E+01 7.856E-02 7.798E-02 0.9926 +6.500E+00 4.233E+01 2.104E-02 4.236E+01 9.002E-02 8.937E-02 0.9928 +7.000E+00 4.006E+01 1.969E-02 4.008E+01 1.022E-01 1.014E-01 0.9930 +7.500E+00 3.804E+01 1.850E-02 3.806E+01 1.150E-01 1.142E-01 0.9931 +8.000E+00 3.624E+01 1.746E-02 3.626E+01 1.284E-01 1.276E-01 0.9933 +8.500E+00 3.462E+01 1.654E-02 3.463E+01 1.425E-01 1.416E-01 0.9934 +9.000E+00 3.315E+01 1.571E-02 3.317E+01 1.573E-01 1.563E-01 0.9935 +9.500E+00 3.182E+01 1.496E-02 3.183E+01 1.727E-01 1.716E-01 0.9936 +1.000E+01 3.060E+01 1.428E-02 3.061E+01 1.887E-01 1.875E-01 0.9937 +1.250E+01 2.579E+01 1.167E-02 2.581E+01 2.780E-01 2.764E-01 0.9940 +1.500E+01 2.241E+01 9.887E-03 2.242E+01 3.823E-01 3.801E-01 0.9943 +1.750E+01 1.989E+01 8.590E-03 1.989E+01 5.009E-01 4.981E-01 0.9945 +2.000E+01 1.792E+01 7.601E-03 1.793E+01 6.335E-01 6.301E-01 0.9946 +2.500E+01 1.506E+01 6.192E-03 1.507E+01 9.390E-01 9.342E-01 0.9949 +2.750E+01 1.398E+01 5.671E-03 1.399E+01 1.111E+00 1.106E+00 0.9949 +3.000E+01 1.306E+01 5.233E-03 1.307E+01 1.296E+00 1.290E+00 0.9950 +3.500E+01 1.158E+01 4.537E-03 1.159E+01 1.704E+00 1.695E+00 0.9952 +4.000E+01 1.044E+01 4.008E-03 1.044E+01 2.159E+00 2.149E+00 0.9953 +4.500E+01 9.525E+00 3.592E-03 9.529E+00 2.661E+00 2.649E+00 0.9954 +5.000E+01 8.780E+00 3.256E-03 8.783E+00 3.208E+00 3.193E+00 0.9954 +5.500E+01 8.159E+00 2.979E-03 8.162E+00 3.799E+00 3.782E+00 0.9955 +6.000E+01 7.633E+00 2.746E-03 7.636E+00 4.433E+00 4.413E+00 0.9956 +6.500E+01 7.182E+00 2.548E-03 7.184E+00 5.108E+00 5.086E+00 0.9956 +7.000E+01 6.790E+00 2.377E-03 6.792E+00 5.824E+00 5.799E+00 0.9957 +7.500E+01 6.446E+00 2.228E-03 6.449E+00 6.580E+00 6.552E+00 0.9957 +8.000E+01 6.142E+00 2.097E-03 6.145E+00 7.375E+00 7.344E+00 0.9958 +8.500E+01 5.872E+00 1.981E-03 5.874E+00 8.207E+00 8.173E+00 0.9958 +9.000E+01 5.629E+00 1.878E-03 5.631E+00 9.077E+00 9.040E+00 0.9959 +9.500E+01 5.410E+00 1.785E-03 5.412E+00 9.983E+00 9.942E+00 0.9959 +1.000E+02 5.212E+00 1.701E-03 5.213E+00 1.092E+01 1.088E+01 0.9959 +1.250E+02 4.443E+00 1.378E-03 4.445E+00 1.614E+01 1.608E+01 0.9961 +1.500E+02 3.918E+00 1.161E-03 3.919E+00 2.215E+01 2.207E+01 0.9962 +1.750E+02 3.536E+00 1.003E-03 3.537E+00 2.888E+01 2.877E+01 0.9963 +2.000E+02 3.246E+00 8.844E-04 3.246E+00 3.627E+01 3.614E+01 0.9964 +2.250E+02 3.017E+00 7.912E-04 3.018E+00 4.426E+01 4.411E+01 0.9965 +2.500E+02 2.834E+00 7.162E-04 2.834E+00 5.282E+01 5.264E+01 0.9966 +2.750E+02 2.683E+00 6.545E-04 2.683E+00 6.189E+01 6.168E+01 0.9966 +3.000E+02 2.556E+00 6.027E-04 2.557E+00 7.144E+01 7.120E+01 0.9967 +3.500E+02 2.358E+00 5.210E-04 2.358E+00 9.184E+01 9.154E+01 0.9968 +4.000E+02 2.210E+00 4.591E-04 2.210E+00 1.138E+02 1.134E+02 0.9969 +4.500E+02 2.095E+00 4.107E-04 2.095E+00 1.370E+02 1.366E+02 0.9970 +5.000E+02 2.004E+00 3.718E-04 2.004E+00 1.614E+02 1.610E+02 0.9971 +5.500E+02 1.931E+00 3.397E-04 1.931E+00 1.869E+02 1.863E+02 0.9972 +6.000E+02 1.871E+00 3.129E-04 1.871E+00 2.132E+02 2.126E+02 0.9972 +6.500E+02 1.821E+00 2.901E-04 1.821E+00 2.403E+02 2.396E+02 0.9973 +7.000E+02 1.779E+00 2.705E-04 1.779E+00 2.681E+02 2.674E+02 0.9974 +7.500E+02 1.744E+00 2.534E-04 1.744E+00 2.965E+02 2.957E+02 0.9974 +8.000E+02 1.713E+00 2.384E-04 1.714E+00 3.254E+02 3.246E+02 0.9975 +8.500E+02 1.688E+00 2.252E-04 1.688E+00 3.548E+02 3.539E+02 0.9975 +9.000E+02 1.665E+00 2.133E-04 1.665E+00 3.846E+02 3.837E+02 0.9976 +9.500E+02 1.646E+00 2.027E-04 1.646E+00 4.148E+02 4.138E+02 0.9976 +1.000E+03 1.629E+00 1.932E-04 1.629E+00 4.454E+02 4.443E+02 0.9977 +1.500E+03 1.542E+00 1.318E-04 1.542E+00 7.626E+02 7.611E+02 0.9980 +2.000E+03 1.521E+00 1.006E-04 1.521E+00 1.090E+03 1.088E+03 0.9983 +2.500E+03 1.523E+00 8.159E-05 1.523E+00 1.418E+03 1.416E+03 0.9984 +3.000E+03 1.535E+00 6.877E-05 1.535E+00 1.745E+03 1.743E+03 0.9986 +4.000E+03 1.567E+00 5.253E-05 1.567E+00 2.391E+03 2.388E+03 0.9988 +5.000E+03 1.601E+00 4.264E-05 1.601E+00 3.022E+03 3.018E+03 0.9989 +6.000E+03 1.634E+00 3.596E-05 1.634E+00 3.640E+03 3.636E+03 0.9990 +7.000E+03 1.665E+00 3.113E-05 1.665E+00 4.246E+03 4.242E+03 0.9991 +8.000E+03 1.693E+00 2.748E-05 1.693E+00 4.842E+03 4.838E+03 0.9992 +9.000E+03 1.719E+00 2.462E-05 1.719E+00 5.428E+03 5.424E+03 0.9992 +1.000E+04 1.743E+00 2.231E-05 1.743E+00 6.005E+03 6.001E+03 0.9993 From c3c12299b859219ba5232c6ff6ad57f511e5c5ea Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Thu, 20 Apr 2023 16:29:38 -0700 Subject: [PATCH 147/180] Flash matching restructured for efficiency --- analysis/classes/evaluator.py | 7 +- analysis/manager.py | 87 +++++++++++---- analysis/post_processing/common.py | 62 ++++++----- analysis/post_processing/pmt/FlashManager.py | 48 +++++---- .../post_processing/pmt/flash_matching.py | 101 ++++++------------ 5 files changed, 159 insertions(+), 146 deletions(-) diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index 49d6cf80..86d5e79b 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -394,9 +394,10 @@ def match_interactions(self, entry, mode='pred_to_true', domain_particles = [p for p in domain_particles if p.points.shape[0] > 0] codomain_particles = [p for p in codomain_particles if p.points.shape[0] > 0] if matching_mode == 'one_way': - matched_particles, _ = match_particles_fn(domain_particles, codomain_particles, - min_overlap=self.min_overlap_count, - overlap_mode=self.overlap_mode) + matched_particles, _ = match_particles_fn(domain_particles, + codomain_particles, + min_overlap=self.min_overlap_count, + overlap_mode=self.overlap_mode) elif matching_mode == 'optimal': matched_particles, _ = match_particles_optimal(domain_particles, codomain_particles, min_overlap=self.min_overlap_count, diff --git a/analysis/manager.py b/analysis/manager.py index a961039b..049c375e 100644 --- a/analysis/manager.py +++ b/analysis/manager.py @@ -1,9 +1,10 @@ -import time, os, sys, copy +import time, os, sys, copy, yaml from collections import defaultdict +from functools import lru_cache from mlreco.iotools.factories import loader_factory from mlreco.trainval import trainval -from mlreco.main_funcs import cycle +from mlreco.main_funcs import cycle, process_config from mlreco.iotools.readers import HDF5Reader from mlreco.iotools.writers import CSVWriter @@ -11,8 +12,11 @@ from analysis.producers import scripts from analysis.post_processing.common import PostProcessor from analysis.producers.common import ScriptProcessor +from analysis.post_processing.pmt.FlashManager import FlashMatcherInterface from analysis.classes.builders import ParticleBuilder, InteractionBuilder, FragmentBuilder +SUPPORTED_BUILDERS = ['ParticleBuilder', 'InteractionBuilder', 'FragmentBuilder'] + class AnaToolsManager: """ Chain of responsibility mananger for running analysis related tasks @@ -40,30 +44,33 @@ class AnaToolsManager: """ def __init__(self, ana_cfg, verbose=True, cfg=None): - self.config = cfg - self.ana_config = ana_cfg + self.config = cfg + self.ana_config = ana_cfg self.max_iteration = self.ana_config['analysis']['iteration'] - self.log_dir = self.ana_config['analysis']['log_dir'] - self.ana_mode = self.ana_config['analysis'].get('run_mode', None) + self.log_dir = self.ana_config['analysis']['log_dir'] + self.ana_mode = self.ana_config['analysis'].get('run_mode', 'all') # Initialize data product builders self.data_builders = self.ana_config['analysis']['data_builders'] - self.builders = {} - supported_builders = ['ParticleBuilder', 'InteractionBuilder', 'FragmentBuilder'] + self.builders = {} for builder_name in self.data_builders: - if builder_name not in supported_builders: - raise ValueError(f"{builder_name} is not a valid data product builder!") + if builder_name not in SUPPORTED_BUILDERS: + msg = f"{builder_name} is not a valid data product builder!" + raise ValueError(msg) builder = eval(builder_name)() self.builders[builder_name] = builder - self._data_reader = None + self._data_reader = None self._reader_state = None - self.verbose = verbose - self.writers = {} + self.verbose = verbose + self.writers = {} + self.profile = self.ana_config['analysis'].get('profile', False) + self.logger = CSVWriter(os.path.join(self.log_dir, 'log.csv')) + self.logger_dict = {} + + self.flash_manager_initialized = False + self.fm = None - self.profile = self.ana_config['analysis'].get('profile', False) - self.logger = CSVWriter(os.path.join(self.log_dir, 'log.csv')) - self.logger_dict = {} def _set_iteration(self, dataset): """Sets maximum number of iteration given dataset @@ -77,6 +84,7 @@ def _set_iteration(self, dataset): if self.max_iteration == -1: self.max_iteration = len(dataset) assert self.max_iteration <= len(dataset) + def initialize(self): """Initializer for setting up inference mode full chain forwarding @@ -106,6 +114,7 @@ def initialize(self): self._data_reader = Reader self._reader_state = 'hdf5' self._set_iteration(Reader) + def forward(self, iteration=None): """Read one minibatch worth of image from dataset. @@ -133,6 +142,7 @@ def forward(self, iteration=None): raise ValueError(f"Data reader {self._reader_state} is not supported!") return data, res + def _build_reco_reps(self, data, result): """Build representations for reconstructed objects. @@ -161,6 +171,7 @@ def _build_reco_reps(self, data, result): length_check.append(len(result['ParticleFragments'])) return length_check + def _build_truth_reps(self, data, result): """Build representations for true objects. @@ -188,6 +199,7 @@ def _build_truth_reps(self, data, result): result['TruthParticleFragments'] = self.builders['FragmentBuilder'].build(data, result, mode='truth') length_check.append(len(result['TruthParticleFragments'])) return length_check + def build_representations(self, data, result, mode='all'): """Build human readable data structures from full chain output. @@ -221,6 +233,27 @@ def build_representations(self, data, result, mode='all'): assert lreco == num_batches for ltruth in lcheck_truth: assert ltruth == num_batches + + + def initialize_flash_manager(self, meta): + + # Only run once, to save time + if not self.flash_manager_initialized: + + pp_flash_matching = self.ana_config['post_processing']['run_flash_matching'] + opflash_keys = pp_flash_matching['opflash_keys'] + volume_boundaries = pp_flash_matching['volume_boundaries'] + ADC_to_MeV = pp_flash_matching['ADC_to_MeV'] + self.fm_config = pp_flash_matching['fmatch_config'] + + self.fm = FlashMatcherInterface(self.config, + self.fm_config, + boundaries=volume_boundaries, + opflash_keys=opflash_keys, + ADC_to_MeV=ADC_to_MeV) + self.fm.initialize_flash_manager(meta) + self.flash_manager_initialized = True + def run_post_processing(self, data, result): """Run all registered post-processing scripts. @@ -232,6 +265,10 @@ def run_post_processing(self, data, result): result : dict Result dictionary """ + + meta = data['meta'][0] + self.initialize_flash_manager(meta) + if 'post_processing' in self.ana_config: post_processor_interface = PostProcessor(data, result) # Gather post processing functions, register by priority @@ -240,17 +277,22 @@ def run_post_processing(self, data, result): local_pcfg = copy.deepcopy(pcfg) priority = local_pcfg.pop('priority', -1) profile = local_pcfg.pop('profile', False) - run_on_batch = local_pcfg.pop('run_on_batch', False) processor_name = processor_name.split('+')[0] processor = getattr(post_processing,str(processor_name)) + # Exception for Flash Matching + if processor_name == 'run_flash_matching': + local_pcfg = { + 'fm': self.fm, + 'opflash_keys': local_pcfg['opflash_keys'] + } post_processor_interface.register_function(processor, - priority, - processor_cfg=local_pcfg, - run_on_batch=run_on_batch, - profile=profile) + priority, + processor_cfg=local_pcfg, + profile=profile) post_processor_interface.process_and_modify() self.logger_dict.update(post_processor_interface._profile) + def run_ana_scripts(self, data, result): """Run all registered analysis scripts (under producers/scripts) @@ -282,6 +324,7 @@ def run_ana_scripts(self, data, result): out[processor_name] = fname_to_update_list return out + def write(self, ana_output): """Method to gather logging information from each analysis script and save to csv files. @@ -365,6 +408,7 @@ def step(self, iteration): end = time.time() self.logger_dict['write_csv_time'] = end-start + def log(self, iteration): """Generate analysis tools iteration log. This is a separate logging operation from the subroutines in analysis.producers.loggers. @@ -378,6 +422,7 @@ def log(self, iteration): row_dict.update(self.logger_dict) self.logger.append(row_dict) + def run(self): iteration = 0 while iteration < self.max_iteration: diff --git a/analysis/post_processing/common.py b/analysis/post_processing/common.py index 3f12d875..83b6d188 100644 --- a/analysis/post_processing/common.py +++ b/analysis/post_processing/common.py @@ -11,7 +11,7 @@ class PostProcessor: """ def __init__(self, data, result, debug=True, profile=False): self._funcs = defaultdict(list) - self._batch_funcs = defaultdict(list) + # self._batch_funcs = defaultdict(list) self._num_batches = len(data['index']) self.data = data self.result = result @@ -44,10 +44,7 @@ def register_function(self, f, priority, pf._result_capture_optional = result_capture_optional if profile: pf = self.profile(pf) - if run_on_batch: - self._batch_funcs[priority].append(pf) - else: - self._funcs[priority].append(pf) + self._funcs[priority].append(pf) print(f"Registered post-processor {f.__name__}") def process_event(self, image_id, f_list): @@ -85,23 +82,23 @@ def process_event(self, image_id, f_list): return image_dict - def process_batch(self): - out_dict = defaultdict(list) - sorted_processors = sorted([x for x in self._batch_funcs.items()], reverse=True) - for priority, f_list in sorted_processors: - for f in f_list: + # def process_batch(self): + # out_dict = defaultdict(list) + # sorted_processors = sorted([x for x in self._batch_funcs.items()], reverse=True) + # for priority, f_list in sorted_processors: + # for f in f_list: - data_batch, result_batch = {}, {} - for data_key in f._data_capture: - data_batch[data_key] = self.data[data_key] - for result_key in f._result_capture: - result_batch[result_key] = self.result[result_key] - for result_key in f._result_capture_optional: - if result_key in self.result: - result_batch[result_key] = self.result[result_key] - update_dict = f(data_batch, result_batch) - out_dict.update(update_dict) - return out_dict + # data_batch, result_batch = {}, {} + # for data_key in f._data_capture: + # data_batch[data_key] = self.data[data_key] + # for result_key in f._result_capture: + # result_batch[result_key] = self.result[result_key] + # for result_key in f._result_capture_optional: + # if result_key in self.result: + # result_batch[result_key] = self.result[result_key] + # update_dict = f(data_batch, result_batch) + # out_dict.update(update_dict) + # return out_dict def process_and_modify(self): """ @@ -128,17 +125,18 @@ def process_and_modify(self): raise RuntimeError(msg) else: self.result[key] = val - batch_fn_output = self.process_batch() - # Check batch processed output length agrees with batch size - for key, val in batch_fn_output.items(): - assert len(val) == self._num_batches - if key in self.result: - msg = 'Output {} in post-processing function {},'\ - ' caused a dictionary key conflict. You may '\ - 'want to change the output dict key for that function.' - raise ValueError(msg) - else: - self.result[key] = val + + # batch_fn_output = self.process_batch() + # # Check batch processed output length agrees with batch size + # for key, val in batch_fn_output.items(): + # assert len(val) == self._num_batches + # if key in self.result: + # msg = 'Output {} in post-processing function {},'\ + # ' caused a dictionary key conflict. You may '\ + # 'want to change the output dict key for that function.' + # raise ValueError(msg) + # else: + # self.result[key] = val def extent(voxels): diff --git a/analysis/post_processing/pmt/FlashManager.py b/analysis/post_processing/pmt/FlashManager.py index 0d1f5419..6c6368df 100644 --- a/analysis/post_processing/pmt/FlashManager.py +++ b/analysis/post_processing/pmt/FlashManager.py @@ -28,8 +28,9 @@ def __init__(self, config, fm_config, self.reflash_merging_window = kwargs.get('reflash_merging_window', None) self.detector_specs = kwargs.get('detector_specs', None) self.ADC_to_MeV = kwargs.get('ADC_to_MeV', 1.) + self.ADC_to_MeV = eval(self.ADC_to_MeV) self.use_depositions_MeV = kwargs.get('use_depositions_MeV', False) - self.boundaries = kwargs.get('boundaries', None) + self.boundaries = boundaries self.flash_matches = {} if self.boundaries is not None: @@ -51,8 +52,6 @@ def get_flash_matches(self, opflashes, use_true_tpc_objects=False, volume=None, - use_depositions_MeV=False, - ADC_to_MeV=1., restrict_interactions=[]): """ If flash matches has not yet been computed for this volume, then it will @@ -81,18 +80,19 @@ def get_flash_matches(self, list of tuple (Interaction, larcv::Flash, flashmatch::FlashMatch_t) """ # No caching done if matching a subset of interactions - if (entry, volume, use_true_tpc_objects) not in self.flash_matches or len(restrict_interactions): + if (entry, volume, use_true_tpc_objects) not in self.flash_matches \ + or len(restrict_interactions): out = self._run_flash_matching(entry, - interactions, - opflashes, - use_true_tpc_objects=use_true_tpc_objects, - volume=volume, - use_depositions_MeV=use_depositions_MeV, - ADC_to_MeV=ADC_to_MeV, - restrict_interactions=restrict_interactions) + interactions, + opflashes, + use_true_tpc_objects=use_true_tpc_objects, + volume=volume, + restrict_interactions=restrict_interactions) if len(restrict_interactions) == 0: - tpc_v, pmt_v, matches = self.flash_matches[(entry, volume, use_true_tpc_objects)] + tpc_v, pmt_v, matches = self.flash_matches[(entry, + volume, + use_true_tpc_objects)] else: # it wasn't cached, we just computed it tpc_v, pmt_v, matches = out return [(tpc_v[m.tpc_id], pmt_v[m.flash_id], m) for m in matches] @@ -102,8 +102,6 @@ def _run_flash_matching(self, entry, interactions, opflashes, use_true_tpc_objects=False, volume=None, - use_depositions_MeV=False, - ADC_to_MeV=1., restrict_interactions=[]): """ Parameters @@ -133,11 +131,16 @@ def _run_flash_matching(self, entry, interactions, # back to the reference of the first volume. if volume is not None: for tpc_object in tpc_v: - tpc_object.points = self._untranslate(tpc_object.points, volume) - input_tpc_v = self.fm.make_qcluster(tpc_v, use_depositions_MeV=use_depositions_MeV, ADC_to_MeV=ADC_to_MeV) + tpc_object.points = self._untranslate(tpc_object.points, + volume) + input_tpc_v = self.fm.make_qcluster( + tpc_v, + use_depositions_MeV=self.use_depositions_MeV, + ADC_to_MeV=self.ADC_to_MeV) if volume is not None: for tpc_object in tpc_v: - tpc_object.points = self._translate(tpc_object.points, volume) + tpc_object.points = self._translate(tpc_object.points, + volume) # Now making Flash_t objects selected_opflash_keys = self.opflash_keys @@ -146,8 +149,8 @@ def _run_flash_matching(self, entry, interactions, selected_opflash_keys = [self.opflash_keys[volume]] pmt_v = [] for key in selected_opflash_keys: - pmt_v.extend(opflashes[key][entry]) - input_pmt_v = self.fm.make_flash([opflashes[key][entry] for key in selected_opflash_keys]) + pmt_v.extend(opflashes[key]) + input_pmt_v = self.fm.make_flash([opflashes[key] for key in selected_opflash_keys]) # input_pmt_v might be a filtered version of pmt_v, # and we want to store larcv::Flash objects not @@ -347,6 +350,7 @@ def make_qcluster(self, interactions, ======= list of flashmatch::QCluster_t """ + from flashmatch import flashmatch if self.min_x is None: @@ -359,11 +363,15 @@ def make_qcluster(self, interactions, qcluster.time = 0 # assumed time w.r.t. trigger for reconstruction for i in range(p.size): # Create a geoalgo::QPoint_t + if not use_depositions_MeV: + light_yield = p.depositions[i] * ADC_to_MeV * self.det.LightYield() + else: + light_yield = p.depositions_MeV[i] * self.det.LightYield() qpoint = flashmatch.QPoint_t( p.points[i, 0] * self.size_voxel_x + self.min_x, p.points[i, 1] * self.size_voxel_y + self.min_y, p.points[i, 2] * self.size_voxel_z + self.min_z, - p.depositions[i]*ADC_to_MeV*self.det.LightYield() if not use_depositions_MeV else p.depositions_MeV[i]*self.det.LightYield()) + light_yield) # Add it to geoalgo::QCluster_t qcluster.push_back(qpoint) tpc_v.append(qcluster) diff --git a/analysis/post_processing/pmt/flash_matching.py b/analysis/post_processing/pmt/flash_matching.py index 4795e8f3..85ecc732 100644 --- a/analysis/post_processing/pmt/flash_matching.py +++ b/analysis/post_processing/pmt/flash_matching.py @@ -13,11 +13,7 @@ @post_processing(data_capture=['meta', 'index', 'opflash_cryoE', 'opflash_cryoW'], result_capture=['Interactions']) def run_flash_matching(data_dict, result_dict, - config_path=None, - fmatch_config=None, - reflash_merging_window=None, - volume_boundaries=None, - ADC_to_MeV=1., + fm=None, opflash_keys=[]): """ Post processor for running flash matching using OpT0Finder. @@ -49,82 +45,47 @@ def run_flash_matching(data_dict, result_dict, interaction.fmatch_total_pE: float interaction.fmatch_id: int """ + opflashes = {} + assert len(opflash_keys) > 0 for key in opflash_keys: opflashes[key] = data_dict[key] - ADC_to_MeV = ADC_TO_MEV - - if config_path is None: - raise ValueError("You need to give the path to your full chain config.") - if fmatch_config is None: - raise ValueError("You need a flash matching config to run flash matching.") - if volume_boundaries is None: - raise ValueError("You need to set volume boundaries to run flash matching.") - - config = yaml.safe_load(open(config_path, 'r')) - process_config(config, verbose=False) - - fm = FlashMatcherInterface(config, fmatch_config, - boundaries=volume_boundaries, - opflash_keys=opflash_keys, - reflash_merging_window=reflash_merging_window) - if isinstance(data_dict['meta'][0], float): - fm.initialize_flash_manager(data_dict['meta']) - elif isinstance(data_dict['meta'][0], list): - fm.initialize_flash_manager(data_dict['meta'][0]) - else: - print(type(data_dict['meta'][0])) - raise AssertionError - update_dict = {} - - flash_matches_cryoE = [] - flash_matches_cryoW = [] - for entry, image_id in enumerate(data_dict['index']): - interactions = result_dict['Interactions'][entry] + interactions = result_dict['Interactions'] + entry = data_dict['index'] - fmatches_E = fm.get_flash_matches(entry, - interactions, - opflashes, - use_true_tpc_objects=False, - volume=0, - use_depositions_MeV=False, - ADC_to_MeV=ADC_to_MeV, - restrict_interactions=[]) - fmatches_W = fm.get_flash_matches(entry, - interactions, - opflashes, - use_true_tpc_objects=False, - volume=1, - use_depositions_MeV=False, - ADC_to_MeV=ADC_to_MeV, - restrict_interactions=[]) - flash_matches_cryoE.append(fmatches_E) - flash_matches_cryoW.append(fmatches_W) + fmatches_E = fm.get_flash_matches(entry, + interactions, + opflashes, + volume=0, + restrict_interactions=[]) + fmatches_W = fm.get_flash_matches(entry, + interactions, + opflashes, + volume=1, + restrict_interactions=[]) update_dict = defaultdict(list) - for tuple_list in flash_matches_cryoE: - flash_dict_E = {} - for ia, flash, match in tuple_list: - flash_dict_E[ia.id] = (flash, match) - ia.fmatched = True - ia.fmatch_time = flash.time() - ia.fmatch_total_pE = flash.TotalPE() - ia.fmatch_id = flash.id() - update_dict['flash_matches_cryoE'].append(flash_dict_E) + flash_dict_E = {} + for ia, flash, match in fmatches_E: + flash_dict_E[ia.id] = (flash, match) + ia.fmatched = True + ia.fmatch_time = flash.time() + ia.fmatch_total_pE = flash.TotalPE() + ia.fmatch_id = flash.id() + update_dict['flash_matches_cryoE'].append(flash_dict_E) - for tuple_list in flash_matches_cryoW: - flash_dict_W = {} - for ia, flash, match in tuple_list: - flash_dict_W[ia.id] = (flash, match) - ia.fmatched = True - ia.fmatch_time = flash.time() - ia.fmatch_total_pE = flash.TotalPE() - ia.fmatch_id = flash.id() - update_dict['flash_matches_cryoW'].append(flash_dict_W) + flash_dict_W = {} + for ia, flash, match in fmatches_W: + flash_dict_W[ia.id] = (flash, match) + ia.fmatched = True + ia.fmatch_time = flash.time() + ia.fmatch_total_pE = flash.TotalPE() + ia.fmatch_id = flash.id() + update_dict['flash_matches_cryoW'].append(flash_dict_W) assert len(update_dict['flash_matches_cryoE'])\ == len(update_dict['flash_matches_cryoW']) From 5bbe4c8380c903beb5bbd26bfa9af7bec972d72a Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Thu, 20 Apr 2023 16:40:56 -0700 Subject: [PATCH 148/180] [WIP] Overhaul of the analysis tool data structures to support HDF5 file storage --- analysis/algorithms/logger.py | 15 +- analysis/classes/Interaction.py | 179 +++++------ analysis/classes/Particle.py | 300 ++++++++++++------ analysis/classes/ParticleFragment.py | 132 ++++++-- analysis/classes/TruthInteraction.py | 92 +++--- analysis/classes/TruthParticle.py | 89 +++--- analysis/classes/TruthParticleFragment.py | 35 +- analysis/classes/builders.py | 156 +++++---- analysis/classes/particle_utils.py | 6 +- analysis/manager.py | 24 +- analysis/run.py | 5 +- mlreco/iotools/writers.py | 114 +++---- .../layers/gnn/losses/node_kinematics.py | 2 +- mlreco/utils/globals.py | 35 +- mlreco/utils/unwrap.py | 5 +- 15 files changed, 707 insertions(+), 482 deletions(-) diff --git a/analysis/algorithms/logger.py b/analysis/algorithms/logger.py index 28cd19be..1d583a42 100644 --- a/analysis/algorithms/logger.py +++ b/analysis/algorithms/logger.py @@ -4,7 +4,7 @@ import numpy as np import sys -from mlreco.utils.globals import PID_LABEL_TO_PARTICLE, PARTICLE_TO_PID_LABEL +from mlreco.utils.globals import PID_LABELS from analysis.classes import TruthInteraction, TruthParticle, Interaction def tag(tag_name): @@ -253,7 +253,7 @@ def size(ia): @staticmethod def count_primary_particles(ia, ptypes=None): - all_types = sorted(list(PID_LABEL_TO_PARTICLE.keys())) + all_types = list(PID_LABELS.keys()) if ptypes is None: ptypes = all_types elif set(ptypes).issubset(set(all_types)): @@ -265,15 +265,16 @@ def count_primary_particles(ia, ptypes=None): either be None or a list of particle type ids \ to be counted.') + ptypes = [PID_LABELS[p] for p in ptypes] out = OrderedDict({'count_primary_'+name.lower() : 0 \ - for name in PARTICLE_TO_PID_LABEL.keys() \ - if PARTICLE_TO_PID_LABEL[name] in ptypes}) + for name in PID_LABELS.values() \ + if name.capitalize() in ptypes}) if ia is not None and hasattr(ia, 'primary_particle_counts'): out.update({'count_primary_'+key.lower() : val \ for key, val in ia.primary_particle_counts.items() \ - if key.upper() != 'OTHER' \ - and PARTICLE_TO_PID_LABEL[key.upper()] in ptypes}) + if key.capitalize() != 'Other' \ + and key.capitalize() in ptypes}) return out @@ -346,4 +347,4 @@ def flash_match_info(ia): out['fmatch_time'] = ia.fmatch_time out['fmatch_total_pE'] = ia.fmatch_total_pE out['fmatch_id'] = ia.fmatch_id - return out \ No newline at end of file + return out diff --git a/analysis/classes/Interaction.py b/analysis/classes/Interaction.py index 201de80f..0cc4b93a 100644 --- a/analysis/classes/Interaction.py +++ b/analysis/classes/Interaction.py @@ -4,6 +4,7 @@ from typing import Counter, List, Union from collections import OrderedDict, Counter from . import Particle +from mlreco.utils.globals import PID_LABELS class Interaction: @@ -13,68 +14,69 @@ class Interaction: Attributes ---------- - id: int + id : int, default -1 Unique ID (Interaction ID) of this interaction. - particles: List[Particle] - List of objects that belong to this Interaction. - vertex: (1,3) np.array (Optional) + particle_ids : np.ndarray, default np.array([]) + List of Particle IDs that make up this interaction + num_particles: int, default 0 + Total number of particles in this interaction + num_primaries: int, default 0 + Total number of primary particles in this interaction + nu_id : int, default -1 + ID of the particle's parent neutrino + volume_id : int, default -1 + ID of the detector volume the interaction lives in + image_id : int, default -1 + ID of the image the interaction lives in + index : np.ndarray, default np.array([]) + (N) IDs of voxels that correspondn to the particle within the image coordinate tensor that + vertex : np.ndarray, optional 3D coordinates of the predicted interaction vertex - nu_id: int (Optional, TODO) - Label indicating whether this interaction is a neutrino interaction - WARNING: The nu_id label is most likely unreliable. Don't use this in reconstruction (used for debugging) - num_particles: int - total number of particles in this interaction. """ - def __init__(self, interaction_id: int, particles : OrderedDict, vertex=None, nu_id=-1, volume=0): - self.id = interaction_id - self.pid_keys = { - 0: 'Photon', - 1: 'Electron', - 2: 'Muon', - 3: 'Pion', - 4: 'Proton', - -1: 'Other' - } - self.particles = particles - self.match = [] - self._match_counts = {} - # Voxel indices of an interaction is defined by the union of - # constituent particle voxel indices - self.voxel_indices = [] - self.points = [] - self.depositions = [] - for p in self.particles: - if p.points.shape[0] > 0: - self.voxel_indices.append(p.voxel_indices) - self.points.append(p.points) - self.depositions.append(p.depositions) - assert p.interaction_id == interaction_id - if len(self.voxel_indices) > 0: - self.voxel_indices = np.hstack(self.voxel_indices) - if len(self.points) > 0: - self.points = np.concatenate(self.points, axis=0) - if len(self.depositions) > 0: - self.depositions = np.hstack(self.depositions) - - self.size = len(self.voxel_indices) - self.num_particles = len(self.particles) - - self.get_particles_summary() - - self.vertex = vertex - self.vertex_candidate_count = -1 - if self.vertex is None: - self.vertex = np.array([-1, -1, -1]) - - self.nu_id = nu_id - self.volume = volume - self._pi0_tagged_photons = [] - - - @property - def particles(self): - return list(self._particles.values()) + def __init__(self, + interaction_id: int = -1, + particles: List[Particle] = None, + nu_id: int = -1, + volume_id: int = -1, + image_id: int = -1, + vertex: np.ndarray = -np.ones(3, dtype=np.float32), + is_neutrino: bool = False): + + # Initialize attributes + self.id = interaction_id + self.nu_id = nu_id + self.volume_id = volume_id + self.image_id = image_id + self.vertex = vertex + + # Aggregate individual particle information + self.particle_ids = np.empty(0, dtype=np.int64) + self.num_particles = 0 + self.num_primaries = 0 + self.index = np.empty(0, dtype=np.int64) + self.depositions = np.empty(0, dtype=np.float32) + if particles is not None: + id_list, index_list, depositions_list = [], [], [] + for p in particles: + if p.size > 0: + id_list.append(p.id) + index_list.append(p.index) + depositions_list.append(p.depositions) + self.num_primaries += int(p.is_primary) + + self.particle_ids = np.array(id_list, dtype=np.int64) + self.num_particles = len(particles) + self.index = np.concatenate(index_list) + self.depositions = np.concatenate(depositions_list) + + self._get_particles_summary(particles) + + self.size = len(self.index) + + # Quantities to be set by the particle matcher + self.match = np.empty(0, np.int64) + self._match_counts = np.empty(0, np.float32) def check_particle_input(self, x): assert isinstance(x, Particle) @@ -82,53 +84,46 @@ def check_particle_input(self, x): def update_info(self): self.particle_ids = list(self._particles.keys()) - self.particle_counts = Counter({ self.pid_keys[i] : 0 for i in list(self.pid_keys.keys())}) - self.particle_counts.update([self.pid_keys[p.pid] for p in self._particles.values()]) + self.particle_counts = Counter({ PID_LABELS[i] : 0 for i in PID_LABELS.keys() }) + self.particle_counts.update([PID_LAEBLS[p.pid] for p in self._particles.values()]) - self.primary_particle_counts = Counter({ self.pid_keys[i] : 0 for i in list(self.pid_keys.keys())}) - self.primary_particle_counts.update([self.pid_keys[p.pid] for p in self._particles.values() if p.is_primary]) + self.primary_particle_counts = Counter({ PID_LABELS[i] : 0 for i in PID_LABELS.keys() }) + self.primary_particle_counts.update([PID_LABELS[p.pid] for p in self._particles.values() if p.is_primary]) if sum(self.primary_particle_counts.values()) > 0: self.is_valid = True else: self.is_valid = False - - @particles.setter - def particles(self, value): - assert isinstance(value, OrderedDict) - parts = {} - for p in value.values(): - self.check_particle_input(p) - # Clear match information since Interaction is rebuilt - p.match = [] - p._match_counts = {} - parts[p.id] = p - self._particles = OrderedDict(sorted(parts.items(), key=lambda t: t[0])) - self.update_info() - - - def get_particles_summary(self): - primary_str = {True: '*', False: '-'} - self.particles_summary = "" - for p in sorted(self.particles, key=lambda x: x.is_primary, reverse=True): - pmsg = " {} Particle {}: PID = {}, Size = {}, Match = {} \n".format( - primary_str[p.is_primary], p.id, self.pid_keys[p.pid], p.points.shape[0], str(p.match)) - self.particles_summary += pmsg - +# @particles.setter +# def particles(self, value): +# assert isinstance(value, OrderedDict) +# parts = {} +# for p in value.values(): +# self.check_particle_input(p) +# # Clear match information since Interaction is rebuilt +# p.match = [] +# p._match_counts = {} +# parts[p.id] = p +# self._particles = OrderedDict(sorted(parts.items(), key=lambda t: t[0])) +# self.update_info() def __getitem__(self, key): return self._particles[key] + def __repr__(self): + return "Interaction(id={}, vertex={}, nu_id={}, Particles={})".format( + self.id, str(self.vertex), self.nu_id, str(self.particle_ids)) def __str__(self): - - self.get_particles_summary() msg = "Interaction {}, Vertex: x={:.2f}, y={:.2f}, z={:.2f}\n"\ "--------------------------------------------------------------------\n".format( self.id, self.vertex[0], self.vertex[1], self.vertex[2]) - return msg + self.particles_summary - - def __repr__(self): - return "Interaction(id={}, vertex={}, nu_id={}, Particles={})".format( - self.id, str(self.vertex), self.nu_id, str(self.particle_ids)) + return msg + self._particles_summary + def _get_particles_summary(self, particles): + primary_str = {True: '*', False: '-'} + self._particles_summary = "" + for p in sorted(particles, key=lambda x: x.is_primary, reverse=True): + pmsg = " {} Particle {}: PID = {}, Size = {}, Match = {} \n".format( + primary_str[p.is_primary], p.id, PID_LABELS[p.pid], p.size, str(p.match)) + self._particles_summary += pmsg diff --git a/analysis/classes/Particle.py b/analysis/classes/Particle.py index 186adefc..76803205 100644 --- a/analysis/classes/Particle.py +++ b/analysis/classes/Particle.py @@ -2,110 +2,125 @@ import pandas as pd from typing import Counter, List, Union +from mlreco.utils.globals import SHAPE_LABELS, PID_LABELS class Particle: ''' - Data Structure for managing Particle-level - full chain output information + Data structure for managing particle-level + full chain output information. Attributes ---------- - id: int + id : int, default -1 Unique ID of the particle - points: (N, 3) np.array - 3D coordinates of the voxels that belong to this particle - size: int + fragment_ids : np.ndarray, default np.array([]) + List of ParticleFragment IDs that make up this particle + num_fragments: int + Total number of fragments in this particle + interaction_id : int, default -1 + ID of the particle's parent interaction + nu_id : int, default -1 + ID of the particle's parent neutrino + volume_id : int, default -1 + ID of the detector volume the particle lives in + image_id : int, default -1 + ID of the image the particle lives in + size : int Total number of voxels that belong to this particle - depositions: (N, 1) np.array - Array of energy deposition values for each voxel (rescaled, ADC units) - voxel_indices: (N, ) np.array - Numeric integer indices of voxel positions of this particle - with respect to the total array of point in a single image. - semantic_type: int - Semantic type (shower fragment (0), track (1), - michel (2), delta (3), lowE (4)) of this particle. - pid: int + index : np.ndarray, default np.array([]) + (N) IDs of voxels that correspondn to the particle within the image coordinate tensor that + depositions : np.ndarray, defaul np.array([]) + (N) Array of energy deposition values for each voxel (rescaled, ADC units) + depositions_sum : float + Sum of energy depositions + semantic_type : int, default -1 + Semantic type (shower (0), track (1), + michel (2), delta (3), low energy (4)) of this particle. + pid : int PDG Type (Photon (0), Electron (1), Muon (2), - Charged Pion (3), Proton (4)) of this particle. - pid_conf: float - Softmax probability score for the most likely pid prediction - interaction_id: int - Integer ID of the particle's parent interaction - image_id: int - ID of the image in which this particle resides in - is_primary: bool - Indicator whether this particle is a primary from an interaction. - match: List[int] + Charged Pion (3), Proton (4)) of this particle + pid_scores : np.ndarray + (P) Array of softmax scores associated with each of particle class + is_primary : bool + Indicator whether this particle is a primary from an interaction + primary_scores : np.ndarray + (2) Array of softmax scores associated with secondary and primary + start_point : np.ndarray, default np.array([-1, -1, -1]) + (3) Particle start point + end_point : np.ndarray, default np.array([-1, -1, -1]) + (3) Particle end point + start_dir : np.ndarray, default np.array([-1, -1, -1]) + (3) Particle direction estimate w.r.t. the start point + end_dir : np.ndarray, default np.array([-1, -1, -1]) + (3) Particle direction estimate w.r.t. the end point + energy_sum : float, default -1 + Energy reconstructed from the particle deposition sum + momentum_range : float, default -1 + Momentum reconstructed from the particle range + momentum_mcs : float, default -1 + Momentum reconstructed using the MCS method + match : List[int] List of TruthParticle IDs for which this particle is matched to - - startpoint: (1,3) np.array - (1, 3) array of particle's startpoint, if it could be assigned - endpoint: (1,3) np.array - (1, 3) array of particle's endpoint, if it could be assigned ''' def __init__(self, - coords, - group_id, - semantic_type, - interaction_id, - image_id, - nu_id=-1, - voxel_indices=None, - depositions=None, - volume=-1, **kwargs): + group_id: int = -1, + fragment_ids: np.ndarray = np.empty(0, dtype=np.int64), + interaction_id: int = -1, + nu_id: int = -1, + volume_id: int = -1, + image_id: int = -1, + semantic_type: int = -1, + pid: int = -1, + is_primary: int = -1, + index: np.ndarray = np.empty(0, dtype=np.int64), + depositions: np.ndarray = np.empty(0, dtype=np.float32), + pid_scores: np.ndarray = -np.ones(np.max(list((PID_LABELS.keys())))+1, dtype=np.float32), + primary_scores: np.ndarray = -np.ones(2, dtype=np.float32), + start_point: np.ndarray = -np.ones(3, dtype=np.float32), + end_point: np.ndarray = -np.ones(3, dtype=np.float32), + start_dir: np.ndarray = -np.ones(3, dtype=np.float32), + end_dir: np.ndarray = -np.ones(3, dtype=np.float32), + momentum_range: float = -1., + momentum_mcs: float = -1.): + + # Initialize private attributes to be assigned through setters only + self._num_fragments = None + self._size = None + self._index = None + self._depositions = None + self._pid_scores = None + self._primary_scores = None + + # Initialize attributes self.id = group_id - self.points = coords - self.size = coords.shape[0] - self.depositions = depositions # In rescaled ADC - self.voxel_indices = voxel_indices - self.semantic_type = semantic_type + self.fragment_ids = fragment_ids self.interaction_id = interaction_id + self.nu_id = nu_id self.image_id = image_id + self.volume_id = volume_id + self.semantic_type = semantic_type - self.nu_id = nu_id - self.primary_scores = kwargs.get('primary_scores', None) - self.pid_scores = kwargs.get('pid_scores', None) - - assert 'pid' in kwargs or self.pid_scores is not None - assert 'is_primary' in kwargs or self.primary_scores is not None - - # Override argmax pid if pid is explicitly given. - if kwargs.get('pid', None) is not None: - self.pid = kwargs['pid'] - else: - self.pid = np.argmax(self.pid_scores) - - # Overrite argmax primary label if primariness is explicity given. - if 'is_primary' in kwargs: - self.is_primary = kwargs['is_primary'] - else: - self.is_primary = np.argmax(self.primary_scores) - - self.match = [] - self._match_counts = {} -# self.fragments = fragment_ids - self.semantic_keys = { - 0: 'Shower Fragment', - 1: 'Track', - 2: 'Michel Electron', - 3: 'Delta Ray', - 4: 'LowE Depo' - } - - self.pid_keys = { - -1: 'None', - 0: 'Photon', - 1: 'Electron', - 2: 'Muon', - 3: 'Pion', - 4: 'Proton' - } - - self.sum_edep = np.sum(self.depositions) - self.volume = volume - self.startpoint = None - self.endpoint = None + self.index = index + self.depositions = depositions + + self.pid_scores = pid_scores + if np.all(pid_scores < 0): + self.pid = pid + self.primary_scores = primary_scores + if np.all(primary_scores < 0): + self.is_primary = is_primary + + self.start_point = start_point + self.end_point = end_point + self.start_dir = start_dir + self.end_dir = end_dir + self.momentum_range = momentum_range + self.momentum_mcs = momentum_mcs + + # Quantities to be set by the particle matcher + self.match = np.empty(0, np.int64) + self._match_counts = np.empty(0, np.float32) def __repr__(self): msg = "Particle(image_id={}, id={}, pid={}, size={})".format(self.image_id, self.id, self.pid, self.size) @@ -115,11 +130,114 @@ def __str__(self): fmt = "Particle( Image ID={:<3} | Particle ID={:<3} | Semantic_type: {:<15}"\ " | PID: {:<8} | Primary: {:<2} | Interaction ID: {:<2} | Size: {:<5} | Volume: {:<2} )" msg = fmt.format(self.image_id, self.id, - self.semantic_keys[self.semantic_type] if self.semantic_type in self.semantic_keys else "None", - self.pid_keys[self.pid] if self.pid in self.pid_keys else "None", + SHAPE_LABELS[self.semantic_type] if self.semantic_type in list(range(len(SHAPE_LABELS))) else "None", + PID_LABELS[self.pid] if self.pid in PID_LABELS else "None", self.is_primary, self.interaction_id, - self.points.shape[0], - self.volume) + self.size, + self.volume_id) return msg + @property + def num_fragments(self): + ''' + Number of particle fragments getter. This attribute has no setter, + as it can only be set by providing a list of fragment ids. + ''' + return self._num_fragments + + @property + def fragment_ids(self): + ''' + ParticleFragment indices getter/setter. The setter also sets + the number of fragments. + ''' + return self._index + + @fragment_ids.setter + def fragment_ids(self, fragment_ids): + # Count the number of fragments + self._fragment_ids = fragment_ids + self._num_fragments = len(fragment_ids) + + @property + def size(self): + ''' + Particle size (i.e. voxel count) getter. This attribute has no setter, + as it can only be set by providing a set of voxel indices. + ''' + return self._size + + @property + def index(self): + ''' + Particle voxel indices getter/setter. The setter also sets + the particle size, i.e. the voxel count. + ''' + return self._index + + @index.setter + def index(self, index): + # Count the number of voxels + self._index = index + self._size = len(index) + + @property + def depositions_sum(self): + ''' + Total amount of charge/energy deposited. This attribute has no setter, + as it can only be set by providing a set of depositions. + ''' + return self._size + + @property + def depositions(self): + ''' + Particle depositions getter/setter. The setter also sets + the particle depositions sum. + ''' + return self._depositions + + @depositions.setter + def depositions(self, depositions): + # Sum all the depositions + self._depositions = depositions + self._depositions_sum = np.sum(depositions) + + @property + def pid_scores(self): + ''' + Particle ID scores getter/setter. The setter converts the + scores to an particle ID prediction through argmax. + ''' + return self._pid_scores + + @pid_scores.setter + def pid_scores(self, pid_scores): + # If no PID scores are providen, the PID is unknown + if pid_scores[0] < 0.: + self._pid_scores = pid_scores + self.pid = -1 + + # Store the PID scores and give a best guess + self._pid_scores = pid_scores + self.pid = np.argmax(pid_scores) + + @property + def primary_scores(self): + ''' + Primary ID scores getter/setter. The setter converts the + scores to a primary prediction through argmax. + ''' + return self._primary_scores + + @primary_scores.setter + def primary_scores(self, primary_scores): + # If no primary scores are given, the primary status is unknown + if primary_scores[0] < 0.: + self._primary_scores = primary_scores + self.is_primary = -1 + + # Store the PID scores and give a best guess + self._primary_scores = primary_scores + self.is_primary = np.argmax(primary_scores) diff --git a/analysis/classes/ParticleFragment.py b/analysis/classes/ParticleFragment.py index db25feac..4209315e 100644 --- a/analysis/classes/ParticleFragment.py +++ b/analysis/classes/ParticleFragment.py @@ -1,4 +1,6 @@ +import numpy as np +from mlreco.utils.globals import SHAPE_LABELS class ParticleFragment: ''' @@ -7,52 +9,124 @@ class ParticleFragment: Attributes ---------- - See documentation for shared attributes. - Below are attributes exclusive to ParticleFragment - id: int - fragment ID of this particle fragment (different from particle id) + Unique ID of the particle fragment (different from particle id) group_id: int Group ID (alias for Particle ID) for which this fragment belongs to. + num_fragments: int + Total number of fragments in this particle + interaction_id : int, default -1 + ID of the particle's parent interaction + nu_id : int, default -1 + ID of the particle's parent neutrino + volume_id : int, default -1 + ID of the detector volume the particle lives in + image_id : int, default -1 + ID of the image the particle lives in + size : int + Total number of voxels that belong to this particle + index : np.ndarray, default np.array([]) + (N) IDs of voxels that correspondn to the fragment within the image coordinate tensor that + depositions : np.ndarray, defaul np.array([]) + (N) Array of energy deposition values for each voxel (rescaled, ADC units) is_primary: bool If True, then this particle fragment corresponds to a primary ionization trajectory within the group of fragments that compose a particle. ''' - def __init__(self, coords, fragment_id, semantic_type, interaction_id, - group_id, image_id=0, voxel_indices=None, - depositions=None, volume=0, **kwargs): - self.id = fragment_id - self.points = coords - self.size = coords.shape[0] - self.depositions = depositions # In rescaled ADC - self.voxel_indices = voxel_indices - self.semantic_type = semantic_type - self.group_id = group_id + def __init__(self, + fragment_id: int = -1, + group_id: int = -1, + interaction_id: int = -1, + image_id: int = -1, + volume_id: int = -1, + semantic_type: int = -1, + index: np.ndarray = np.empty(0, dtype=np.int64), + depositions: np.ndarray = np.empty(0, dtype=np.float32), + is_primary: int = -1, + start_point: np.ndarray = -np.ones(3, dtype=np.float32), + end_point: np.ndarray = -np.ones(3, dtype=np.float32), + start_dir: np.ndarray = -np.ones(3, dtype=np.float32), + end_dir: np.ndarray = -np.ones(3, dtype=np.float32)): + + # Initialize private attributes to be assigned through setters only + self._size = None + self._index = None + self._depositions = None + + # Initialize attributes + self.id = fragment_id + self.group_id = group_id self.interaction_id = interaction_id - self.image_id = image_id - self.is_primary = kwargs.get('is_primary', False) - self.semantic_keys = { - 0: 'Shower Fragment', - 1: 'Track', - 2: 'Michel Electron', - 3: 'Delta Ray', - 4: 'LowE Depo' - } - self.volume = volume + self.image_id = image_id + self.volume_id = volume_id + self.semantic_type = semantic_type - def __str__(self): - return self.__repr__() + self.index = index + self.depositions = depositions + + self.is_primary = is_primary + + self.start_point = start_point + self.end_point = end_point + self.start_dir = start_dir + self.end_dir = end_dir def __repr__(self): fmt = "ParticleFragment( Image ID={:<3} | Fragment ID={:<3} | Semantic_type: {:<15}"\ " | Group ID: {:<3} | Primary: {:<2} | Interaction ID: {:<2} | Size: {:<5} | Volume: {:<2})" msg = fmt.format(self.image_id, self.id, - self.semantic_keys[self.semantic_type] if self.semantic_type in self.semantic_keys else "None", + SHAPE_LABELS[self.semantic_type] if self.semantic_type in list(range(len(SHAPE_LABELS))) else "None", self.group_id, self.is_primary, self.interaction_id, - self.points.shape[0], - self.volume) + self.size, + self.volume_id) return msg + def __str__(self): + return self.__repr__() + + @property + def size(self): + ''' + Fragment size (i.e. voxel count) getter. This attribute has no setter, + as it can only be set by providing a set of voxel indices. + ''' + return self._size + + @property + def index(self): + ''' + Fragment voxel indices getter/setter. The setter also sets + the fragment size, i.e. the voxel count. + ''' + return self._index + + @index.setter + def index(self, index): + # Count the number of voxels + self._index = index + self._size = len(index) + + @property + def depositions_sum(self): + ''' + Total amount of charge/energy deposited. This attribute has no setter, + as it can only be set by providing a set of depositions. + ''' + return self._size + + @property + def depositions(self): + ''' + Fragment depositions getter/setter. The setter also sets + the fragment depositions sum. + ''' + return self._depositions + + @depositions.setter + def depositions(self, depositions): + # Sum all the depositions + self._depositions = depositions + self._depositions_sum = np.sum(depositions) diff --git a/analysis/classes/TruthInteraction.py b/analysis/classes/TruthInteraction.py index 57055c62..adbde04b 100644 --- a/analysis/classes/TruthInteraction.py +++ b/analysis/classes/TruthInteraction.py @@ -6,50 +6,64 @@ class TruthInteraction(Interaction): """ - Analogous data structure for Interactions retrieved from true labels. + Data structure mirroring , reserved for true interactions + derived from true labels / true MC information. + + See documentation for shared attributes. + Below are attributes exclusive to TruthInteraction + + Attributes + ---------- + depositions_MeV : np.ndarray, default np.array([]) + Similar as `depositions`, i.e. using adapted true labels. + Using true MeV energy deposits instead of rescaled ADC units. """ - def __init__(self, *args, **kwargs): - super(TruthInteraction, self).__init__(*args, **kwargs) - self.match = [] - self._match_counts = {} - self.depositions_MeV = [] - self.num_primaries = 0 - for p in self.particles: - self.depositions_MeV.append(p.depositions_MeV) - if p.is_primary: self.num_primaries += 1 - self.depositions_MeV = np.hstack(self.depositions_MeV) - self.nu_info = None - - @property - def particles(self): - return list(self._particles.values()) - - @particles.setter - def particles(self, value): - assert isinstance(value, OrderedDict) - parts = {} - for p in value.values(): - self.check_particle_input(p) - # Clear match information since Interaction is rebuilt - p.match = [] - p._match_counts = {} - parts[p.id] = p - self._particles = OrderedDict(sorted(parts.items(), key=lambda t: t[0])) - self.update_info() + + def __init__(self, + interaction_id, + particles, + **kwargs): + super(TruthInteraction, self).__init__(interaction_id, particles, **kwargs) + + self.depositions_MeV = np.empty(0, dtype=np.float32) + if particles is not None: + depositions_MeV_list = [] + for p in particles: + depositions_MeV_list.append(p.depositions_MeV) + self.depositions_MeV = np.concatenate(depositions_MeV_list) + + # Neutrino-specific information to be filled elsewhere + self.nu_interaction_type = -1 + self.nu_interaction_mode = -1 + self.nu_current_type = -1 + self.nu_energy_init = -1. + +# @property +# def particles(self): +# return list(self._particles.values()) +# +# @particles.setter +# def particles(self, value): +# assert isinstance(value, OrderedDict) +# parts = {} +# for p in value.values(): +# self.check_particle_input(p) +# # Clear match information since Interaction is rebuilt +# p.match = [] +# p._match_counts = {} +# parts[p.id] = p +# self._particles = OrderedDict(sorted(parts.items(), key=lambda t: t[0])) +# self.update_info() @staticmethod def check_particle_input(x): assert isinstance(x, TruthParticle) - def __str__(self): - - self.get_particles_summary() - msg = "TruthInteraction {}, Vertex: x={:.2f}, y={:.2f}, z={:.2f}\n"\ - "-----------------------------------------------\n".format( - self.id, self.vertex[0], self.vertex[1], self.vertex[2]) - return msg + self.particles_summary - def __repr__(self): - return "TruthInteraction(id={}, vertex={}, nu_id={}, Particles={})".format( - self.id, str(self.vertex), self.nu_id, str(self.particle_ids)) + msg = super(TruthInteraction, self).__repr__() + return 'Truth'+msg + + def __str__(self): + msg = super(TruthInteraction, self).__repr__() + return 'Truth'+msg diff --git a/analysis/classes/TruthParticle.py b/analysis/classes/TruthParticle.py index c480e1d7..dfb1d05c 100644 --- a/analysis/classes/TruthParticle.py +++ b/analysis/classes/TruthParticle.py @@ -10,69 +10,66 @@ class TruthParticle(Particle): Data structure mirroring , reserved for true particles derived from true labels / true MC information. - Attributes - ---------- See documentation for shared attributes. - Below are attributes exclusive to TruthParticle + Below are attributes exclusive to TruthParticle. - asis: larcv.Particle C++ object (Optional) - Raw larcv.Particle C++ object as retrived from parse_particles_asis. - match: List[int] - List of Particle IDs that match to this TruthParticle - coords_noghost: - Coordinates using true labels (not adapted to deghosting output) - depositions_noghost: - Depositions using true labels (not adapted to deghosting output), in MeV. - depositions_MeV: + Attributes + ---------- + depositions_MeV : np.ndarray Similar as `depositions`, i.e. using adapted true labels. Using true MeV energy deposits instead of rescaled ADC units. + true_depositions : np.ndarray + Rescaled charge depositions in the set of true voxels associated + with the particle. + true_depositions_MeV : np.ndarray + MeV charge depositions in the set of true voxels associated + with the particle. + start_position : np.ndarray + True start position of the particle + end_position : np.ndarray + True end position of the particle ''' - def __init__(self, *args, particle_asis=None, coords_noghost=None, depositions_noghost=None, - depositions_MeV=None, **kwargs): + def __init__(self, + *args, + depositions_MeV=np.empty(0, dtype=np.float32), + true_index=np.empty(0, dtype=np.int64), + true_depositions=np.empty(0, dtype=np.float32), + true_depositions_MeV=np.empty(0, dtype=np.float32), + particle_asis=None, + **kwargs): super(TruthParticle, self).__init__(*args, **kwargs) - self.asis = particle_asis - self.match = [] - self._match_counts = {} - self.coords_noghost = coords_noghost - self.depositions_noghost = depositions_noghost - self.depositions_MeV = depositions_MeV - self.startpoint = None - self.endpoint = None + # Initialize attributes + self.depositions_MeV = depositions_MeV + self.true_index = true_index + self.true_depositions = true_depositions + self.true_depositions_MeV = true_depositions_MeV + if particle_asis is not None: + self.start_position = particle_asis.position() + self.end_position = particle_asis.end_position() def __repr__(self): - msg = "TruthParticle(image_id={}, id={}, pid={}, size={})".format(self.image_id, self.id, self.pid, self.size) - return msg - + msg = super(TruthParticle, self).__repr__() + return 'Truth'+msg def __str__(self): - fmt = "TruthParticle( Image ID={:<3} | Particle ID={:<3} | Semantic_type: {:<15}"\ - " | PID: {:<8} | Primary: {:<2} | Interaction ID: {:<2} | Size: {:<5} | Volume: {:<2} )" - msg = fmt.format(self.image_id, self.id, - self.semantic_keys[self.semantic_type] if self.semantic_type in self.semantic_keys else "None", - self.pid_keys[self.pid] if self.pid in self.pid_keys else "None", - self.is_primary, - self.interaction_id, - self.points.shape[0], - self.volume) - return msg - + msg = super(TruthParticle, self).__str__() + return 'Truth'+msg def is_contained(self, spatial_size): - p = self.asis - check_contained = p.position().x() >= 0 and p.position().x() <= spatial_size \ - and p.position().y() >= 0 and p.position().y() <= spatial_size \ - and p.position().z() >= 0 and p.position().z() <= spatial_size \ - and p.end_position().x() >= 0 and p.end_position().x() <= spatial_size \ - and p.end_position().y() >= 0 and p.end_position().y() <= spatial_size \ - and p.end_position().z() >= 0 and p.end_position().z() <= spatial_size + check_contained = self.start_position.x() >= 0 and self.start_position.x() <= spatial_size \ + and self.start_position.y() >= 0 and self.start_position.y() <= spatial_size \ + and self.start_position.z() >= 0 and self.start_position.z() <= spatial_size \ + and self.end_position.x() >= 0 and self.end_position.x() <= spatial_size \ + and self.end_position.y() >= 0 and self.end_position.y() <= spatial_size \ + and self.end_position.z() >= 0 and self.end_position.z() <= spatial_size return check_contained def purity_efficiency(self, other_particle): - overlap = len(np.intersect1d(self.voxel_indices, other_particle.voxel_indices)) + overlap = len(np.intersect1d(self.index, other_particle.index)) return { - "purity": overlap / len(other_particle.voxel_indices), - "efficiency": overlap / len(self.voxel_indices) + "purity": overlap / len(other_particle.index), + "efficiency": overlap / len(self.index) } diff --git a/analysis/classes/TruthParticleFragment.py b/analysis/classes/TruthParticleFragment.py index 9df9366b..a2025541 100644 --- a/analysis/classes/TruthParticleFragment.py +++ b/analysis/classes/TruthParticleFragment.py @@ -1,24 +1,35 @@ import numpy as np -import pandas as pd from typing import Counter, List, Union from . import ParticleFragment class TruthParticleFragment(ParticleFragment): + """ + Data structure mirroring , reserved for true fragments + derived from true labels / true MC information. - def __init__(self, *args, depositions_MeV=None, **kwargs): + See documentation for shared attributes. + Below are attributes exclusive to TruthInteraction + + Attributes + ---------- + depositions_MeV : np.ndarray, default np.array([]) + Similar as `depositions`, i.e. using adapted true labels. + Using true MeV energy deposits instead of rescaled ADC units. + """ + + def __init__(self, + *args, + depositions_MeV: np.ndarray = np.empty(0, dtype=np.float32), + **kwargs): super(TruthParticleFragment, self).__init__(*args, **kwargs) self.depositions_MeV = depositions_MeV def __repr__(self): - fmt = "TruthParticleFragment( Image ID={:<3} | Fragment ID={:<3} | Semantic_type: {:<15}"\ - " | Group ID: {:<3} | Primary: {:<2} | Interaction ID: {:<2} | Size: {:<5} | Volume: {:<2})" - msg = fmt.format(self.image_id, self.id, - self.semantic_keys[self.semantic_type] if self.semantic_type in self.semantic_keys else "None", - self.group_id, - self.is_primary, - self.interaction_id, - self.points.shape[0], - self.volume) - return msg + msg = super(TruthParticleFragment, self).__repr__() + return 'Truth'+msg + + def __str__(self): + msg = super(TruthParticleFragment, self).__str__() + return 'Truth'+msg diff --git a/analysis/classes/builders.py b/analysis/classes/builders.py index a675ba18..277bddb5 100644 --- a/analysis/classes/builders.py +++ b/analysis/classes/builders.py @@ -91,28 +91,23 @@ def _build_reco(self, primary_scores = softmax(primary_logits, axis=1) for i, p in enumerate(particles): - voxels = point_cloud[p] volume_id, cts = np.unique(volume_labels[p], return_counts=True) volume_id = int(volume_id[cts.argmax()]) seg_label = particle_seg[i] - pid = np.argmax(pid_scores[i]) - if seg_label == 2 or seg_label == 3: + if seg_label == 2 or seg_label == 3: # DANGEROUS pid = 1 interaction_id = inter_ids[i] - part = Particle(voxels, - i, - seg_label, - interaction_id, - entry, - pid=pid, - voxel_indices=p, + part = Particle(group_id=i, + interaction_id=interaction_id, + image_id=entry, + semantic_type=seg_label, + index=p, depositions=depositions[p], - volume=volume_id, + volume_id=volume_id, + pid_scores=pid_scores[i], primary_scores=primary_scores[i], - pid_scores=pid_scores[i]) - - part.startpoint = particle_start_points[i] - part.endpoint = particle_end_points[i] + start_point = particle_start_points[i], + end_point = particle_end_points[i]) out.append(part) @@ -165,6 +160,7 @@ def _build_true(self, depositions_MeV = labels[mask][:, VALUE_COL] depositions = rescaled_charge[mask] # Will be in ADC coords_noghost = labels_nonghost[mask_nonghost][:, COORD_COLS] + true_voxel_indices = np.where(mask_nonghost)[0] depositions_noghost = labels_nonghost[mask_nonghost][:, VALUE_COL].squeeze() volume_labels = labels_nonghost[mask_nonghost][:, BATCH_COL] @@ -183,31 +179,31 @@ def _build_true(self, mask, pid=pid) - particle = TruthParticle(coords, - id, - semantic_type, - int_id, - entry, - particle_asis=lpart, - coords_noghost=coords_noghost, - depositions_noghost=depositions_noghost, - depositions_MeV=depositions_MeV, + particle = TruthParticle(group_id=id, + interaction_id=int_id, nu_id=nu_id, - voxel_indices=voxel_indices, + image_id=entry, + volume_id=volume_id, + semantic_type=semantic_type, + index=voxel_indices, depositions=depositions, - volume=volume_id, + depositions_MeV=depositions_MeV, + true_index=true_voxel_indices, + true_depositions=np.empty(0, dtype=np.float32), #TODO + true_depositions_MeV=depositions_noghost, is_primary=is_primary, - pid=pid) + pid=pid, + particle_asis=lpart) particle.p = np.array([lpart.px(), lpart.py(), lpart.pz()]) # particle.fragments = fragments - particle.startpoint = np.array([lpart.first_step().x(), + particle.start_point = np.array([lpart.first_step().x(), lpart.first_step().y(), lpart.first_step().z()]) if semantic_type == 1: - particle.endpoint = np.array([lpart.last_step().x(), + particle.end_point = np.array([lpart.last_step().x(), lpart.last_step().y(), lpart.last_step().z()]) out.append(particle) @@ -261,12 +257,11 @@ def decorate_true_interactions(self, entry, data, interactions): # true_particles_track_ids = [p.track_id() for p in true_particles] # for nu in neutrinos: # if nu.mct_index() not in true_particles_track_ids: continue - ia.nu_info = { - 'nu_interaction_type': nu.interaction_type(), - 'nu_interaction_mode': nu.interaction_mode(), - 'nu_current_type': nu.current_type(), - 'nu_energy_init': nu.energy_init() - } + ia.nu_interaction_type = nu.interaction_type() + ia.nu_interation_mode = nu.interacion_mode() + ia.nu_current_type = nu.current_type() + ia.nu_energy_init = nu.energy_init() + return interactions def get_true_vertices(self, entry, data: dict): @@ -313,9 +308,9 @@ def _build_reco(self, entry, shower_frag_primary = np.argmax( result['shower_fragment_node_pred'][entry], axis=1) - shower_startpoints = result['shower_fragment_start_points'][entry][:, COORD_COLS] - track_startpoints = result['track_fragment_start_points'][entry][:, COORD_COLS] - track_endpoints = result['track_fragment_end_points'][entry][:, COORD_COLS] + shower_start_points = result['shower_fragment_start_points'][entry][:, COORD_COLS] + track_start_points = result['track_fragment_start_points'][entry][:, COORD_COLS] + track_end_points = result['track_fragment_end_points'][entry][:, COORD_COLS] assert len(fragments_seg) == len(fragments) @@ -333,43 +328,41 @@ def _build_reco(self, entry, volume_id, cts = np.unique(volume_labels[p], return_counts=True) volume_id = int(volume_id[cts.argmax()]) - part = ParticleFragment(voxels, - i, seg_label, - group_id=group_ids[i], - interaction_id=inter_ids[i], - image_id=entry, - voxel_indices=p, - depositions=depositions[p], - is_primary=False, - pid_conf=-1, - alias='Fragment', - volume=volume_id) + part = ParticleFragment(fragment_id=i, + group_id=group_ids[i], + interaction_id=inter_ids[i], + image_id=entry, + volume_id=volume_id, + semantic_type=seg_label, + index=p, + depositions=depositions[p], + is_primary=False) temp.append(part) - # Label shower fragments as primaries and attach startpoint + # Label shower fragments as primaries and attach start_point shower_counter = 0 for p in np.array(temp)[shower_mask]: is_primary = shower_frag_primary[shower_counter] p.is_primary = bool(is_primary) - p.startpoint = shower_startpoints[shower_counter] + p.start_point = shower_start_points[shower_counter] # p.group_id = int(shower_group_pred[shower_counter]) shower_counter += 1 assert shower_counter == shower_frag_primary.shape[0] - # Attach endpoint to track fragments + # Attach end_point to track fragments track_counter = 0 for p in temp: if p.semantic_type == 1: # p.group_id = int(track_group_pred[track_counter]) - p.startpoint = track_startpoints[track_counter] - p.endpoint = track_endpoints[track_counter] + p.start_point = track_start_points[track_counter] + p.end_point = track_end_points[track_counter] track_counter += 1 # assert track_counter == track_group_pred.shape[0] # Apply fragment voxel cut out = [] for p in temp: - if p.points.shape[0] < self.min_particle_voxel_count: + if p.size < self.min_particle_voxel_count: continue out.append(p) @@ -456,18 +449,16 @@ def _build_true(self, entry, data: dict, result: dict): else: is_primary = is_primary[0] - part = TruthParticleFragment(points, - fid, - semantic_type, - interaction_id, - group_id, + part = TruthParticleFragment(fragment_id=fid, + group_id=group_id, + interaction_id=interaction_id, + semantic_type=semantic_type, image_id=entry, - voxel_indices=voxel_indices, + volume_id=volume_id, + index=voxel_indices, depositions=depositions, depositions_MeV=depositions_MeV, - is_primary=is_primary, - volume=volume_id, - alias='Fragment') + is_primary=is_primary) fragments.append(part) return fragments @@ -489,6 +480,7 @@ def handle_empty_true_particles(labels_noghost, coords_noghost, depositions_noghost = np.array([]), np.array([]) if np.count_nonzero(mask_noghost) > 0: coords_noghost = labels_noghost[mask_noghost][:, COORD_COLS] + true_voxel_indices = np.where(mask_noghost)[0] depositions_noghost = labels_noghost[mask_noghost][:, VALUE_COL].squeeze() semantic_type, interaction_id, nu_id = get_true_particle_labels(labels_noghost, mask_noghost, @@ -497,33 +489,33 @@ def handle_empty_true_particles(labels_noghost, volume_id, cts = np.unique(labels_noghost[:, BATCH_COL][mask_noghost].astype(int), return_counts=True) volume_id = int(volume_id[cts.argmax()]) - particle = TruthParticle(coords, - pid, - semantic_type, - interaction_id, - entry, - particle_asis=p, - coords_noghost=coords_noghost, - depositions_noghost=depositions_noghost, - depositions_MeV=np.array([]), - nu_id=nu_id, - voxel_indices=voxel_indices, - depositions=depositions, - volume=volume_id, - is_primary=is_primary, - pid=pdg) + particle = TruthParticle(group_id=pid, + interaction_id=interaction_id, + nu_id=nu_id, + volume_id=volume_id, + image_id=entry, + semantic_type=semantic_type, + index=voxel_indices, + depositions=depositions, + depositions_MeV=np.empty(0, dtype=np.float32), + true_index=true_voxel_indices, + true_depositions=np.empty(0, dtype=np.float32), #TODO + true_depositions_MeV=depositions_noghost, + is_primary=is_primary, + pid=pdg, + particle_asis=p) particle.p = np.array([p.px(), p.py(), p.pz()]) # particle.fragments = [] # particle.particle_asis = p # particle.nu_id = nu_id # particle.voxel_indices = voxel_indices - particle.startpoint = np.array([p.first_step().x(), + particle.start_point = np.array([p.first_step().x(), p.first_step().y(), p.first_step().z()]) if semantic_type == 1: - particle.endpoint = np.array([p.last_step().x(), + particle.end_point = np.array([p.last_step().x(), p.last_step().y(), p.last_step().z()]) return particle @@ -605,4 +597,4 @@ def match_points_to_particles(ppn_points : np.ndarray, for particle in particles: dist = cdist(ppn_coords, particle.points) matches = ppn_points_type[dist.min(axis=1) < ppn_distance_threshold] - particle.ppn_candidates = matches.reshape(-1, 7) \ No newline at end of file + particle.ppn_candidates = matches.reshape(-1, 7) diff --git a/analysis/classes/particle_utils.py b/analysis/classes/particle_utils.py index 266de2ec..2f8dff42 100644 --- a/analysis/classes/particle_utils.py +++ b/analysis/classes/particle_utils.py @@ -418,16 +418,16 @@ def group_particles_to_interactions_fn(particles : List[Particle], else: nu_id = nu_id[0] - counter = Counter([p.volume for p in particles if p.volume != -1]) + counter = Counter([p.volume_id for p in particles if p.volume_id != -1]) if not bool(counter): volume_id = -1 else: volume_id = counter.most_common(1)[0][0] particles_dict = OrderedDict({p.id : p for p in particles}) if mode == 'pred': - interactions[int_id] = Interaction(int_id, particles_dict, nu_id=nu_id, volume=volume_id) + interactions[int_id] = Interaction(int_id, particles_dict.values(), nu_id=nu_id, volume_id=volume_id) elif mode == 'truth': - interactions[int_id] = TruthInteraction(int_id, particles_dict, nu_id=nu_id, volume=volume_id) + interactions[int_id] = TruthInteraction(int_id, particles_dict.values(), nu_id=nu_id, volume_id=volume_id) else: raise ValueError diff --git a/analysis/manager.py b/analysis/manager.py index af3d3fc3..7c6849d9 100644 --- a/analysis/manager.py +++ b/analysis/manager.py @@ -5,7 +5,7 @@ from mlreco.trainval import trainval from mlreco.main_funcs import cycle from mlreco.iotools.readers import HDF5Reader -from mlreco.iotools.writers import CSVWriter +from mlreco.iotools.writers import CSVWriter, HDF5Writer from analysis import post_processing from analysis.algorithms import scripts @@ -33,9 +33,9 @@ class AnaToolsManager: ---------- cfg : dict Processed full chain config (after applying process_config) - ana_cfg: dict + ana_cfg : dict Analysis config that specifies configurations for steps 1-4. - profile: bool + profile : bool Whether to print out execution times. """ @@ -58,6 +58,7 @@ def __init__(self, cfg, ana_cfg, profile=True): self._data_reader = None self._reader_state = None + self._data_writer = None self.profile = profile self.writers = {} @@ -92,6 +93,14 @@ def initialize(self): self._reader_state = 'hdf5' self._set_iteration(Reader) + if 'writer' in self.ana_config: + writer_cfg = copy.deepcopy(self.ana_config['writer']) + assert 'name' in writer_cfg + writer_cfg.pop('name') + + Writer = HDF5Writer(**writer_cfg) + self._data_writer = Writer + def forward(self, iteration=None): if self.profile: start = time.time() @@ -252,8 +261,11 @@ def step(self, iteration): print("No output from analysis scripts.") self.write(ana_output) + # 5. Write output, if requested + if self._data_writer: + self._data_writer.append(data, res) + + def run(self): - iteration = 0 - while iteration < self.max_iteration: + for iteration in range(self.max_iteration): self.step(iteration) - \ No newline at end of file diff --git a/analysis/run.py b/analysis/run.py index 49904268..4a48fad9 100644 --- a/analysis/run.py +++ b/analysis/run.py @@ -3,7 +3,6 @@ import os, sys import numpy as np import copy -from pprint import pprint # Setup OpT0Finder for flash matching as needed if os.getenv('FMATCH_BASEDIR') is not None: @@ -26,8 +25,8 @@ def main(analysis_cfg_path, model_cfg_path): analysis_config = yaml.safe_load(open(analysis_cfg_path, 'r')) config = yaml.safe_load(open(model_cfg_path, 'r')) process_config(config, verbose=False) - - pprint(analysis_config) + + print(yaml.dump(analysis_config, default_flow_style=None)) if 'analysis' not in analysis_config: raise Exception('Analysis configuration needs to live under `analysis` section.') diff --git a/mlreco/iotools/writers.py b/mlreco/iotools/writers.py index 5cbc1f58..19a4a175 100644 --- a/mlreco/iotools/writers.py +++ b/mlreco/iotools/writers.py @@ -5,6 +5,7 @@ import numpy as np from collections import defaultdict from larcv import larcv +from analysis import classes as analysis class HDF5Writer: @@ -17,14 +18,31 @@ class HDF5Writer: More documentation to come. ''' + CPP_DATAOBJS = [ + larcv.Particle, + larcv.Neutrino, + larcv.Flash, + larcv.CRTHit + ] + + ANA_DATAOBJS = [ + analysis.ParticleFragment, + analysis.TruthParticleFragment, + analysis.Particle, + analysis.TruthParticle, + analysis.Interaction, + analysis.TruthInteraction + ] + + DATAOBJS = CPP_DATAOBJS + ANA_DATAOBJS + def __init__(self, file_name: str = 'output.h5', input_keys: list = None, skip_input_keys: list = [], result_keys: list = None, skip_result_keys: list = [], - append_file: bool = False, - add_results: bool = False): + append_file: bool = False): ''' Initializes the basics of the output file @@ -42,8 +60,6 @@ def __init__(self, List of result keys to skip append_file: bool, default False Add new values to the end of an existing file - add_results: bool, default False - Add new keys to an existing file (must match existing length) ''' # Store attributes self.file_name = file_name @@ -52,10 +68,8 @@ def __init__(self, self.result_keys = result_keys self.skip_result_keys = skip_result_keys self.append_file = append_file - self.add_results = add_results self.ready = False - - assert not (append_file and add_results), 'Cannot append a file with new keys' + self.object_dtypes = {} def create(self, data_blob, result_blob=None, cfg=None): ''' @@ -177,29 +191,12 @@ def register_key(self, blob, key, category): else: # List containing a list/array of objects per batch ID - if isinstance(blob[key][0][0], larcv.Particle): - # List containing a single list of larcv.Particle object per batch ID - if not hasattr(self, 'particle_dtype'): - self.particle_dtype = self.get_object_dtype(blob[key][0][0]) - self.key_dict[key]['dtype'] = self.particle_dtype - - elif isinstance(blob[key][0][0], larcv.Neutrino): - # List containing a single list of larcv.Neutrino object per batch ID - if not hasattr(self, 'neutrino_dtype'): - self.neutrino_dtype = self.get_object_dtype(blob[key][0][0]) - self.key_dict[key]['dtype'] = self.neutrino_dtype - - elif isinstance(blob[key][0][0], larcv.Flash): - # List containing a single list of larcv.Flash object per batch ID - if not hasattr(self, 'flash_dtype'): - self.flash_dtype = self.get_object_dtype(blob[key][0][0]) - self.key_dict[key]['dtype'] = self.flash_dtype - - elif isinstance(blob[key][0][0], larcv.CRTHit): - # List containing a single list of larcv.CRTHit object per batch ID - if not hasattr(self, 'crthit_dtype'): - self.crthit_dtype = self.get_object_dtype(blob[key][0][0]) - self.key_dict[key]['dtype'] = self.crthit_dtype + if isinstance(blob[key][0][0], tuple(self.DATAOBJS)): + # List containing a single list of dataclass objects per batch ID + object_type = type(blob[key][0][0]) + if not object_type in self.object_dtypes: + self.object_dtypes[object_type] = self.get_object_dtype(blob[key][0][0]) + self.key_dict[key]['dtype'] = self.object_dtypes[object_type] elif not hasattr(blob[key][0][0], '__len__'): # List containing a single list of scalars per batch ID @@ -243,19 +240,38 @@ def get_object_dtype(self, obj): members = inspect.getmembers(obj) skip_keys = ['add_trajectory_point', 'dump', 'momentum', 'boundingbox_2d', 'boundingbox_3d'] +\ [k+a for k in ['', 'parent_', 'ancestor_'] for a in ['x', 'y', 'z', 't']] - attr_names = [k for k, _ in members if '__' not in k and k not in skip_keys] + attr_names = [k for k, _ in members if k[0] != '_' and k not in skip_keys] + is_cpp = type(obj) in self.CPP_DATAOBJS for key in attr_names: - val = getattr(obj, key)() - if isinstance(val, (int, float)): - object_dtype.append((key, type(val))) - elif isinstance(val, str): + # Fetch the attribute value + if is_cpp: + val = getattr(obj, key)() + else: + val = getattr(obj, key) + if callable(val): + continue + + # Append the relevant data type + if isinstance(val, str): + # String object_dtype.append((key, h5py.string_dtype())) + elif np.isscalar(val): + # Scalar + object_dtype.append((key, type(val))) elif isinstance(val, larcv.Vertex): + # Three-vector object_dtype.append((key, h5py.vlen_dtype(np.float32))) - elif hasattr(val, '__len__') and len(val) and isinstance(val[0], (int, float)): - object_dtype.append((key, h5py.vlen_dtype(type(val[0])))) elif hasattr(val, '__len__'): - pass # Empty list, no point in storing + # List/array of values + if hasattr(val, 'dtype'): + # Numpy array + object_dtype.append((key, h5py.vlen_dtype(val.dtype))) + elif len(val) and np.isscalar(val[0]): + # List of scalars + object_dtype.append((key, h5py.vlen_dtype(type(val[0])))) + else: + # Empty list (typing unknown, cannot store) + pass else: raise ValueError('Unexpected key') @@ -321,12 +337,8 @@ def append(self, data_blob=None, result_blob=None, cfg=None): Dictionary containing the ML chain configuration ''' # If this function has never been called, initialiaze the HDF5 file - if not self.ready: - if not self.add_results: - if not self.append_file or not os.path.isfile(self.file_name): - self.create(data_blob, result_blob, cfg) - else: - self.add_keys(result_blob) + if not self.ready and (not self.append_file or os.path.isfile(self.file_name)): + self.create(data_blob, result_blob, cfg) self.ready = True # Append file @@ -378,14 +390,8 @@ def append_key(self, file, event, blob, key, batch_id): if not hasattr(obj, '__len__'): obj = [obj] - if hasattr(self, 'particle_dtype') and val['dtype'] == self.particle_dtype: - self.store_objects(file[cat], event, key, obj, self.particle_dtype) - elif hasattr(self, 'neutrino_dtype') and val['dtype'] == self.neutrino_dtype: - self.store_objects(file[cat], event, key, obj, self.neutrino_dtype) - elif hasattr(self, 'flash_dtype') and val['dtype'] == self.flash_dtype: - self.store_objects(file[cat], event, key, obj, self.flash_dtype) - elif hasattr(self, 'crthit_dtype') and val['dtype'] == self.crthit_dtype: - self.store_objects(file[cat], event, key, obj, self.crthit_dtype) + if val['dtype'] in self.object_dtypes.values(): + self.store_objects(file[cat], event, key, obj, val['dtype']) else: self.store(file[cat], event, key, obj) @@ -542,14 +548,14 @@ def store_objects(group, event, key, array, obj_dtype): objects = np.empty(len(array), obj_dtype) for i, o in enumerate(array): for k, dtype in obj_dtype: - attr = getattr(o, k)() + attr = getattr(o, k)() if callable(getattr(o, k)) else getattr(o, k) if isinstance(attr, (int, float, str)): objects[i][k] = attr elif isinstance(attr, larcv.Vertex): vertex = np.array([getattr(attr, a)() for a in ['x', 'y', 'z', 't']], dtype=np.float32) objects[i][k] = vertex elif hasattr(attr, '__len__'): - vals = np.array([attr[i] for i in range(len(attr))], dtype=np.int32) + vals = np.array([attr[i] for i in range(len(attr))]) objects[i][k] = vals # Extend the dataset, store array diff --git a/mlreco/models/layers/gnn/losses/node_kinematics.py b/mlreco/models/layers/gnn/losses/node_kinematics.py index c1d120e6..1b615147 100644 --- a/mlreco/models/layers/gnn/losses/node_kinematics.py +++ b/mlreco/models/layers/gnn/losses/node_kinematics.py @@ -92,7 +92,7 @@ def __init__(self, loss_config, batch_col=0, coords_col=(1, 4)): self.coords_col = coords_col self.group_col = loss_config.get('cluster_col', GROUP_COL) - self.type_col = loss_config.get('type_col', TYPE_COL) + self.type_col = loss_config.get('type_col', PID_COL) self.momentum_col = loss_config.get('momentum_col', MOM_COL) self.vtx_col = loss_config.get('vtx_col', VTX_COLS[0]) self.vtx_positives_col = loss_config.get('vtx_positives_col', PGRP_COL) diff --git a/mlreco/utils/globals.py b/mlreco/utils/globals.py index 55367295..f1b52d1c 100644 --- a/mlreco/utils/globals.py +++ b/mlreco/utils/globals.py @@ -1,3 +1,4 @@ +from collections import defaultdict from larcv import larcv # Column which specifies the batch ID in a sparse tensor @@ -14,26 +15,15 @@ GROUP_COL = 6 INTER_COL = 7 NU_COL = 8 -TYPE_COL = 9 +PID_COL = 9 PSHOW_COL = 10 PGRP_COL = 11 VTX_COLS = (12,13,14) MOM_COL = 15 -# Colum which specifies the shape ID of a voxel in a sparse tensor +# Colum which specifies the shape ID of a voxel in a sparse or cluster label tensor SHAPE_COL = -1 -# Convention for particle type labels -PARTICLE_TO_PID_LABEL = { - 'PHOTON': 0, - 'ELECTRON': 1, - 'MUON': 2, - 'PION': 3, - 'PROTON': 4 -} - -PID_LABEL_TO_PARTICLE = {val : key for key, val in PARTICLE_TO_PID_LABEL.items()} - # Shape ID of each type of voxel category SHOWR_SHP = larcv.kShapeShower # 0 TRACK_SHP = larcv.kShapeTrack # 1 @@ -46,13 +36,17 @@ # Shape precedence used in the cluster labeling process SHAPE_PREC = [TRACK_SHP, MICHL_SHP, SHOWR_SHP, DELTA_SHP, LOWES_SHP] +# Shape labels +SHAPE_LABELS = ['Shower', 'Track', 'Michel', 'Delta', 'Low Energy', 'Ghost', 'Unkown'] + # Invalid larcv.Particle labels INVAL_ID = larcv.kINVALID_INSTANCEID # Particle group/parent/interaction ID INVAL_TID = larcv.kINVALID_UINT # Particle Geant4 track ID INVAL_PDG = 0 # Particle PDG code # Mapping between particle PDG code and particle ID labels -PDG_TO_PID = { +PDG_TO_PID = defaultdict(lambda: -1) +PDG_TO_PID.update({ 22: 0, # photon 11: 1, # e- -11: 1, # e+ @@ -61,12 +55,23 @@ 211: 3, # pi+ -211: 3, # pi- 2212: 4, # protons +}) + +# Particle type labels +PID_LABELS = { + -1: 'Unknown', + 0: 'Photon', + 1: 'Electron', + 2: 'Muon', + 3: 'Pion', + 4: 'Proton' } # Physical constants -MUON_MASS = 105.7 # [MeV/c^2] ELECTRON_MASS = 0.511998 # [MeV/c^2] +MUON_MASS = 105.7 # [MeV/c^2] PROTON_MASS = 938.272 # [MeV/c^2] + ARGON_DENSITY = 1.396 # [g/cm^3] ADC_TO_MEV = 1. / 350. # < MUST GO diff --git a/mlreco/utils/unwrap.py b/mlreco/utils/unwrap.py index 38a1862b..e9bdb312 100644 --- a/mlreco/utils/unwrap.py +++ b/mlreco/utils/unwrap.py @@ -61,7 +61,8 @@ def __call__(self, data_blob, result_blob): class Rule: ''' Simple dataclass which stores the relevant - rule attributes with human-readable names. + unwrapping rule attributes for a speicific + data product human-readable names. Attributes ---------- @@ -149,7 +150,7 @@ def _build_batch_masks(self, data_blob, result_blob): # For an index tensor, only need to record the batch offsets within the wrapped tensor elif self.rules[key].method == 'index_tensor': ref_key = self.rules[key].ref_key - assert ref_key in comb_blob, f'Must provide reference tensor ({ref_key}) to unwrap {key}' + assert ref_key in comb_blob, f'Must provide reference tensor ({ref_key}) to unwrap {key}' if not self.rules[key].done and ref_key not in self.masks: self.masks[ref_key] = [self._batch_masks(comb_blob[ref_key][g]) for g in range(self.num_gpus)] if ref_key not in self.offsets: From a75f6c51bbec1647d5fa4528289b676766dea365 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Thu, 20 Apr 2023 16:43:42 -0700 Subject: [PATCH 149/180] Full chain now stores rescaled charge using collection charge only --- mlreco/models/full_chain.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/mlreco/models/full_chain.py b/mlreco/models/full_chain.py index 5e895cd2..9678169d 100644 --- a/mlreco/models/full_chain.py +++ b/mlreco/models/full_chain.py @@ -91,6 +91,7 @@ def __init__(self, cfg): self.deghost_input_features = self.uresnet_deghost.net.num_input self.RETURNS.update(self.uresnet_deghost.RETURNS) self.RETURNS['input_rescaled'] = ['tensor', 'input_rescaled', False, True] + self.RETURNS['input_rescaled_coll'] = ['tensor', 'input_rescaled', False, True] self.RETURNS['segmentation'][1] = 'input_rescaled' self.RETURNS['segment_label_tmp'][1] = 'input_rescaled' self.RETURNS['fragment_clusts'][1][0] = 'input_rescaled' @@ -233,8 +234,17 @@ def full_chain_cnn(self, input): # Rescale the charge column, store it charges = compute_rescaled_charge(input[0], deghost, last_index=last_index) - input[0][deghost, 4] = charges - result.update({'input_rescaled':[input[0][deghost,:5]]}) + charges_coll = compute_rescaled_charge(input[0], deghost, last_index=last_index, collection_only=True) + #input[0][deghost, 4] = charges + input[0][deghost, 4] = charges_coll + + input_rescaled = input[0][deghost,:5].clone() + input_rescaled[:,4] = charges + input_rescaled_coll = input[0][deghost,:5].clone() + input_rescaled_coll[:,4] = charges_coll + + result.update({'input_rescaled':[input_rescaled]}) + result.update({'input_rescaled_coll':[input_rescaled_coll]}) if self.enable_uresnet: if not self.enable_charge_rescaling: @@ -427,4 +437,4 @@ def __init__(self, cfg): # assert self._enable_graph_spice self._enable_graph_spice = True self.spatial_embeddings_loss = GraphSPICELoss(cfg, name='graph_spice_loss') - self._gspice_skip_classes = cfg.get('graph_spice_loss', {}).get('skip_classes', []) + self._gspice_skip_classes = cfg.get('graph_spice', {}).get('skip_classes', []) From 02edc5045da7106aa7573ead4d6aec8ab48957fb Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Thu, 20 Apr 2023 23:42:50 -0700 Subject: [PATCH 150/180] Temporary python flash filter --- analysis/post_processing/common.py | 30 ---------------- analysis/post_processing/pmt/filters.py | 35 +++++++++++++++++++ .../post_processing/pmt/flash_matching.py | 12 +++---- 3 files changed, 39 insertions(+), 38 deletions(-) create mode 100644 analysis/post_processing/pmt/filters.py diff --git a/analysis/post_processing/common.py b/analysis/post_processing/common.py index 83b6d188..38eba612 100644 --- a/analysis/post_processing/common.py +++ b/analysis/post_processing/common.py @@ -82,24 +82,6 @@ def process_event(self, image_id, f_list): return image_dict - # def process_batch(self): - # out_dict = defaultdict(list) - # sorted_processors = sorted([x for x in self._batch_funcs.items()], reverse=True) - # for priority, f_list in sorted_processors: - # for f in f_list: - - # data_batch, result_batch = {}, {} - # for data_key in f._data_capture: - # data_batch[data_key] = self.data[data_key] - # for result_key in f._result_capture: - # result_batch[result_key] = self.result[result_key] - # for result_key in f._result_capture_optional: - # if result_key in self.result: - # result_batch[result_key] = self.result[result_key] - # update_dict = f(data_batch, result_batch) - # out_dict.update(update_dict) - # return out_dict - def process_and_modify(self): """ @@ -125,18 +107,6 @@ def process_and_modify(self): raise RuntimeError(msg) else: self.result[key] = val - - # batch_fn_output = self.process_batch() - # # Check batch processed output length agrees with batch size - # for key, val in batch_fn_output.items(): - # assert len(val) == self._num_batches - # if key in self.result: - # msg = 'Output {} in post-processing function {},'\ - # ' caused a dictionary key conflict. You may '\ - # 'want to change the output dict key for that function.' - # raise ValueError(msg) - # else: - # self.result[key] = val def extent(voxels): diff --git a/analysis/post_processing/pmt/filters.py b/analysis/post_processing/pmt/filters.py new file mode 100644 index 00000000..cbc99d94 --- /dev/null +++ b/analysis/post_processing/pmt/filters.py @@ -0,0 +1,35 @@ +import numpy as np +from collections import defaultdict + +def filter_opflashes(opflashes, beam_window=(0, 1.6)): + """Python implementation for filtering opflashes. + + Only meant to be temporary, will be implemented in C++ to OpT0Finder. + + Parameters + ---------- + opflashes : dict + Dictionary of List[larcv.Flash], corresponding to each + east and west cryostat. + + Returns + ------- + out_flashes : dict + filtered List[larcv.Flash] dictionary. + """ + + out_flashes = defaultdict(list) + flash_dist_dict = {} + + for key in opflashes: + for flash in opflashes[key]: + if (flash.time() < beam_window[1]) and (flash.time() > beam_window[0]): + flash_dist_dict[flash.id()] = 0 + else: + dt1 = flash.time() - beam_window[0] + dt2 = flash.time() - beam_window[1] + dt = max(dt1, dt2) + flash_dist_dict[flash.id()] = dt + + + return out_flashes \ No newline at end of file diff --git a/analysis/post_processing/pmt/flash_matching.py b/analysis/post_processing/pmt/flash_matching.py index 85ecc732..de429509 100644 --- a/analysis/post_processing/pmt/flash_matching.py +++ b/analysis/post_processing/pmt/flash_matching.py @@ -1,14 +1,8 @@ import numpy as np -import yaml -from pprint import pprint from collections import defaultdict - -from mlreco.utils.gnn.cluster import get_cluster_directions from analysis.post_processing import post_processing from mlreco.utils.globals import * -from mlreco.main_funcs import process_config -from . import FlashMatcherInterface - +from .filters import filter_opflashes @post_processing(data_capture=['meta', 'index', 'opflash_cryoE', 'opflash_cryoW'], result_capture=['Interactions']) @@ -55,7 +49,9 @@ def run_flash_matching(data_dict, result_dict, interactions = result_dict['Interactions'] entry = data_dict['index'] - + + opflashes = filter_opflashes(opflashes) + fmatches_E = fm.get_flash_matches(entry, interactions, opflashes, From 56260141ceb306304e57e15564e04b77267d7fb9 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Fri, 21 Apr 2023 01:15:21 -0700 Subject: [PATCH 151/180] AnaTools with writer WIP --- analysis/classes/Interaction.py | 7 ++++--- analysis/classes/Particle.py | 2 +- analysis/classes/adaptor.py | 13 +++++++++++++ analysis/classes/builders.py | 3 +-- analysis/manager.py | 5 ++--- 5 files changed, 21 insertions(+), 9 deletions(-) create mode 100644 analysis/classes/adaptor.py diff --git a/analysis/classes/Interaction.py b/analysis/classes/Interaction.py index 3201d725..f9b368f4 100644 --- a/analysis/classes/Interaction.py +++ b/analysis/classes/Interaction.py @@ -67,8 +67,9 @@ def __init__(self, self.particle_ids = np.array(id_list, dtype=np.int64) self.num_particles = len(particles) - self.index = np.concatenate(index_list) - self.depositions = np.concatenate(depositions_list) + if len(index_list) > 0: + self.index = np.concatenate(index_list) + self.depositions = np.concatenate(depositions_list) self._get_particles_summary(particles) @@ -91,7 +92,7 @@ def update_info(self): """ self.particle_ids = list(self._particles.keys()) self.particle_counts = Counter({ PID_LABELS[i] : 0 for i in PID_LABELS.keys() }) - self.particle_counts.update([PID_LAEBLS[p.pid] for p in self._particles.values()]) + self.particle_counts.update([PID_LABELS[p.pid] for p in self._particles.values()]) self.primary_particle_counts = Counter({ PID_LABELS[i] : 0 for i in PID_LABELS.keys() }) self.primary_particle_counts.update([PID_LABELS[p.pid] for p in self._particles.values() if p.is_primary]) diff --git a/analysis/classes/Particle.py b/analysis/classes/Particle.py index 76803205..03cc8aa5 100644 --- a/analysis/classes/Particle.py +++ b/analysis/classes/Particle.py @@ -188,7 +188,7 @@ def depositions_sum(self): Total amount of charge/energy deposited. This attribute has no setter, as it can only be set by providing a set of depositions. ''' - return self._size + return self._depositions_sum @property def depositions(self): diff --git a/analysis/classes/adaptor.py b/analysis/classes/adaptor.py new file mode 100644 index 00000000..a88becd0 --- /dev/null +++ b/analysis/classes/adaptor.py @@ -0,0 +1,13 @@ +from analysis.classes.data import * + + +class ParticleAdaptor: + + def __init__(self, meta=None): + self._meta = meta + + def cast(self, blueprint): + pass + + def make_blueprint(self, particle: Particle): + pass \ No newline at end of file diff --git a/analysis/classes/builders.py b/analysis/classes/builders.py index 5b02bafb..308fa09f 100644 --- a/analysis/classes/builders.py +++ b/analysis/classes/builders.py @@ -679,6 +679,7 @@ def get_true_particle_labels(labels, mask, pid=-1, verbose=False): return semantic_type, interaction_id, nu_id + def match_points_to_particles(ppn_points : np.ndarray, particles : List[Particle], semantic_type=None, ppn_distance_threshold=2): @@ -718,5 +719,3 @@ def match_points_to_particles(ppn_points : np.ndarray, dist = cdist(ppn_coords, particle.points) matches = ppn_points_type[dist.min(axis=1) < ppn_distance_threshold] particle.ppn_candidates = matches.reshape(-1, 7) - - return semantic_type, interaction_id, nu_id diff --git a/analysis/manager.py b/analysis/manager.py index d8a27fed..afe7a530 100644 --- a/analysis/manager.py +++ b/analysis/manager.py @@ -275,10 +275,9 @@ def run_post_processing(self, data, result): Result dictionary """ - meta = data['meta'][0] - self.initialize_flash_manager(meta) - if 'post_processing' in self.ana_config: + meta = data['meta'][0] + self.initialize_flash_manager(meta) post_processor_interface = PostProcessor(data, result) # Gather post processing functions, register by priority From 3d508648b8b528264ae34098bd7402e900706baa Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Fri, 21 Apr 2023 01:56:42 -0700 Subject: [PATCH 152/180] Flash time should be in us? Removed temporary flash filter --- analysis/post_processing/pmt/filters.py | 14 ++++---------- analysis/post_processing/pmt/flash_matching.py | 2 +- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/analysis/post_processing/pmt/filters.py b/analysis/post_processing/pmt/filters.py index cbc99d94..4f23acaf 100644 --- a/analysis/post_processing/pmt/filters.py +++ b/analysis/post_processing/pmt/filters.py @@ -1,7 +1,7 @@ import numpy as np from collections import defaultdict -def filter_opflashes(opflashes, beam_window=(0, 1.6)): +def filter_opflashes(opflashes, beam_window=(0, 1.6), tolerance=0.4): """Python implementation for filtering opflashes. Only meant to be temporary, will be implemented in C++ to OpT0Finder. @@ -19,17 +19,11 @@ def filter_opflashes(opflashes, beam_window=(0, 1.6)): """ out_flashes = defaultdict(list) - flash_dist_dict = {} for key in opflashes: for flash in opflashes[key]: - if (flash.time() < beam_window[1]) and (flash.time() > beam_window[0]): - flash_dist_dict[flash.id()] = 0 - else: - dt1 = flash.time() - beam_window[0] - dt2 = flash.time() - beam_window[1] - dt = max(dt1, dt2) - flash_dist_dict[flash.id()] = dt + if (flash.time() < beam_window[1] + tolerance) and \ + (flash.time() > beam_window[0] - tolerance): + out_flashes[key].append(flash) - return out_flashes \ No newline at end of file diff --git a/analysis/post_processing/pmt/flash_matching.py b/analysis/post_processing/pmt/flash_matching.py index de429509..96d46e3a 100644 --- a/analysis/post_processing/pmt/flash_matching.py +++ b/analysis/post_processing/pmt/flash_matching.py @@ -50,7 +50,7 @@ def run_flash_matching(data_dict, result_dict, interactions = result_dict['Interactions'] entry = data_dict['index'] - opflashes = filter_opflashes(opflashes) + # opflashes = filter_opflashes(opflashes) fmatches_E = fm.get_flash_matches(entry, interactions, From d42fe5e14aebf99ae40b7cf4fe41d69eda6bd459 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Fri, 21 Apr 2023 16:22:03 -0700 Subject: [PATCH 153/180] Analysis tools Particle/Interaction now contain more info again, just not stored --- analysis/classes/Interaction.py | 67 ++++++++++++++++------------ analysis/classes/Particle.py | 13 ++++-- analysis/classes/ParticleFragment.py | 3 ++ analysis/classes/TruthParticle.py | 27 ++++++++--- analysis/classes/builders.py | 24 ++++------ mlreco/iotools/writers.py | 30 ++++++++++--- 6 files changed, 103 insertions(+), 61 deletions(-) diff --git a/analysis/classes/Interaction.py b/analysis/classes/Interaction.py index 3201d725..db4ef4e8 100644 --- a/analysis/classes/Interaction.py +++ b/analysis/classes/Interaction.py @@ -30,6 +30,8 @@ class Interaction: ID of the image the interaction lives in index : np.ndarray, default np.array([]) (N) IDs of voxels that correspondn to the particle within the image coordinate tensor that + points : np.dnarray, default np.array([], shape=(0,3)) + (N,3) Set of voxel coordinates that make up this interaction in the input tensor vertex : np.ndarray, optional 3D coordinates of the predicted interaction vertex in reconstruction (used for debugging) @@ -43,6 +45,9 @@ def __init__(self, vertex: np.ndarray = -np.ones(3, dtype=np.float32), is_neutrino: bool = False): + # Initialize private attributes to be set by setter only + self._particles = None + # Initialize attributes self.id = interaction_id self.nu_id = nu_id @@ -55,24 +60,10 @@ def __init__(self, self.num_particles = 0 self.num_primaries = 0 self.index = np.empty(0, dtype=np.int64) + self.points = np.empty((0,3), dtype=np.float32) self.depositions = np.empty(0, dtype=np.float32) - if particles is not None: - id_list, index_list, depositions_list = [], [], [] - for p in particles: - if p.size > 0: - id_list.append(p.id) - index_list.append(p.index) - depositions_list.append(p.depositions) - self.num_primaries += int(p.is_primary) - - self.particle_ids = np.array(id_list, dtype=np.int64) - self.num_particles = len(particles) - self.index = np.concatenate(index_list) - self.depositions = np.concatenate(depositions_list) - - self._get_particles_summary(particles) - - self.size = len(self.index) + self.particles = particles + self.size = len(self.index) # Quantities to be set by the particle matcher self.match = np.empty(0, np.int64) @@ -100,18 +91,34 @@ def update_info(self): else: self.is_valid = False -# @particles.setter -# def particles(self, value): -# assert isinstance(value, OrderedDict) -# parts = {} -# for p in value.values(): -# self.check_particle_input(p) -# # Clear match information since Interaction is rebuilt -# p.match = [] -# p._match_counts = {} -# parts[p.id] = p -# self._particles = OrderedDict(sorted(parts.items(), key=lambda t: t[0])) -# self.update_info() + @property + def particles(self): + return self._particles + + @particles.setter + def particles(self, particles): + ''' + list getter/setter. The setter also sets + the general interaction properties + ''' + self._particles = particles + + if particles is not None: + id_list, index_list, points_list, depositions_list = [], [], [], [] + for p in particles: + id_list.append(p.id) + index_list.append(p.index) + points_list.append(p.points) + depositions_list.append(p.depositions) + self.num_primaries += int(p.is_primary) + + self.particle_ids = np.array(id_list, dtype=np.int64) + self.num_particles = len(particles) + self.index = np.concatenate(index_list) + self.points = np.vstack(points_list) + self.depositions = np.concatenate(depositions_list) + + self._get_particles_summary(particles) def __getitem__(self, key): return self._particles[key] @@ -127,8 +134,10 @@ def __str__(self): return msg + self._particles_summary def _get_particles_summary(self, particles): + primary_str = {True: '*', False: '-'} self._particles_summary = "" + if particles is None: return for p in sorted(particles, key=lambda x: x.is_primary, reverse=True): pmsg = " {} Particle {}: PID = {}, Size = {}, Match = {} \n".format( primary_str[p.is_primary], p.id, PID_LABELS[p.pid], p.size, str(p.match)) diff --git a/analysis/classes/Particle.py b/analysis/classes/Particle.py index 76803205..f44cab6a 100644 --- a/analysis/classes/Particle.py +++ b/analysis/classes/Particle.py @@ -29,9 +29,11 @@ class Particle: size : int Total number of voxels that belong to this particle index : np.ndarray, default np.array([]) - (N) IDs of voxels that correspondn to the particle within the image coordinate tensor that + (N) IDs of voxels that correspond to the particle within the input tensor + points : np.dnarray, default np.array([], shape=(0,3)) + (N,3) Set of voxel coordinates that make up this particle in the input tensor depositions : np.ndarray, defaul np.array([]) - (N) Array of energy deposition values for each voxel (rescaled, ADC units) + (N) Array of charge deposition values for each voxel depositions_sum : float Sum of energy depositions semantic_type : int, default -1 @@ -74,6 +76,7 @@ def __init__(self, pid: int = -1, is_primary: int = -1, index: np.ndarray = np.empty(0, dtype=np.int64), + points: np.ndarray = np.empty(0, dtype=np.float32), depositions: np.ndarray = np.empty(0, dtype=np.float32), pid_scores: np.ndarray = -np.ones(np.max(list((PID_LABELS.keys())))+1, dtype=np.float32), primary_scores: np.ndarray = -np.ones(2, dtype=np.float32), @@ -89,6 +92,7 @@ def __init__(self, self._size = None self._index = None self._depositions = None + self._depositions_sum = None self._pid_scores = None self._primary_scores = None @@ -102,6 +106,7 @@ def __init__(self, self.semantic_type = semantic_type self.index = index + self.points = points self.depositions = depositions self.pid_scores = pid_scores @@ -152,7 +157,7 @@ def fragment_ids(self): ParticleFragment indices getter/setter. The setter also sets the number of fragments. ''' - return self._index + return self._fragment_ids @fragment_ids.setter def fragment_ids(self, fragment_ids): @@ -188,7 +193,7 @@ def depositions_sum(self): Total amount of charge/energy deposited. This attribute has no setter, as it can only be set by providing a set of depositions. ''' - return self._size + return self._depositions_sum @property def depositions(self): diff --git a/analysis/classes/ParticleFragment.py b/analysis/classes/ParticleFragment.py index 4209315e..fa8bff74 100644 --- a/analysis/classes/ParticleFragment.py +++ b/analysis/classes/ParticleFragment.py @@ -27,6 +27,8 @@ class ParticleFragment: Total number of voxels that belong to this particle index : np.ndarray, default np.array([]) (N) IDs of voxels that correspondn to the fragment within the image coordinate tensor that + points : np.dnarray, default np.array([], shape=(0,3)) + (N,3) Set of voxel coordinates that make up this fragment in the input tensor depositions : np.ndarray, defaul np.array([]) (N) Array of energy deposition values for each voxel (rescaled, ADC units) is_primary: bool @@ -42,6 +44,7 @@ def __init__(self, volume_id: int = -1, semantic_type: int = -1, index: np.ndarray = np.empty(0, dtype=np.int64), + points: np.ndarray = np.empty((0,3), dtype=np.float32), depositions: np.ndarray = np.empty(0, dtype=np.float32), is_primary: int = -1, start_point: np.ndarray = -np.ones(3, dtype=np.float32), diff --git a/analysis/classes/TruthParticle.py b/analysis/classes/TruthParticle.py index 215e0e37..9b65f687 100644 --- a/analysis/classes/TruthParticle.py +++ b/analysis/classes/TruthParticle.py @@ -16,25 +16,29 @@ class TruthParticle(Particle): Attributes ---------- depositions_MeV : np.ndarray - Similar as `depositions`, i.e. using adapted true labels. - Using true MeV energy deposits instead of rescaled ADC units. + (N) Array of energy deposition values for each voxel in MeV + true_index : np.ndarray, default np.array([]) + (N) IDs of voxels that correspond to the particle within the label tensor + true_points : np.dnarray, default np.array([], shape=(0,3)) + (N,3) Set of voxel coordinates that make up this particle in the label tensor true_depositions : np.ndarray - Rescaled charge depositions in the set of true voxels associated - with the particle. + (N) Array of charge deposition values for each true voxel true_depositions_MeV : np.ndarray - MeV charge depositions in the set of true voxels associated - with the particle. + (N) Array of energy deposition values for each true voxel in MeV start_position : np.ndarray True start position of the particle end_position : np.ndarray True end position of the particle momentum : float, default np.array([-1,-1,-1]) True 3-momentum of the particle + asis : larcv.Particle, optional + Original larcv.Paticle instance which contains all the truth information ''' def __init__(self, *args, depositions_MeV: np.ndarray = np.empty(0, dtype=np.float32), true_index: np.ndarray = np.empty(0, dtype=np.int64), + true_points: np.ndarray = np.empty((0,3), dtype=np.float32), true_depositions: np.ndarray = np.empty(0, dtype=np.float32), true_depositions_MeV: np.ndarray = np.empty(0, dtype=np.float32), momentum: np.ndarray = -np.ones(3, dtype=np.float32), @@ -45,12 +49,23 @@ def __init__(self, # Initialize attributes self.depositions_MeV = depositions_MeV self.true_index = true_index + self.true_points = true_points self.true_depositions = true_depositions self.true_depositions_MeV = true_depositions_MeV if particle_asis is not None: self.start_position = particle_asis.position() self.end_position = particle_asis.end_position() + self.asis = particle_asis + + self.start_point = np.array([getattr(particle_asis.first_step(), a)() for a in ['x', 'y', 'z']], dtype=np.float32) + if self.semantic_type == 1: + self.end_point = np.array([getattr(particle_asis.last_step(), a)() for a in ['x', 'y', 'z']], dtype=np.float32) + + self.momentum = np.array([getattr(particle_asis, a)() for a in ['x', 'y', 'z']], dtype=np.float32) + if np.linalg.norm(self.momentum) > 0.: + self.start_dir = self.momentum = self.momentum/np.linalg.norm(self.momentum) + def __repr__(self): msg = super(TruthParticle, self).__repr__() return 'Truth'+msg diff --git a/analysis/classes/builders.py b/analysis/classes/builders.py index 5b02bafb..eed809d1 100644 --- a/analysis/classes/builders.py +++ b/analysis/classes/builders.py @@ -144,6 +144,7 @@ def _build_reco(self, image_id=entry, semantic_type=seg_label, index=p, + points=point_cloud[p], depositions=depositions[p], volume_id=volume_id, pid_scores=pid_scores[i], @@ -235,28 +236,17 @@ def _build_true(self, volume_id=volume_id, semantic_type=semantic_type, index=voxel_indices, + points=coords, depositions=depositions, depositions_MeV=depositions_MeV, true_index=true_voxel_indices, + true_points=coords_noghost, true_depositions=np.empty(0, dtype=np.float32), #TODO true_depositions_MeV=depositions_noghost, is_primary=is_primary, pid=pdg, particle_asis=lpart) - particle.momentum = np.array([lpart.px(), lpart.py(), lpart.pz()]) - pmag = np.linalg.norm(particle.momentum) - if pmag > 0: - particle.start_dir = particle.momentum/pmag - - particle.start_point = np.array([lpart.first_step().x(), - lpart.first_step().y(), - lpart.first_step().z()]) - - if semantic_type == 1: - particle.end_point = np.array([lpart.last_step().x(), - lpart.last_step().y(), - lpart.last_step().z()]) out.append(particle) return out @@ -422,6 +412,7 @@ def _build_reco(self, entry, volume_id=volume_id, semantic_type=seg_label, index=p, + points=point_cloud[p], depositions=depositions[p], is_primary=False) temp.append(part) @@ -543,6 +534,7 @@ def _build_true(self, entry, data: dict, result: dict): image_id=entry, volume_id=volume_id, index=voxel_indices, + points=points, depositions=depositions, depositions_MeV=depositions_MeV, is_primary=is_primary) @@ -583,8 +575,8 @@ def handle_empty_true_particles(labels_noghost, is_primary = p.group_id() == p.parent_id() semantic_type, interaction_id, nu_id = -1, -1, -1 - coords, depositions, voxel_indices = np.array([]), np.array([]), np.array([]) - coords_noghost, depositions_noghost = np.array([]), np.array([]) + coords, depositions, voxel_indices = np.empty((0,3)), np.array([]), np.array([]) + coords_noghost, depositions_noghost = np.empty((0,3)), np.array([]) if np.count_nonzero(mask_noghost) > 0: coords_noghost = labels_noghost[mask_noghost][:, COORD_COLS] true_voxel_indices = np.where(mask_noghost)[0] @@ -603,9 +595,11 @@ def handle_empty_true_particles(labels_noghost, image_id=entry, semantic_type=semantic_type, index=voxel_indices, + points=coords, depositions=depositions, depositions_MeV=np.empty(0, dtype=np.float32), true_index=true_voxel_indices, + true_points=coords_noghost, true_depositions=np.empty(0, dtype=np.float32), #TODO true_depositions_MeV=depositions_noghost, is_primary=is_primary, diff --git a/mlreco/iotools/writers.py b/mlreco/iotools/writers.py index 19a4a175..28c0b1f7 100644 --- a/mlreco/iotools/writers.py +++ b/mlreco/iotools/writers.py @@ -18,13 +18,27 @@ class HDF5Writer: More documentation to come. ''' - CPP_DATAOBJS = [ + # LArCV object attributes that do not need to be stored to HDF5 + LARCV_SKIP = [ + 'add_trajectory_point', 'dump', 'momentum', 'boundingbox_2d', 'boundingbox_3d', + *[k + a for k in ['', 'parent_', 'ancestor_'] for a in ['x', 'y', 'z', 't']] + ] + + # Analysis object attributes that do not need to be stored to HDF5 + ANA_SKIP = [ + 'index', 'true_index', 'points', 'true_points', 'particles', 'fragments', 'asis', + 'depositions', 'depositions_MeV', 'true_depositions', 'true_depositions_MeV' + ] + + # List of recognized LArCV objects + LARCV_DATAOBJS = [ larcv.Particle, larcv.Neutrino, larcv.Flash, larcv.CRTHit ] + # List of recognized Analysis objects ANA_DATAOBJS = [ analysis.ParticleFragment, analysis.TruthParticleFragment, @@ -34,7 +48,8 @@ class HDF5Writer: analysis.TruthInteraction ] - DATAOBJS = CPP_DATAOBJS + ANA_DATAOBJS + # List of recognized objects + DATAOBJS = LARCV_DATAOBJS + ANA_DATAOBJS def __init__(self, file_name: str = 'output.h5', @@ -238,13 +253,12 @@ def get_object_dtype(self, obj): ''' object_dtype = [] members = inspect.getmembers(obj) - skip_keys = ['add_trajectory_point', 'dump', 'momentum', 'boundingbox_2d', 'boundingbox_3d'] +\ - [k+a for k in ['', 'parent_', 'ancestor_'] for a in ['x', 'y', 'z', 't']] + is_larcv = type(obj) in self.LARCV_DATAOBJS + skip_keys = self.LARCV_SKIP if is_larcv else self.ANA_SKIP attr_names = [k for k, _ in members if k[0] != '_' and k not in skip_keys] - is_cpp = type(obj) in self.CPP_DATAOBJS for key in attr_names: # Fetch the attribute value - if is_cpp: + if is_larcv: val = getattr(obj, key)() else: val = getattr(obj, key) @@ -555,7 +569,9 @@ def store_objects(group, event, key, array, obj_dtype): vertex = np.array([getattr(attr, a)() for a in ['x', 'y', 'z', 't']], dtype=np.float32) objects[i][k] = vertex elif hasattr(attr, '__len__'): - vals = np.array([attr[i] for i in range(len(attr))]) + vals = attr + if not isinstance(attr, np.ndarray): + vals = np.array([attr[i] for i in range(len(attr))]) objects[i][k] = vals # Extend the dataset, store array From f73d4e4ba865c7d80cff271ed6b240c198a2a2b1 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Fri, 21 Apr 2023 17:49:12 -0700 Subject: [PATCH 154/180] Bug fix related to LArCV loading from HDF5 --- mlreco/iotools/readers.py | 2 +- mlreco/iotools/writers.py | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/mlreco/iotools/readers.py b/mlreco/iotools/readers.py index a55ab266..cf93e552 100644 --- a/mlreco/iotools/readers.py +++ b/mlreco/iotools/readers.py @@ -182,7 +182,7 @@ def load_key(self, file, event, data_blob, result_blob, key, nested): # If the dataset has multiple attributes, it contains an object array = group[key][region_ref] names = array.dtype.names - if self.to_larcv: + if self.to_larcv and ('larcv' not in group[key].attrs or group[key].attrs['larcv']): blob[key] = self.make_larcv_objects(array, names) else: blob[key] = [] diff --git a/mlreco/iotools/writers.py b/mlreco/iotools/writers.py index 28c0b1f7..fabbb629 100644 --- a/mlreco/iotools/writers.py +++ b/mlreco/iotools/writers.py @@ -49,7 +49,7 @@ class HDF5Writer: ] # List of recognized objects - DATAOBJS = LARCV_DATAOBJS + ANA_DATAOBJS + DATAOBJS = tuple(LARCV_DATAOBJS + ANA_DATAOBJS) def __init__(self, file_name: str = 'output.h5', @@ -106,7 +106,7 @@ def create(self, data_blob, result_blob=None, cfg=None): self.batch_size = len(data_blob['index']) # Initialize a dictionary to store keys and their properties (dtype and shape) - self.key_dict = defaultdict(lambda: {'category': None, 'dtype':None, 'width':0, 'merge':False, 'scalar':False}) + self.key_dict = defaultdict(lambda: {'category': None, 'dtype':None, 'width':0, 'merge':False, 'scalar':False, 'larcv':False}) # If requested, loop over input_keys and add them to what needs to be tracked if self.input_keys is None: self.input_keys = data_blob.keys() @@ -206,12 +206,13 @@ def register_key(self, blob, key, category): else: # List containing a list/array of objects per batch ID - if isinstance(blob[key][0][0], tuple(self.DATAOBJS)): + if isinstance(blob[key][0][0], self.DATAOBJS): # List containing a single list of dataclass objects per batch ID object_type = type(blob[key][0][0]) if not object_type in self.object_dtypes: self.object_dtypes[object_type] = self.get_object_dtype(blob[key][0][0]) self.key_dict[key]['dtype'] = self.object_dtypes[object_type] + self.key_dict[key]['larcv'] = object_type in self.LARCV_DATAOBJS elif not hasattr(blob[key][0][0], '__len__'): # List containing a single list of scalars per batch ID @@ -311,8 +312,8 @@ def initialize_datasets(self, file): w = val['width'] shape, maxshape = [(0, w), (None, w)] if w else [(0,), (None,)] grp.create_dataset(key, shape, maxshape=maxshape, dtype=val['dtype']) - if val['scalar']: - grp[key].attrs['scalar'] = True + grp[key].attrs['scalar'] = val['scalar'] + grp[key].attrs['larcv'] = val['larcv'] elif not val['merge']: # If the elements of the list are of variable widths, refer to one From 8a93d13cfef771a8610667f652e1c17e87c71735 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Mon, 24 Apr 2023 13:56:58 -0700 Subject: [PATCH 155/180] Finished particle/truthparticle loading from HDF5 --- analysis/classes/Particle.py | 29 +++++- analysis/classes/TruthParticle.py | 54 +++++++++--- analysis/classes/builders.py | 141 ++++++++++++++++++++++++++++++ mlreco/iotools/writers.py | 3 +- 4 files changed, 210 insertions(+), 17 deletions(-) diff --git a/analysis/classes/Particle.py b/analysis/classes/Particle.py index f44cab6a..f2074efb 100644 --- a/analysis/classes/Particle.py +++ b/analysis/classes/Particle.py @@ -85,7 +85,7 @@ def __init__(self, start_dir: np.ndarray = -np.ones(3, dtype=np.float32), end_dir: np.ndarray = -np.ones(3, dtype=np.float32), momentum_range: float = -1., - momentum_mcs: float = -1.): + momentum_mcs: float = -1., **kwargs): # Initialize private attributes to be assigned through setters only self._num_fragments = None @@ -124,8 +124,12 @@ def __init__(self, self.momentum_mcs = momentum_mcs # Quantities to be set by the particle matcher - self.match = np.empty(0, np.int64) - self._match_counts = np.empty(0, np.float32) + self.match = kwargs.get('match', np.empty(0, np.int64)) + self._match_counts = kwargs.get('match_counts', np.empty(0, np.float32)) + + # ADC to MeV + self._ADC_to_MeV = kwargs.get('ADC_to_MeV', 1./350.) + self._depositions_MeV = self.depositions * self._ADC_to_MeV def __repr__(self): msg = "Particle(image_id={}, id={}, pid={}, size={})".format(self.image_id, self.id, self.pid, self.size) @@ -142,6 +146,15 @@ def __str__(self): self.size, self.volume_id) return msg + + @property + def ADC_to_MeV(self): + return self._ADC_to_MeV + + @ADC_to_MeV.setter + def ADC_to_MeV(self, value): + assert value > 0 + self._ADC_to_MeV = value @property def num_fragments(self): @@ -194,6 +207,16 @@ def depositions_sum(self): as it can only be set by providing a set of depositions. ''' return self._depositions_sum + + @property + def depositions_MeV(self): + self._depositions_MeV = self._depositions * self._ADC_to_MeV + return self._depositions_MeV + + @depositions_MeV.setter + def depositions_MeV(self, value): + self._depositions_MeV = value + self._depositions = value * 1./ self._ADC_to_MeV @property def depositions(self): diff --git a/analysis/classes/TruthParticle.py b/analysis/classes/TruthParticle.py index 9b65f687..2cedfc14 100644 --- a/analysis/classes/TruthParticle.py +++ b/analysis/classes/TruthParticle.py @@ -42,29 +42,33 @@ def __init__(self, true_depositions: np.ndarray = np.empty(0, dtype=np.float32), true_depositions_MeV: np.ndarray = np.empty(0, dtype=np.float32), momentum: np.ndarray = -np.ones(3, dtype=np.float32), - particle_asis: List[object] = None, + particle_asis: object = None, **kwargs): super(TruthParticle, self).__init__(*args, **kwargs) # Initialize attributes - self.depositions_MeV = depositions_MeV - self.true_index = true_index - self.true_points = true_points - self.true_depositions = true_depositions - self.true_depositions_MeV = true_depositions_MeV + self.depositions_MeV = depositions_MeV + self.true_index = true_index + self.true_points = true_points + self._true_size = true_points.shape[0] + self._true_depositions = true_depositions # Must be ADC + self._true_depositions_MeV = true_depositions_MeV # Must be MeV if particle_asis is not None: - self.start_position = particle_asis.position() - self.end_position = particle_asis.end_position() + self.start_position = particle_asis.position() + self.end_position = particle_asis.end_position() self.asis = particle_asis - self.start_point = np.array([getattr(particle_asis.first_step(), a)() for a in ['x', 'y', 'z']], dtype=np.float32) + self.start_point = np.array([getattr(particle_asis.first_step(), a)() \ + for a in ['x', 'y', 'z']], dtype=np.float32) if self.semantic_type == 1: - self.end_point = np.array([getattr(particle_asis.last_step(), a)() for a in ['x', 'y', 'z']], dtype=np.float32) + self.end_point = np.array([getattr(particle_asis.last_step(), a)() \ + for a in ['x', 'y', 'z']], dtype=np.float32) - self.momentum = np.array([getattr(particle_asis, a)() for a in ['x', 'y', 'z']], dtype=np.float32) + self.momentum = np.array([getattr(particle_asis, a)() \ + for a in ['x', 'y', 'z']], dtype=np.float32) if np.linalg.norm(self.momentum) > 0.: - self.start_dir = self.momentum = self.momentum/np.linalg.norm(self.momentum) + self.start_dir = self.momentum/np.linalg.norm(self.momentum) def __repr__(self): msg = super(TruthParticle, self).__repr__() @@ -73,7 +77,31 @@ def __repr__(self): def __str__(self): msg = super(TruthParticle, self).__str__() return 'Truth'+msg - + + @property + def true_size(self): + return self._true_size + + @property + def true_depositions(self): + return self._true_depositions + + @true_depositions.setter + def true_depositions(self, value): + assert value.shape[0] == self._true_size + self._true_depositions = value + self._true_depositions_MeV = value * self._ADC_to_MeV + + @property + def true_depositions_MeV(self): + return self._true_depositions_MeV + + @true_depositions_MeV.setter + def true_depositions_MeV(self, value): + assert value.shape[0] == self._true_size + self._true_depositions_MeV = value + self._true_depositions = value * 1./ self._ADC_to_MeV + def is_contained(self, spatial_size): check_contained = self.start_position.x() >= 0 and self.start_position.x() <= spatial_size \ diff --git a/analysis/classes/builders.py b/analysis/classes/builders.py index 2063e178..10fcbe81 100644 --- a/analysis/classes/builders.py +++ b/analysis/classes/builders.py @@ -4,6 +4,7 @@ import numpy as np from scipy.special import softmax from scipy.spatial.distance import cdist +import copy from mlreco.utils.globals import (BATCH_COL, COORD_COLS, @@ -76,6 +77,66 @@ def _build_true(self, entry, data: dict, result: dict): @abstractmethod def _build_reco(self, entry, data: dict, result: dict): raise NotImplementedError + + # @abstractmethod + # def _load_reco(self, entry, data: dict, result: dict): + # raise NotImplementedError + + # @abstractmethod + # def _load_true(self, entry, data: dict, result: dict): + # raise NotImplementedError + + def load_image(self, entry: int, data: dict, result: dict, mode='reco'): + """Load single image worth of entity blueprint from HDF5 + and construct original data structure instance. + + Parameters + ---------- + entry : int + Image ID + data : dict + Data dictionary + result : dict + Result dictionary + mode : str, optional + Whether to load reco or true entities, by default 'reco' + + Returns + ------- + entities: List[Any] + List of constructed entities from their HDF5 blueprints. + """ + if mode == 'truth': + entities = self._load_true(entry, data, result) + elif mode == 'reco': + entities = self._load_reco(entry, data, result) + else: + raise ValueError(f"Particle loader mode {mode} not supported!") + + return entities + + def load(self, data: dict, result: dict, mode='reco'): + """Process all images in the current batch of HDF5 data and + construct original data structures. + + Parameters + ---------- + data: dict + Data dictionary + result: dict + Result dictionary + mode: str + Indicator for building reconstructed vs true data formats. + In other words, mode='reco' will produce and + data formats, while mode='truth' is reserved for + and + """ + output = [] + num_batches = len(data['index']) + for bidx in range(num_batches): + entities = self.load_image(bidx, data, result, mode=mode) + output.append(entities) + return output class ParticleBuilder(DataBuilder): @@ -101,6 +162,86 @@ class ParticleBuilder(DataBuilder): """ def __init__(self, builder_cfg={}): self.cfg = builder_cfg + + def _load_reco(self, entry, data: dict, result: dict): + """Construct Particle objects from loading HDF5 blueprints. + + Parameters + ---------- + entry : int + Image ID + data : dict + Data dictionary + result : dict + Result dictionary + + Returns + ------- + out : List[Particle] + List of restored particle instances built from HDF5 blueprints. + """ + if 'input_rescaled' in result: + point_cloud = result['input_rescaled'] + elif 'input_data' in data: + point_cloud = data['input_data'] + else: + msg = "To build Particle objects from HDF5 data, need either "\ + "input_data inside data dictionary or input_rescaled inside"\ + " result dictionary." + raise KeyError(msg) + out = [] + blueprints = result['Particles'][0] + for i, bp in enumerate(blueprints): + mask = bp['index'] + prepared_bp = copy.deepcopy(bp) + prepared_bp.pop('depositions_sum', None) + group_id = prepared_bp.pop('id', -1) + prepared_bp['group_id'] = group_id + prepared_bp.update({ + 'points': point_cloud[mask][:, COORD_COLS], + 'depositions': point_cloud[mask][:, VALUE_COL], + }) + particle = Particle(**prepared_bp) + assert particle.image_id == entry + out.append(particle) + + return out + + + def _load_true(self, entry, data, result): + out = [] + true_nonghost = data['cluster_label'][0] + particles_asis = data['particles_asis'][0] + pred_nonghost = result['cluster_label_adapted'][0] + blueprints = result['TruthParticles'][0] + for i, bp in enumerate(blueprints): + mask = bp['index'] + true_mask = bp['true_index'] + pasis_selected = None + # Find particles_asis + for pasis in particles_asis: + if pasis.id() == bp['id']: + pasis_selected = pasis + assert pasis_selected is not None + prepared_bp = copy.deepcopy(bp) + group_id = prepared_bp.pop('id', -1) + prepared_bp['group_id'] = group_id + prepared_bp.pop('depositions_sum', None) + prepared_bp.update({ + + 'points': pred_nonghost[mask][:, COORD_COLS], + 'depositions': pred_nonghost[mask][:, VALUE_COL], + 'true_points': true_nonghost[true_mask][:, COORD_COLS], + 'true_depositions': true_nonghost[true_mask][:, VALUE_COL], + 'particle_asis': pasis_selected + }) + truth_particle = TruthParticle(**prepared_bp) + assert truth_particle.image_id == entry + assert truth_particle.true_size > 0 + out.append(truth_particle) + + return out + def _build_reco(self, entry: int, diff --git a/mlreco/iotools/writers.py b/mlreco/iotools/writers.py index fabbb629..71c4efa7 100644 --- a/mlreco/iotools/writers.py +++ b/mlreco/iotools/writers.py @@ -26,8 +26,9 @@ class HDF5Writer: # Analysis object attributes that do not need to be stored to HDF5 ANA_SKIP = [ - 'index', 'true_index', 'points', 'true_points', 'particles', 'fragments', 'asis', + 'points', 'true_points', 'particles', 'fragments', 'asis', 'depositions', 'depositions_MeV', 'true_depositions', 'true_depositions_MeV' + # 'index', 'true_index' ] # List of recognized LArCV objects From ce746385791acbc674056201146c35a02e5f64b5 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Mon, 24 Apr 2023 16:18:58 -0700 Subject: [PATCH 156/180] Added support for enumerated types in the HDF5 output --- analysis/classes/Particle.py | 4 ++-- analysis/classes/ParticleFragment.py | 2 +- mlreco/iotools/writers.py | 11 +++++++++++ mlreco/utils/globals.py | 16 +++++++++++++--- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/analysis/classes/Particle.py b/analysis/classes/Particle.py index f44cab6a..c51b2f81 100644 --- a/analysis/classes/Particle.py +++ b/analysis/classes/Particle.py @@ -78,7 +78,7 @@ def __init__(self, index: np.ndarray = np.empty(0, dtype=np.int64), points: np.ndarray = np.empty(0, dtype=np.float32), depositions: np.ndarray = np.empty(0, dtype=np.float32), - pid_scores: np.ndarray = -np.ones(np.max(list((PID_LABELS.keys())))+1, dtype=np.float32), + pid_scores: np.ndarray = -np.ones(len(PID_LABELS), dtype=np.float32), primary_scores: np.ndarray = -np.ones(2, dtype=np.float32), start_point: np.ndarray = -np.ones(3, dtype=np.float32), end_point: np.ndarray = -np.ones(3, dtype=np.float32), @@ -135,7 +135,7 @@ def __str__(self): fmt = "Particle( Image ID={:<3} | Particle ID={:<3} | Semantic_type: {:<15}"\ " | PID: {:<8} | Primary: {:<2} | Interaction ID: {:<2} | Size: {:<5} | Volume: {:<2} )" msg = fmt.format(self.image_id, self.id, - SHAPE_LABELS[self.semantic_type] if self.semantic_type in list(range(len(SHAPE_LABELS))) else "None", + SHAPE_LABELS[self.semantic_type] if self.semantic_type in SHAPE_LABELS else "None", PID_LABELS[self.pid] if self.pid in PID_LABELS else "None", self.is_primary, self.interaction_id, diff --git a/analysis/classes/ParticleFragment.py b/analysis/classes/ParticleFragment.py index fa8bff74..492fcd7f 100644 --- a/analysis/classes/ParticleFragment.py +++ b/analysis/classes/ParticleFragment.py @@ -79,7 +79,7 @@ def __repr__(self): fmt = "ParticleFragment( Image ID={:<3} | Fragment ID={:<3} | Semantic_type: {:<15}"\ " | Group ID: {:<3} | Primary: {:<2} | Interaction ID: {:<2} | Size: {:<5} | Volume: {:<2})" msg = fmt.format(self.image_id, self.id, - SHAPE_LABELS[self.semantic_type] if self.semantic_type in list(range(len(SHAPE_LABELS))) else "None", + SHAPE_LABELS[self.semantic_type] if self.semantic_type in SHAPE_LABELS else "None", self.group_id, self.is_primary, self.interaction_id, diff --git a/mlreco/iotools/writers.py b/mlreco/iotools/writers.py index fabbb629..542c293d 100644 --- a/mlreco/iotools/writers.py +++ b/mlreco/iotools/writers.py @@ -7,6 +7,8 @@ from larcv import larcv from analysis import classes as analysis +from mlreco.utils.globals import SHAPE_LABELS, PID_LABELS + class HDF5Writer: ''' @@ -30,6 +32,12 @@ class HDF5Writer: 'depositions', 'depositions_MeV', 'true_depositions', 'true_depositions_MeV' ] + # Analysis object attributes to be stored as enumerators and their associated rules + ANA_ENUM = { + 'semantic_type': {v:k for k, v in SHAPE_LABELS.items()}, + 'pid': {v:k for k, v in PID_LABELS.items()} + } + # List of recognized LArCV objects LARCV_DATAOBJS = [ larcv.Particle, @@ -270,6 +278,9 @@ def get_object_dtype(self, obj): if isinstance(val, str): # String object_dtype.append((key, h5py.string_dtype())) + elif not is_larcv and key in self.ANA_ENUM: + # Known enumerator + object_dtype.append((key, h5py.enum_dtype(self.ANA_ENUM[key], basetype=type(val)))) elif np.isscalar(val): # Scalar object_dtype.append((key, type(val))) diff --git a/mlreco/utils/globals.py b/mlreco/utils/globals.py index 7df1ea71..1ea57410 100644 --- a/mlreco/utils/globals.py +++ b/mlreco/utils/globals.py @@ -38,7 +38,15 @@ SHAPE_PREC = [TRACK_SHP, MICHL_SHP, SHOWR_SHP, DELTA_SHP, LOWES_SHP] # Shape labels -SHAPE_LABELS = ['Shower', 'Track', 'Michel', 'Delta', 'Low Energy', 'Ghost', 'Unkown'] +SHAPE_LABELS = { + 0: 'Shower', + 1: 'Track', + 2: 'Michel', + 3: 'Delta', + 4: 'Low Energy', + 5: 'Ghost', + 6: 'Unknown' +} # Invalid larcv.Particle labels INVAL_ID = larcv.kINVALID_INSTANCEID # Particle group/parent/interaction ID @@ -56,16 +64,18 @@ 211: 3, # pi+ -211: 3, # pi- 2212: 4, # protons + #321: 5, # K+ + #-321: 5 # K- }) # Particle type labels PID_LABELS = { - -1: 'Unknown', 0: 'Photon', 1: 'Electron', 2: 'Muon', 3: 'Pion', - 4: 'Proton' + 4: 'Proton', + #5: 'Kaon' } # Physical constants From 7aeeb0e30181e40c055ced27db550f75677fc078 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Tue, 25 Apr 2023 02:23:22 -0700 Subject: [PATCH 157/180] Loading Particles and Interactions from HDF5, changes to data structure using python properties --- analysis/classes/Interaction.py | 206 +++++++++++++++++++++------ analysis/classes/Particle.py | 60 +++----- analysis/classes/TruthInteraction.py | 125 ++++++++++++++-- analysis/classes/TruthParticle.py | 2 - analysis/classes/adaptor.py | 13 -- analysis/classes/builders.py | 116 +++++++++++++-- analysis/classes/particle_utils.py | 50 ++++--- mlreco/iotools/writers.py | 3 +- 8 files changed, 434 insertions(+), 141 deletions(-) delete mode 100644 analysis/classes/adaptor.py diff --git a/analysis/classes/Interaction.py b/analysis/classes/Interaction.py index 6b6eb5b5..69a127ab 100644 --- a/analysis/classes/Interaction.py +++ b/analysis/classes/Interaction.py @@ -2,9 +2,10 @@ import pandas as pd from typing import Counter, List, Union -from collections import OrderedDict, Counter +from collections import OrderedDict, Counter, defaultdict from . import Particle from mlreco.utils.globals import PID_LABELS +from functools import cached_property class Interaction: @@ -43,84 +44,161 @@ def __init__(self, volume_id: int = -1, image_id: int = -1, vertex: np.ndarray = -np.ones(3, dtype=np.float32), - is_neutrino: bool = False): - - # Initialize private attributes to be set by setter only - self._particles = None + is_neutrino: bool = False, + index: np.ndarray = np.empty(0, dtype=np.int64), + points: np.ndarray = np.empty((0,3), dtype=np.float32), + depositions: np.ndarray = np.empty(0, dtype=np.float32)): # Initialize attributes - self.id = interaction_id - self.nu_id = nu_id - self.volume_id = volume_id - self.image_id = image_id + self.id = int(interaction_id) + self.nu_id = int(nu_id) + self.volume_id = int(volume_id) + self.image_id = int(image_id) self.vertex = vertex + self.is_neutrino = is_neutrino # TODO: Not implemented + + # Initialize private attributes to be set by setter only + self._particles = None + # Invoke particles setter + self.particles = particles # Aggregate individual particle information - self.particle_ids = np.empty(0, dtype=np.int64) - self.num_particles = 0 - self.num_primaries = 0 - self.index = np.empty(0, dtype=np.int64) - self.points = np.empty((0,3), dtype=np.float32) - self.depositions = np.empty(0, dtype=np.float32) - self.particles = particles - self.size = len(self.index) + if self._particles is None: + self._particle_ids = np.empty(0, dtype=np.int64) + self._num_particles = 0 + self._num_primaries = 0 + self.index = index + self.points = points + self.depositions = depositions + self._particles = particles + self.size = len(self.index) # Quantities to be set by the particle matcher self.match = np.empty(0, np.int64) self._match_counts = np.empty(0, np.float32) + + + @classmethod + def from_particles(cls, particles, verbose=False, **kwargs): + + assert len(particles) > 0 + init_args = defaultdict(list) + reserved_attributes = [ + 'interaction_id', 'nu_id', 'volume_id', + 'image_id', 'points', 'index', 'depositions' + ] + + processed_args = {'particles': []} + for key, val in kwargs.items(): + processed_args[key] = val + + for p in particles: + assert type(p) is Particle + for key in reserved_attributes: + if key not in kwargs: + init_args[key].append(getattr(p, key)) + processed_args['particles'].append(p) + + _process_interaction_attributes(init_args, processed_args, **kwargs) + + interaction = cls(**processed_args) + return interaction + def check_particle_input(self, x): """ Consistency check for particle interaction id and self.id """ - assert isinstance(x, Particle) + assert type(x) is Particle assert x.interaction_id == self.id - def update_info(self): - """ - Method for updating basic interaction particle count information. - """ - self.particle_ids = list(self._particles.keys()) - self.particle_counts = Counter({ PID_LABELS[i] : 0 for i in PID_LABELS.keys() }) - self.particle_counts.update([PID_LABELS[p.pid] for p in self._particles.values()]) - - self.primary_particle_counts = Counter({ PID_LABELS[i] : 0 for i in PID_LABELS.keys() }) - self.primary_particle_counts.update([PID_LABELS[p.pid] for p in self._particles.values() if p.is_primary]) - if sum(self.primary_particle_counts.values()) > 0: - self.is_valid = True - else: - self.is_valid = False - @property def particles(self): return self._particles @particles.setter - def particles(self, particles): + def particles(self, value): ''' list getter/setter. The setter also sets the general interaction properties ''' - self._particles = particles + + if self._particles is not None: + msg = f"Interaction {self.id} already has a populated list of "\ + "particles. You cannot change the list of particles in a "\ + "given Interaction once it has been set." + raise AttributeError(msg) - if particles is not None: + if value is not None: + self._particles = value id_list, index_list, points_list, depositions_list = [], [], [], [] - for p in particles: + for p in value: + self.check_particle_input(p) id_list.append(p.id) index_list.append(p.index) points_list.append(p.points) depositions_list.append(p.depositions) - self.num_primaries += int(p.is_primary) - self.particle_ids = np.array(id_list, dtype=np.int64) - self.num_particles = len(particles) + self._particle_ids = np.array(id_list, dtype=np.int64) + self._num_particles = len(value) + self._num_primaries = len([1 for p in value if p.is_primary]) self.index = np.concatenate(index_list) self.points = np.vstack(points_list) self.depositions = np.concatenate(depositions_list) - - self._get_particles_summary(particles) + + @property + def particle_ids(self): + return self._particle_ids + + @particle_ids.setter + def particle_ids(self, value): + # If particles exist as attribute, disallow manual assignment + assert self._particles is None + self._particle_ids = value + + @property + def particle_counts(self): + if self._particles is not None: + self._particle_counts = Counter({ PID_LABELS[i] : 0 for i in PID_LABELS.keys() }) + self._particle_counts.update([PID_LABELS[p.pid] for p in self._particles.values()]) + self._num_particles = sum(self._particle_counts.values()) + return self._particle_counts + else: + msg = "Need full list of Particle instances under "\ + " self.particles to count particles. Returning None." + print(msg) + return None + + @property + def primary_counts(self): + if self._particles is not None: + self._primary_particle_counts = Counter({ PID_LABELS[i] : 0 \ + for i in PID_LABELS.keys() }) + self._primary_particle_counts.update([PID_LABELS[p.pid] \ + for p in self._particles.values() if p.is_primary]) + self._num_primaries = sum(self._primary_particle_counts.values()) + return self._primary_particle_counts + else: + msg = "Need full list of Particle instances under "\ + "self.particles to count primary particles. Returning None." + print(msg) + return None + + @property + def num_primaries(self): + return self._num_primaries + + @property + def num_particles(self): + return self._num_particles def __getitem__(self, key): + if self._particles is None: + msg = "You can't access member particles of an interactions by "\ + "__getitem__ method if instances are missing. "\ + "Either initialize Interactions with the "\ + "constructor or manually assign particles. " + raise KeyError(msg) return self._particles[key] def __repr__(self): @@ -131,14 +209,50 @@ def __str__(self): msg = "Interaction {}, Vertex: x={:.2f}, y={:.2f}, z={:.2f}\n"\ "--------------------------------------------------------------------\n".format( self.id, self.vertex[0], self.vertex[1], self.vertex[2]) - return msg + self._particles_summary + return msg + self.particles_summary - def _get_particles_summary(self, particles): + @cached_property + def particles_summary(self): primary_str = {True: '*', False: '-'} self._particles_summary = "" - if particles is None: return - for p in sorted(particles, key=lambda x: x.is_primary, reverse=True): + if self._particles is None: return + for p in sorted(self._particles, key=lambda x: x.is_primary, reverse=True): pmsg = " {} Particle {}: PID = {}, Size = {}, Match = {} \n".format( primary_str[p.is_primary], p.id, PID_LABELS[p.pid], p.size, str(p.match)) self._particles_summary += pmsg + return self._particles_summary + + +# ------------------------------Helper Functions--------------------------- + +def _process_interaction_attributes(init_args, processed_args, **kwargs): + + # Interaction ID + if 'interaction_id' not in kwargs: + int_id, counts = np.unique(init_args['interaction_id'], + return_counts=True) + int_id = int_id[np.argsort(counts)[::-1]] + if len(int_id) > 1: + msg = "When constructing interaction {} from list of its "\ + "constituent particles, encountered non-unique interaction "\ + "id: {}".format(int_id[0], str(int_id)) + raise AssertionError(msg) + processed_args['interaction_id'] = int_id[0] + + if 'nu_id' not in kwargs: + nu_id, counts = np.unique(init_args['nu_id'], return_counts=True) + processed_args['nu_id'] = nu_id[np.argmax(counts)] + + if 'volume_id' not in kwargs: + volume_id, counts = np.unique(init_args['volume_id'], + return_counts=True) + processed_args['volume_id'] = volume_id[np.argmax(counts)] + + if 'image_id' not in kwargs: + image_id, counts = np.unique(init_args['image_id'], return_counts=True) + processed_args['image_id'] = image_id[np.argmax(counts)] + + processed_args['points'] = np.vstack(init_args['points']) + processed_args['index'] = np.concatenate(init_args['index']) + processed_args['depositions'] = np.concatenate(init_args['depositions']) \ No newline at end of file diff --git a/analysis/classes/Particle.py b/analysis/classes/Particle.py index f2074efb..f7b5a823 100644 --- a/analysis/classes/Particle.py +++ b/analysis/classes/Particle.py @@ -97,21 +97,25 @@ def __init__(self, self._primary_scores = None # Initialize attributes - self.id = group_id + self.id = int(group_id) self.fragment_ids = fragment_ids - self.interaction_id = interaction_id - self.nu_id = nu_id - self.image_id = image_id - self.volume_id = volume_id - self.semantic_type = semantic_type + self.interaction_id = int(interaction_id) + self.nu_id = int(nu_id) + self.image_id = int(image_id) + self.volume_id = int(volume_id) + self.semantic_type = int(semantic_type) self.index = index self.points = points self.depositions = depositions + self._force_pid = False + if pid > 0: + self._force_pid = True + self._pid = pid self.pid_scores = pid_scores if np.all(pid_scores < 0): - self.pid = pid + self._pid = pid self.primary_scores = primary_scores if np.all(primary_scores < 0): self.is_primary = is_primary @@ -126,13 +130,9 @@ def __init__(self, # Quantities to be set by the particle matcher self.match = kwargs.get('match', np.empty(0, np.int64)) self._match_counts = kwargs.get('match_counts', np.empty(0, np.float32)) - - # ADC to MeV - self._ADC_to_MeV = kwargs.get('ADC_to_MeV', 1./350.) - self._depositions_MeV = self.depositions * self._ADC_to_MeV def __repr__(self): - msg = "Particle(image_id={}, id={}, pid={}, size={})".format(self.image_id, self.id, self.pid, self.size) + msg = "Particle(image_id={}, id={}, pid={}, size={})".format(self.image_id, self.id, self._pid, self.size) return msg def __str__(self): @@ -140,21 +140,12 @@ def __str__(self): " | PID: {:<8} | Primary: {:<2} | Interaction ID: {:<2} | Size: {:<5} | Volume: {:<2} )" msg = fmt.format(self.image_id, self.id, SHAPE_LABELS[self.semantic_type] if self.semantic_type in list(range(len(SHAPE_LABELS))) else "None", - PID_LABELS[self.pid] if self.pid in PID_LABELS else "None", + PID_LABELS[self._pid] if self._pid in PID_LABELS else "None", self.is_primary, self.interaction_id, self.size, self.volume_id) return msg - - @property - def ADC_to_MeV(self): - return self._ADC_to_MeV - - @ADC_to_MeV.setter - def ADC_to_MeV(self, value): - assert value > 0 - self._ADC_to_MeV = value @property def num_fragments(self): @@ -184,7 +175,7 @@ def size(self): Particle size (i.e. voxel count) getter. This attribute has no setter, as it can only be set by providing a set of voxel indices. ''' - return self._size + return int(self._size) @property def index(self): @@ -207,16 +198,6 @@ def depositions_sum(self): as it can only be set by providing a set of depositions. ''' return self._depositions_sum - - @property - def depositions_MeV(self): - self._depositions_MeV = self._depositions * self._ADC_to_MeV - return self._depositions_MeV - - @depositions_MeV.setter - def depositions_MeV(self, value): - self._depositions_MeV = value - self._depositions = value * 1./ self._ADC_to_MeV @property def depositions(self): @@ -245,12 +226,17 @@ def pid_scores(self, pid_scores): # If no PID scores are providen, the PID is unknown if pid_scores[0] < 0.: self._pid_scores = pid_scores - self.pid = -1 + self._pid = -1 - # Store the PID scores and give a best guess + # Store the PID scores self._pid_scores = pid_scores - self.pid = np.argmax(pid_scores) - + if not self._force_pid: + self._pid = int(np.argmax(pid_scores)) + + @property + def pid(self): + return int(self._pid) + @property def primary_scores(self): ''' diff --git a/analysis/classes/TruthInteraction.py b/analysis/classes/TruthInteraction.py index adbde04b..25b93fb4 100644 --- a/analysis/classes/TruthInteraction.py +++ b/analysis/classes/TruthInteraction.py @@ -1,8 +1,9 @@ +from typing import List import numpy as np import pandas as pd -from collections import OrderedDict +from collections import OrderedDict, defaultdict from . import Interaction, TruthParticle - +from .Interaction import _process_interaction_attributes class TruthInteraction(Interaction): """ @@ -20,24 +21,126 @@ class TruthInteraction(Interaction): """ def __init__(self, - interaction_id, - particles, + interaction_id: int = -1, + particles: List[TruthParticle] = None, + depositions_MeV : np.ndarray = np.empty(0, dtype=np.float32), + true_index: np.ndarray = np.empty(0, dtype=np.int64), + true_points: np.ndarray = np.empty((0,3), dtype=np.float32), + true_depositions: np.ndarray = np.empty(0, dtype=np.float32), + true_depositions_MeV: np.ndarray = np.empty(0, dtype=np.float32), **kwargs): + + # Initialize private attributes to be set by setter only + self._particles = None + # Invoke particles setter + self.particles = particles + + if self._particles is None: + self._depositions_MeV = depositions_MeV + self._true_depositions = true_depositions + self._true_depositions_MeV = true_depositions_MeV + self.true_points = true_points + self.true_index = true_index + super(TruthInteraction, self).__init__(interaction_id, particles, **kwargs) - self.depositions_MeV = np.empty(0, dtype=np.float32) - if particles is not None: - depositions_MeV_list = [] - for p in particles: - depositions_MeV_list.append(p.depositions_MeV) - self.depositions_MeV = np.concatenate(depositions_MeV_list) - # Neutrino-specific information to be filled elsewhere self.nu_interaction_type = -1 self.nu_interaction_mode = -1 self.nu_current_type = -1 self.nu_energy_init = -1. + + @property + def particles(self): + return self._particles + + @particles.setter + def particles(self, value): + ''' + list getter/setter. The setter also sets + the general interaction properties + ''' + + if self._particles is not None: + msg = f"Interaction {self.id} already has a populated list of "\ + "particles. You cannot change the list of particles in a "\ + "given Interaction once it has been set." + raise AttributeError(msg) + + if value is not None: + self._particles = value + id_list, index_list, points_list, depositions_list = [], [], [], [] + true_index_list, true_points_list = [], [] + true_depositions_list, true_depositions_MeV_list = [], [] + depositions_MeV_list = [] + for p in value: + self.check_particle_input(p) + id_list.append(p.id) + index_list.append(p.index) + points_list.append(p.points) + depositions_list.append(p.depositions) + depositions_MeV_list.append(p.depositions_MeV) + true_index_list.append(p.true_index) + true_points_list.append(p.true_points) + true_depositions_list.append(p.true_depositions) + true_depositions_MeV_list.append(p.true_depositions_MeV) + + self._particle_ids = np.array(id_list, dtype=np.int64) + self._num_particles = len(value) + self._num_primaries = len([1 for p in value if p.is_primary]) + self.index = np.concatenate(index_list) + self.points = np.vstack(points_list) + self.depositions = np.concatenate(depositions_list) + self.true_points = np.concatenate(true_points_list) + self.true_index = np.concatenate(true_index_list) + self._depositions_MeV = np.concatenate(depositions_MeV_list) + self._true_depositions = np.concatenate(true_depositions_list) + self._true_depositions_MeV = np.concatenate(true_depositions_MeV_list) + + @classmethod + def from_particles(cls, particles, verbose=False, **kwargs): + + assert len(particles) > 0 + init_args = defaultdict(list) + reserved_attributes = [ + 'interaction_id', 'nu_id', 'volume_id', + 'image_id', 'points', 'index', 'depositions', 'depositions_MeV', + 'true_depositions_MeV', 'true_depositions' + ] + + processed_args = {'particles': []} + for key, val in kwargs.items(): + processed_args[key] = val + for p in particles: + assert type(p) is TruthParticle + for key in reserved_attributes: + if key not in kwargs: + init_args[key].append(getattr(p, key)) + processed_args['particles'].append(p) + + _process_interaction_attributes(init_args, processed_args, **kwargs) + + # Handle depositions_MeV for TruthParticles + processed_args['depositions_MeV'] = np.concatenate(init_args['depositions_MeV']) + processed_args['true_depositions'] = np.concatenate(init_args['true_depositions']) + processed_args['true_depositions_MeV'] = np.concatenate(init_args['true_depositions_MeV']) + + truth_interaction = cls(**processed_args) + + return truth_interaction + + @property + def depositions_MeV(self): + return self._depositions_MeV + + @property + def true_depositions(self): + return self._true_depositions + @property + def true_depositions_MeV(self): + return self._true_depositions_MeV + # @property # def particles(self): # return list(self._particles.values()) diff --git a/analysis/classes/TruthParticle.py b/analysis/classes/TruthParticle.py index 2cedfc14..dabbad47 100644 --- a/analysis/classes/TruthParticle.py +++ b/analysis/classes/TruthParticle.py @@ -90,7 +90,6 @@ def true_depositions(self): def true_depositions(self, value): assert value.shape[0] == self._true_size self._true_depositions = value - self._true_depositions_MeV = value * self._ADC_to_MeV @property def true_depositions_MeV(self): @@ -100,7 +99,6 @@ def true_depositions_MeV(self): def true_depositions_MeV(self, value): assert value.shape[0] == self._true_size self._true_depositions_MeV = value - self._true_depositions = value * 1./ self._ADC_to_MeV def is_contained(self, spatial_size): diff --git a/analysis/classes/adaptor.py b/analysis/classes/adaptor.py deleted file mode 100644 index a88becd0..00000000 --- a/analysis/classes/adaptor.py +++ /dev/null @@ -1,13 +0,0 @@ -from analysis.classes.data import * - - -class ParticleAdaptor: - - def __init__(self, meta=None): - self._meta = meta - - def cast(self, blueprint): - pass - - def make_blueprint(self, particle: Particle): - pass \ No newline at end of file diff --git a/analysis/classes/builders.py b/analysis/classes/builders.py index 10fcbe81..9c18575a 100644 --- a/analysis/classes/builders.py +++ b/analysis/classes/builders.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod from typing import List +from pprint import pprint import numpy as np from scipy.special import softmax @@ -181,9 +182,9 @@ def _load_reco(self, entry, data: dict, result: dict): List of restored particle instances built from HDF5 blueprints. """ if 'input_rescaled' in result: - point_cloud = result['input_rescaled'] + point_cloud = result['input_rescaled'][0] elif 'input_data' in data: - point_cloud = data['input_data'] + point_cloud = data['input_data'][0] else: msg = "To build Particle objects from HDF5 data, need either "\ "input_data inside data dictionary or input_rescaled inside"\ @@ -202,7 +203,7 @@ def _load_reco(self, entry, data: dict, result: dict): 'depositions': point_cloud[mask][:, VALUE_COL], }) particle = Particle(**prepared_bp) - assert particle.image_id == entry + # assert particle.image_id == entry out.append(particle) return out @@ -236,7 +237,7 @@ def _load_true(self, entry, data, result): 'particle_asis': pasis_selected }) truth_particle = TruthParticle(**prepared_bp) - assert truth_particle.image_id == entry + # assert truth_particle.image_id == entry assert truth_particle.true_size > 0 out.append(truth_particle) @@ -257,6 +258,7 @@ def _build_reco(self, out = [] # Essential Information + image_index = data['index'][entry] volume_labels = result['input_rescaled'][entry][:, BATCH_COL] point_cloud = result['input_rescaled'][entry][:, COORD_COLS] depositions = result['input_rescaled'][entry][:, 4] @@ -277,12 +279,13 @@ def _build_reco(self, volume_id, cts = np.unique(volume_labels[p], return_counts=True) volume_id = int(volume_id[cts.argmax()]) seg_label = particle_seg[i] - if seg_label == 2 or seg_label == 3: # DANGEROUS - pid = 1 + # pid = -1 + # if seg_label == 2 or seg_label == 3: # DANGEROUS + # pid = 1 interaction_id = inter_ids[i] part = Particle(group_id=i, interaction_id=interaction_id, - image_id=entry, + image_id=image_index, semantic_type=seg_label, index=p, points=point_cloud[p], @@ -310,7 +313,7 @@ def _build_true(self, """ out = [] - + image_index = data['index'][entry] labels = result['cluster_label_adapted'][entry] labels_nonghost = data['cluster_label'][entry] larcv_particles = data['particles_asis'][entry] @@ -373,7 +376,7 @@ def _build_true(self, particle = TruthParticle(group_id=id, interaction_id=int_id, nu_id=nu_id, - image_id=entry, + image_id=image_index, volume_id=volume_id, semantic_type=semantic_type, index=voxel_indices, @@ -415,15 +418,108 @@ def _build_reco(self, entry: int, data: dict, result: dict) -> List[Interaction] mode='pred') return out + def _load_reco(self, entry, data, result): + if 'input_rescaled' in result: + point_cloud = result['input_rescaled'][0] + elif 'input_data' in data: + point_cloud = data['input_data'][0] + else: + msg = "To build Particle objects from HDF5 data, need either "\ + "input_data inside data dictionary or input_rescaled inside"\ + " result dictionary." + raise KeyError(msg) + + out = [] + blueprints = result['Interactions'][0] + use_particles = 'Particles' in result + + if not use_particles: + msg = "Loading Interactions without building Particles. "\ + "This means Interaction.particles will be empty!" + print(msg) + + for i, bp in enumerate(blueprints): + info = { + 'interaction_id': bp['id'], + 'image_id': bp['image_id'], + 'is_neutrino': bp['is_neutrino'], + 'nu_id': bp['nu_id'], + 'volume_id': bp['volume_id'], + 'vertex': bp['vertex'] + } + if use_particles: + particles = [] + for p in result['Particles'][0]: + if p.interaction_id == bp['id']: + particles.append(p) + continue + ia = Interaction.from_particles(particles, + verbose=False, **info) + else: + mask = bp['index'] + info.update({ + 'index': mask, + 'points': point_cloud[mask][:, COORD_COLS], + 'depositions': point_cloud[mask][:, VALUE_COL] + }) + ia = Interaction(**info) + out.append(ia) + return out + def _build_true(self, entry: int, data: dict, result: dict) -> List[TruthInteraction]: particles = result['TruthParticles'][entry] out = group_particles_to_interactions_fn(particles, get_nu_id=True, mode='truth') - out = self.decorate_true_interactions(entry, data, out) return out + def _load_true(self, entry, data, result): + true_nonghost = data['cluster_label'][0] + pred_nonghost = result['cluster_label_adapted'][0] + + out = [] + blueprints = result['TruthInteractions'][0] + use_particles = 'TruthParticles' in result + + if not use_particles: + msg = "Loading TruthInteractions without building TruthParticles. "\ + "This means TruthInteraction.particles will be empty!" + print(msg) + + for i, bp in enumerate(blueprints): + info = { + 'interaction_id': bp['id'], + 'image_id': bp['image_id'], + 'is_neutrino': bp['is_neutrino'], + 'nu_id': bp['nu_id'], + 'volume_id': bp['volume_id'], + 'vertex': bp['vertex'] + } + if use_particles: + particles = [] + for p in result['TruthParticles'][0]: + if p.interaction_id == bp['id']: + particles.append(p) + continue + ia = TruthInteraction.from_particles(particles, + verbose=False, + **info) + else: + mask = bp['index'] + true_mask = bp['true_index'] + info.update({ + 'index': mask, + 'true_index': true_mask, + 'points': pred_nonghost[mask][:, COORD_COLS], + 'depositions': pred_nonghost[mask][:, VALUE_COL], + 'true_points': true_nonghost[true_mask][:, COORD_COLS], + 'true_depositions_MeV': true_nonghost[true_mask][:, VALUE_COL], + }) + ia = TruthInteraction(**info) + out.append(ia) + return out + def build_true_using_particles(self, entry, data, particles): out = group_particles_to_interactions_fn(particles, get_nu_id=True, diff --git a/analysis/classes/particle_utils.py b/analysis/classes/particle_utils.py index 2f8dff42..af58b871 100644 --- a/analysis/classes/particle_utils.py +++ b/analysis/classes/particle_utils.py @@ -405,31 +405,39 @@ def group_particles_to_interactions_fn(particles : List[Particle], interactions = defaultdict(list) for p in particles: interactions[p.interaction_id].append(p) - - nu_id = -1 + for int_id, particles in interactions.items(): - if get_nu_id: - nu_id = np.unique([p.nu_id for p in particles]) - if nu_id.shape[0] > 1: - if verbose: - print("Interaction {} has non-unique particle "\ - "nu_ids: {}".format(int_id, str(nu_id))) - nu_id = nu_id[0] - else: - nu_id = nu_id[0] - - counter = Counter([p.volume_id for p in particles if p.volume_id != -1]) - if not bool(counter): - volume_id = -1 - else: - volume_id = counter.most_common(1)[0][0] - particles_dict = OrderedDict({p.id : p for p in particles}) if mode == 'pred': - interactions[int_id] = Interaction(int_id, particles_dict.values(), nu_id=nu_id, volume_id=volume_id) + interactions[int_id] = Interaction.from_particles(particles) elif mode == 'truth': - interactions[int_id] = TruthInteraction(int_id, particles_dict.values(), nu_id=nu_id, volume_id=volume_id) + interactions[int_id] = TruthInteraction.from_particles(particles) else: - raise ValueError + raise ValueError(f"Unknown aggregation mode {mode}.") + + # nu_id = -1 + # for int_id, particles in interactions.items(): + # if get_nu_id: + # nu_id = np.unique([p.nu_id for p in particles]) + # if nu_id.shape[0] > 1: + # if verbose: + # print("Interaction {} has non-unique particle "\ + # "nu_ids: {}".format(int_id, str(nu_id))) + # nu_id = nu_id[0] + # else: + # nu_id = nu_id[0] + + # counter = Counter([p.volume_id for p in particles if p.volume_id != -1]) + # if not bool(counter): + # volume_id = -1 + # else: + # volume_id = counter.most_common(1)[0][0] + # particles_dict = OrderedDict({p.id : p for p in particles}) + # if mode == 'pred': + # interactions[int_id] = Interaction(int_id, particles_dict.values(), nu_id=nu_id, volume_id=volume_id) + # elif mode == 'truth': + # interactions[int_id] = TruthInteraction(int_id, particles_dict.values(), nu_id=nu_id, volume_id=volume_id) + # else: + # raise ValueError return list(interactions.values()) diff --git a/mlreco/iotools/writers.py b/mlreco/iotools/writers.py index 71c4efa7..bf7dd9c0 100644 --- a/mlreco/iotools/writers.py +++ b/mlreco/iotools/writers.py @@ -27,7 +27,8 @@ class HDF5Writer: # Analysis object attributes that do not need to be stored to HDF5 ANA_SKIP = [ 'points', 'true_points', 'particles', 'fragments', 'asis', - 'depositions', 'depositions_MeV', 'true_depositions', 'true_depositions_MeV' + 'depositions', 'depositions_MeV', 'true_depositions', 'true_depositions_MeV', + 'particles_summary' # 'index', 'true_index' ] From c66d214c7aba25723bc4d32b963123d9295fb036 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Tue, 25 Apr 2023 02:36:20 -0700 Subject: [PATCH 158/180] Manager update to include loading mode --- analysis/manager.py | 63 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/analysis/manager.py b/analysis/manager.py index afe7a530..0ee02100 100644 --- a/analysis/manager.py +++ b/analysis/manager.py @@ -243,6 +243,64 @@ def build_representations(self, data, result, mode='all'): for ltruth in lcheck_truth: assert ltruth == num_batches + + def _load_reco_reps(self, data, result): + """Load representations for reconstructed objects. + + Parameters + ---------- + data : dict + Data dictionary + result : dict + Result dictionary + + Returns + ------- + length_check: List[int] + List of integers representing the length of each data structure + from DataBuilders, used for checking validity. + """ + if 'ParticleBuilder' in self.builders: + result['Particles'] = self.builders['ParticleBuilder'].load(data, result, mode='reco') + if 'InteractionBuilder' in self.builders: + result['Interactions'] = self.builders['InteractionBuilder'].load(data, result, mode='reco') + + + def _load_truth_reps(self, data, result): + """Load representations for true objects. + + Parameters + ---------- + data : dict + Data dictionary + result : dict + Result dictionary + + Returns + ------- + length_check: List[int] + List of integers representing the length of each data structure + from DataBuilders, used for checking validity. + """ + if 'ParticleBuilder' in self.builders: + result['TruthParticles'] = self.builders['ParticleBuilder'].load(data, result, mode='truth') + if 'InteractionBuilder' in self.builders: + result['TruthInteractions'] = self.builders['InteractionBuilder'].load(data, result, mode='truth') + + + def load_representations(self, data, result, mode='all'): + if self.ana_mode is not None: + mode = self.ana_mode + if mode == 'reco': + self._load_reco_reps(data, result) + elif mode == 'truth': + self._load_truth_reps(data, result) + elif mode is None or mode == 'all': + self._load_reco_reps(data, result) + self._load_truth_reps(data, result) + else: + raise ValueError(f"DataBuilder mode {mode} is not supported!") + def initialize_flash_manager(self, meta): @@ -402,7 +460,10 @@ def step(self, iteration): start = end # 2. Build data representations - self.build_representations(data, res) + if self._reader_state == 'hdf5': + self.load_representations(data, res) + else: + self.build_representations(data, res) end = time.time() self.logger_dict['build_reps_time'] = end-start start = end From d3b986f085886f9e1ed9e2263866cc074932e480 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Tue, 25 Apr 2023 10:22:34 -0700 Subject: [PATCH 159/180] Add option to store data products at the root level in HDF5 --- mlreco/iotools/readers.py | 26 ++++++++++--- mlreco/iotools/writers.py | 80 +++++++++++++++------------------------ 2 files changed, 50 insertions(+), 56 deletions(-) diff --git a/mlreco/iotools/readers.py b/mlreco/iotools/readers.py index cf93e552..aa2ace4d 100644 --- a/mlreco/iotools/readers.py +++ b/mlreco/iotools/readers.py @@ -35,13 +35,21 @@ def __init__(self, file_keys, entry_list=[], skip_entry_list=[], to_larcv=False) self.file_paths.extend(sorted(file_paths)) # Loop over the input files, build a map from index to file ID - self.num_entries = 0 - self.file_index = [] + self.num_entries = 0 + self.file_index = [] + self.split_groups = None for i, path in enumerate(self.file_paths): with h5py.File(path, 'r') as file: + # Check that there are events in the file and the storage mode assert 'events' in file, 'File does not contain an event tree' + split_groups = 'data' in file and 'result' in file + assert self.split_groups is None or self.split_groups == split_groups,\ + 'Cannot load files with different storing schemes' + self.split_groups = split_groups + self.num_entries += len(file['events']) self.file_index.append(i*np.ones(len(file['events']), dtype=np.int32)) + print('Registered', path) self.file_index = np.concatenate(self.file_index) @@ -110,7 +118,10 @@ def get(self, idx, nested=False): for key in event.dtype.names: self.load_key(file, event, data_blob, result_blob, key, nested) - return data_blob, result_blob + if self.split_groups: + return data_blob, result_blob + else: + return dict(data_blob, **result_blob) def get_entry_list(self, entry_list, skip_entry_list): ''' @@ -169,9 +180,12 @@ def load_key(self, file, event, data_blob, result_blob, key, nested): ''' # The event-level information is a region reference: fetch it region_ref = event[key] - cat = 'data' if key in file['data'] else 'result' - blob = data_blob if cat == 'data' else result_blob - group = file[cat] + group = file + blob = result_blob + if self.split_groups: + cat = 'data' if key in file['data'] else 'result' + blob = data_blob if cat == 'data' else result_blob + group = file[cat] if isinstance(group[key], h5py.Dataset): if not group[key].dtype.names: # If the reference points at a simple dataset, return diff --git a/mlreco/iotools/writers.py b/mlreco/iotools/writers.py index 542c293d..1aa710c0 100644 --- a/mlreco/iotools/writers.py +++ b/mlreco/iotools/writers.py @@ -32,7 +32,7 @@ class HDF5Writer: 'depositions', 'depositions_MeV', 'true_depositions', 'true_depositions_MeV' ] - # Analysis object attributes to be stored as enumerators and their associated rules + # Analysis object attributes to be stored as enumerated types and their associated rules ANA_ENUM = { 'semantic_type': {v:k for k, v in SHAPE_LABELS.items()}, 'pid': {v:k for k, v in PID_LABELS.items()} @@ -65,7 +65,8 @@ def __init__(self, skip_input_keys: list = [], result_keys: list = None, skip_result_keys: list = [], - append_file: bool = False): + append_file: bool = False, + merge_groups: bool = False): ''' Initializes the basics of the output file @@ -83,6 +84,8 @@ def __init__(self, List of result keys to skip append_file: bool, default False Add new values to the end of an existing file + merge_groups: bool, default False + Merge `data` and `result` blobs in the root directory of the HDF5 file ''' # Store attributes self.file_name = file_name @@ -91,6 +94,7 @@ def __init__(self, self.result_keys = result_keys self.skip_result_keys = skip_result_keys self.append_file = append_file + self.merge_groups = merge_groups self.ready = False self.object_dtypes = {} @@ -149,37 +153,6 @@ def create(self, data_blob, result_blob=None, cfg=None): # Mark file as ready for use self.ready = True - def add_keys(self, result_blob): - ''' - Add more keys to the results group of an existing file. - - Parameters - ---------- - result_blob : dict - Dictionary containing the additional output to store - ''' - # Make sure there is something to store - assert result_blob, 'Must provide a non-empty result blob' - - # Get the expected batch_size from the data_blob (index must be present) - self.batch_size = 1 - - # Initialize a dictionary to store keys and their properties (dtype and shape) - self.key_dict = defaultdict(lambda: {'category': None, 'dtype':None, 'width':0, 'merge':False, 'scalar':False}) - - # Loop over the result_keys and add them to what needs to be tracked - self.result_keys = list(result_blob.keys()) - for key in self.result_keys: - self.register_key(result_blob, key, 'result') - - # Initialize the output HDF5 file - with h5py.File(self.file_name, 'a') as file: - # Initialize the event dataset and the corresponding reference array datasets - self.initialize_datasets(file) - - # Mark file as ready for use - self.ready = True - def register_key(self, blob, key, category): ''' Identify the dtype and shape objects to be dealt with. @@ -315,37 +288,40 @@ def initialize_datasets(self, file): self.event_dtype = [] ref_dtype = h5py.special_dtype(ref=h5py.RegionReference) for key, val in self.key_dict.items(): - cat = val['category'] - grp = file[cat] if cat in file else file.create_group(cat) + group = file + if not self.merge_groups: + cat = val['category'] + group = file[cat] if cat in file else file.create_group(cat) self.event_dtype.append((key, ref_dtype)) + if not val['merge'] and not isinstance(val['width'], list): # If the key contains a list of objects of identical shape w = val['width'] shape, maxshape = [(0, w), (None, w)] if w else [(0,), (None,)] - grp.create_dataset(key, shape, maxshape=maxshape, dtype=val['dtype']) - grp[key].attrs['scalar'] = val['scalar'] - grp[key].attrs['larcv'] = val['larcv'] + group.create_dataset(key, shape, maxshape=maxshape, dtype=val['dtype']) + group[key].attrs['scalar'] = val['scalar'] + group[key].attrs['larcv'] = val['larcv'] elif not val['merge']: # If the elements of the list are of variable widths, refer to one # dataset per element. An index is stored alongside the dataset to break # each element downstream. n_arrays = len(val['width']) - subgrp = grp.create_group(key) - subgrp.create_dataset(f'index', (0, n_arrays), maxshape=(None, n_arrays), dtype=ref_dtype) + subgroup = group.create_group(key) + subgroup.create_dataset(f'index', (0, n_arrays), maxshape=(None, n_arrays), dtype=ref_dtype) for i, w in enumerate(val['width']): shape, maxshape = [(0, w), (None, w)] if w else [(0,), (None,)] - subgrp.create_dataset(f'element_{i}', shape, maxshape=maxshape, dtype=val['dtype']) + subgroup.create_dataset(f'element_{i}', shape, maxshape=maxshape, dtype=val['dtype']) else: # If the elements of the list are of equal width, store them all # to one dataset. An index is stored alongside the dataset to break # it into individual elements downstream. - subgrp = grp.create_group(key) + subgroup = group.create_group(key) w = val['width'][0] shape, maxshape = [(0, w), (None, w)] if w else [(0,), (None,)] - subgrp.create_dataset('elements', shape, maxshape=maxshape, dtype=val['dtype']) - subgrp.create_dataset('index', (0,), maxshape=(None,), dtype=ref_dtype) + subgroup.create_dataset('elements', shape, maxshape=maxshape, dtype=val['dtype']) + subgroup.create_dataset('index', (0,), maxshape=(None,), dtype=ref_dtype) file.create_dataset('events', (0,), maxshape=(None,), dtype=self.event_dtype) @@ -405,8 +381,12 @@ def append_key(self, file, event, blob, key, batch_id): batch_id : int Batch ID to be stored ''' - val = self.key_dict[key] - cat = val['category'] + val = self.key_dict[key] + group = file + if not self.merge_groups: + cat = val['category'] + group = file[cat] + if not val['merge'] and not isinstance(val['width'], list): # Store single object if self.is_scalar(blob[key]): @@ -417,17 +397,17 @@ def append_key(self, file, event, blob, key, batch_id): obj = [obj] if val['dtype'] in self.object_dtypes.values(): - self.store_objects(file[cat], event, key, obj, val['dtype']) + self.store_objects(group, event, key, obj, val['dtype']) else: - self.store(file[cat], event, key, obj) + self.store(group, event, key, obj) elif not val['merge']: # Store the array and its reference for each element in the list - self.store_jagged(file[cat], event, key, blob[key][batch_id]) + self.store_jagged(group, event, key, blob[key][batch_id]) else: # Store one array of for all in the list and a index to break them - self.store_flat(file[cat], event, key, blob[key][batch_id]) + self.store_flat(group, event, key, blob[key][batch_id]) @staticmethod def is_scalar(obj): From 019c23d74f1329b4a32abb63e8301861cab096a8 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Tue, 25 Apr 2023 11:28:06 -0700 Subject: [PATCH 160/180] More granular skip list for HDF5 writer --- mlreco/iotools/writers.py | 59 +++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 31 deletions(-) diff --git a/mlreco/iotools/writers.py b/mlreco/iotools/writers.py index 1aa710c0..797f88ed 100644 --- a/mlreco/iotools/writers.py +++ b/mlreco/iotools/writers.py @@ -19,45 +19,42 @@ class HDF5Writer: More documentation to come. ''' - - # LArCV object attributes that do not need to be stored to HDF5 - LARCV_SKIP = [ - 'add_trajectory_point', 'dump', 'momentum', 'boundingbox_2d', 'boundingbox_3d', - *[k + a for k in ['', 'parent_', 'ancestor_'] for a in ['x', 'y', 'z', 't']] - ] - - # Analysis object attributes that do not need to be stored to HDF5 - ANA_SKIP = [ - 'index', 'true_index', 'points', 'true_points', 'particles', 'fragments', 'asis', - 'depositions', 'depositions_MeV', 'true_depositions', 'true_depositions_MeV' - ] - # Analysis object attributes to be stored as enumerated types and their associated rules ANA_ENUM = { 'semantic_type': {v:k for k, v in SHAPE_LABELS.items()}, 'pid': {v:k for k, v in PID_LABELS.items()} } - # List of recognized LArCV objects - LARCV_DATAOBJS = [ - larcv.Particle, - larcv.Neutrino, - larcv.Flash, - larcv.CRTHit + # LArCV object attributes that do not need to be stored to HDF5 + LARCV_SKIP_ATTRS = [ + 'add_trajectory_point', 'dump', 'momentum', 'boundingbox_2d', 'boundingbox_3d', + *[k + a for k in ['', 'parent_', 'ancestor_'] for a in ['x', 'y', 'z', 't']] ] - # List of recognized Analysis objects - ANA_DATAOBJS = [ - analysis.ParticleFragment, - analysis.TruthParticleFragment, - analysis.Particle, - analysis.TruthParticle, - analysis.Interaction, - analysis.TruthInteraction + LARCV_SKIP = { + larcv.Particle: LARCV_SKIP_ATTRS, + larcv.Neutrino: LARCV_SKIP_ATTRS, + larcv.Flash: LARCV_SKIP_ATTRS, + larcv.CRTHit: LARCV_SKIP_ATTRS + } + + # Analysis particle object attributes that do not need to be stored to HDF5 + ANA_SKIP_ATTRS = [ + 'points', 'true_points', 'particles', 'fragments', 'asis', + 'depositions', 'depositions_MeV', 'true_depositions', 'true_depositions_MeV' ] + ANA_SKIP = { + analysis.ParticleFragment: ANA_SKIP_ATTRS, + analysis.TruthParticleFragment: ANA_SKIP_ATTRS, + analysis.Particle: ANA_SKIP_ATTRS, + analysis.TruthParticle: ANA_SKIP_ATTRS, + analysis.Interaction: ANA_SKIP_ATTRS + ['index', 'true_index'], + analysis.TruthInteraction: ANA_SKIP_ATTRS + ['index', 'true_index'] + } + # List of recognized objects - DATAOBJS = tuple(LARCV_DATAOBJS + ANA_DATAOBJS) + DATAOBJS = tuple(list(LARCV_SKIP.keys()) + list(ANA_SKIP.keys())) def __init__(self, file_name: str = 'output.h5', @@ -193,7 +190,7 @@ def register_key(self, blob, key, category): if not object_type in self.object_dtypes: self.object_dtypes[object_type] = self.get_object_dtype(blob[key][0][0]) self.key_dict[key]['dtype'] = self.object_dtypes[object_type] - self.key_dict[key]['larcv'] = object_type in self.LARCV_DATAOBJS + self.key_dict[key]['larcv'] = object_type in self.LARCV_SKIP elif not hasattr(blob[key][0][0], '__len__'): # List containing a single list of scalars per batch ID @@ -235,8 +232,8 @@ def get_object_dtype(self, obj): ''' object_dtype = [] members = inspect.getmembers(obj) - is_larcv = type(obj) in self.LARCV_DATAOBJS - skip_keys = self.LARCV_SKIP if is_larcv else self.ANA_SKIP + is_larcv = type(obj) in self.LARCV_SKIP + skip_keys = self.LARCV_SKIP[type(obj)] if is_larcv else self.ANA_SKIP[type(obj)] attr_names = [k for k, _ in members if k[0] != '_' and k not in skip_keys] for key in attr_names: # Fetch the attribute value From 007b5cd382c89a073aa9a3f6986e90f2d11f7130 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Tue, 25 Apr 2023 13:17:13 -0700 Subject: [PATCH 161/180] Checked match_interactions and match_particles work after refactoring --- analysis/classes/Interaction.py | 33 ++++++--- analysis/classes/Particle.py | 14 +++- analysis/classes/TruthInteraction.py | 52 +++++++------- analysis/classes/TruthParticle.py | 46 ++++++------ analysis/classes/builders.py | 72 +++++++++---------- analysis/classes/evaluator.py | 28 ++++---- .../{particle_utils.py => matching.py} | 48 ++++++------- analysis/classes/predictor.py | 5 +- mlreco/iotools/writers.py | 6 +- 9 files changed, 164 insertions(+), 140 deletions(-) rename analysis/classes/{particle_utils.py => matching.py} (91%) diff --git a/analysis/classes/Interaction.py b/analysis/classes/Interaction.py index 69a127ab..af55d0bb 100644 --- a/analysis/classes/Interaction.py +++ b/analysis/classes/Interaction.py @@ -59,6 +59,7 @@ def __init__(self, # Initialize private attributes to be set by setter only self._particles = None + self._size = None # Invoke particles setter self.particles = particles @@ -71,12 +72,24 @@ def __init__(self, self.points = points self.depositions = depositions self._particles = particles - self.size = len(self.index) # Quantities to be set by the particle matcher - self.match = np.empty(0, np.int64) - self._match_counts = np.empty(0, np.float32) + # self._match = [] + self._match_counts = OrderedDict() + @property + def size(self): + if self._size is None: + self._size = len(self.index) + return self._size + + @property + def match(self): + return np.array(list(self._match_counts.keys()), dtype=np.int64) + + @property + def match_counts(self): + return np.array(list(self._match_counts.values()), dtype=np.float32) @classmethod def from_particles(cls, particles, verbose=False, **kwargs): @@ -114,7 +127,7 @@ def check_particle_input(self, x): @property def particles(self): - return self._particles + return self._particles.values() @particles.setter def particles(self, value): @@ -122,6 +135,7 @@ def particles(self, value): list getter/setter. The setter also sets the general interaction properties ''' + assert isinstance(value, list) if self._particles is not None: msg = f"Interaction {self.id} already has a populated list of "\ @@ -130,7 +144,9 @@ def particles(self, value): raise AttributeError(msg) if value is not None: - self._particles = value + self._particles = {p.id : p for p in value} + self._particle_ids = np.array(list(self._particles.keys()), + dtype=np.int64) id_list, index_list, points_list, depositions_list = [], [], [], [] for p in value: self.check_particle_input(p) @@ -139,7 +155,7 @@ def particles(self, value): points_list.append(p.points) depositions_list.append(p.depositions) - self._particle_ids = np.array(id_list, dtype=np.int64) + # self._particle_ids = np.array(id_list, dtype=np.int64) self._num_particles = len(value) self._num_primaries = len([1 for p in value if p.is_primary]) self.index = np.concatenate(index_list) @@ -202,8 +218,7 @@ def __getitem__(self, key): return self._particles[key] def __repr__(self): - return "Interaction(id={}, vertex={}, nu_id={}, Particles={})".format( - self.id, str(self.vertex), self.nu_id, str(self.particle_ids)) + return f"Interaction(id={self.id}, vertex={str(self.vertex)}, nu_id={self.nu_id}, size={self.size}, Particles={str(self.particle_ids)})" def __str__(self): msg = "Interaction {}, Vertex: x={:.2f}, y={:.2f}, z={:.2f}\n"\ @@ -217,7 +232,7 @@ def particles_summary(self): primary_str = {True: '*', False: '-'} self._particles_summary = "" if self._particles is None: return - for p in sorted(self._particles, key=lambda x: x.is_primary, reverse=True): + for p in sorted(self._particles.values(), key=lambda x: x.is_primary, reverse=True): pmsg = " {} Particle {}: PID = {}, Size = {}, Match = {} \n".format( primary_str[p.is_primary], p.id, PID_LABELS[p.pid], p.size, str(p.match)) self._particles_summary += pmsg diff --git a/analysis/classes/Particle.py b/analysis/classes/Particle.py index f7b5a823..eacdef35 100644 --- a/analysis/classes/Particle.py +++ b/analysis/classes/Particle.py @@ -2,6 +2,7 @@ import pandas as pd from typing import Counter, List, Union +from collections import OrderedDict from mlreco.utils.globals import SHAPE_LABELS, PID_LABELS @@ -128,8 +129,17 @@ def __init__(self, self.momentum_mcs = momentum_mcs # Quantities to be set by the particle matcher - self.match = kwargs.get('match', np.empty(0, np.int64)) - self._match_counts = kwargs.get('match_counts', np.empty(0, np.float32)) + self._match = list(kwargs.get('match', [])) + self._match_counts = kwargs.get('match_counts', OrderedDict()) + + @property + def match(self): + self._match = list(self._match_counts.keys()) + return np.array(self._match, dtype=np.int64) + + @property + def match_counts(self): + return np.array(self._match_counts.values(), dtype=np.float32) def __repr__(self): msg = "Particle(image_id={}, id={}, pid={}, size={})".format(self.image_id, self.id, self._pid, self.size) diff --git a/analysis/classes/TruthInteraction.py b/analysis/classes/TruthInteraction.py index 25b93fb4..44934139 100644 --- a/analysis/classes/TruthInteraction.py +++ b/analysis/classes/TruthInteraction.py @@ -24,10 +24,10 @@ def __init__(self, interaction_id: int = -1, particles: List[TruthParticle] = None, depositions_MeV : np.ndarray = np.empty(0, dtype=np.float32), - true_index: np.ndarray = np.empty(0, dtype=np.int64), - true_points: np.ndarray = np.empty((0,3), dtype=np.float32), - true_depositions: np.ndarray = np.empty(0, dtype=np.float32), - true_depositions_MeV: np.ndarray = np.empty(0, dtype=np.float32), + truth_index: np.ndarray = np.empty(0, dtype=np.int64), + truth_points: np.ndarray = np.empty((0,3), dtype=np.float32), + truth_depositions: np.ndarray = np.empty(0, dtype=np.float32), + truth_depositions_MeV: np.ndarray = np.empty(0, dtype=np.float32), **kwargs): # Initialize private attributes to be set by setter only @@ -37,10 +37,10 @@ def __init__(self, if self._particles is None: self._depositions_MeV = depositions_MeV - self._true_depositions = true_depositions - self._true_depositions_MeV = true_depositions_MeV - self.true_points = true_points - self.true_index = true_index + self._truth_depositions = truth_depositions + self._truth_depositions_MeV = truth_depositions_MeV + self.truth_points = truth_points + self.truth_index = truth_index super(TruthInteraction, self).__init__(interaction_id, particles, **kwargs) @@ -52,7 +52,7 @@ def __init__(self, @property def particles(self): - return self._particles + return self._particles.values() @particles.setter def particles(self, value): @@ -68,7 +68,7 @@ def particles(self, value): raise AttributeError(msg) if value is not None: - self._particles = value + self._particles = {p.id : p for p in value} id_list, index_list, points_list, depositions_list = [], [], [], [] true_index_list, true_points_list = [], [] true_depositions_list, true_depositions_MeV_list = [], [] @@ -80,10 +80,10 @@ def particles(self, value): points_list.append(p.points) depositions_list.append(p.depositions) depositions_MeV_list.append(p.depositions_MeV) - true_index_list.append(p.true_index) - true_points_list.append(p.true_points) - true_depositions_list.append(p.true_depositions) - true_depositions_MeV_list.append(p.true_depositions_MeV) + true_index_list.append(p.truth_index) + true_points_list.append(p.truth_points) + true_depositions_list.append(p.truth_depositions) + true_depositions_MeV_list.append(p.truth_depositions_MeV) self._particle_ids = np.array(id_list, dtype=np.int64) self._num_particles = len(value) @@ -91,11 +91,11 @@ def particles(self, value): self.index = np.concatenate(index_list) self.points = np.vstack(points_list) self.depositions = np.concatenate(depositions_list) - self.true_points = np.concatenate(true_points_list) - self.true_index = np.concatenate(true_index_list) + self.truth_points = np.concatenate(true_points_list) + self.truth_index = np.concatenate(true_index_list) self._depositions_MeV = np.concatenate(depositions_MeV_list) - self._true_depositions = np.concatenate(true_depositions_list) - self._true_depositions_MeV = np.concatenate(true_depositions_MeV_list) + self._truth_depositions = np.concatenate(true_depositions_list) + self._truth_depositions_MeV = np.concatenate(true_depositions_MeV_list) @classmethod def from_particles(cls, particles, verbose=False, **kwargs): @@ -105,7 +105,7 @@ def from_particles(cls, particles, verbose=False, **kwargs): reserved_attributes = [ 'interaction_id', 'nu_id', 'volume_id', 'image_id', 'points', 'index', 'depositions', 'depositions_MeV', - 'true_depositions_MeV', 'true_depositions' + 'truth_depositions_MeV', 'truth_depositions' ] processed_args = {'particles': []} @@ -122,8 +122,8 @@ def from_particles(cls, particles, verbose=False, **kwargs): # Handle depositions_MeV for TruthParticles processed_args['depositions_MeV'] = np.concatenate(init_args['depositions_MeV']) - processed_args['true_depositions'] = np.concatenate(init_args['true_depositions']) - processed_args['true_depositions_MeV'] = np.concatenate(init_args['true_depositions_MeV']) + processed_args['truth_depositions'] = np.concatenate(init_args['truth_depositions']) + processed_args['truth_depositions_MeV'] = np.concatenate(init_args['truth_depositions_MeV']) truth_interaction = cls(**processed_args) @@ -134,12 +134,12 @@ def depositions_MeV(self): return self._depositions_MeV @property - def true_depositions(self): - return self._true_depositions + def truth_depositions(self): + return self._truth_depositions @property - def true_depositions_MeV(self): - return self._true_depositions_MeV + def truth_depositions_MeV(self): + return self._truth_depositions_MeV # @property # def particles(self): @@ -167,6 +167,6 @@ def __repr__(self): return 'Truth'+msg def __str__(self): - msg = super(TruthInteraction, self).__repr__() + msg = super(TruthInteraction, self).__str__() return 'Truth'+msg diff --git a/analysis/classes/TruthParticle.py b/analysis/classes/TruthParticle.py index dabbad47..46046060 100644 --- a/analysis/classes/TruthParticle.py +++ b/analysis/classes/TruthParticle.py @@ -37,10 +37,10 @@ class TruthParticle(Particle): def __init__(self, *args, depositions_MeV: np.ndarray = np.empty(0, dtype=np.float32), - true_index: np.ndarray = np.empty(0, dtype=np.int64), - true_points: np.ndarray = np.empty((0,3), dtype=np.float32), - true_depositions: np.ndarray = np.empty(0, dtype=np.float32), - true_depositions_MeV: np.ndarray = np.empty(0, dtype=np.float32), + truth_index: np.ndarray = np.empty(0, dtype=np.int64), + truth_points: np.ndarray = np.empty((0,3), dtype=np.float32), + truth_depositions: np.ndarray = np.empty(0, dtype=np.float32), + truth_depositions_MeV: np.ndarray = np.empty(0, dtype=np.float32), momentum: np.ndarray = -np.ones(3, dtype=np.float32), particle_asis: object = None, **kwargs): @@ -48,11 +48,11 @@ def __init__(self, # Initialize attributes self.depositions_MeV = depositions_MeV - self.true_index = true_index - self.true_points = true_points - self._true_size = true_points.shape[0] - self._true_depositions = true_depositions # Must be ADC - self._true_depositions_MeV = true_depositions_MeV # Must be MeV + self.truth_index = truth_index + self.truth_points = truth_points + self._truth_size = truth_points.shape[0] + self._truth_depositions = truth_depositions # Must be ADC + self._truth_depositions_MeV = truth_depositions_MeV # Must be MeV if particle_asis is not None: self.start_position = particle_asis.position() self.end_position = particle_asis.end_position() @@ -79,26 +79,26 @@ def __str__(self): return 'Truth'+msg @property - def true_size(self): - return self._true_size + def truth_size(self): + return self._truth_size @property - def true_depositions(self): - return self._true_depositions + def truth_depositions(self): + return self._truth_depositions - @true_depositions.setter - def true_depositions(self, value): - assert value.shape[0] == self._true_size - self._true_depositions = value + @truth_depositions.setter + def truth_depositions(self, value): + assert value.shape[0] == self._truth_size + self._truth_depositions = value @property - def true_depositions_MeV(self): - return self._true_depositions_MeV + def truth_depositions_MeV(self): + return self._truth_depositions_MeV - @true_depositions_MeV.setter - def true_depositions_MeV(self, value): - assert value.shape[0] == self._true_size - self._true_depositions_MeV = value + @truth_depositions_MeV.setter + def truth_depositions_MeV(self, value): + assert value.shape[0] == self._truth_size + self._truth_depositions_MeV = value def is_contained(self, spatial_size): diff --git a/analysis/classes/builders.py b/analysis/classes/builders.py index 9c18575a..2b3a5744 100644 --- a/analysis/classes/builders.py +++ b/analysis/classes/builders.py @@ -22,7 +22,7 @@ TruthInteraction, ParticleFragment, TruthParticleFragment) -from analysis.classes.particle_utils import group_particles_to_interactions_fn +from analysis.classes.matching import group_particles_to_interactions_fn from mlreco.utils.vertex import get_vertex from mlreco.utils.gnn.cluster import get_cluster_label @@ -63,7 +63,7 @@ def build_image(self, entry: int, data: dict, result: dict, mode='reco'): Batch id number for the image. """ if mode == 'truth': - entities = self._build_true(entry, data, result) + entities = self._build_truth(entry, data, result) elif mode == 'reco': entities = self._build_reco(entry, data, result) else: @@ -72,7 +72,7 @@ def build_image(self, entry: int, data: dict, result: dict, mode='reco'): return entities @abstractmethod - def _build_true(self, entry, data: dict, result: dict): + def _build_truth(self, entry, data: dict, result: dict): raise NotImplementedError @abstractmethod @@ -108,7 +108,7 @@ def load_image(self, entry: int, data: dict, result: dict, mode='reco'): List of constructed entities from their HDF5 blueprints. """ if mode == 'truth': - entities = self._load_true(entry, data, result) + entities = self._load_truth(entry, data, result) elif mode == 'reco': entities = self._load_reco(entry, data, result) else: @@ -209,7 +209,7 @@ def _load_reco(self, entry, data: dict, result: dict): return out - def _load_true(self, entry, data, result): + def _load_truth(self, entry, data, result): out = [] true_nonghost = data['cluster_label'][0] particles_asis = data['particles_asis'][0] @@ -217,7 +217,7 @@ def _load_true(self, entry, data, result): blueprints = result['TruthParticles'][0] for i, bp in enumerate(blueprints): mask = bp['index'] - true_mask = bp['true_index'] + true_mask = bp['truth_index'] pasis_selected = None # Find particles_asis for pasis in particles_asis: @@ -232,13 +232,13 @@ def _load_true(self, entry, data, result): 'points': pred_nonghost[mask][:, COORD_COLS], 'depositions': pred_nonghost[mask][:, VALUE_COL], - 'true_points': true_nonghost[true_mask][:, COORD_COLS], - 'true_depositions': true_nonghost[true_mask][:, VALUE_COL], + 'truth_points': true_nonghost[true_mask][:, COORD_COLS], + 'truth_depositions': true_nonghost[true_mask][:, VALUE_COL], 'particle_asis': pasis_selected }) truth_particle = TruthParticle(**prepared_bp) # assert truth_particle.image_id == entry - assert truth_particle.true_size > 0 + assert truth_particle.truth_size > 0 out.append(truth_particle) return out @@ -300,7 +300,7 @@ def _build_reco(self, return out - def _build_true(self, + def _build_truth(self, entry: int, data: dict, result: dict) -> List[TruthParticle]: @@ -331,7 +331,7 @@ def _build_true(self, continue # Skip larcv particles with no true depositions # 1. Check if current pid is one of the existing group ids if id not in particle_ids: - particle = handle_empty_true_particles(labels_nonghost, + particle = handle_empty_truth_particles(labels_nonghost, mask_nonghost, lpart, entry) @@ -369,7 +369,7 @@ def _build_true(self, # continue # 2. Process particle-level labels - semantic_type, int_id, nu_id = get_true_particle_labels(labels, + semantic_type, int_id, nu_id = get_truth_particle_labels(labels, mask, pid=pdg) @@ -383,10 +383,10 @@ def _build_true(self, points=coords, depositions=depositions, depositions_MeV=depositions_MeV, - true_index=true_voxel_indices, - true_points=coords_noghost, - true_depositions=np.empty(0, dtype=np.float32), #TODO - true_depositions_MeV=depositions_noghost, + truth_index=true_voxel_indices, + truth_points=coords_noghost, + truth_depositions=np.empty(0, dtype=np.float32), #TODO + truth_depositions_MeV=depositions_noghost, is_primary=is_primary, pid=pdg, particle_asis=lpart) @@ -466,15 +466,15 @@ def _load_reco(self, entry, data, result): out.append(ia) return out - def _build_true(self, entry: int, data: dict, result: dict) -> List[TruthInteraction]: + def _build_truth(self, entry: int, data: dict, result: dict) -> List[TruthInteraction]: particles = result['TruthParticles'][entry] out = group_particles_to_interactions_fn(particles, get_nu_id=True, mode='truth') - out = self.decorate_true_interactions(entry, data, out) + out = self.decorate_truth_interactions(entry, data, out) return out - def _load_true(self, entry, data, result): + def _load_truth(self, entry, data, result): true_nonghost = data['cluster_label'][0] pred_nonghost = result['cluster_label_adapted'][0] @@ -507,32 +507,32 @@ def _load_true(self, entry, data, result): **info) else: mask = bp['index'] - true_mask = bp['true_index'] + true_mask = bp['truth_index'] info.update({ 'index': mask, - 'true_index': true_mask, + 'truth_index': true_mask, 'points': pred_nonghost[mask][:, COORD_COLS], 'depositions': pred_nonghost[mask][:, VALUE_COL], - 'true_points': true_nonghost[true_mask][:, COORD_COLS], - 'true_depositions_MeV': true_nonghost[true_mask][:, VALUE_COL], + 'truth_points': true_nonghost[true_mask][:, COORD_COLS], + 'truth_depositions_MeV': true_nonghost[true_mask][:, VALUE_COL], }) ia = TruthInteraction(**info) out.append(ia) return out - def build_true_using_particles(self, entry, data, particles): + def build_truth_using_particles(self, entry, data, particles): out = group_particles_to_interactions_fn(particles, get_nu_id=True, mode='truth') - out = self.decorate_true_interactions(entry, data, out) + out = self.decorate_truth_interactions(entry, data, out) return out - def decorate_true_interactions(self, entry, data, interactions): + def decorate_truth_interactions(self, entry, data, interactions): """ Helper function for attaching additional information to TruthInteraction instances. """ - vertices = self.get_true_vertices(entry, data) + vertices = self.get_truth_vertices(entry, data) for ia in interactions: if ia.id in vertices: ia.vertex = vertices[ia.id] @@ -556,7 +556,7 @@ def decorate_true_interactions(self, entry, data, interactions): return interactions - def get_true_vertices(self, entry, data: dict): + def get_truth_vertices(self, entry, data: dict): """ Helper function for retrieving true vertex information. """ @@ -690,7 +690,7 @@ def _build_reco(self, entry, return out - def _build_true(self, entry, data: dict, result: dict): + def _build_truth(self, entry, data: dict, result: dict): fragments = [] @@ -782,7 +782,7 @@ def _build_true(self, entry, data: dict, result: dict): # --------------------------Helper functions--------------------------- -def handle_empty_true_particles(labels_noghost, +def handle_empty_truth_particles(labels_noghost, mask_noghost, p, entry, @@ -818,7 +818,7 @@ def handle_empty_true_particles(labels_noghost, coords_noghost = labels_noghost[mask_noghost][:, COORD_COLS] true_voxel_indices = np.where(mask_noghost)[0] depositions_noghost = labels_noghost[mask_noghost][:, VALUE_COL].squeeze() - semantic_type, interaction_id, nu_id = get_true_particle_labels(labels_noghost, + semantic_type, interaction_id, nu_id = get_truth_particle_labels(labels_noghost, mask_noghost, pid=pid, verbose=verbose) @@ -835,10 +835,10 @@ def handle_empty_true_particles(labels_noghost, points=coords, depositions=depositions, depositions_MeV=np.empty(0, dtype=np.float32), - true_index=true_voxel_indices, - true_points=coords_noghost, - true_depositions=np.empty(0, dtype=np.float32), #TODO - true_depositions_MeV=depositions_noghost, + truth_index=true_voxel_indices, + truth_points=coords_noghost, + truth_depositions=np.empty(0, dtype=np.float32), #TODO + truth_depositions_MeV=depositions_noghost, is_primary=is_primary, pid=pdg, particle_asis=p) @@ -859,7 +859,7 @@ def handle_empty_true_particles(labels_noghost, return particle -def get_true_particle_labels(labels, mask, pid=-1, verbose=False): +def get_truth_particle_labels(labels, mask, pid=-1, verbose=False): """ Helper function for fetching true particle labels from voxel label array. diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index 206023e7..b4366733 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -2,7 +2,7 @@ import numpy as np from analysis.classes import TruthParticleFragment, TruthParticle, Interaction -from analysis.classes.particle_utils import (match_particles_fn, +from analysis.classes.matching import (match_particles_fn, match_interactions_fn, match_interactions_optimal, match_particles_optimal) @@ -56,7 +56,7 @@ def __init__(self, data_blob, result, evaluator_cfg={}, **kwargs): if self.overlap_mode == 'counts': assert self.min_overlap_count >= 0 - def build_representations(self): + def build_representations(self, mode='all'): """ Method using DataBuilders to construct high level data structures. The constructed data structures are stored inside result dict. @@ -73,18 +73,11 @@ def build_representations(self): ------- None (operation is in-place) """ - if 'Particles' not in self.result: - self.result['Particles'] = self.particle_builder.build(self.data_blob, self.result, mode='reco') - if 'TruthParticles' not in self.result: - self.result['TruthParticles'] = self.particle_builder.build(self.data_blob, self.result, mode='truth') - if 'Interactions' not in self.result: - self.result['Interactions'] = self.interaction_builder.build(self.data_blob, self.result, mode='reco') - if 'TruthInteractions' not in self.result: - self.result['TruthInteractions'] = self.interaction_builder.build(self.data_blob, self.result, mode='truth') - if 'ParticleFragments' not in self.result: - self.result['ParticleFragments'] = self.fragment_builder.build(self.data_blob, self.result, mode='reco') - if 'TruthParticleFragments' not in self.result: - self.result['TruthParticleFragments'] = self.fragment_builder.build(self.data_blob, self.result, mode='truth') + for key in self.builders: + if key not in self.result and key in self.scope: + self.result[key] = self.builders[key].build(self.data_blob, + self.result, + mode=mode) def get_true_label(self, entry, name, schema='cluster_label_adapted'): """ @@ -179,9 +172,11 @@ def get_true_particles(self, entry, if only_primaries: out_particles_list = [p for p in particles if p.is_primary] + else: + out_particles_list = [p for p in particles] + if volume is not None: - out_particles_list = [p for p in particles if p.volume == volume] - + out_particles_list = [p for p in out_particles_list if p.volume_id == volume] return out_particles_list @@ -304,6 +299,7 @@ def match_particles(self, entry, else: raise ValueError("Mode {} is not valid. For matching each"\ " prediction to truth, use 'pred_to_true' (and vice versa).".format(mode)) + all_kwargs = {"min_overlap": self.min_overlap_count, "overlap_mode": self.overlap_mode, **kwargs} if matching_mode == 'one_way': matched_pairs, counts = match_particles_fn(particles_from, particles_to, diff --git a/analysis/classes/particle_utils.py b/analysis/classes/matching.py similarity index 91% rename from analysis/classes/particle_utils.py rename to analysis/classes/matching.py index af58b871..6a36228c 100644 --- a/analysis/classes/particle_utils.py +++ b/analysis/classes/matching.py @@ -28,8 +28,8 @@ def matrix_counts(particles_x, particles_y): overlap_matrix = np.zeros((len(particles_y), len(particles_x)), dtype=np.int64) for i, py in enumerate(particles_y): for j, px in enumerate(particles_x): - overlap_matrix[i, j] = len(np.intersect1d(py.voxel_indices, - px.voxel_indices)) + overlap_matrix[i, j] = len(np.intersect1d(py.index, + px.index)) return overlap_matrix @@ -54,8 +54,8 @@ def matrix_iou(particles_x, particles_y): overlap_matrix = np.zeros((len(particles_y), len(particles_x)), dtype=np.float32) for i, py in enumerate(particles_y): for j, px in enumerate(particles_x): - cap = np.intersect1d(py.voxel_indices, px.voxel_indices) - cup = np.union1d(py.voxel_indices, px.voxel_indices) + cap = np.intersect1d(py.index, px.index) + cup = np.union1d(py.index, px.index) overlap_matrix[i, j] = float(cap.shape[0] / cup.shape[0]) return overlap_matrix @@ -91,13 +91,13 @@ def matrix_chamfer(particles_x, particles_y, mode='default'): dist = cdist(px.points, py.points) elif mode == 'true_nonghost': if type(px) == TruthParticle and type(py) == Particle: - dist = cdist(px.coords_noghost, py.points) + dist = cdist(px.truth_points, py.points) elif type(px) == Particle and type(py) == TruthParticle: - dist = cdist(px.points, py.coords_noghost) + dist = cdist(px.points, py.truth_points) elif type(px) == Particle and type(py) == Particle: dist = cdist(px.points, py.points) else: - dist = cdist(px.coords_noghost, py.coords_noghost) + dist = cdist(px.truth_points, py.truth_points) else: raise ValueError('Particle overlap computation mode {} is not implemented!'.format(mode)) loss_x = np.min(dist, axis=0) @@ -198,15 +198,15 @@ def match_particles_fn(particles_from : Union[List[Particle], List[TruthParticle matched_truth = None else: matched_truth = particles_y[select_idx] - px.match.append(matched_truth.id) + # px._match.append(matched_truth.id) px._match_counts[matched_truth.id] = intersections[j] - matched_truth.match.append(px.id) + # matched_truth._match.append(px.id) matched_truth._match_counts[px.id] = intersections[j] matches.append((px, matched_truth)) - for p in particles_y: - p.match = sorted(p.match, key=lambda x: p._match_counts[x], - reverse=True) + # for p in particles_y: + # p._match = sorted(list(p._match_counts.keys()), key=lambda x: p._match_counts[x], + # reverse=True) return matches, intersections @@ -216,14 +216,14 @@ def match_particles_optimal(particles_from : Union[List[Particle], List[TruthPar min_overlap=0, num_classes=5, verbose=False, - overlap_mode='iou', - use_true_nonghost_voxels=False): + overlap_mode='iou'): ''' Match particles so that the final resulting sum of the overlap matrix is optimal. The number of matches will be equal to length of the longer list. ''' + if len(particles_from) <= len(particles_to): particles_x, particles_y = particles_from, particles_to else: @@ -263,8 +263,8 @@ def match_particles_optimal(particles_from : Union[List[Particle], List[TruthPar else: overlap = overlap_matrix[i, j] intersections.append(overlap) - particles_y[j].match.append(particles_x[i].id) - particles_x[i].match.append(particles_y[j].id) + # particles_y[j]._match.append(particles_x[i].id) + # particles_x[i]._match.append(particles_y[j].id) particles_y[j]._match_counts[particles_x[i].id] = overlap particles_x[i]._match_counts[particles_y[j].id] = overlap match = (particles_x[i], particles_y[j]) @@ -312,16 +312,16 @@ def match_interactions_fn(ints_from : List[Interaction], matched_truth = None else: matched_truth = ints_y[select_idx] - interaction.match.append(matched_truth.id) + # interaction._match.append(matched_truth.id) interaction._match_counts[matched_truth.id] = intersections[j] - matched_truth.match.append(interaction.id) + # matched_truth._match.append(interaction.id) matched_truth._match_counts[interaction.id] = intersections[j] matches.append((interaction, matched_truth)) - for interaction in ints_y: - interaction.match = sorted(interaction.match, - key=lambda x: interaction._match_counts[x], - reverse=True) + # for interaction in ints_y: + # interaction._match = sorted(list(interaction._match_counts.keys()), + # key=lambda x: interaction._match_counts[x], + # reverse=True) return matches, intersections @@ -363,8 +363,8 @@ def match_interactions_optimal(ints_from : List[Interaction], else: overlap = overlap_matrix[i, j] intersections.append(overlap) - ints_y[j].match.append(ints_x[i].id) - ints_x[i].match.append(ints_y[j].id) + # ints_y[j]._match.append(ints_x[i].id) + # ints_x[i]._match.append(ints_y[j].id) ints_y[j]._match_counts[ints_x[i].id] = overlap ints_x[i]._match_counts[ints_y[j].id] = overlap match = (ints_x[i], ints_y[j]) diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index 22aaf372..e646e0a7 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -71,6 +71,9 @@ def __init__(self, data_blob, result, predictor_cfg={}): # Min/max boundaries in each dimension haev to be specified. self.vb = predictor_cfg.get('volume_boundaries', None) self.set_volume_boundaries() + + # Data Structure Scopes + self.scope = predictor_cfg.get('scope', ['Particles', 'Interactions']) def set_volume_boundaries(self): @@ -95,7 +98,7 @@ def set_volume_boundaries(self): def build_representations(self): for key in self.builders: - if key not in self.result: + if key not in self.result and key in self.scope: self.result[key] = self.builders[key].build(self.data_blob, self.result, mode='reco') diff --git a/mlreco/iotools/writers.py b/mlreco/iotools/writers.py index bf7dd9c0..86619246 100644 --- a/mlreco/iotools/writers.py +++ b/mlreco/iotools/writers.py @@ -26,10 +26,10 @@ class HDF5Writer: # Analysis object attributes that do not need to be stored to HDF5 ANA_SKIP = [ - 'points', 'true_points', 'particles', 'fragments', 'asis', - 'depositions', 'depositions_MeV', 'true_depositions', 'true_depositions_MeV', + 'points', 'truth_points', 'particles', 'fragments', 'asis', + 'depositions', 'depositions_MeV', 'truth_depositions', 'truth_depositions_MeV', 'particles_summary' - # 'index', 'true_index' + # 'index', 'truth_index' ] # List of recognized LArCV objects From c9b9a8187f74dc703f2b9818e66a3366e08e76b1 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Tue, 25 Apr 2023 13:46:03 -0700 Subject: [PATCH 162/180] Checked save to HDF5 -> load from HDF5 works --- analysis/classes/Particle.py | 9 ++++++++- analysis/classes/builders.py | 29 +++++++++++++++++++++++++++++ analysis/classes/evaluator.py | 2 ++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/analysis/classes/Particle.py b/analysis/classes/Particle.py index eacdef35..bee7bdb0 100644 --- a/analysis/classes/Particle.py +++ b/analysis/classes/Particle.py @@ -131,6 +131,8 @@ def __init__(self, # Quantities to be set by the particle matcher self._match = list(kwargs.get('match', [])) self._match_counts = kwargs.get('match_counts', OrderedDict()) + if not isinstance(self._match_counts, dict): + raise ValueError(f"{type(self._match_counts)}") @property def match(self): @@ -139,7 +141,12 @@ def match(self): @property def match_counts(self): - return np.array(self._match_counts.values(), dtype=np.float32) + return np.array(list(self._match_counts.values()), dtype=np.float32) + + @match_counts.setter + def match_counts(self, value): + assert type(value) is OrderedDict + self._match_counts = value def __repr__(self): msg = "Particle(image_id={}, id={}, pid={}, size={})".format(self.image_id, self.id, self._pid, self.size) diff --git a/analysis/classes/builders.py b/analysis/classes/builders.py index 2b3a5744..f5f8edd7 100644 --- a/analysis/classes/builders.py +++ b/analysis/classes/builders.py @@ -1,6 +1,7 @@ from abc import ABC, abstractmethod from typing import List from pprint import pprint +from collections import OrderedDict import numpy as np from scipy.special import softmax @@ -195,6 +196,11 @@ def _load_reco(self, entry, data: dict, result: dict): for i, bp in enumerate(blueprints): mask = bp['index'] prepared_bp = copy.deepcopy(bp) + + match = prepared_bp.pop('match', []) + match_counts = prepared_bp.pop('match_counts', []) + assert len(match) == len(match_counts) + prepared_bp.pop('depositions_sum', None) group_id = prepared_bp.pop('id', -1) prepared_bp['group_id'] = group_id @@ -203,6 +209,9 @@ def _load_reco(self, entry, data: dict, result: dict): 'depositions': point_cloud[mask][:, VALUE_COL], }) particle = Particle(**prepared_bp) + if len(match) > 0: + particle.match_counts = OrderedDict({ + key : val for key, val in zip(match, match_counts)}) # assert particle.image_id == entry out.append(particle) @@ -224,7 +233,20 @@ def _load_truth(self, entry, data, result): if pasis.id() == bp['id']: pasis_selected = pasis assert pasis_selected is not None + + # recipe = { + # 'index': mask, + # 'truth_index': true_mask, + # 'points': pred_nonghost[mask][:, COORD_COLS], + # 'depositions': pred_nonghost[mask][:, VALUE_COL], + # 'truth_points': true_nonghost[true_mask][:, COORD_COLS], + # 'truth_depositions': true_nonghost[true_mask][:, VALUE_COL], + # 'particle_asis': pasis_selected, + # 'group_id': group_id + # } + prepared_bp = copy.deepcopy(bp) + group_id = prepared_bp.pop('id', -1) prepared_bp['group_id'] = group_id prepared_bp.pop('depositions_sum', None) @@ -236,7 +258,14 @@ def _load_truth(self, entry, data, result): 'truth_depositions': true_nonghost[true_mask][:, VALUE_COL], 'particle_asis': pasis_selected }) + + match = prepared_bp.pop('match', []) + match_counts = prepared_bp.pop('match_counts', []) + truth_particle = TruthParticle(**prepared_bp) + if len(match) > 0: + truth_particle.match_counts = OrderedDict({ + key : val for key, val in zip(match, match_counts)}) # assert truth_particle.image_id == entry assert truth_particle.truth_size > 0 out.append(truth_particle) diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index b4366733..c1385c5e 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -381,6 +381,8 @@ def match_interactions(self, entry, mode='pred_to_true', if match_particles: for interactions in matched_interactions: domain, codomain = interactions + print(domain) + print(codomain) domain_particles, codomain_particles = [], [] if domain is not None: domain_particles = domain.particles From 3fdd8800eb95bc99a410becd356054def55a0551 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Tue, 25 Apr 2023 13:54:33 -0700 Subject: [PATCH 163/180] Change names from true to truth --- mlreco/iotools/writers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mlreco/iotools/writers.py b/mlreco/iotools/writers.py index 9cc527a2..3ff6b2ff 100644 --- a/mlreco/iotools/writers.py +++ b/mlreco/iotools/writers.py @@ -40,8 +40,8 @@ class HDF5Writer: # Analysis particle object attributes that do not need to be stored to HDF5 ANA_SKIP_ATTRS = [ - 'points', 'true_points', 'particles', 'fragments', 'asis', - 'depositions', 'depositions_MeV', 'true_depositions', 'true_depositions_MeV', + 'points', 'truth_points', 'particles', 'fragments', 'asis', + 'depositions', 'depositions_MeV', 'truth_depositions', 'truth_depositions_MeV', 'particles_summary' ] @@ -50,8 +50,8 @@ class HDF5Writer: analysis.TruthParticleFragment: ANA_SKIP_ATTRS, analysis.Particle: ANA_SKIP_ATTRS, analysis.TruthParticle: ANA_SKIP_ATTRS, - analysis.Interaction: ANA_SKIP_ATTRS + ['index', 'true_index'], - analysis.TruthInteraction: ANA_SKIP_ATTRS + ['index', 'true_index'] + analysis.Interaction: ANA_SKIP_ATTRS + ['index', 'truth_index'], + analysis.TruthInteraction: ANA_SKIP_ATTRS + ['index', 'truth_index'] } # List of recognized objects From 2bdbe183aaaf55a9cbc6a1c9b27c57628a620831 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Tue, 25 Apr 2023 15:25:17 -0700 Subject: [PATCH 164/180] Flash matching attributes for Interaction data structure --- analysis/classes/Interaction.py | 25 +++++++++----- analysis/classes/Particle.py | 2 +- analysis/classes/TruthInteraction.py | 33 +++++++++++-------- analysis/classes/TruthParticle.py | 10 +++--- analysis/classes/builders.py | 8 +++-- analysis/post_processing/common.py | 6 ++-- analysis/post_processing/pmt/FlashManager.py | 4 +-- .../post_processing/pmt/flash_matching.py | 16 +++++---- analysis/run.py | 3 ++ 9 files changed, 66 insertions(+), 41 deletions(-) diff --git a/analysis/classes/Interaction.py b/analysis/classes/Interaction.py index af55d0bb..37cd82dc 100644 --- a/analysis/classes/Interaction.py +++ b/analysis/classes/Interaction.py @@ -1,7 +1,6 @@ import numpy as np -import pandas as pd - from typing import Counter, List, Union +import sys from collections import OrderedDict, Counter, defaultdict from . import Particle from mlreco.utils.globals import PID_LABELS @@ -47,7 +46,11 @@ def __init__(self, is_neutrino: bool = False, index: np.ndarray = np.empty(0, dtype=np.int64), points: np.ndarray = np.empty((0,3), dtype=np.float32), - depositions: np.ndarray = np.empty(0, dtype=np.float32)): + depositions: np.ndarray = np.empty(0, dtype=np.float32), + fmatch_time: float = -float(sys.maxsize), + fmatched: bool = False, + fmatch_total_pE: float = -1, + fmatch_id: int = -1): # Initialize attributes self.id = int(interaction_id) @@ -68,15 +71,21 @@ def __init__(self, self._particle_ids = np.empty(0, dtype=np.int64) self._num_particles = 0 self._num_primaries = 0 - self.index = index - self.points = points - self.depositions = depositions + self.index = np.atleast_1d(index) + self.points = np.atleast_1d(points) + self.depositions = np.atleast_1d(depositions) self._particles = particles # Quantities to be set by the particle matcher # self._match = [] self._match_counts = OrderedDict() + # Flash matching quantities + self.fmatch_time = fmatch_time + self.fmatched = fmatched + self.fmatch_total_pE = fmatch_total_pE + self.fmatch_id = fmatch_id + @property def size(self): if self._size is None: @@ -158,9 +167,9 @@ def particles(self, value): # self._particle_ids = np.array(id_list, dtype=np.int64) self._num_particles = len(value) self._num_primaries = len([1 for p in value if p.is_primary]) - self.index = np.concatenate(index_list) + self.index = np.atleast_1d(np.concatenate(index_list)) self.points = np.vstack(points_list) - self.depositions = np.concatenate(depositions_list) + self.depositions = np.atleast_1d(np.concatenate(depositions_list)) @property def particle_ids(self): diff --git a/analysis/classes/Particle.py b/analysis/classes/Particle.py index 47771cc1..83c14ede 100644 --- a/analysis/classes/Particle.py +++ b/analysis/classes/Particle.py @@ -108,7 +108,7 @@ def __init__(self, self.index = index self.points = points - self.depositions = depositions + self.depositions = np.atleast_1d(depositions) self._force_pid = False if pid > 0: diff --git a/analysis/classes/TruthInteraction.py b/analysis/classes/TruthInteraction.py index 44934139..c540c91a 100644 --- a/analysis/classes/TruthInteraction.py +++ b/analysis/classes/TruthInteraction.py @@ -36,7 +36,7 @@ def __init__(self, self.particles = particles if self._particles is None: - self._depositions_MeV = depositions_MeV + self._depositions_MeV = depositions_MeV self._truth_depositions = truth_depositions self._truth_depositions_MeV = truth_depositions_MeV self.truth_points = truth_points @@ -85,17 +85,17 @@ def particles(self, value): true_depositions_list.append(p.truth_depositions) true_depositions_MeV_list.append(p.truth_depositions_MeV) - self._particle_ids = np.array(id_list, dtype=np.int64) - self._num_particles = len(value) - self._num_primaries = len([1 for p in value if p.is_primary]) - self.index = np.concatenate(index_list) - self.points = np.vstack(points_list) - self.depositions = np.concatenate(depositions_list) - self.truth_points = np.concatenate(true_points_list) - self.truth_index = np.concatenate(true_index_list) - self._depositions_MeV = np.concatenate(depositions_MeV_list) - self._truth_depositions = np.concatenate(true_depositions_list) - self._truth_depositions_MeV = np.concatenate(true_depositions_MeV_list) + self._particle_ids = np.array(id_list, dtype=np.int64) + self._num_particles = len(value) + self._num_primaries = len([1 for p in value if p.is_primary]) + self.index = np.atleast_1d(np.concatenate(index_list)) + self.points = np.atleast_1d(np.vstack(points_list)) + self.depositions = np.atleast_1d(np.concatenate(depositions_list)) + self.truth_points = np.atleast_1d(np.concatenate(true_points_list)) + self.truth_index = np.atleast_1d(np.concatenate(true_index_list)) + self._depositions_MeV = np.atleast_1d(np.concatenate(depositions_MeV_list)) + self._truth_depositions = np.atleast_1d(np.concatenate(true_depositions_list)) + self._truth_depositions_MeV = np.atleast_1d(np.concatenate(true_depositions_MeV_list)) @classmethod def from_particles(cls, particles, verbose=False, **kwargs): @@ -105,7 +105,7 @@ def from_particles(cls, particles, verbose=False, **kwargs): reserved_attributes = [ 'interaction_id', 'nu_id', 'volume_id', 'image_id', 'points', 'index', 'depositions', 'depositions_MeV', - 'truth_depositions_MeV', 'truth_depositions' + 'truth_depositions_MeV', 'truth_depositions', 'truth_index' ] processed_args = {'particles': []} @@ -120,8 +120,13 @@ def from_particles(cls, particles, verbose=False, **kwargs): _process_interaction_attributes(init_args, processed_args, **kwargs) + for i, t in enumerate(init_args['truth_depositions_MeV']): + if len(t.shape) == 0: + print(t, t.shape) + print(init_args['truth_index'][i]) + # Handle depositions_MeV for TruthParticles - processed_args['depositions_MeV'] = np.concatenate(init_args['depositions_MeV']) + processed_args['depositions_MeV'] = np.concatenate(init_args['depositions_MeV']) processed_args['truth_depositions'] = np.concatenate(init_args['truth_depositions']) processed_args['truth_depositions_MeV'] = np.concatenate(init_args['truth_depositions_MeV']) diff --git a/analysis/classes/TruthParticle.py b/analysis/classes/TruthParticle.py index 46046060..fc805e77 100644 --- a/analysis/classes/TruthParticle.py +++ b/analysis/classes/TruthParticle.py @@ -47,12 +47,12 @@ def __init__(self, super(TruthParticle, self).__init__(*args, **kwargs) # Initialize attributes - self.depositions_MeV = depositions_MeV + self.depositions_MeV = np.atleast_1d(depositions_MeV) self.truth_index = truth_index self.truth_points = truth_points self._truth_size = truth_points.shape[0] - self._truth_depositions = truth_depositions # Must be ADC - self._truth_depositions_MeV = truth_depositions_MeV # Must be MeV + self._truth_depositions = np.atleast_1d(truth_depositions) # Must be ADC + self._truth_depositions_MeV = np.atleast_1d(truth_depositions_MeV) # Must be MeV if particle_asis is not None: self.start_position = particle_asis.position() self.end_position = particle_asis.end_position() @@ -89,7 +89,7 @@ def truth_depositions(self): @truth_depositions.setter def truth_depositions(self, value): assert value.shape[0] == self._truth_size - self._truth_depositions = value + self._truth_depositions = np.atleast_1d(value) @property def truth_depositions_MeV(self): @@ -98,7 +98,7 @@ def truth_depositions_MeV(self): @truth_depositions_MeV.setter def truth_depositions_MeV(self, value): assert value.shape[0] == self._truth_size - self._truth_depositions_MeV = value + self._truth_depositions_MeV = np.atleast_1d(value) def is_contained(self, spatial_size): diff --git a/analysis/classes/builders.py b/analysis/classes/builders.py index f5f8edd7..6317ebb9 100644 --- a/analysis/classes/builders.py +++ b/analysis/classes/builders.py @@ -474,7 +474,11 @@ def _load_reco(self, entry, data, result): 'is_neutrino': bp['is_neutrino'], 'nu_id': bp['nu_id'], 'volume_id': bp['volume_id'], - 'vertex': bp['vertex'] + 'vertex': bp['vertex'], + 'fmatch_time': bp['fmatch_time'], + 'fmatched': bp['fmatched'], + 'fmatch_id': bp['fmatch_id'], + 'fmatch_total_pE': bp['fmatch_total_pE'] } if use_particles: particles = [] @@ -579,7 +583,7 @@ def decorate_truth_interactions(self, entry, data, interactions): # for nu in neutrinos: # if nu.mct_index() not in true_particles_track_ids: continue ia.nu_interaction_type = nu.interaction_type() - ia.nu_interation_mode = nu.interacion_mode() + ia.nu_interation_mode = nu.interaction_mode() ia.nu_current_type = nu.current_type() ia.nu_energy_init = nu.energy_init() diff --git a/analysis/post_processing/common.py b/analysis/post_processing/common.py index 38eba612..43c21726 100644 --- a/analysis/post_processing/common.py +++ b/analysis/post_processing/common.py @@ -102,9 +102,9 @@ def process_and_modify(self): assert len(val) == self._num_batches if key in self.result: msg = "Post processing script output key {} "\ - "is already in result_dict, you may want"\ - "to rename it.".format(key) - raise RuntimeError(msg) + "is already in result_dict, it will be overwritten "\ + "unless you rename it.".format(key) + # raise RuntimeError(msg) else: self.result[key] = val diff --git a/analysis/post_processing/pmt/FlashManager.py b/analysis/post_processing/pmt/FlashManager.py index 6c6368df..e40bcc8e 100644 --- a/analysis/post_processing/pmt/FlashManager.py +++ b/analysis/post_processing/pmt/FlashManager.py @@ -115,9 +115,9 @@ def _run_flash_matching(self, entry, interactions, if not hasattr(self, 'get_true_interactions'): raise Exception('This Predictor does not know about truth info.') - tpc_v = [ia for ia in interactions if volume is None or ia.volume == volume] + tpc_v = [ia for ia in interactions if volume is None or ia.volume_id == volume] else: - tpc_v = [ia for ia in interactions if volume is None or ia.volume == volume] + tpc_v = [ia for ia in interactions if volume is None or ia.volume_id == volume] if len(restrict_interactions) > 0: # by default, use all interactions tpc_v_select = [] diff --git a/analysis/post_processing/pmt/flash_matching.py b/analysis/post_processing/pmt/flash_matching.py index 96d46e3a..1f2be20b 100644 --- a/analysis/post_processing/pmt/flash_matching.py +++ b/analysis/post_processing/pmt/flash_matching.py @@ -69,21 +69,25 @@ def run_flash_matching(data_dict, result_dict, for ia, flash, match in fmatches_E: flash_dict_E[ia.id] = (flash, match) ia.fmatched = True - ia.fmatch_time = flash.time() - ia.fmatch_total_pE = flash.TotalPE() - ia.fmatch_id = flash.id() + ia.fmatch_time = float(flash.time()) + ia.fmatch_total_pE = float(flash.TotalPE()) + ia.fmatch_id = int(flash.id()) + update_dict['Interactions'].append(ia) update_dict['flash_matches_cryoE'].append(flash_dict_E) flash_dict_W = {} for ia, flash, match in fmatches_W: flash_dict_W[ia.id] = (flash, match) ia.fmatched = True - ia.fmatch_time = flash.time() - ia.fmatch_total_pE = flash.TotalPE() - ia.fmatch_id = flash.id() + ia.fmatch_time = float(flash.time()) + ia.fmatch_total_pE = float(flash.TotalPE()) + ia.fmatch_id = int(flash.id()) + update_dict['Interactions'].append(ia) update_dict['flash_matches_cryoW'].append(flash_dict_W) assert len(update_dict['flash_matches_cryoE'])\ == len(update_dict['flash_matches_cryoW']) + + # print(update_dict) return update_dict \ No newline at end of file diff --git a/analysis/run.py b/analysis/run.py index 1241b549..901d0eb7 100644 --- a/analysis/run.py +++ b/analysis/run.py @@ -23,6 +23,9 @@ def main(analysis_cfg_path, model_cfg_path=None): analysis_config = yaml.safe_load(open(analysis_cfg_path, 'r')) + if 'chain_config' in analysis_config['analysis']: + if model_cfg_path is None: + model_cfg_path = analysis_config['analysis']['chain_config'] config = None if model_cfg_path is not None: config = yaml.safe_load(open(model_cfg_path, 'r')) From 93bb66332f171fa81ecb213d7aeb8abfcf5e66f3 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 26 Apr 2023 04:46:54 -0700 Subject: [PATCH 165/180] Fixed particle_counts, momentum_range, direction, flash match attribute names --- analysis/classes/Interaction.py | 46 +++++++------------ analysis/classes/builders.py | 4 +- .../reconstruction/calorimetry.py | 4 +- .../reconstruction/geometry.py | 3 +- 4 files changed, 24 insertions(+), 33 deletions(-) diff --git a/analysis/classes/Interaction.py b/analysis/classes/Interaction.py index 37cd82dc..3e4b2313 100644 --- a/analysis/classes/Interaction.py +++ b/analysis/classes/Interaction.py @@ -47,10 +47,10 @@ def __init__(self, index: np.ndarray = np.empty(0, dtype=np.int64), points: np.ndarray = np.empty((0,3), dtype=np.float32), depositions: np.ndarray = np.empty(0, dtype=np.float32), - fmatch_time: float = -float(sys.maxsize), + flash_time: float = -float(sys.maxsize), fmatched: bool = False, - fmatch_total_pE: float = -1, - fmatch_id: int = -1): + flash_total_pE: float = -1, + flash_id: int = -1): # Initialize attributes self.id = int(interaction_id) @@ -64,6 +64,8 @@ def __init__(self, self._particles = None self._size = None # Invoke particles setter + self._particle_counts = np.zeros(6, dtype=np.int64) + self._primary_counts = np.zeros(6, dtype=np.int64) self.particles = particles # Aggregate individual particle information @@ -81,10 +83,10 @@ def __init__(self, self._match_counts = OrderedDict() # Flash matching quantities - self.fmatch_time = fmatch_time - self.fmatched = fmatched - self.fmatch_total_pE = fmatch_total_pE - self.fmatch_id = fmatch_id + self.flash_time = flash_time + self.fmatched = fmatched + self.flash_total_pE = flash_total_pE + self.flash_id = flash_id @property def size(self): @@ -163,6 +165,12 @@ def particles(self, value): index_list.append(p.index) points_list.append(p.points) depositions_list.append(p.depositions) + if p.pid >= 0: + self._particle_counts[p.pid] += 1 + self._primary_counts[p.pid] += int(p.is_primary) + else: + self._particle_counts[-1] += 1 + self._primary_counts[-1] += int(p.is_primary) # self._particle_ids = np.array(id_list, dtype=np.int64) self._num_particles = len(value) @@ -183,31 +191,11 @@ def particle_ids(self, value): @property def particle_counts(self): - if self._particles is not None: - self._particle_counts = Counter({ PID_LABELS[i] : 0 for i in PID_LABELS.keys() }) - self._particle_counts.update([PID_LABELS[p.pid] for p in self._particles.values()]) - self._num_particles = sum(self._particle_counts.values()) - return self._particle_counts - else: - msg = "Need full list of Particle instances under "\ - " self.particles to count particles. Returning None." - print(msg) - return None + return self._particle_counts @property def primary_counts(self): - if self._particles is not None: - self._primary_particle_counts = Counter({ PID_LABELS[i] : 0 \ - for i in PID_LABELS.keys() }) - self._primary_particle_counts.update([PID_LABELS[p.pid] \ - for p in self._particles.values() if p.is_primary]) - self._num_primaries = sum(self._primary_particle_counts.values()) - return self._primary_particle_counts - else: - msg = "Need full list of Particle instances under "\ - "self.particles to count primary particles. Returning None." - print(msg) - return None + return self._primary_counts @property def num_primaries(self): diff --git a/analysis/classes/builders.py b/analysis/classes/builders.py index 6317ebb9..d7780b75 100644 --- a/analysis/classes/builders.py +++ b/analysis/classes/builders.py @@ -298,8 +298,8 @@ def _build_reco(self, particle_end_points = result['particle_end_points'][entry][:, COORD_COLS] inter_ids = result['particle_group_pred'][entry] - type_logits = result['particle_node_pred_type'][entry] - primary_logits = result['particle_node_pred_vtx'][entry] + type_logits = result['particle_node_pred_type'][entry] + primary_logits = result['particle_node_pred_vtx'][entry] pid_scores = softmax(type_logits, axis=1) primary_scores = softmax(primary_logits, axis=1) diff --git a/analysis/post_processing/reconstruction/calorimetry.py b/analysis/post_processing/reconstruction/calorimetry.py index 583faca6..08debdb4 100644 --- a/analysis/post_processing/reconstruction/calorimetry.py +++ b/analysis/post_processing/reconstruction/calorimetry.py @@ -49,7 +49,8 @@ def calorimetric_energy(data_dict, result_capture=['particle_clusts', 'particle_seg', 'input_rescaled', - 'particle_node_pred_type']) + 'particle_node_pred_type', + 'Particles']) def range_based_track_energy(data_dict, result_dict, bin_size=17, include_pids=[2, 3, 4], table_path=''): """Compute track energy by the CSDA (continuous slowing-down approximation) @@ -104,6 +105,7 @@ def range_based_track_energy(data_dict, result_dict, length = compute_track_length(points, bin_size=bin_size) particle_length[i] = length particle_energy[i] = splines[pred_ptypes[i]](length * PIXELS_TO_CM) + result_dict['Particles'][i].momentum_range = particle_energy[i] update_dict['particle_length'] = particle_length update_dict['particle_range_based_energy'] = particle_energy diff --git a/analysis/post_processing/reconstruction/geometry.py b/analysis/post_processing/reconstruction/geometry.py index d8e6603b..219f3405 100644 --- a/analysis/post_processing/reconstruction/geometry.py +++ b/analysis/post_processing/reconstruction/geometry.py @@ -37,6 +37,7 @@ def particle_direction(data_dict, } for i, p in enumerate(result_dict['Particles']): - p.direction = update_dict['particle_start_directions'][i] + p.start_dir = update_dict['particle_start_directions'][i] + p.end_dir = update_dict['particle_end_directions'][i] return update_dict From 45684ffc54d139a5faef8a316e4bc6790ca924ae Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 26 Apr 2023 09:40:33 -0700 Subject: [PATCH 166/180] Interaction/Particle matching in post-processors --- analysis/classes/evaluator.py | 2 - analysis/post_processing/__init__.py | 3 +- .../post_processing/evaluation/__init__.py | 1 + analysis/post_processing/evaluation/match.py | 125 ++++++++++++++++++ 4 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 analysis/post_processing/evaluation/__init__.py create mode 100644 analysis/post_processing/evaluation/match.py diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index c1385c5e..b4366733 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -381,8 +381,6 @@ def match_interactions(self, entry, mode='pred_to_true', if match_particles: for interactions in matched_interactions: domain, codomain = interactions - print(domain) - print(codomain) domain_particles, codomain_particles = [], [] if domain is not None: domain_particles = domain.particles diff --git a/analysis/post_processing/__init__.py b/analysis/post_processing/__init__.py index 89bdfc2f..ad3b7b4c 100644 --- a/analysis/post_processing/__init__.py +++ b/analysis/post_processing/__init__.py @@ -1,3 +1,4 @@ from .decorator import post_processing from .reconstruction import * -from .pmt import * \ No newline at end of file +from .pmt import * +from .evaluation import * \ No newline at end of file diff --git a/analysis/post_processing/evaluation/__init__.py b/analysis/post_processing/evaluation/__init__.py new file mode 100644 index 00000000..dec2ae79 --- /dev/null +++ b/analysis/post_processing/evaluation/__init__.py @@ -0,0 +1 @@ +from .match import match_interactions \ No newline at end of file diff --git a/analysis/post_processing/evaluation/match.py b/analysis/post_processing/evaluation/match.py new file mode 100644 index 00000000..79a5d597 --- /dev/null +++ b/analysis/post_processing/evaluation/match.py @@ -0,0 +1,125 @@ +import numpy as np +from pprint import pprint + +from analysis.post_processing import post_processing +from mlreco.utils.globals import * +from analysis.classes.matching import (match_particles_fn, + match_interactions_fn, + match_interactions_optimal, + match_particles_optimal) +from analysis.classes.data import * + +@post_processing(data_capture=['index'], + result_capture=['Particles', + 'TruthParticles', + 'Interactions', + 'TruthInteractions']) +def match_interactions(data_dict, + result_dict, + matching_mode='optimal', + matching_direction='pred_to_true', + match_particles=True, + min_overlap=0, + overlap_mode='iou'): + + pred_interactions = result_dict['Interactions'] + true_interactions = result_dict['TruthInteractions'] + + if matching_mode == 'optimal': + matched_interactions, counts = match_interactions_optimal( + pred_interactions, + true_interactions, + min_overlap=min_overlap, + overlap_mode=overlap_mode) + + if matching_mode == 'one_way': + if matching_direction == 'pred_to_true': + matched_interactions, counts = match_interactions_fn( + pred_interactions, + true_interactions, + min_overlap=min_overlap, + overlap_mode=overlap_mode) + elif matching_direction == 'true_to_pred': + matched_interactions, counts = match_interactions_fn( + true_interactions, + pred_interactions, + min_overlap=min_overlap, + overlap_mode=overlap_mode) + + if match_particles: + for interactions in matched_interactions: + domain, codomain = interactions + domain_particles, codomain_particles = [], [] + if domain is not None: + domain_particles = domain.particles + if codomain is not None: + codomain_particles = codomain.particles + domain_particles = [p for p in domain_particles if p.points.shape[0] > 0] + codomain_particles = [p for p in codomain_particles if p.points.shape[0] > 0] + if matching_mode == 'one_way': + matched_particles, _ = match_particles_fn(domain_particles, + codomain_particles, + min_overlap=min_overlap, + overlap_mode=overlap_mode) + elif matching_mode == 'optimal': + matched_particles, _ = match_particles_optimal(domain_particles, + codomain_particles, + min_overlap=min_overlap, + overlap_mode=overlap_mode) + else: + raise ValueError(f"Particle matching mode {matching_mode} is not supported!") + + pmatches, pcounts = match_parts_within_ints(matched_interactions) + + update_dict = { + 'matched_interactions': matched_interactions, + 'matched_particles': matched_particles, + 'interaction_match_values': counts, + 'particle_match_values': pcounts + } + + return update_dict + + +# ----------------------------- Helper functions ------------------------------- + +def match_parts_within_ints(int_matches): + ''' + Given list of matches Tuple[(Truth)Interaction, (Truth)Interaction], + return list of particle matches Tuple[TruthParticle, Particle]. + + This means rather than matching all predicted particles againts + all true particles, it has an additional constraint that only + particles within a matched interaction pair can be considered + for matching. + ''' + + matched_particles, match_counts = [], [] + + for m in int_matches: + ia1, ia2 = m[0], m[1] + num_parts_1, num_parts_2 = -1, -1 + if m[0] is not None: + num_parts_1 = len(m[0].particles) + if m[1] is not None: + num_parts_2 = len(m[1].particles) + if num_parts_1 <= num_parts_2: + ia1, ia2 = m[0], m[1] + else: + ia1, ia2 = m[1], m[0] + + for p in ia2.particles: + if len(p.match) == 0: + if type(p) is Particle: + matched_particles.append((None, p)) + match_counts.append(-1) + else: + matched_particles.append((p, None)) + match_counts.append(-1) + for match_id in p.match: + if type(p) is Particle: + matched_particles.append((ia1[match_id], p)) + else: + matched_particles.append((p, ia1[match_id])) + match_counts.append(p._match_counts[match_id]) + return matched_particles, np.array(match_counts) \ No newline at end of file From c45d3d176c26e339461702722ca7dee3bc64eda4 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 26 Apr 2023 11:06:27 -0700 Subject: [PATCH 167/180] Changed dict keys to lowercase, fixed loader error when data structures don't exist inside hdf5 --- analysis/README.md | 12 +++--- analysis/classes/builders.py | 27 ++++++------ analysis/classes/evaluator.py | 8 ++-- analysis/classes/predictor.py | 12 +++--- analysis/manager.py | 41 ++++++++++++------- analysis/post_processing/evaluation/match.py | 12 +++--- .../post_processing/pmt/flash_matching.py | 8 ++-- .../reconstruction/calorimetry.py | 4 +- .../reconstruction/geometry.py | 4 +- .../post_processing/reconstruction/ppn.py | 4 +- .../post_processing/reconstruction/vertex.py | 4 +- analysis/producers/arxiv/example_nue.py | 8 ++-- analysis/producers/logger.py | 12 +++--- 13 files changed, 83 insertions(+), 73 deletions(-) diff --git a/analysis/README.md b/analysis/README.md index 03a373f1..7fb92850 100644 --- a/analysis/README.md +++ b/analysis/README.md @@ -97,12 +97,12 @@ manager.build_representation(data, result) # This will save 'Particle' and 'Inte ```python from analysis.classes.builders import ParticleBuilder particle_builder = ParticleBuilder() -result['Particles'] = particle_builder.build(data, result, mode='reco') -result['TruthParticles'] = particle_builder.build(data, result, mode='truth') +result['particles'] = particle_builder.build(data, result, mode='reco') +result['truth_particles'] = particle_builder.build(data, result, mode='truth') ``` We can try printing out the third particle in the first image: ```python -print(result['Particles'][0][3]) +print(result['particles'][0][3]) ----------------------------- Particle( Image ID=0 | Particle ID=3 | Semantic_type: Shower Fragment | PID: Electron | Primary: 1 | Interaction ID: 3 | Size: 302 | Volume: 0 ) ``` @@ -112,12 +112,12 @@ We may further organize information by aggregating particles the same interactio ```python from analysis.classes.builders import InteractionBuilder interaction_builder = InteractionBuilder() -result['Interactions'] = interaction_builder.build(data, result, mode='reco') -result['TruthInteractions'] = interaction_builder.build(data, result, mode='truth') +result['interactions'] = interaction_builder.build(data, result, mode='reco') +result['truth_interactions'] = interaction_builder.build(data, result, mode='truth') ``` Since `Interactions` are built using `Particle` instances, one has to build `Particles` first to build `Interactions`. ```python -for ia in result['Interactions'][0]: +for ia in result['interactions'][0]: print(ia) ----------------------------- Interaction 4, Vertex: x=-1.00, y=-1.00, z=-1.00 diff --git a/analysis/classes/builders.py b/analysis/classes/builders.py index d7780b75..93eb6802 100644 --- a/analysis/classes/builders.py +++ b/analysis/classes/builders.py @@ -25,7 +25,6 @@ TruthParticleFragment) from analysis.classes.matching import group_particles_to_interactions_fn from mlreco.utils.vertex import get_vertex -from mlreco.utils.gnn.cluster import get_cluster_label class DataBuilder(ABC): """Abstract base class for building all data structures @@ -192,7 +191,7 @@ def _load_reco(self, entry, data: dict, result: dict): " result dictionary." raise KeyError(msg) out = [] - blueprints = result['Particles'][0] + blueprints = result['particles'][0] for i, bp in enumerate(blueprints): mask = bp['index'] prepared_bp = copy.deepcopy(bp) @@ -223,7 +222,7 @@ def _load_truth(self, entry, data, result): true_nonghost = data['cluster_label'][0] particles_asis = data['particles_asis'][0] pred_nonghost = result['cluster_label_adapted'][0] - blueprints = result['TruthParticles'][0] + blueprints = result['truth_particles'][0] for i, bp in enumerate(blueprints): mask = bp['index'] true_mask = bp['truth_index'] @@ -441,7 +440,7 @@ def __init__(self, builder_cfg={}): self.cfg = builder_cfg def _build_reco(self, entry: int, data: dict, result: dict) -> List[Interaction]: - particles = result['Particles'][entry] + particles = result['particles'][entry] out = group_particles_to_interactions_fn(particles, get_nu_id=True, mode='pred') @@ -459,8 +458,8 @@ def _load_reco(self, entry, data, result): raise KeyError(msg) out = [] - blueprints = result['Interactions'][0] - use_particles = 'Particles' in result + blueprints = result['interactions'][0] + use_particles = 'particles' in result if not use_particles: msg = "Loading Interactions without building Particles. "\ @@ -475,14 +474,14 @@ def _load_reco(self, entry, data, result): 'nu_id': bp['nu_id'], 'volume_id': bp['volume_id'], 'vertex': bp['vertex'], - 'fmatch_time': bp['fmatch_time'], + 'flash_time': bp['flash_time'], 'fmatched': bp['fmatched'], - 'fmatch_id': bp['fmatch_id'], - 'fmatch_total_pE': bp['fmatch_total_pE'] + 'flash_id': bp['flash_id'], + 'flash_total_pE': bp['flash_total_pE'] } if use_particles: particles = [] - for p in result['Particles'][0]: + for p in result['particles'][0]: if p.interaction_id == bp['id']: particles.append(p) continue @@ -500,7 +499,7 @@ def _load_reco(self, entry, data, result): return out def _build_truth(self, entry: int, data: dict, result: dict) -> List[TruthInteraction]: - particles = result['TruthParticles'][entry] + particles = result['truth_particles'][entry] out = group_particles_to_interactions_fn(particles, get_nu_id=True, mode='truth') @@ -512,8 +511,8 @@ def _load_truth(self, entry, data, result): pred_nonghost = result['cluster_label_adapted'][0] out = [] - blueprints = result['TruthInteractions'][0] - use_particles = 'TruthParticles' in result + blueprints = result['truth_interactions'][0] + use_particles = 'truth_particles' in result if not use_particles: msg = "Loading TruthInteractions without building TruthParticles. "\ @@ -531,7 +530,7 @@ def _load_truth(self, entry, data, result): } if use_particles: particles = [] - for p in result['TruthParticles'][0]: + for p in result['truth_particles'][0]: if p.interaction_id == bp['id']: particles.append(p) continue diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index b4366733..7de63496 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -64,9 +64,9 @@ def build_representations(self, mode='all'): Will not build data structures if the key corresponding to the data structure class is already contained in the result dictionary. - For example, if result['Particles'] exists and contains lists of + For example, if result['particles'] exists and contains lists of reconstructed instances, then methods inside the - Evaluator will use the already existing result['Particles'] + Evaluator will use the already existing result['particles'] rather than building new lists from scratch. Returns @@ -168,7 +168,7 @@ def get_true_particles(self, entry, List of TruthParticles in image # ''' out_particles_list = [] - particles = self.result['TruthParticles'][entry] + particles = self.result['truth_particles'][entry] if only_primaries: out_particles_list = [p for p in particles if p.is_primary] @@ -201,7 +201,7 @@ def get_true_interactions(self, entry) -> List[Interaction]: out: List[Interaction] List of TruthInteraction in image # ''' - out = self.result['TruthInteractions'][entry] + out = self.result['truth_interactions'][entry] return out diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index e646e0a7..89815a45 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -43,12 +43,12 @@ def __init__(self, data_blob, result, predictor_cfg={}): self.interaction_builder = InteractionBuilder() self.fragment_builder = FragmentBuilder() - build_reps = predictor_cfg.get('build_reps', ['Particles', 'Interactions']) + build_reps = predictor_cfg.get('build_reps', ['particles', 'interactions']) self.builders = {} for key in build_reps: - if key == 'Particles': + if key == 'particles': self.builders[key] = ParticleBuilder() - if key == 'Interactions': + if key == 'interactions': self.builders[key] = InteractionBuilder() if key == 'Fragments': self.builders[key] = FragmentBuilder() @@ -73,7 +73,7 @@ def __init__(self, data_blob, result, predictor_cfg={}): self.set_volume_boundaries() # Data Structure Scopes - self.scope = predictor_cfg.get('scope', ['Particles', 'Interactions']) + self.scope = predictor_cfg.get('scope', ['particles', 'interactions']) def set_volume_boundaries(self): @@ -432,7 +432,7 @@ def get_particles(self, entry, only_primaries=False, volume=None) -> List[Partic List of instances (see Particle class definition). ''' self._check_volume(volume) - out = self.result['Particles'][entry] + out = self.result['particles'][entry] out = self._decorate_particles(entry, out, only_primaries=only_primaries, volume=volume) @@ -496,7 +496,7 @@ def get_interactions(self, entry, Returns: - out: List of instances (see particle.Interaction). ''' - out = self.result['Interactions'][entry] + out = self.result['interactions'][entry] return out diff --git a/analysis/manager.py b/analysis/manager.py index 0ee02100..b215926e 100644 --- a/analysis/manager.py +++ b/analysis/manager.py @@ -170,11 +170,11 @@ def _build_reco_reps(self, data, result): """ length_check = [] if 'ParticleBuilder' in self.builders: - result['Particles'] = self.builders['ParticleBuilder'].build(data, result, mode='reco') - length_check.append(len(result['Particles'])) + result['particles'] = self.builders['ParticleBuilder'].build(data, result, mode='reco') + length_check.append(len(result['particles'])) if 'InteractionBuilder' in self.builders: - result['Interactions'] = self.builders['InteractionBuilder'].build(data, result, mode='reco') - length_check.append(len(result['Interactions'])) + result['interactions'] = self.builders['InteractionBuilder'].build(data, result, mode='reco') + length_check.append(len(result['interactions'])) if 'FragmentBuilder' in self.builders: result['ParticleFragments'] = self.builders['FragmentBuilder'].build(data, result, mode='reco') length_check.append(len(result['ParticleFragments'])) @@ -199,11 +199,11 @@ def _build_truth_reps(self, data, result): """ length_check = [] if 'ParticleBuilder' in self.builders: - result['TruthParticles'] = self.builders['ParticleBuilder'].build(data, result, mode='truth') - length_check.append(len(result['TruthParticles'])) + result['truth_particles'] = self.builders['ParticleBuilder'].build(data, result, mode='truth') + length_check.append(len(result['truth_particles'])) if 'InteractionBuilder' in self.builders: - result['TruthInteractions'] = self.builders['InteractionBuilder'].build(data, result, mode='truth') - length_check.append(len(result['TruthInteractions'])) + result['truth_interactions'] = self.builders['InteractionBuilder'].build(data, result, mode='truth') + length_check.append(len(result['truth_interactions'])) if 'FragmentBuilder' in self.builders: result['TruthParticleFragments'] = self.builders['FragmentBuilder'].build(data, result, mode='truth') length_check.append(len(result['TruthParticleFragments'])) @@ -261,10 +261,15 @@ def _load_reco_reps(self, data, result): from DataBuilders, used for checking validity. """ if 'ParticleBuilder' in self.builders: - result['Particles'] = self.builders['ParticleBuilder'].load(data, result, mode='reco') + if 'particles' not in result: + result['particles'] = self.builders['ParticleBuilder'].build(data, result, mode='reco') + else: + result['particles'] = self.builders['ParticleBuilder'].load(data, result, mode='reco') if 'InteractionBuilder' in self.builders: - result['Interactions'] = self.builders['InteractionBuilder'].load(data, result, mode='reco') - + if 'interactions' not in result: + result['interactions'] = self.builders['InteractionBuilder'].build(data, result, mode='reco') + else: + result['interactions'] = self.builders['InteractionBuilder'].load(data, result, mode='reco') def _load_truth_reps(self, data, result): """Load representations for true objects. @@ -283,10 +288,15 @@ def _load_truth_reps(self, data, result): from DataBuilders, used for checking validity. """ if 'ParticleBuilder' in self.builders: - result['TruthParticles'] = self.builders['ParticleBuilder'].load(data, result, mode='truth') + if 'truth_particles' not in result: + result['truth_particles'] = self.builders['ParticleBuilder'].build(data, result, mode='truth') + else: + result['truth_particles'] = self.builders['ParticleBuilder'].load(data, result, mode='truth') if 'InteractionBuilder' in self.builders: - result['TruthInteractions'] = self.builders['InteractionBuilder'].load(data, result, mode='truth') - + if 'truth_interactions' not in result: + result['truth_interactions'] = self.builders['InteractionBuilder'].build(data, result, mode='truth') + else: + result['truth_interactions'] = self.builders['InteractionBuilder'].load(data, result, mode='truth') def load_representations(self, data, result, mode='all'): if self.ana_mode is not None: @@ -335,7 +345,8 @@ def run_post_processing(self, data, result): if 'post_processing' in self.ana_config: meta = data['meta'][0] - self.initialize_flash_manager(meta) + if 'run_flash_matching' in self.ana_config['post_processing']: + self.initialize_flash_manager(meta) post_processor_interface = PostProcessor(data, result) # Gather post processing functions, register by priority diff --git a/analysis/post_processing/evaluation/match.py b/analysis/post_processing/evaluation/match.py index 79a5d597..9100e6a7 100644 --- a/analysis/post_processing/evaluation/match.py +++ b/analysis/post_processing/evaluation/match.py @@ -10,10 +10,10 @@ from analysis.classes.data import * @post_processing(data_capture=['index'], - result_capture=['Particles', - 'TruthParticles', - 'Interactions', - 'TruthInteractions']) + result_capture=['particles', + 'truth_particles', + 'interactions', + 'truth_interactions']) def match_interactions(data_dict, result_dict, matching_mode='optimal', @@ -22,8 +22,8 @@ def match_interactions(data_dict, min_overlap=0, overlap_mode='iou'): - pred_interactions = result_dict['Interactions'] - true_interactions = result_dict['TruthInteractions'] + pred_interactions = result_dict['interactions'] + true_interactions = result_dict['truth_interactions'] if matching_mode == 'optimal': matched_interactions, counts = match_interactions_optimal( diff --git a/analysis/post_processing/pmt/flash_matching.py b/analysis/post_processing/pmt/flash_matching.py index 1f2be20b..df424b16 100644 --- a/analysis/post_processing/pmt/flash_matching.py +++ b/analysis/post_processing/pmt/flash_matching.py @@ -5,7 +5,7 @@ from .filters import filter_opflashes @post_processing(data_capture=['meta', 'index', 'opflash_cryoE', 'opflash_cryoW'], - result_capture=['Interactions']) + result_capture=['interactions']) def run_flash_matching(data_dict, result_dict, fm=None, opflash_keys=[]): @@ -47,7 +47,7 @@ def run_flash_matching(data_dict, result_dict, update_dict = {} - interactions = result_dict['Interactions'] + interactions = result_dict['interactions'] entry = data_dict['index'] # opflashes = filter_opflashes(opflashes) @@ -72,7 +72,7 @@ def run_flash_matching(data_dict, result_dict, ia.fmatch_time = float(flash.time()) ia.fmatch_total_pE = float(flash.TotalPE()) ia.fmatch_id = int(flash.id()) - update_dict['Interactions'].append(ia) + update_dict['interactions'].append(ia) update_dict['flash_matches_cryoE'].append(flash_dict_E) flash_dict_W = {} @@ -82,7 +82,7 @@ def run_flash_matching(data_dict, result_dict, ia.fmatch_time = float(flash.time()) ia.fmatch_total_pE = float(flash.TotalPE()) ia.fmatch_id = int(flash.id()) - update_dict['Interactions'].append(ia) + update_dict['interactions'].append(ia) update_dict['flash_matches_cryoW'].append(flash_dict_W) assert len(update_dict['flash_matches_cryoE'])\ diff --git a/analysis/post_processing/reconstruction/calorimetry.py b/analysis/post_processing/reconstruction/calorimetry.py index 08debdb4..1f9927c2 100644 --- a/analysis/post_processing/reconstruction/calorimetry.py +++ b/analysis/post_processing/reconstruction/calorimetry.py @@ -50,7 +50,7 @@ def calorimetric_energy(data_dict, 'particle_seg', 'input_rescaled', 'particle_node_pred_type', - 'Particles']) + 'particles']) def range_based_track_energy(data_dict, result_dict, bin_size=17, include_pids=[2, 3, 4], table_path=''): """Compute track energy by the CSDA (continuous slowing-down approximation) @@ -105,7 +105,7 @@ def range_based_track_energy(data_dict, result_dict, length = compute_track_length(points, bin_size=bin_size) particle_length[i] = length particle_energy[i] = splines[pred_ptypes[i]](length * PIXELS_TO_CM) - result_dict['Particles'][i].momentum_range = particle_energy[i] + result_dict['particles'][i].momentum_range = particle_energy[i] update_dict['particle_length'] = particle_length update_dict['particle_range_based_energy'] = particle_energy diff --git a/analysis/post_processing/reconstruction/geometry.py b/analysis/post_processing/reconstruction/geometry.py index 219f3405..88b1d398 100644 --- a/analysis/post_processing/reconstruction/geometry.py +++ b/analysis/post_processing/reconstruction/geometry.py @@ -9,7 +9,7 @@ 'particle_clusts', 'particle_start_points', 'particle_end_points', - 'Particles']) + 'particles']) def particle_direction(data_dict, result_dict, neighborhood_radius=5, @@ -36,7 +36,7 @@ def particle_direction(data_dict, optimize) } - for i, p in enumerate(result_dict['Particles']): + for i, p in enumerate(result_dict['particles']): p.start_dir = update_dict['particle_start_directions'][i] p.end_dir = update_dict['particle_end_directions'][i] diff --git a/analysis/post_processing/reconstruction/ppn.py b/analysis/post_processing/reconstruction/ppn.py index 19065cd9..a006017b 100644 --- a/analysis/post_processing/reconstruction/ppn.py +++ b/analysis/post_processing/reconstruction/ppn.py @@ -12,7 +12,7 @@ PPN_SCORE_COL = (8,9) @post_processing(data_capture=[], result_capture=['input_rescaled', - 'Particles', + 'particles', 'ppn_classify_endpoints', 'ppn_output_coords', 'ppn_points', @@ -72,7 +72,7 @@ def assign_ppn_candidates(data_dict, result_dict): ppn_candidates = np.empty((0, 5 if not enable_classify_endpoints else 7), dtype=np.float32) - match_points_to_particles(ppn_candidates, result_dict['Particles']) + match_points_to_particles(ppn_candidates, result_dict['particles']) return {} diff --git a/analysis/post_processing/reconstruction/vertex.py b/analysis/post_processing/reconstruction/vertex.py index 5326b9ea..67941a88 100644 --- a/analysis/post_processing/reconstruction/vertex.py +++ b/analysis/post_processing/reconstruction/vertex.py @@ -15,7 +15,7 @@ 'particle_group_pred', 'particle_node_pred_vtx', 'input_rescaled', - 'Interactions'], + 'interactions'], result_capture_optional=['particle_dirs']) def reconstruct_vertex(data_dict, result_dict, mode='all', @@ -116,7 +116,7 @@ def reconstruct_vertex(data_dict, result_dict, vertices = {key: val for key, val in zip(interaction_ids.squeeze(), vertices)} - for i, ia in enumerate(result_dict['Interactions']): + for i, ia in enumerate(result_dict['interactions']): ia.vertex = vertices[ia.id] return {} diff --git a/analysis/producers/arxiv/example_nue.py b/analysis/producers/arxiv/example_nue.py index 08970202..0b06f99f 100644 --- a/analysis/producers/arxiv/example_nue.py +++ b/analysis/producers/arxiv/example_nue.py @@ -120,16 +120,16 @@ def debug_pid(data_blob, res, data_idx, analysis_cfg, cfg): volume = true_int.volume if true_int is not None else pred_int.volume flash_matches = flash_matches_cryoW if volume == 1 else flash_matches_cryoE pred_int_dict['fmatched'] = False - pred_int_dict['fmatch_time'] = None + pred_int_dict['flash_time'] = None pred_int_dict['fmatch_total_pe'] = None - pred_int_dict['fmatch_id'] = None + pred_int_dict['flash_id'] = None if pred_int is not None: for interaction, flash, match in flash_matches: if interaction.id != pred_int.id: continue pred_int_dict['fmatched'] = True - pred_int_dict['fmatch_time'] = flash.time() + pred_int_dict['flash_time'] = flash.time() pred_int_dict['fmatch_total_pe'] = flash.TotalPE() - pred_int_dict['fmatch_id'] = flash.id() + pred_int_dict['flash_id'] = flash.id() break interactions_dict = OrderedDict(index_dict.copy()) diff --git a/analysis/producers/logger.py b/analysis/producers/logger.py index 1d583a42..48021dff 100644 --- a/analysis/producers/logger.py +++ b/analysis/producers/logger.py @@ -337,14 +337,14 @@ def flash_match_info(ia): assert (ia is None) or (type(ia) is Interaction) out = { 'fmatched': False, - 'fmatch_time': -sys.maxsize, - 'fmatch_total_pE': -sys.maxsize, - 'fmatch_id': -sys.maxsize + 'flash_time': -sys.maxsize, + 'flash_total_pE': -sys.maxsize, + 'flash_id': -sys.maxsize } if ia is not None: if hasattr(ia, 'fmatched'): out['fmatched'] = ia.fmatched - out['fmatch_time'] = ia.fmatch_time - out['fmatch_total_pE'] = ia.fmatch_total_pE - out['fmatch_id'] = ia.fmatch_id + out['flash_time'] = ia.fmatch_time + out['flash_total_pE'] = ia.fmatch_total_pE + out['flash_id'] = ia.fmatch_id return out From 0dacf7cb1c383882be605d908797b35cacfd2237 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 26 Apr 2023 12:22:32 -0700 Subject: [PATCH 168/180] Flash matching fix and matched_particle fix --- analysis/post_processing/evaluation/match.py | 18 +++++++++++++++--- analysis/post_processing/pmt/flash_matching.py | 18 ++++++++---------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/analysis/post_processing/evaluation/match.py b/analysis/post_processing/evaluation/match.py index 9100e6a7..e695c246 100644 --- a/analysis/post_processing/evaluation/match.py +++ b/analysis/post_processing/evaluation/match.py @@ -23,7 +23,13 @@ def match_interactions(data_dict, overlap_mode='iou'): pred_interactions = result_dict['interactions'] - true_interactions = result_dict['truth_interactions'] + if overlap_mode == 'chamfer': + true_interactions = [ia for ia in result_dict['truth_interactions'] if ia.truth_size > 0] + else: + true_interactions = [ia for ia in result_dict['truth_interactions'] if ia.size > 0] + + # Only consider interactions with nonzero predicted nonghost + matched_particles = [] if matching_mode == 'optimal': matched_interactions, counts = match_interactions_optimal( @@ -56,18 +62,24 @@ def match_interactions(data_dict, codomain_particles = codomain.particles domain_particles = [p for p in domain_particles if p.points.shape[0] > 0] codomain_particles = [p for p in codomain_particles if p.points.shape[0] > 0] + # print('-------------------------------') + # pprint(list(domain_particles)) + # print("Domain = ", domain.size if domain is not None else None) + # pprint(list(codomain_particles)) + # print("Codomain = ", codomain.size if codomain is not None else None) if matching_mode == 'one_way': - matched_particles, _ = match_particles_fn(domain_particles, + mparticles, _ = match_particles_fn(domain_particles, codomain_particles, min_overlap=min_overlap, overlap_mode=overlap_mode) elif matching_mode == 'optimal': - matched_particles, _ = match_particles_optimal(domain_particles, + mparticles, _ = match_particles_optimal(domain_particles, codomain_particles, min_overlap=min_overlap, overlap_mode=overlap_mode) else: raise ValueError(f"Particle matching mode {matching_mode} is not supported!") + matched_particles.extend(mparticles) pmatches, pcounts = match_parts_within_ints(matched_interactions) diff --git a/analysis/post_processing/pmt/flash_matching.py b/analysis/post_processing/pmt/flash_matching.py index df424b16..a22e69be 100644 --- a/analysis/post_processing/pmt/flash_matching.py +++ b/analysis/post_processing/pmt/flash_matching.py @@ -52,12 +52,12 @@ def run_flash_matching(data_dict, result_dict, # opflashes = filter_opflashes(opflashes) - fmatches_E = fm.get_flash_matches(entry, + fmatches_E = fm.get_flash_matches(int(entry), interactions, opflashes, volume=0, restrict_interactions=[]) - fmatches_W = fm.get_flash_matches(entry, + fmatches_W = fm.get_flash_matches(int(entry), interactions, opflashes, volume=1, @@ -69,9 +69,9 @@ def run_flash_matching(data_dict, result_dict, for ia, flash, match in fmatches_E: flash_dict_E[ia.id] = (flash, match) ia.fmatched = True - ia.fmatch_time = float(flash.time()) - ia.fmatch_total_pE = float(flash.TotalPE()) - ia.fmatch_id = int(flash.id()) + ia.flash_time = float(flash.time()) + ia.flash_total_pE = float(flash.TotalPE()) + ia.flash_id = int(flash.id()) update_dict['interactions'].append(ia) update_dict['flash_matches_cryoE'].append(flash_dict_E) @@ -79,15 +79,13 @@ def run_flash_matching(data_dict, result_dict, for ia, flash, match in fmatches_W: flash_dict_W[ia.id] = (flash, match) ia.fmatched = True - ia.fmatch_time = float(flash.time()) - ia.fmatch_total_pE = float(flash.TotalPE()) - ia.fmatch_id = int(flash.id()) + ia.flash_time = float(flash.time()) + ia.flash_total_pE = float(flash.TotalPE()) + ia.flash_id = int(flash.id()) update_dict['interactions'].append(ia) update_dict['flash_matches_cryoW'].append(flash_dict_W) assert len(update_dict['flash_matches_cryoE'])\ == len(update_dict['flash_matches_cryoW']) - - # print(update_dict) return update_dict \ No newline at end of file From 50a23690688f3da740cf5cbad8c8439fed5e8890 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 26 Apr 2023 14:12:41 -0700 Subject: [PATCH 169/180] Don't call build_representations during __init__ of ui classes --- analysis/classes/evaluator.py | 3 +-- analysis/classes/predictor.py | 9 ++++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index 7de63496..741366be 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -45,7 +45,6 @@ class FullChainEvaluator(FullChainPredictor): def __init__(self, data_blob, result, evaluator_cfg={}, **kwargs): super(FullChainEvaluator, self).__init__(data_blob, result, evaluator_cfg, **kwargs) - self.build_representations() self.michel_primary_ionization_only = evaluator_cfg.get('michel_primary_ionization_only', False) # For matching particles and interactions self.min_overlap_count = evaluator_cfg.get('min_overlap_count', 0) @@ -201,7 +200,7 @@ def get_true_interactions(self, entry) -> List[Interaction]: out: List[Interaction] List of TruthInteraction in image # ''' - out = self.result['truth_interactions'][entry] + out = self.result['TruthInteractions'][entry] return out diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index 89815a45..42bfe346 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -52,9 +52,11 @@ def __init__(self, data_blob, result, predictor_cfg={}): self.builders[key] = InteractionBuilder() if key == 'Fragments': self.builders[key] = FragmentBuilder() - - self.build_representations() + # Data Structure Scopes + self.scope = predictor_cfg.get('scope', ['particles', 'interactions']) + + # self.build_representations() self.num_images = len(self.data_blob['index']) self.index = self.data_blob['index'] @@ -71,9 +73,6 @@ def __init__(self, data_blob, result, predictor_cfg={}): # Min/max boundaries in each dimension haev to be specified. self.vb = predictor_cfg.get('volume_boundaries', None) self.set_volume_boundaries() - - # Data Structure Scopes - self.scope = predictor_cfg.get('scope', ['particles', 'interactions']) def set_volume_boundaries(self): From a2c3fab3e5ef9bea74b4e697aa7b79a47339e174 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Wed, 26 Apr 2023 15:51:27 -0700 Subject: [PATCH 170/180] Reader now returns index lists as array (instead of list) of arrays --- analysis/manager.py | 1 + mlreco/iotools/readers.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/analysis/manager.py b/analysis/manager.py index b215926e..cf30cb65 100644 --- a/analysis/manager.py +++ b/analysis/manager.py @@ -92,6 +92,7 @@ def initialize(self): or reading data from HDF5. """ if 'reader' not in self.ana_config: + assert self.config is not None, 'Must specify `chain_config` path under the `analysis` block' event_list = self.config['iotool']['dataset'].get('event_list', None) if event_list is not None: event_list = eval(event_list) diff --git a/mlreco/iotools/readers.py b/mlreco/iotools/readers.py index aa2ace4d..5c4c3f52 100644 --- a/mlreco/iotools/readers.py +++ b/mlreco/iotools/readers.py @@ -206,7 +206,8 @@ def load_key(self, file, event, data_blob, result_blob, key, nested): # If the reference points at a group, unpack el_refs = group[key]['index'][region_ref].flatten() if len(group[key]['index'].shape) == 1: - ret = [group[key]['elements'][r] for r in el_refs] + ret = np.empty(len(el_refs), dtype=np.object) + ret[:] = [group[key]['elements'][r] for r in el_refs] else: ret = [group[key][f'element_{i}'][r] for i, r in enumerate(el_refs)] blob[key] = ret From 840d00ef391283e5c55101f7d691174c048bff9c Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Wed, 26 Apr 2023 16:45:12 -0700 Subject: [PATCH 171/180] Got rid of local torch.cdist implementation --- analysis/post_processing/arxiv/metrics/ppn_simple.py | 11 ++++++----- mlreco/models/layers/cluster_cnn/losses.py | 3 +-- mlreco/models/layers/common/ppnplus.py | 10 +++++----- mlreco/models/layers/common/vertex_ppn.py | 1 - mlreco/models/layers/gnn/encoders/geometric.py | 3 +-- mlreco/utils/gnn/data.py | 3 +-- mlreco/utils/ppn.py | 3 +-- mlreco/utils/utils.py | 9 +-------- 8 files changed, 16 insertions(+), 27 deletions(-) diff --git a/analysis/post_processing/arxiv/metrics/ppn_simple.py b/analysis/post_processing/arxiv/metrics/ppn_simple.py index bf92f5bf..ccf0dd07 100644 --- a/analysis/post_processing/arxiv/metrics/ppn_simple.py +++ b/analysis/post_processing/arxiv/metrics/ppn_simple.py @@ -3,7 +3,6 @@ import scipy import os from mlreco.post_processing import post_processing -from mlreco.utils import local_cdist, CSVData from mlreco.utils.dbscan import dbscan_points from mlreco.utils.ppn import uresnet_ppn_type_point_selector @@ -80,16 +79,18 @@ def ppn_simple(cfg, processor_cfg, data_blob, result, logdir, iteration, pred_endpoint_type = ppn_endpoint_type[i] segmentation_voxels = segment_label[data_idx][:, 1:4][pred_seg == pred_point_type] if segmentation_voxels.shape[0] > 0: - d_same_type = local_cdist( + d_same_type = torch.cdist( torch.Tensor(pred_point).view(1, -1), - torch.Tensor(segmentation_voxels)).numpy() + torch.Tensor(segmentation_voxels), + compute_mode='donot_use_mm_for_euclid_dist').numpy() d_same_type_closest = d_same_type.min(axis=1)[0] else: d_same_type_closest = -1 if true_mip_voxels.shape[0] > 0: - d_mip = local_cdist( + d_mip = torch.cdist( torch.Tensor(pred_point).view(1, -1), - torch.Tensor(true_mip_voxels[:, 1:4])).numpy() + torch.Tensor(true_mip_voxels[:, 1:4]), + compute_mode='donot_use_mm_for_euclid_dist').numpy() d_closest_mip = d_mip.min(axis=1)[0] else: diff --git a/mlreco/models/layers/cluster_cnn/losses.py b/mlreco/models/layers/cluster_cnn/losses.py index 4cddaab3..90adc73b 100644 --- a/mlreco/models/layers/cluster_cnn/losses.py +++ b/mlreco/models/layers/cluster_cnn/losses.py @@ -1,7 +1,6 @@ import torch import torch.nn as nn -from mlreco.utils import local_cdist from mlreco.models.layers.cluster_cnn.losses.lovasz import lovasz_hinge_flat from mlreco.models.layers.cluster_cnn.losses.lovasz import StableBCELoss from collections import defaultdict @@ -71,7 +70,7 @@ def inter_cluster_loss(self, cluster_means, margin=0.2): else: indices = torch.triu_indices(cluster_means.shape[0], cluster_means.shape[0], 1) - dist = local_cdist(cluster_means, cluster_means) + dist = torch.cdist(cluster_means, cluster_means, compute_mode='donot_use_mm_for_euclid_dist') return torch.pow(torch.clamp(2.0 * margin - dist[indices[0, :], \ indices[1, :]], min=0), 2).mean() diff --git a/mlreco/models/layers/common/ppnplus.py b/mlreco/models/layers/common/ppnplus.py index f195c69b..54eabd95 100644 --- a/mlreco/models/layers/common/ppnplus.py +++ b/mlreco/models/layers/common/ppnplus.py @@ -5,7 +5,6 @@ import MinkowskiEngine as ME import MinkowskiFunctional as MF -from mlreco.utils import local_cdist from mlreco.utils.globals import * from mlreco.models.layers.common.blocks import ResNetBlock, SPP, ASPP from mlreco.models.layers.common.activation_normalization_factories import activations_construct @@ -544,9 +543,10 @@ def forward(self, result, segment_label, particles_label): if len(scores_event.shape) == 0: continue - d_true = local_cdist( + d_true = torch.cdist( points_label, - points_event[:, 1:4].float().to(device)) + points_event[:, 1:4].float().to(device), + compute_mode='donot_use_mm_for_euclid_dist') d_positives = (d_true < self.resolution * \ 2**(len(ppn_layers) - layer)).any(dim=0) @@ -572,7 +572,7 @@ def forward(self, result, segment_label, particles_label): pixel_logits = ppn_points[batch_particle_index][:, 3:8] pixel_pred = ppn_points[batch_particle_index][:, :3] + anchors - d = local_cdist(points_label, pixel_pred) + d = torch.cdist(points_label, pixel_pred, compute_mode='donot_use_mm_for_euclid_dist') positives = (d < self.resolution).any(dim=0) if (torch.sum(positives) < 1): continue @@ -593,7 +593,7 @@ def forward(self, result, segment_label, particles_label): res['num_voxels'] += float(pixel_pred.shape[0]) / float(num_batches) # Type Segmentation Loss - # d = local_cdist(points_label, pixel_pred) + # d = torch.cdist(points_label, pixel_pred, compute_mode='donot_use_mm_for_euclid_dist') # positives = (d < self.resolution).any(dim=0) distance_positives = d[:, positives] event_types_label = particles[particles[:, 0] == b]\ diff --git a/mlreco/models/layers/common/vertex_ppn.py b/mlreco/models/layers/common/vertex_ppn.py index 98ae1ee4..02fc140e 100644 --- a/mlreco/models/layers/common/vertex_ppn.py +++ b/mlreco/models/layers/common/vertex_ppn.py @@ -5,7 +5,6 @@ import MinkowskiEngine as ME import MinkowskiFunctional as MF -from mlreco.utils import local_cdist from mlreco.models.layers.common.blocks import ResNetBlock from mlreco.models.layers.common.activation_normalization_factories import activations_construct from mlreco.models.layers.common.configuration import setup_cnn_configuration diff --git a/mlreco/models/layers/gnn/encoders/geometric.py b/mlreco/models/layers/gnn/encoders/geometric.py index fbee7bb2..dc011812 100644 --- a/mlreco/models/layers/gnn/encoders/geometric.py +++ b/mlreco/models/layers/gnn/encoders/geometric.py @@ -3,7 +3,6 @@ import numpy as np from torch_scatter import scatter_min -from mlreco.utils import local_cdist from mlreco.utils.gnn.data import cluster_features, cluster_edge_features class ClustGeoNodeEncoder(torch.nn.Module): @@ -144,7 +143,7 @@ def forward(self, data, clusts, edge_index, closest_index=None): x2 = voxels[clusts[e[1]]] # Find the closest set point in each cluster - d12 = local_cdist(x1,x2) + d12 = torch.cdist(x1, x2, compute_mode='donot_use_mm_for_euclid_dist') imin = torch.argmin(d12) i1, i2 = imin//len(x2), imin%len(x2) v1 = x1[i1,:] # closest point in c1 diff --git a/mlreco/utils/gnn/data.py b/mlreco/utils/gnn/data.py index 9145cfff..54c9af68 100644 --- a/mlreco/utils/gnn/data.py +++ b/mlreco/utils/gnn/data.py @@ -6,7 +6,6 @@ import mlreco.utils.numba_local as nbl from mlreco.utils.wrapper import numba_wrapper -from mlreco.utils import local_cdist from mlreco.utils.ppn import get_track_endpoints_geo from .cluster import get_cluster_features, get_cluster_features_extended @@ -234,7 +233,7 @@ def _get_extra_gnn_features(fragments, end_points = torch.cat([start, start]) if not allow_outside and (frag_seg[mask][i] != 1 or (frag_seg[mask][i] == 1 and enhance)): - dist_mat = local_cdist(end_points.reshape(-1,3), fragment_voxels) + dist_mat = torch.cdist(end_points.reshape(-1,3), fragment_voxels, compute_mode='donot_use_mm_for_euclid_dist') argmins = torch.argmin(dist_mat, dim=1) end_points = torch.cat([fragment_voxels[argmins[0]], fragment_voxels[argmins[1]]]) diff --git a/mlreco/utils/ppn.py b/mlreco/utils/ppn.py index 456f2df9..3662f352 100644 --- a/mlreco/utils/ppn.py +++ b/mlreco/utils/ppn.py @@ -2,7 +2,6 @@ import scipy import torch -from mlreco.utils import local_cdist from mlreco.utils.dbscan import dbscan_types, dbscan_points from mlreco.utils.numba_local import farthest_pair @@ -434,7 +433,7 @@ def get_track_endpoints_geo(data, f, points_tensor=None, use_numpy=False, use_pr sigmoid = scipy.special.expit cat = lambda x: np.stack(x, axis=0) else: - cdist = local_cdist + cdist = lambda x1, x2: torch.cdist(x1, x2, compute_mode='donot_use_mm_for_euclid_dist') argmax = torch.argmax sigmoid = torch.sigmoid cat = torch.cat diff --git a/mlreco/utils/utils.py b/mlreco/utils/utils.py index c01d07ad..bc08fbe0 100644 --- a/mlreco/utils/utils.py +++ b/mlreco/utils/utils.py @@ -2,13 +2,6 @@ import torch import time import torch_geometric -import pandas as pd -import os - -def local_cdist(v1, v2): - v1_2 = v1.unsqueeze(1).expand(v1.size(0), v2.size(0), v1.size(1)) - v2_2 = v2.unsqueeze(0).expand(v1.size(0), v2.size(0), v1.size(1)) - return torch.sqrt(torch.pow(v2_2 - v1_2, 2).sum(2)) def to_numpy(s): @@ -222,4 +215,4 @@ def flush(self): def close(self): if self._str is not None: - self._fout.close() \ No newline at end of file + self._fout.close() From ca8231aeab46dc17b5db748897ff1f378130fa28 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 26 Apr 2023 16:48:45 -0700 Subject: [PATCH 172/180] Particle and interaction matcher corrections --- analysis/classes/evaluator.py | 95 +++++++++++++------- analysis/classes/matching.py | 12 +-- analysis/classes/predictor.py | 2 +- analysis/post_processing/evaluation/match.py | 26 +++--- 4 files changed, 84 insertions(+), 51 deletions(-) diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index 741366be..d7c25d0f 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -54,6 +54,26 @@ def __init__(self, data_blob, result, evaluator_cfg={}, **kwargs): assert self.min_overlap_count <= 1 and self.min_overlap_count >= 0 if self.overlap_mode == 'counts': assert self.min_overlap_count >= 0 + + def _build_reco_reps(self): + if 'particles' not in self.result and 'particles' in self.scope: + self.result['particles'] = self.builders['particles'].build(self.data_blob, + self.result, + mode='reco') + if 'interactions' not in self.result and 'interactions' in self.scope: + self.result['interactions'] = self.builders['interactions'].build(self.data_blob, + self.result, + mode='reco') + + def _build_truth_reps(self): + if 'truth_particles' not in self.result and 'particles' in self.scope: + self.result['truth_particles'] = self.builders['particles'].build(self.data_blob, + self.result, + mode='truth') + if 'truth_interactions' not in self.result and 'interactions' in self.scope: + self.result['truth_interactions'] = self.builders['interactions'].build(self.data_blob, + self.result, + mode='truth') def build_representations(self, mode='all'): """ @@ -72,11 +92,15 @@ def build_representations(self, mode='all'): ------- None (operation is in-place) """ - for key in self.builders: - if key not in self.result and key in self.scope: - self.result[key] = self.builders[key].build(self.data_blob, - self.result, - mode=mode) + if mode == 'reco': + self._build_reco_reps() + elif mode == 'truth': + self._build_truth_reps() + elif mode == 'all': + self._build_reco_reps() + self._build_truth_reps() + else: + raise ValueError(f"Data structure building mode {mode} not supported!") def get_true_label(self, entry, name, schema='cluster_label_adapted'): """ @@ -200,7 +224,7 @@ def get_true_interactions(self, entry) -> List[Interaction]: out: List[Interaction] List of TruthInteraction in image # ''' - out = self.result['TruthInteractions'][entry] + out = self.result['truth_interactions'][entry] return out @@ -215,7 +239,10 @@ def match_parts_within_ints(int_matches): particles within a matched interaction pair can be considered for matching. ''' - + for m in int_matches: + print('-------------------') + print(m[0]) + print(m[1]) matched_particles, match_counts = [], [] for m in int_matches: @@ -239,6 +266,7 @@ def match_parts_within_ints(int_matches): matched_particles.append((p, None)) match_counts.append(-1) for match_id in p.match: + # print(ia1[match_id], match_id, p.match, p.id, p.size) if type(p) is Particle: matched_particles.append((ia1[match_id], p)) else: @@ -250,7 +278,7 @@ def match_parts_within_ints(int_matches): def match_particles(self, entry, only_primaries=False, mode='pred_to_true', - matching_mode='one_way', + matching_mode='optimal', return_counts=False, **kwargs): ''' @@ -320,7 +348,7 @@ def match_interactions(self, entry, mode='pred_to_true', drop_nonprimary_particles=False, match_particles=True, return_counts=False, - matching_mode='one_way', + matching_mode='optimal', **kwargs): """ Method for matching reco and true interactions. @@ -353,28 +381,35 @@ def match_interactions(self, entry, mode='pred_to_true', """ all_matches, all_counts = [], [] - if mode == 'pred_to_true': - ints_from = self.get_interactions(entry, - drop_nonprimary_particles=drop_nonprimary_particles) - ints_to = self.get_true_interactions(entry) - elif mode == 'true_to_pred': - ints_to = self.get_interactions(entry, + pred_interactions = self.get_interactions(entry, drop_nonprimary_particles=drop_nonprimary_particles) - ints_from = self.get_true_interactions(entry) - else: - raise ValueError("Mode {} is not valid. For matching each"\ - " prediction to truth, use 'pred_to_true' (and vice versa).".format(mode)) - + true_interactions = self.get_true_interactions(entry) + all_kwargs = {"min_overlap": self.min_overlap_count, "overlap_mode": self.overlap_mode, **kwargs} + if all_kwargs['overlap_mode'] == 'chamfer': + true_interactions_masked = [ia for ia in true_interactions if ia.truth_size > 0] + else: + true_interactions_masked = [ia for ia in true_interactions if ia.size > 0] + if matching_mode == 'one_way': - matched_interactions, counts = match_interactions_fn(ints_from, ints_to, - **all_kwargs) - elif matching_mode == 'optimal': - matched_interactions, counts = match_interactions_optimal(ints_from, ints_to, + if mode == 'pred_to_true': + matched_interactions, counts = match_interactions_fn(pred_interactions, + true_interactions_masked, + **all_kwargs) + elif mode == 'true_to_pred': + matched_interactions, counts = match_interactions_fn(true_interactions_masked, + pred_interactions, **all_kwargs) + else: + raise ValueError(f"One-way matching mode {mode} not supported, either use 'pred_to_true' or 'true_to_pred'.") + elif matching_mode == 'optimal': + matched_interactions, counts = match_interactions_optimal(pred_interactions, + true_interactions_masked, + **all_kwargs) else: raise ValueError + if len(matched_interactions) == 0: return [], [] if match_particles: @@ -386,20 +421,20 @@ def match_interactions(self, entry, mode='pred_to_true', if codomain is not None: codomain_particles = codomain.particles # continue - domain_particles = [p for p in domain_particles if p.points.shape[0] > 0] - codomain_particles = [p for p in codomain_particles if p.points.shape[0] > 0] + domain_particles_masked = [p for p in domain_particles if p.points.shape[0] > 0] + codomain_particles_masked = [p for p in codomain_particles if p.points.shape[0] > 0] if matching_mode == 'one_way': - matched_particles, _ = match_particles_fn(domain_particles, - codomain_particles, + matched_particles, _ = match_particles_fn(domain_particles_masked, + codomain_particles_masked, min_overlap=self.min_overlap_count, overlap_mode=self.overlap_mode) elif matching_mode == 'optimal': - matched_particles, _ = match_particles_optimal(domain_particles, codomain_particles, + matched_particles, _ = match_particles_optimal(domain_particles_masked, codomain_particles_masked, min_overlap=self.min_overlap_count, overlap_mode=self.overlap_mode) else: raise ValueError(f"Particle matching mode {matching_mode} is not supported!") - + pmatches, pcounts = self.match_parts_within_ints(matched_interactions) self._matched_particles = pmatches diff --git a/analysis/classes/matching.py b/analysis/classes/matching.py index 6a36228c..af7bceea 100644 --- a/analysis/classes/matching.py +++ b/analysis/classes/matching.py @@ -300,6 +300,7 @@ def match_interactions_fn(ints_from : List[Interaction], overlap_matrix = matrix_iou(ints_x, ints_y) else: raise ValueError("Overlap matrix mode {} is not supported.".format(overlap_mode)) + idx = overlap_matrix.argmax(axis=0) intersections = overlap_matrix.max(axis=0) @@ -316,12 +317,13 @@ def match_interactions_fn(ints_from : List[Interaction], interaction._match_counts[matched_truth.id] = intersections[j] # matched_truth._match.append(interaction.id) matched_truth._match_counts[interaction.id] = intersections[j] - matches.append((interaction, matched_truth)) + match = (interaction, matched_truth) + matches.append(match) - # for interaction in ints_y: - # interaction._match = sorted(list(interaction._match_counts.keys()), - # key=lambda x: interaction._match_counts[x], - # reverse=True) + # if (type(match[0]) is Interaction) or (type(match[1]) is TruthInteraction): + # p1, p2 = match[1], match[0] + # match = (p1, p2) + # matches.append(match) return matches, intersections diff --git a/analysis/classes/predictor.py b/analysis/classes/predictor.py index 42bfe346..50b92d24 100644 --- a/analysis/classes/predictor.py +++ b/analysis/classes/predictor.py @@ -44,7 +44,7 @@ def __init__(self, data_blob, result, predictor_cfg={}): self.fragment_builder = FragmentBuilder() build_reps = predictor_cfg.get('build_reps', ['particles', 'interactions']) - self.builders = {} + self.builders = OrderedDict() for key in build_reps: if key == 'particles': self.builders[key] = ParticleBuilder() diff --git a/analysis/post_processing/evaluation/match.py b/analysis/post_processing/evaluation/match.py index e695c246..471cadeb 100644 --- a/analysis/post_processing/evaluation/match.py +++ b/analysis/post_processing/evaluation/match.py @@ -60,23 +60,19 @@ def match_interactions(data_dict, domain_particles = domain.particles if codomain is not None: codomain_particles = codomain.particles - domain_particles = [p for p in domain_particles if p.points.shape[0] > 0] - codomain_particles = [p for p in codomain_particles if p.points.shape[0] > 0] - # print('-------------------------------') - # pprint(list(domain_particles)) - # print("Domain = ", domain.size if domain is not None else None) - # pprint(list(codomain_particles)) - # print("Codomain = ", codomain.size if codomain is not None else None) + domain_particles_masked = [p for p in domain_particles if p.points.shape[0] > 0] + codomain_particles_masked = [p for p in codomain_particles if p.points.shape[0] > 0] + if matching_mode == 'one_way': - mparticles, _ = match_particles_fn(domain_particles, - codomain_particles, - min_overlap=min_overlap, - overlap_mode=overlap_mode) + mparticles, _ = match_particles_fn(domain_particles_masked, + codomain_particles_masked, + min_overlap=min_overlap, + overlap_mode=overlap_mode) elif matching_mode == 'optimal': - mparticles, _ = match_particles_optimal(domain_particles, - codomain_particles, - min_overlap=min_overlap, - overlap_mode=overlap_mode) + mparticles, _ = match_particles_optimal(domain_particles_masked, + codomain_particles_masked, + min_overlap=min_overlap, + overlap_mode=overlap_mode) else: raise ValueError(f"Particle matching mode {matching_mode} is not supported!") matched_particles.extend(mparticles) From 7320965e4ab09d04706cd1ab118671c7e5a48124 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Wed, 26 Apr 2023 21:27:11 -0700 Subject: [PATCH 173/180] Refactored decorator nomenclature --- analysis/classes/Interaction.py | 8 ++++-- analysis/classes/Particle.py | 2 +- analysis/classes/ParticleFragment.py | 1 + analysis/classes/TruthInteraction.py | 6 ++-- analysis/classes/TruthParticle.py | 1 - analysis/classes/TruthParticleFragment.py | 1 + mlreco/utils/{wrapper.py => decorators.py} | 25 +++++++++++++++-- mlreco/utils/gnn/cluster.py | 32 +++++++++++----------- mlreco/utils/gnn/data.py | 4 +-- mlreco/utils/gnn/network.py | 12 ++++---- mlreco/utils/gnn/voxels.py | 4 +-- mlreco/utils/unwrap.py | 6 ++-- mlreco/utils/utils.py | 28 ++----------------- 13 files changed, 67 insertions(+), 63 deletions(-) rename mlreco/utils/{wrapper.py => decorators.py} (82%) diff --git a/analysis/classes/Interaction.py b/analysis/classes/Interaction.py index 3e4b2313..c47490e5 100644 --- a/analysis/classes/Interaction.py +++ b/analysis/classes/Interaction.py @@ -1,10 +1,12 @@ +import sys import numpy as np + from typing import Counter, List, Union -import sys from collections import OrderedDict, Counter, defaultdict +from functools import cached_property + from . import Particle from mlreco.utils.globals import PID_LABELS -from functools import cached_property class Interaction: @@ -267,4 +269,4 @@ def _process_interaction_attributes(init_args, processed_args, **kwargs): processed_args['points'] = np.vstack(init_args['points']) processed_args['index'] = np.concatenate(init_args['index']) - processed_args['depositions'] = np.concatenate(init_args['depositions']) \ No newline at end of file + processed_args['depositions'] = np.concatenate(init_args['depositions']) diff --git a/analysis/classes/Particle.py b/analysis/classes/Particle.py index 83c14ede..0887e1ba 100644 --- a/analysis/classes/Particle.py +++ b/analysis/classes/Particle.py @@ -1,8 +1,8 @@ import numpy as np -import pandas as pd from typing import Counter, List, Union from collections import OrderedDict + from mlreco.utils.globals import SHAPE_LABELS, PID_LABELS diff --git a/analysis/classes/ParticleFragment.py b/analysis/classes/ParticleFragment.py index 492fcd7f..47d7212d 100644 --- a/analysis/classes/ParticleFragment.py +++ b/analysis/classes/ParticleFragment.py @@ -2,6 +2,7 @@ from mlreco.utils.globals import SHAPE_LABELS + class ParticleFragment: ''' Data structure for managing fragment-level diff --git a/analysis/classes/TruthInteraction.py b/analysis/classes/TruthInteraction.py index c540c91a..a8d651cc 100644 --- a/analysis/classes/TruthInteraction.py +++ b/analysis/classes/TruthInteraction.py @@ -1,10 +1,12 @@ -from typing import List import numpy as np -import pandas as pd + +from typing import List from collections import OrderedDict, defaultdict + from . import Interaction, TruthParticle from .Interaction import _process_interaction_attributes + class TruthInteraction(Interaction): """ Data structure mirroring , reserved for true interactions diff --git a/analysis/classes/TruthParticle.py b/analysis/classes/TruthParticle.py index fc805e77..ca4ff40a 100644 --- a/analysis/classes/TruthParticle.py +++ b/analysis/classes/TruthParticle.py @@ -1,5 +1,4 @@ import numpy as np -import pandas as pd from typing import Counter, List, Union from . import Particle diff --git a/analysis/classes/TruthParticleFragment.py b/analysis/classes/TruthParticleFragment.py index a2025541..59d9eb86 100644 --- a/analysis/classes/TruthParticleFragment.py +++ b/analysis/classes/TruthParticleFragment.py @@ -1,6 +1,7 @@ import numpy as np from typing import Counter, List, Union + from . import ParticleFragment diff --git a/mlreco/utils/wrapper.py b/mlreco/utils/decorators.py similarity index 82% rename from mlreco/utils/wrapper.py rename to mlreco/utils/decorators.py index 4d394f33..2bac2124 100644 --- a/mlreco/utils/wrapper.py +++ b/mlreco/utils/decorators.py @@ -2,10 +2,31 @@ import numba as nb import torch import inspect +from time import time from functools import wraps -def numba_wrapper(cast_args=[], list_args=[], keep_torch=False, ref_arg=None): +def timing(fn): + ''' + Function which wraps any function and times it. + + Returns + ------- + callable + Timed function + ''' + @wraps(fn) + def wrap(*args, **kwargs): + ts = time() + result = fn(*args, **kwargs) + te = time() + print('func:%r args:[%r, %r] took: %2.f sec' % \ + (fn.__name__, args, kwargs, te-ts)) + return result + return wrap + + +def numbafy(cast_args=[], list_args=[], keep_torch=False, ref_arg=None): ''' Function which wraps a *numba* function with some checks on the input to make the relevant conversions to numpy where necessary. @@ -24,7 +45,7 @@ def numba_wrapper(cast_args=[], list_args=[], keep_torch=False, ref_arg=None): Returns ------- callable - Wrapped function + Wrapped function which ensures input type compatibility with numba ''' def outer(fn): @wraps(fn) diff --git a/mlreco/utils/gnn/cluster.py b/mlreco/utils/gnn/cluster.py index e295bba7..61099c85 100644 --- a/mlreco/utils/gnn/cluster.py +++ b/mlreco/utils/gnn/cluster.py @@ -5,10 +5,10 @@ from typing import List import mlreco.utils.numba_local as nbl -from mlreco.utils.wrapper import numba_wrapper +from mlreco.utils.decorators import numbafy -@numba_wrapper(cast_args=['data'], list_args=['cluster_classes'], keep_torch=True, ref_arg='data') +@numbafy(cast_args=['data'], list_args=['cluster_classes'], keep_torch=True, ref_arg='data') def form_clusters(data, min_size=-1, column=5, batch_index=0, cluster_classes=[-1], shape_index=-1): """ Function that returns a list of of arrays of voxel IDs @@ -63,7 +63,7 @@ def _form_clusters(data: nb.float64[:,:], return clusts -@numba_wrapper(cast_args=['data'], keep_torch=True, ref_arg='data') +@numbafy(cast_args=['data'], keep_torch=True, ref_arg='data') def reform_clusters(data, clust_ids, batch_ids, column=5, batch_col=0): """ Function that returns a list of of arrays of voxel IDs @@ -91,7 +91,7 @@ def _reform_clusters(data: nb.float64[:,:], return clusts -@numba_wrapper(cast_args=['data'], list_args=['clusts']) +@numbafy(cast_args=['data'], list_args=['clusts']) def get_cluster_batch(data, clusts, batch_index=0): """ Function that returns the batch ID of each cluster. @@ -120,7 +120,7 @@ def _get_cluster_batch(data: nb.float64[:,:], return labels -@numba_wrapper(cast_args=['data'], list_args=['clusts']) +@numbafy(cast_args=['data'], list_args=['clusts']) def get_cluster_label(data, clusts, column=5): """ Function that returns the majority label of each cluster, @@ -147,7 +147,7 @@ def _get_cluster_label(data: nb.float64[:,:], return labels -@numba_wrapper(cast_args=['data'], list_args=['clusts']) +@numbafy(cast_args=['data'], list_args=['clusts']) def get_cluster_primary_label(data, clusts, column, cluster_column=5, group_column=6): """ Function that returns the majority label of the primary component @@ -187,7 +187,7 @@ def _get_cluster_primary_label(data: nb.float64[:,:], return labels -@numba_wrapper(cast_args=['data'], list_args=['clusts'], keep_torch=True, ref_arg='data') +@numbafy(cast_args=['data'], list_args=['clusts'], keep_torch=True, ref_arg='data') def get_momenta_label(data, clusts, column=8): """ Function that returns the momentum unit vector of each cluster @@ -211,7 +211,7 @@ def _get_momenta_label(data: nb.float64[:,:], return labels -@numba_wrapper(cast_args=['data'], list_args=['clusts', 'coords_index'], keep_torch=True, ref_arg='data') +@numbafy(cast_args=['data'], list_args=['clusts', 'coords_index'], keep_torch=True, ref_arg='data') def get_cluster_centers(data, clusts, coords_index=[1, 4]): """ Function that returns the coordinate of the centroid @@ -235,7 +235,7 @@ def _get_cluster_centers(data: nb.float64[:,:], return centers -@numba_wrapper(cast_args=['data'], list_args=['clusts']) +@numbafy(cast_args=['data'], list_args=['clusts']) def get_cluster_sizes(data, clusts): """ Function that returns the sizes of @@ -258,7 +258,7 @@ def _get_cluster_sizes(data: nb.float64[:,:], return sizes -@numba_wrapper(cast_args=['data'], list_args=['clusts'], keep_torch=True, ref_arg='data') +@numbafy(cast_args=['data'], list_args=['clusts'], keep_torch=True, ref_arg='data') def get_cluster_energies(data, clusts): """ Function that returns the energies deposited by @@ -281,7 +281,7 @@ def _get_cluster_energies(data: nb.float64[:,:], return energies -@numba_wrapper(cast_args=['data'], list_args=['clusts'], keep_torch=True, ref_arg='data') +@numbafy(cast_args=['data'], list_args=['clusts'], keep_torch=True, ref_arg='data') def get_cluster_features(data: nb.float64[:,:], clusts: nb.types.List(nb.int64[:]), batch_col: nb.int64 = 0, @@ -353,7 +353,7 @@ def _get_cluster_features(data: nb.float64[:,:], return feats -@numba_wrapper(cast_args=['data'], list_args=['clusts'], keep_torch=True, ref_arg='data') +@numbafy(cast_args=['data'], list_args=['clusts'], keep_torch=True, ref_arg='data') def get_cluster_features_extended(data, clusts, batch_col=0, coords_col=(1, 4)): """ Function that returns the an array of 3 additional features for @@ -389,7 +389,7 @@ def _get_cluster_features_extended(data: nb.float64[:,:], return feats -@numba_wrapper(cast_args=['data','particles'], list_args=['clusts','coords_index'], keep_torch=True, ref_arg='data') +@numbafy(cast_args=['data','particles'], list_args=['clusts','coords_index'], keep_torch=True, ref_arg='data') def get_cluster_points_label(data, particles, clusts, random_order=True, batch_col=0, coords_index=[1, 4]): """ Function that gets label points for each cluster. @@ -440,7 +440,7 @@ def _get_cluster_points_label(data: nb.float64[:,:], return points -@numba_wrapper(cast_args=['data'], list_args=['clusts'], keep_torch=True, ref_arg='data') +@numbafy(cast_args=['data'], list_args=['clusts'], keep_torch=True, ref_arg='data') def get_cluster_start_points(data, clusts): """ Function that estimates the start point of clusters @@ -464,7 +464,7 @@ def _get_cluster_start_points(data: nb.float64[:,:], return points -@numba_wrapper(cast_args=['data','starts'], list_args=['clusts'], keep_torch=True, ref_arg='data') +@numbafy(cast_args=['data','starts'], list_args=['clusts'], keep_torch=True, ref_arg='data') def get_cluster_directions(data, starts, clusts, max_dist=-1, optimize=False): """ Finds the orientation of all the clusters. @@ -496,7 +496,7 @@ def _get_cluster_directions(data: nb.float64[:,:], return dirs -@numba_wrapper(cast_args=['data','values','starts'], list_args=['clusts'], keep_torch=True, ref_arg='data') +@numbafy(cast_args=['data','values','starts'], list_args=['clusts'], keep_torch=True, ref_arg='data') def get_cluster_dedxs(data, values, starts, clusts, max_dist=-1): """ Finds the start dEdxs of all the clusters. diff --git a/mlreco/utils/gnn/data.py b/mlreco/utils/gnn/data.py index 54c9af68..4e70ebc6 100644 --- a/mlreco/utils/gnn/data.py +++ b/mlreco/utils/gnn/data.py @@ -5,7 +5,7 @@ from typing import Tuple import mlreco.utils.numba_local as nbl -from mlreco.utils.wrapper import numba_wrapper +from mlreco.utils.decorators import numbafy from mlreco.utils.ppn import get_track_endpoints_geo from .cluster import get_cluster_features, get_cluster_features_extended @@ -261,7 +261,7 @@ def _get_extra_gnn_features(fragments, return mask, kwargs -@numba_wrapper(list_args=['clusts']) +@numbafy(list_args=['clusts']) def split_clusts(clusts, batch_ids, batches, counts): """ Splits a batched list of clusters into individual diff --git a/mlreco/utils/gnn/network.py b/mlreco/utils/gnn/network.py index ffe21024..3340abd4 100644 --- a/mlreco/utils/gnn/network.py +++ b/mlreco/utils/gnn/network.py @@ -7,7 +7,7 @@ from scipy.sparse.csgraph import minimum_spanning_tree import mlreco.utils.numba_local as nbl -from mlreco.utils.wrapper import numba_wrapper +from mlreco.utils.decorators import numbafy @nb.njit(cache=True) @@ -260,7 +260,7 @@ def restrict_graph(edge_index: nb.int64[:,:], return edge_index[:, edge_dists < edge_max_dists] -@numba_wrapper(cast_args=['data'], list_args=['clusts'], keep_torch=True, ref_arg='data') +@numbafy(cast_args=['data'], list_args=['clusts'], keep_torch=True, ref_arg='data') def get_cluster_edge_features(data, clusts, edge_index, closest_index=None, batch_col=0, coords_col=(1, 4)): """ Function that returns a tensor of edge features for each of the @@ -348,7 +348,7 @@ def _get_cluster_edge_features_vec(data: nb.float32[:,:], return np.hstack((v1, v2, disp, lend, B)) -@numba_wrapper(cast_args=['data'], keep_torch=True, ref_arg='data') +@numbafy(cast_args=['data'], keep_torch=True, ref_arg='data') def get_voxel_edge_features(data, edge_index, batch_col=0, coords_col=(1, 4)): """ Function that returns a tensor of edge features for each of the @@ -390,7 +390,7 @@ def _get_voxel_edge_features(data: nb.float32[:,:], return feats -@numba_wrapper(cast_args=['voxels'], list_args=['clusts']) +@numbafy(cast_args=['voxels'], list_args=['clusts']) def get_edge_distances(voxels, clusts, edge_index): """ For each edge, finds the closest points of approach (CPAs) between the @@ -430,7 +430,7 @@ def _get_edge_distances(voxels: nb.float32[:,:], return lend, resi, resj -@numba_wrapper(cast_args=['voxels'], list_args=['clusts']) +@numbafy(cast_args=['voxels'], list_args=['clusts']) def inter_cluster_distance(voxels, clusts, batch_ids=None, mode='voxel', algorithm='brute', return_index=False): """ Finds the inter-cluster distance between every pair of clusters within @@ -506,7 +506,7 @@ def _inter_cluster_distance_index(voxels: nb.float32[:,:], return dist_mat, closest_index -@numba_wrapper(cast_args=['graph']) +@numbafy(cast_args=['graph']) def get_fragment_edges(graph, clust_ids): """ Function that converts a set of edges between cluster ids diff --git a/mlreco/utils/gnn/voxels.py b/mlreco/utils/gnn/voxels.py index f29bb8d2..a3dac597 100644 --- a/mlreco/utils/gnn/voxels.py +++ b/mlreco/utils/gnn/voxels.py @@ -3,10 +3,10 @@ import numba as nb import mlreco.utils.numba_local as nbl -from mlreco.utils.wrapper import numba_wrapper +from mlreco.utils.decorators import numbafy -@numba_wrapper(cast_args=['data'], keep_torch=True, ref_arg='data') +@numbafy(cast_args=['data'], keep_torch=True, ref_arg='data') def get_voxel_features(data, max_dist=5.0): """ Function that returns the an array of 16 features for diff --git a/mlreco/utils/unwrap.py b/mlreco/utils/unwrap.py index e9bdb312..1d7bdce8 100644 --- a/mlreco/utils/unwrap.py +++ b/mlreco/utils/unwrap.py @@ -350,6 +350,7 @@ def _concatenate(self, data): else: raise TypeError('Unexpected data type', type(data[0])) + def prefix_unwrapper_rules(rules, prefix): ''' Modifies the default rules of a module to account for @@ -359,8 +360,9 @@ def prefix_unwrapper_rules(rules, prefix): ---------- rules : dict Dictionary which contains a set of unwrapping rules for each - output key of the reconstruction chain. If there is no rule - associated with a key, the list is concatenated. + output key of a given module in the reconstruction chain. + prefix : str + Prefix to add in front of all output names Returns ------- diff --git a/mlreco/utils/utils.py b/mlreco/utils/utils.py index bc08fbe0..22e1ae92 100644 --- a/mlreco/utils/utils.py +++ b/mlreco/utils/utils.py @@ -5,42 +5,18 @@ def to_numpy(s): - use_scn, use_mink = True, True - try: - import sparseconvnet as scn - except ImportError: - use_scn = False - try: - import MinkowskiEngine as ME - except ImportError: - use_mink = False + import MinkowskiEngine as ME if isinstance(s, np.ndarray): return s if isinstance(s, torch.Tensor): return s.cpu().detach().numpy() - elif use_scn and isinstance(s, scn.SparseConvNetTensor): - return torch.cat([s.get_spatial_locations().float(), s.features.cpu()], dim=1).detach().numpy() - elif use_mink and isinstance(s, ME.SparseTensor): + elif isinstance(s, ME.SparseTensor): return torch.cat([s.C.float(), s.F], dim=1).detach().cpu().numpy() - elif isinstance(s, torch_geometric.data.batch.Batch): - return s - elif isinstance(s, pd.DataFrame): - return s else: raise TypeError("Unknown return type %s" % type(s)) -def func_timer(func): - def wrap_func(*args, **kwargs): - t1 = time.time() - result = func(*args, **kwargs) - t2 = time.time() - print(f'Function {func.__name__!r} executed in {(t2-t1):.4f}s') - return result - return wrap_func - - def round_decimals(val, digits): factor = float(np.power(10, digits)) return int(val * factor+0.5) / factor From 9e0e6a435887e7a185ac193f472c30b4d12d03a8 Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 26 Apr 2023 22:12:27 -0700 Subject: [PATCH 174/180] Particle index attribute changed to private to avoid bug --- analysis/classes/Particle.py | 35 +++--- analysis/classes/builders.py | 2 +- analysis/classes/evaluator.py | 7 +- analysis/classes/matching.py | 21 +++- .../post_processing/evaluation/__init__.py | 3 +- analysis/post_processing/evaluation/match.py | 117 ++++++++++++------ 6 files changed, 117 insertions(+), 68 deletions(-) diff --git a/analysis/classes/Particle.py b/analysis/classes/Particle.py index 83c14ede..015aac44 100644 --- a/analysis/classes/Particle.py +++ b/analysis/classes/Particle.py @@ -90,12 +90,12 @@ def __init__(self, # Initialize private attributes to be assigned through setters only self._num_fragments = None - self._size = None - self._index = None - self._depositions = None - self._depositions_sum = None - self._pid_scores = None - self._primary_scores = None + self._index = np.array(index, dtype=np.int64) + self._size = len(self._index) + self._depositions = np.atleast_1d(depositions) + self._depositions_sum = np.sum(self._depositions) + self._pid_scores = pid_scores + self._primary_scores = primary_scores # Initialize attributes self.id = int(group_id) @@ -105,21 +105,17 @@ def __init__(self, self.image_id = int(image_id) self.volume_id = int(volume_id) self.semantic_type = int(semantic_type) - - self.index = index self.points = points - self.depositions = np.atleast_1d(depositions) - self._force_pid = False - if pid > 0: - self._force_pid = True - self._pid = pid - self.pid_scores = pid_scores if np.all(pid_scores < 0): self._pid = pid - self.primary_scores = primary_scores + else: + self._pid = int(np.argmax(pid_scores)) + if np.all(primary_scores < 0): - self.is_primary = is_primary + self._is_primary = is_primary + else: + self._is_primary = int(np.argmax(primary_scores)) self.start_point = start_point self.end_point = end_point @@ -134,6 +130,10 @@ def __init__(self, if not isinstance(self._match_counts, dict): raise ValueError(f"{type(self._match_counts)}") + @property + def is_primary(self): + return int(self._is_primary) + @property def match(self): self._match = list(self._match_counts.keys()) @@ -247,8 +247,7 @@ def pid_scores(self, pid_scores): # Store the PID scores self._pid_scores = pid_scores - if not self._force_pid: - self._pid = int(np.argmax(pid_scores)) + self._pid = int(np.argmax(pid_scores)) @property def pid(self): diff --git a/analysis/classes/builders.py b/analysis/classes/builders.py index 93eb6802..85baea4c 100644 --- a/analysis/classes/builders.py +++ b/analysis/classes/builders.py @@ -874,7 +874,7 @@ def handle_empty_truth_particles(labels_noghost, is_primary=is_primary, pid=pdg, particle_asis=p) - particle.p = np.array([p.px(), p.py(), p.pz()]) + # particle.p = np.array([p.px(), p.py(), p.pz()]) # particle.fragments = [] # particle.particle_asis = p # particle.nu_id = nu_id diff --git a/analysis/classes/evaluator.py b/analysis/classes/evaluator.py index d7c25d0f..5824f079 100644 --- a/analysis/classes/evaluator.py +++ b/analysis/classes/evaluator.py @@ -239,10 +239,6 @@ def match_parts_within_ints(int_matches): particles within a matched interaction pair can be considered for matching. ''' - for m in int_matches: - print('-------------------') - print(m[0]) - print(m[1]) matched_particles, match_counts = [], [] for m in int_matches: @@ -266,7 +262,6 @@ def match_parts_within_ints(int_matches): matched_particles.append((p, None)) match_counts.append(-1) for match_id in p.match: - # print(ia1[match_id], match_id, p.match, p.id, p.size) if type(p) is Particle: matched_particles.append((ia1[match_id], p)) else: @@ -346,7 +341,7 @@ def match_particles(self, entry, def match_interactions(self, entry, mode='pred_to_true', drop_nonprimary_particles=False, - match_particles=True, + match_particles=False, return_counts=False, matching_mode='optimal', **kwargs): diff --git a/analysis/classes/matching.py b/analysis/classes/matching.py index af7bceea..2eaf98f1 100644 --- a/analysis/classes/matching.py +++ b/analysis/classes/matching.py @@ -5,7 +5,6 @@ from scipy.optimize import linear_sum_assignment from scipy.spatial.distance import cdist - from . import Particle, TruthParticle, Interaction, TruthInteraction @@ -56,7 +55,7 @@ def matrix_iou(particles_x, particles_y): for j, px in enumerate(particles_x): cap = np.intersect1d(py.index, px.index) cup = np.union1d(py.index, px.index) - overlap_matrix[i, j] = float(cap.shape[0] / cup.shape[0]) + overlap_matrix[i, j] = float(cap.shape[0]) / float(cup.shape[0]) return overlap_matrix @@ -443,3 +442,21 @@ def group_particles_to_interactions_fn(particles : List[Particle], return list(interactions.values()) + + +def check_particle_matches(loaded_particles, clear=False): + match_dict = OrderedDict({}) + for p in loaded_particles: + for i, m in enumerate(p.match): + match_dict[int(m)] = p.match_counts[i] + if clear: + p._match = [] + p._match_counts = OrderedDict() + + match_counts = np.array(list(match_dict.values())) + match = np.array(list(match_dict.keys())).astype(int) + perm = np.argsort(match_counts)[::-1] + match_counts = match_counts[perm] + match = match[perm] + + return match, match_counts \ No newline at end of file diff --git a/analysis/post_processing/evaluation/__init__.py b/analysis/post_processing/evaluation/__init__.py index dec2ae79..84d4e6f1 100644 --- a/analysis/post_processing/evaluation/__init__.py +++ b/analysis/post_processing/evaluation/__init__.py @@ -1 +1,2 @@ -from .match import match_interactions \ No newline at end of file +from .match import match_interactions +from .match import match_particles \ No newline at end of file diff --git a/analysis/post_processing/evaluation/match.py b/analysis/post_processing/evaluation/match.py index 471cadeb..0c57f3a0 100644 --- a/analysis/post_processing/evaluation/match.py +++ b/analysis/post_processing/evaluation/match.py @@ -1,18 +1,66 @@ import numpy as np -from pprint import pprint +from collections import OrderedDict from analysis.post_processing import post_processing from mlreco.utils.globals import * -from analysis.classes.matching import (match_particles_fn, - match_interactions_fn, - match_interactions_optimal, - match_particles_optimal) +from analysis.classes.matching import (match_particles_fn, + match_particles_optimal, + match_interactions_fn, + match_interactions_optimal) from analysis.classes.data import * @post_processing(data_capture=['index'], result_capture=['particles', - 'truth_particles', - 'interactions', + 'truth_particles']) +def match_particles(data_dict, + result_dict, + matching_mode='optimal', + matching_direction='pred_to_true', + match_particles=True, + min_overlap=0, + overlap_mode='iou'): + pred_particles = result_dict['particles'] + + if overlap_mode == 'chamfer': + true_particles = [ia for ia in result_dict['truth_particles'] if ia.truth_size > 0] + else: + true_particles = [ia for ia in result_dict['truth_particles'] if ia.size > 0] + + # Only consider interactions with nonzero predicted nonghost + matched_particles = [] + + if matching_mode == 'optimal': + matched_particles, counts = match_particles_optimal( + pred_particles, + true_particles, + min_overlap=min_overlap, + overlap_mode=overlap_mode) + + if matching_mode == 'one_way': + if matching_direction == 'pred_to_true': + matched_particles, counts = match_particles_fn( + pred_particles, + true_particles, + min_overlap=min_overlap, + overlap_mode=overlap_mode) + elif matching_direction == 'true_to_pred': + matched_particles, counts = match_particles_fn( + true_particles, + pred_particles, + min_overlap=min_overlap, + overlap_mode=overlap_mode) + + update_dict = { + # 'matched_particles': matched_particles, + 'particle_match_values': np.array(counts, dtype=np.float32), + } + + return update_dict + + + +@post_processing(data_capture=['index'], + result_capture=['interactions', 'truth_interactions']) def match_interactions(data_dict, result_dict, @@ -23,13 +71,13 @@ def match_interactions(data_dict, overlap_mode='iou'): pred_interactions = result_dict['interactions'] + if overlap_mode == 'chamfer': true_interactions = [ia for ia in result_dict['truth_interactions'] if ia.truth_size > 0] else: true_interactions = [ia for ia in result_dict['truth_interactions'] if ia.size > 0] # Only consider interactions with nonzero predicted nonghost - matched_particles = [] if matching_mode == 'optimal': matched_interactions, counts = match_interactions_optimal( @@ -51,39 +99,10 @@ def match_interactions(data_dict, pred_interactions, min_overlap=min_overlap, overlap_mode=overlap_mode) - - if match_particles: - for interactions in matched_interactions: - domain, codomain = interactions - domain_particles, codomain_particles = [], [] - if domain is not None: - domain_particles = domain.particles - if codomain is not None: - codomain_particles = codomain.particles - domain_particles_masked = [p for p in domain_particles if p.points.shape[0] > 0] - codomain_particles_masked = [p for p in codomain_particles if p.points.shape[0] > 0] - - if matching_mode == 'one_way': - mparticles, _ = match_particles_fn(domain_particles_masked, - codomain_particles_masked, - min_overlap=min_overlap, - overlap_mode=overlap_mode) - elif matching_mode == 'optimal': - mparticles, _ = match_particles_optimal(domain_particles_masked, - codomain_particles_masked, - min_overlap=min_overlap, - overlap_mode=overlap_mode) - else: - raise ValueError(f"Particle matching mode {matching_mode} is not supported!") - matched_particles.extend(mparticles) - - pmatches, pcounts = match_parts_within_ints(matched_interactions) update_dict = { - 'matched_interactions': matched_interactions, - 'matched_particles': matched_particles, - 'interaction_match_values': counts, - 'particle_match_values': pcounts + # 'matched_interactions': matched_interactions, + 'interaction_match_values': np.array(counts, dtype=np.float32), } return update_dict @@ -130,4 +149,22 @@ def match_parts_within_ints(int_matches): else: matched_particles.append((p, ia1[match_id])) match_counts.append(p._match_counts[match_id]) - return matched_particles, np.array(match_counts) \ No newline at end of file + return matched_particles, np.array(match_counts) + + +def check_particle_matches(loaded_particles, clear=False): + match_dict = OrderedDict({}) + for p in loaded_particles: + for i, m in enumerate(p.match): + match_dict[int(m)] = p.match_counts[i] + if clear: + p._match = [] + p._match_counts = OrderedDict() + + match_counts = np.array(list(match_dict.values())) + match = np.array(list(match_dict.keys())).astype(int) + perm = np.argsort(match_counts)[::-1] + match_counts = match_counts[perm] + match = match[perm] + + return match, match_counts \ No newline at end of file From d7b8d000b3eb90a20056733abed285c2488cd1f1 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Wed, 26 Apr 2023 23:10:12 -0700 Subject: [PATCH 175/180] Removed locally defined rounding function --- mlreco/main_funcs.py | 15 ++++++-------- mlreco/models/experimental/hyperopt/search.py | 11 ++++------ mlreco/utils/utils.py | 20 +++++++++---------- 3 files changed, 20 insertions(+), 26 deletions(-) diff --git a/mlreco/main_funcs.py b/mlreco/main_funcs.py index 3202f6ec..6b94ee5e 100644 --- a/mlreco/main_funcs.py +++ b/mlreco/main_funcs.py @@ -23,7 +23,6 @@ class Handlers: data_io_iter = None csv_logger = None weight_io = None - train_logger = None watch = None writer = None iteration = 0 @@ -206,7 +205,6 @@ def log(handlers, tstamp_iteration, #tspent_io, tspent_iteration, Log relevant information to CSV files and stdout. """ import torch - from mlreco.utils import utils report_step = cfg['trainval']['report_step'] and \ ((handlers.iteration+1) % cfg['trainval']['report_step'] == 0) @@ -227,7 +225,7 @@ def log(handlers, tstamp_iteration, #tspent_io, tspent_iteration, mem = 0. if torch.cuda.is_available(): - mem = utils.round_decimals(torch.cuda.max_memory_allocated()/1.e9, 3) + mem = round(torch.cuda.max_memory_allocated()/1.e9, 3) # Organize time info t_iter = handlers.watch.time('iteration') @@ -262,11 +260,11 @@ def log(handlers, tstamp_iteration, #tspent_io, tspent_iteration, # Report (stdout) if report_step: - acc = utils.round_decimals(np.mean(res.get('accuracy',-1)), 4) - loss = utils.round_decimals(np.mean(res.get('loss', -1)), 4) - tfrac = utils.round_decimals(t_net/t_iter*100., 2) - tabs = utils.round_decimals(t_net, 3) - epoch = utils.round_decimals(epoch, 2) + acc = round(np.mean(res.get('accuracy',-1)), 4) + loss = round(np.mean(res.get('loss', -1)), 4) + tfrac = round(t_net/t_iter*100., 2) + tabs = round(t_net, 3) + epoch = round(epoch, 2) if cfg['trainval']['train']: msg = 'Iter. %d (epoch %g) @ %s ... train time %g%% (%g [s]) mem. %g GB \n' @@ -278,7 +276,6 @@ def log(handlers, tstamp_iteration, #tspent_io, tspent_iteration, print(msg) sys.stdout.flush() if handlers.csv_logger: handlers.csv_logger.flush() - if handlers.train_logger: handlers.train_logger.flush() def train_loop(handlers): diff --git a/mlreco/models/experimental/hyperopt/search.py b/mlreco/models/experimental/hyperopt/search.py index f1b573cf..2fff4cb8 100644 --- a/mlreco/models/experimental/hyperopt/search.py +++ b/mlreco/models/experimental/hyperopt/search.py @@ -206,10 +206,8 @@ def train_evaluate(self, sampled_params : dict): data_blob, result_blob = trainer.train_step(self.train_io_iter) - acc = utils.round_decimals( - np.mean(result_blob.get('accuracy',-1)), 4) - loss = utils.round_decimals( - np.mean(result_blob.get('loss', -1)), 4) + acc = round(np.mean(result_blob.get('accuracy',-1)), 4) + loss = round(np.mean(result_blob.get('loss', -1)), 4) end = time.time() tabs = end-start @@ -217,8 +215,7 @@ def train_evaluate(self, sampled_params : dict): epoch = iteration / float(len(self.train_io)) if torch.cuda.is_available(): - mem = utils.round_decimals( - torch.cuda.max_memory_allocated()/1.e9, 3) + mem = round(torch.cuda.max_memory_allocated()/1.e9, 3) tstamp_iteration = datetime.datetime.fromtimestamp( time.time()).strftime('%Y-%m-%d %H:%M:%S') @@ -332,4 +329,4 @@ def search(config): name = hyperopt_config['name'] alg_constructor = construct_hyperopt_run(name) model = alg_constructor(config, hyperopt_config.get('eval_func', 'default')) - model.optimize_and_save() \ No newline at end of file + model.optimize_and_save() diff --git a/mlreco/utils/utils.py b/mlreco/utils/utils.py index 22e1ae92..9322ea13 100644 --- a/mlreco/utils/utils.py +++ b/mlreco/utils/utils.py @@ -1,10 +1,15 @@ import numpy as np import torch import time -import torch_geometric def to_numpy(s): + ''' + Function which casts an array-like object + to a `numpy.ndarray`. + + + ''' import MinkowskiEngine as ME if isinstance(s, np.ndarray): @@ -17,11 +22,6 @@ def to_numpy(s): raise TypeError("Unknown return type %s" % type(s)) -def round_decimals(val, digits): - factor = float(np.power(10, digits)) - return int(val * factor+0.5) / factor - - # Compute moving average def moving_average(a, n=3) : ret = np.cumsum(a, dtype=float) @@ -50,10 +50,10 @@ def progress_bar(count, total, message=''): # Memory usage print function def print_memory(msg=''): - max_allocated = round_decimals(torch.cuda.max_memory_allocated()/1.e9, 3) - allocated = round_decimals(torch.cuda.memory_allocated()/1.e9, 3) - max_cached = round_decimals(torch.cuda.max_memory_cached()/1.e9, 3) - cached = round_decimals(torch.cuda.memory_cached()/1.e9, 3) + max_allocated = round(torch.cuda.max_memory_allocated()/1.e9, 3) + allocated = round(torch.cuda.memory_allocated()/1.e9, 3) + max_cached = round(torch.cuda.max_memory_cached()/1.e9, 3) + cached = round(torch.cuda.memory_cached()/1.e9, 3) print(max_allocated, allocated, max_cached, cached, msg) From d082734ba8312e0bb6d4ca521251148993ea7208 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Wed, 26 Apr 2023 23:11:03 -0700 Subject: [PATCH 176/180] Remove storage of feb_id attribute of CRTHits for now (will be reinstated if needed) --- mlreco/iotools/writers.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mlreco/iotools/writers.py b/mlreco/iotools/writers.py index 3ff6b2ff..ce666a80 100644 --- a/mlreco/iotools/writers.py +++ b/mlreco/iotools/writers.py @@ -27,7 +27,8 @@ class HDF5Writer: # LArCV object attributes that do not need to be stored to HDF5 LARCV_SKIP_ATTRS = [ - 'add_trajectory_point', 'dump', 'momentum', 'boundingbox_2d', 'boundingbox_3d', + 'add_trajectory_point', 'dump', 'momentum', + 'boundingbox_2d', 'boundingbox_3d', 'feb_id', 'pesmap', *[k + a for k in ['', 'parent_', 'ancestor_'] for a in ['x', 'y', 'z', 't']] ] @@ -268,9 +269,9 @@ def get_object_dtype(self, obj): object_dtype.append((key, h5py.vlen_dtype(type(val[0])))) else: # Empty list (typing unknown, cannot store) - pass + raise ValueError(f'Attribute {key} of {obj} is an untyped empty list') else: - raise ValueError('Unexpected key') + raise ValueError(f'Attribute {key} of {obj} has unrecognized type {type(val)}') return object_dtype From 7d2c60d33e0eafd78259fe33c2457b38dbc57ffa Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Wed, 26 Apr 2023 23:16:00 -0700 Subject: [PATCH 177/180] More robustness for HDF5 object writers --- mlreco/iotools/writers.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/mlreco/iotools/writers.py b/mlreco/iotools/writers.py index ce666a80..fe895141 100644 --- a/mlreco/iotools/writers.py +++ b/mlreco/iotools/writers.py @@ -27,16 +27,15 @@ class HDF5Writer: # LArCV object attributes that do not need to be stored to HDF5 LARCV_SKIP_ATTRS = [ - 'add_trajectory_point', 'dump', 'momentum', - 'boundingbox_2d', 'boundingbox_3d', 'feb_id', 'pesmap', + 'add_trajectory_point', 'dump', 'momentum', 'boundingbox_2d', 'boundingbox_3d', *[k + a for k in ['', 'parent_', 'ancestor_'] for a in ['x', 'y', 'z', 't']] ] LARCV_SKIP = { larcv.Particle: LARCV_SKIP_ATTRS, larcv.Neutrino: LARCV_SKIP_ATTRS, - larcv.Flash: LARCV_SKIP_ATTRS, - larcv.CRTHit: LARCV_SKIP_ATTRS + larcv.Flash: ['wireCenters', 'wireWidths'], + larcv.CRTHit: ['feb_id', 'pesmap'] # feb_id should be storable } # Analysis particle object attributes that do not need to be stored to HDF5 From 54395ff08f75c0c4425194580bf1296b2efd1bea Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Wed, 26 Apr 2023 23:41:08 -0700 Subject: [PATCH 178/180] Particle output data dtype fix to align with writer --- analysis/classes/Particle.py | 4 ++-- analysis/classes/TruthInteraction.py | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/analysis/classes/Particle.py b/analysis/classes/Particle.py index 015aac44..0c646596 100644 --- a/analysis/classes/Particle.py +++ b/analysis/classes/Particle.py @@ -132,7 +132,7 @@ def __init__(self, @property def is_primary(self): - return int(self._is_primary) + return bool(self._is_primary) @property def match(self): @@ -214,7 +214,7 @@ def depositions_sum(self): Total amount of charge/energy deposited. This attribute has no setter, as it can only be set by providing a set of depositions. ''' - return self._depositions_sum + return float(self._depositions_sum) @property def depositions(self): diff --git a/analysis/classes/TruthInteraction.py b/analysis/classes/TruthInteraction.py index c540c91a..4d055cb3 100644 --- a/analysis/classes/TruthInteraction.py +++ b/analysis/classes/TruthInteraction.py @@ -85,6 +85,13 @@ def particles(self, value): true_depositions_list.append(p.truth_depositions) true_depositions_MeV_list.append(p.truth_depositions_MeV) + if p.pid >= 0: + self._particle_counts[p.pid] += 1 + self._primary_counts[p.pid] += int(p.is_primary) + else: + self._particle_counts[-1] += 1 + self._primary_counts[-1] += int(p.is_primary) + self._particle_ids = np.array(id_list, dtype=np.int64) self._num_particles = len(value) self._num_primaries = len([1 for p in value if p.is_primary]) From c1e30692bc9713a4754e001224841c8a4e763e65 Mon Sep 17 00:00:00 2001 From: Francois Drielsma Date: Wed, 26 Apr 2023 23:56:35 -0700 Subject: [PATCH 179/180] Smarter HDF5 writer scalar type checks --- mlreco/iotools/readers.py | 7 ++++--- mlreco/iotools/writers.py | 38 +++++++++++--------------------------- 2 files changed, 15 insertions(+), 30 deletions(-) diff --git a/mlreco/iotools/readers.py b/mlreco/iotools/readers.py index 5c4c3f52..d5216390 100644 --- a/mlreco/iotools/readers.py +++ b/mlreco/iotools/readers.py @@ -9,7 +9,7 @@ class HDF5Reader: More documentation to come. ''' - + def __init__(self, file_keys, entry_list=[], skip_entry_list=[], to_larcv=False): ''' Load up the HDF5 file. @@ -32,7 +32,8 @@ def __init__(self, file_keys, entry_list=[], skip_entry_list=[], to_larcv=False) for file_key in file_keys: file_paths = glob.glob(file_key) assert len(file_paths), f'File key {file_key} yielded no compatible path' - self.file_paths.extend(sorted(file_paths)) + self.file_paths.extend(file_paths) + self.file_paths = sorted(self.file_paths) # Loop over the input files, build a map from index to file ID self.num_entries = 0 @@ -225,7 +226,7 @@ def make_larcv_objects(array, names): ---------- array : list List of dictionary of larcv object attributes - names: + names: List of class attribute names Returns diff --git a/mlreco/iotools/writers.py b/mlreco/iotools/writers.py index fe895141..4f946ef5 100644 --- a/mlreco/iotools/writers.py +++ b/mlreco/iotools/writers.py @@ -27,7 +27,7 @@ class HDF5Writer: # LArCV object attributes that do not need to be stored to HDF5 LARCV_SKIP_ATTRS = [ - 'add_trajectory_point', 'dump', 'momentum', 'boundingbox_2d', 'boundingbox_3d', + 'add_trajectory_point', 'dump', 'momentum', 'boundingbox_2d', 'boundingbox_3d', *[k + a for k in ['', 'parent_', 'ancestor_'] for a in ['x', 'y', 'z', 't']] ] @@ -35,7 +35,7 @@ class HDF5Writer: larcv.Particle: LARCV_SKIP_ATTRS, larcv.Neutrino: LARCV_SKIP_ATTRS, larcv.Flash: ['wireCenters', 'wireWidths'], - larcv.CRTHit: ['feb_id', 'pesmap'] # feb_id should be storable + larcv.CRTHit: ['feb_id', 'pesmap'] } # Analysis particle object attributes that do not need to be stored to HDF5 @@ -166,7 +166,7 @@ def register_key(self, blob, key, category): ''' # Store the necessary information to know how to store a key self.key_dict[key]['category'] = category - if self.is_scalar(blob[key]): + if np.isscalar(blob[key]): # Single scalar self.key_dict[key]['dtype'] = h5py.string_dtype() if isinstance(blob[key], str) else type(blob[key]) self.key_dict[key]['scalar'] = True @@ -174,11 +174,11 @@ def register_key(self, blob, key, category): else: if len(blob[key]) != self.batch_size: # TODO: Get rid of this possibility upstream # List with a single scalar, regardless of batch_size - assert len(blob[key]) == 1 and self.is_scalar(blob[key][0]),\ + assert len(blob[key]) == 1 and np.isscalar(blob[key][0]),\ 'If there is an array of length mismatched with batch_size, '+\ 'it must contain a single scalar.' - if self.is_scalar(blob[key][0]): + if np.isscalar(blob[key][0]): # List containing a single scalar per batch ID self.key_dict[key]['dtype'] = h5py.string_dtype() if isinstance(blob[key][0], str) else type(blob[key][0]) self.key_dict[key]['scalar'] = True @@ -312,7 +312,7 @@ def initialize_datasets(self, file): subgroup.create_dataset(f'element_{i}', shape, maxshape=maxshape, dtype=val['dtype']) else: - # If the elements of the list are of equal width, store them all + # If the elements of the list are of equal width, store them all # to one dataset. An index is stored alongside the dataset to break # it into individual elements downstream. subgroup = group.create_group(key) @@ -387,7 +387,7 @@ def append_key(self, file, event, blob, key, batch_id): if not val['merge'] and not isinstance(val['width'], list): # Store single object - if self.is_scalar(blob[key]): + if np.isscalar(blob[key]): obj = blob[key] else: obj = blob[key][batch_id] if len(blob[key]) == self.batch_size else blob[key][0] @@ -407,24 +407,6 @@ def append_key(self, file, event, blob, key, batch_id): # Store one array of for all in the list and a index to break them self.store_flat(group, event, key, blob[key][batch_id]) - @staticmethod - def is_scalar(obj): - ''' - Returns true if the object has no __len__ - attribute or is a string object. - - Parameters - ---------- - object : class instance - Instance of an object used to check typing - - Returns - ------- - bool - True if the object is a scalar or a string - ''' - return not hasattr(obj, '__len__') or isinstance(obj, str) - @staticmethod def store(group, event, key, array): ''' @@ -532,7 +514,7 @@ def store_flat(group, event, key, array_list): @staticmethod def store_objects(group, event, key, array, obj_dtype): ''' - Stores a list of objects with understandable attributes in + Stores a list of objects with understandable attributes in the file and stores its mapping in the event dataset. Parameters @@ -553,7 +535,7 @@ def store_objects(group, event, key, array, obj_dtype): for i, o in enumerate(array): for k, dtype in obj_dtype: attr = getattr(o, k)() if callable(getattr(o, k)) else getattr(o, k) - if isinstance(attr, (int, float, str)): + if np.isscalar(attr): objects[i][k] = attr elif isinstance(attr, larcv.Vertex): vertex = np.array([getattr(attr, a)() for a in ['x', 'y', 'z', 't']], dtype=np.float32) @@ -563,6 +545,8 @@ def store_objects(group, event, key, array, obj_dtype): if not isinstance(attr, np.ndarray): vals = np.array([attr[i] for i in range(len(attr))]) objects[i][k] = vals + else: + raise ValueError(f'Type {type(attr)} of attribute {k} of object {o} does not match an expected dtype') # Extend the dataset, store array dataset = group[key] From e8b2f2f9a38d2c8f556e98ca5f538b01a94c44bb Mon Sep 17 00:00:00 2001 From: Dae Heun Koh Date: Thu, 27 Apr 2023 00:24:30 -0700 Subject: [PATCH 180/180] TruthInteraction counts fix --- analysis/classes/Interaction.py | 2 +- analysis/classes/Particle.py | 50 +++++++++++++++------------- analysis/classes/TruthInteraction.py | 7 ++-- analysis/classes/TruthParticle.py | 18 +++++++++- analysis/classes/builders.py | 1 + 5 files changed, 47 insertions(+), 31 deletions(-) diff --git a/analysis/classes/Interaction.py b/analysis/classes/Interaction.py index 3e4b2313..953eed68 100644 --- a/analysis/classes/Interaction.py +++ b/analysis/classes/Interaction.py @@ -231,7 +231,7 @@ def particles_summary(self): if self._particles is None: return for p in sorted(self._particles.values(), key=lambda x: x.is_primary, reverse=True): pmsg = " {} Particle {}: PID = {}, Size = {}, Match = {} \n".format( - primary_str[p.is_primary], p.id, PID_LABELS[p.pid], p.size, str(p.match)) + primary_str[p.is_primary], p.id, p.pid, p.size, str(p.match)) self._particles_summary += pmsg return self._particles_summary diff --git a/analysis/classes/Particle.py b/analysis/classes/Particle.py index 0c646596..1d2a4a74 100644 --- a/analysis/classes/Particle.py +++ b/analysis/classes/Particle.py @@ -74,8 +74,6 @@ def __init__(self, volume_id: int = -1, image_id: int = -1, semantic_type: int = -1, - pid: int = -1, - is_primary: int = -1, index: np.ndarray = np.empty(0, dtype=np.int64), points: np.ndarray = np.empty(0, dtype=np.float32), depositions: np.ndarray = np.empty(0, dtype=np.float32), @@ -90,12 +88,12 @@ def __init__(self, # Initialize private attributes to be assigned through setters only self._num_fragments = None - self._index = np.array(index, dtype=np.int64) - self._size = len(self._index) - self._depositions = np.atleast_1d(depositions) - self._depositions_sum = np.sum(self._depositions) - self._pid_scores = pid_scores - self._primary_scores = primary_scores + self._index = None + self._depositions = None + self._depositions_sum = -1 + self._pid = -1 + self._size = -1 + self._is_primary = -1 # Initialize attributes self.id = int(group_id) @@ -107,15 +105,20 @@ def __init__(self, self.semantic_type = int(semantic_type) self.points = points - if np.all(pid_scores < 0): - self._pid = pid - else: - self._pid = int(np.argmax(pid_scores)) + self.index = index + self.depositions = depositions + self.pid_scores = pid_scores + self.primary_scores = primary_scores - if np.all(primary_scores < 0): - self._is_primary = is_primary - else: - self._is_primary = int(np.argmax(primary_scores)) + # if np.all(pid_scores < 0): + # self._pid = pid + # else: + # self._pid = int(np.argmax(pid_scores)) + + # if np.all(primary_scores < 0): + # self._is_primary = is_primary + # else: + # self._is_primary = int(np.argmax(primary_scores)) self.start_point = start_point self.end_point = end_point @@ -132,7 +135,7 @@ def __init__(self, @property def is_primary(self): - return bool(self._is_primary) + return int(self._is_primary) @property def match(self): @@ -205,7 +208,7 @@ def index(self): @index.setter def index(self, index): # Count the number of voxels - self._index = index + self._index = np.array(index, dtype=np.int64) self._size = len(index) @property @@ -240,14 +243,13 @@ def pid_scores(self): @pid_scores.setter def pid_scores(self, pid_scores): + self._pid_scores = pid_scores # If no PID scores are providen, the PID is unknown if pid_scores[0] < 0.: - self._pid_scores = pid_scores self._pid = -1 - + else: # Store the PID scores - self._pid_scores = pid_scores - self._pid = int(np.argmax(pid_scores)) + self._pid = int(np.argmax(pid_scores)) @property def pid(self): @@ -266,8 +268,8 @@ def primary_scores(self, primary_scores): # If no primary scores are given, the primary status is unknown if primary_scores[0] < 0.: self._primary_scores = primary_scores - self.is_primary = -1 + self._is_primary = -1 # Store the PID scores and give a best guess self._primary_scores = primary_scores - self.is_primary = np.argmax(primary_scores) + self._is_primary = np.argmax(primary_scores) diff --git a/analysis/classes/TruthInteraction.py b/analysis/classes/TruthInteraction.py index 4d055cb3..b60c21f7 100644 --- a/analysis/classes/TruthInteraction.py +++ b/analysis/classes/TruthInteraction.py @@ -32,6 +32,8 @@ def __init__(self, # Initialize private attributes to be set by setter only self._particles = None + self._particle_counts = np.zeros(6, dtype=np.int64) + self._primary_counts = np.zeros(6, dtype=np.int64) # Invoke particles setter self.particles = particles @@ -127,11 +129,6 @@ def from_particles(cls, particles, verbose=False, **kwargs): _process_interaction_attributes(init_args, processed_args, **kwargs) - for i, t in enumerate(init_args['truth_depositions_MeV']): - if len(t.shape) == 0: - print(t, t.shape) - print(init_args['truth_index'][i]) - # Handle depositions_MeV for TruthParticles processed_args['depositions_MeV'] = np.concatenate(init_args['depositions_MeV']) processed_args['truth_depositions'] = np.concatenate(init_args['truth_depositions']) diff --git a/analysis/classes/TruthParticle.py b/analysis/classes/TruthParticle.py index fc805e77..56cc4165 100644 --- a/analysis/classes/TruthParticle.py +++ b/analysis/classes/TruthParticle.py @@ -3,7 +3,7 @@ from typing import Counter, List, Union from . import Particle - +from mlreco.utils.globals import PDG_TO_PID class TruthParticle(Particle): ''' @@ -37,6 +37,8 @@ class TruthParticle(Particle): def __init__(self, *args, depositions_MeV: np.ndarray = np.empty(0, dtype=np.float32), + pid: int = -1, + is_primary: int = -1, truth_index: np.ndarray = np.empty(0, dtype=np.int64), truth_points: np.ndarray = np.empty((0,3), dtype=np.float32), truth_depositions: np.ndarray = np.empty(0, dtype=np.float32), @@ -44,8 +46,12 @@ def __init__(self, momentum: np.ndarray = -np.ones(3, dtype=np.float32), particle_asis: object = None, **kwargs): + super(TruthParticle, self).__init__(*args, **kwargs) + self._pid = pid + self._is_primary = is_primary + # Initialize attributes self.depositions_MeV = np.atleast_1d(depositions_MeV) self.truth_index = truth_index @@ -58,6 +64,7 @@ def __init__(self, self.end_position = particle_asis.end_position() self.asis = particle_asis + assert PDG_TO_PID[int(self.asis.pdg_code())] == self.pid self.start_point = np.array([getattr(particle_asis.first_step(), a)() \ for a in ['x', 'y', 'z']], dtype=np.float32) @@ -70,6 +77,15 @@ def __init__(self, if np.linalg.norm(self.momentum) > 0.: self.start_dir = self.momentum/np.linalg.norm(self.momentum) + + @property + def pid(self): + return int(self._pid) + + @property + def is_primary(self): + return self._is_primary + def __repr__(self): msg = super(TruthParticle, self).__repr__() return 'Truth'+msg diff --git a/analysis/classes/builders.py b/analysis/classes/builders.py index 85baea4c..b3d0f497 100644 --- a/analysis/classes/builders.py +++ b/analysis/classes/builders.py @@ -353,6 +353,7 @@ def _build_truth(self, for i, lpart in enumerate(larcv_particles): id = int(lpart.id()) pdg = PDG_TO_PID.get(lpart.pdg_code(), -1) + # print(pdg) is_primary = lpart.group_id() == lpart.parent_id() mask_nonghost = labels_nonghost[:, 6].astype(int) == id if np.count_nonzero(mask_nonghost) <= 0: