Vectorised beam plotting - #584
Conversation
|
@roussel-ryan @cr-xu this PR might be of interest for you. Let me know your thoughts. |
|
This looks great! Thanks for the feature! |
… support since NumPy 2.0
… is only support since NumPy 2.0" This reverts commit 9312eaf.
There was a problem hiding this comment.
Pull request overview
This pull request adds ensemble plotting capabilities for vectorized ParticleBeam instances, enabling visualization of mean distributions with uncertainty bounds. The changes introduce new statistical utilities for computing histograms over vectorized distributions and extend the existing plotting methods to display confidence intervals.
Changes:
- Added vectorized histogram computation functions (
vectorized_histogram_1d,vectorized_histogram_2d) that efficiently compute histograms across multiple beam instances - Added functions to compute mean histograms with confidence bounds (
distribution_histogram_and_confidence_1d/2d,histograms_mean_and_confidence) supporting three uncertainty methods: standard deviation (sd), standard error (se), and percentile intervals (pi) - Extended
plot_1d_distribution,plot_2d_distribution, andplot_distributionmethods to support uncertainty visualization for vectorized beams - Updated minimum dependency versions: NumPy 1.23.3→2.0.0, Matplotlib 3.5.0→3.9.0, SciPy 1.10.1→1.13.0
Reviewed changes
Copilot reviewed 8 out of 9 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| cheetah/utils/statistics.py | Adds five new functions for vectorized histogram computation and confidence interval calculation over beam ensembles |
| cheetah/utils/init.py | Exports the new statistical functions for public API access |
| cheetah/particles/particle_beam.py | Refactors plotting methods to use new histogram functions and adds support for uncertainty bands via errorbar parameter |
| tests/test_statistics.py | Adds comprehensive tests for new histogram and confidence functions with various vector shapes and errorbar methods |
| tests/test_plotting.py | Adds test for vectorized beam distribution plotting with both histogram and contour styles |
| setup.py | Updates minimum required versions for matplotlib, numpy, and scipy dependencies |
| test_minimum_requirements_constraints.txt | Updates test constraints to match new minimum dependency versions |
| CHANGELOG.md | Documents the new plotting functionality in v0.8.0 release notes |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| smoothed_lower_bound = gaussian_filter(lower_bound, smoothing) | ||
| smoothed_upper_bound = gaussian_filter(upper_bound, smoothing) |
There was a problem hiding this comment.
The gaussian_filter function from scipy.ndimage expects NumPy arrays, but lower_bound and upper_bound are PyTorch tensors returned from distribution_histogram_and_confidence_1d. These tensors need to be converted to NumPy arrays before being passed to gaussian_filter, and the results should be converted back to tensors if needed for consistency with the rest of the code. The same issue applies to histogram on line 1299.
| ax.fill_between( | ||
| bin_centers, | ||
| smoothed_lower_bound, | ||
| smoothed_upper_bound, | ||
| **({"color": "C1", "alpha": 0.5} | (fill_between_kws or {})), | ||
| ) | ||
|
|
||
| ax.plot( | ||
| centers, | ||
| histogram / histogram.max(), | ||
| **{"color": "black"} | (plot_kws or {}), | ||
| ) | ||
| ax.set_xlabel(f"{self.PRETTY_DIMENSION_LABELS[dimension]}") | ||
| smoothed_histogram = gaussian_filter(histogram, smoothing) | ||
| ax.plot(bin_centers, smoothed_histogram, **({"color": "C0"} | (plot_kws or {}))) |
There was a problem hiding this comment.
Matplotlib's plotting functions typically expect NumPy arrays. While bin_centers is a PyTorch tensor, it should be converted to NumPy (using .numpy() or .cpu().numpy()) before being passed to ax.fill_between and ax.plot to ensure compatibility.
| ax.pcolormesh( | ||
| x_edges, | ||
| y_edges, | ||
| clipped_histogram.T / smoothed_histogram.max(), | ||
| **{"cmap": "rainbow"} | (pcolormesh_kws or {}), | ||
| bin_centers_x, | ||
| bin_centers_y, | ||
| smoothed_histogram.mT, | ||
| **({"cmap": "rainbow"} | (pcolormesh_kws or {})), | ||
| ) | ||
| elif style == "contour": | ||
| contour_histogram = gaussian_filter(histogram, contour_smoothing) | ||
|
|
||
| ax.contour( | ||
| x_centers, | ||
| y_centers, | ||
| contour_histogram.T / contour_histogram.max(), | ||
| **{"levels": 3} | (contour_kws or {}), | ||
| contour_set_of_mean = ax.contour( | ||
| bin_centers_x, | ||
| bin_centers_y, | ||
| smoothed_histogram.mT, | ||
| **({"levels": 3} | (distribution_contour_kws or {})), | ||
| ) | ||
|
|
||
| if lower_bound is not None and upper_bound is not None: | ||
| smoothed_lower_bound = gaussian_filter(lower_bound, smoothing) | ||
| smoothed_upper_bound = gaussian_filter(upper_bound, smoothing) | ||
|
|
||
| ax.contour( | ||
| bin_centers_x, | ||
| bin_centers_y, | ||
| smoothed_lower_bound.mT, | ||
| **( | ||
| {"levels": contour_set_of_mean.levels, "linestyles": "--"} | ||
| | (confidence_contour_kws or {}) | ||
| ), | ||
| ) | ||
| ax.contour( | ||
| bin_centers_x, | ||
| bin_centers_y, | ||
| smoothed_upper_bound.mT, | ||
| **( | ||
| {"levels": contour_set_of_mean.levels, "linestyles": "--"} | ||
| | (confidence_contour_kws or {}) | ||
| ), | ||
| ) |
There was a problem hiding this comment.
Matplotlib's pcolormesh and contour functions typically expect NumPy arrays. The bin_centers_x, bin_centers_y, and histogram tensors should be converted to NumPy arrays before being passed to these plotting functions to ensure compatibility.
| bin_indicies_x = torch.bucketize(x_flat.contiguous(), boundaries_x) | ||
| bin_indicies_y = torch.bucketize(y_flat.contiguous(), boundaries_y) | ||
|
|
||
| # Flatten 2-dimensional bin indices to 1 dimension | ||
| bin_indicies_flat = bin_indicies_x * bins[1] + bin_indicies_y | ||
|
|
||
| # Flatten batch with offsets | ||
| vector_offsets = torch.arange(num_vector_elements, device=x.device) * ( | ||
| bins[0] * bins[1] | ||
| ) | ||
| bin_indicies_flat = (bin_indicies_flat + vector_offsets.unsqueeze(1)).flatten() | ||
|
|
||
| # Count occurrences | ||
| histogram_flat = torch.bincount( | ||
| bin_indicies_flat, minlength=num_vector_elements * bins[0] * bins[1] |
There was a problem hiding this comment.
Typo: 'bin_indicies' should be 'bin_indices' (correct spelling). This typo appears throughout the function.
| bin_indicies_x = torch.bucketize(x_flat.contiguous(), boundaries_x) | |
| bin_indicies_y = torch.bucketize(y_flat.contiguous(), boundaries_y) | |
| # Flatten 2-dimensional bin indices to 1 dimension | |
| bin_indicies_flat = bin_indicies_x * bins[1] + bin_indicies_y | |
| # Flatten batch with offsets | |
| vector_offsets = torch.arange(num_vector_elements, device=x.device) * ( | |
| bins[0] * bins[1] | |
| ) | |
| bin_indicies_flat = (bin_indicies_flat + vector_offsets.unsqueeze(1)).flatten() | |
| # Count occurrences | |
| histogram_flat = torch.bincount( | |
| bin_indicies_flat, minlength=num_vector_elements * bins[0] * bins[1] | |
| bin_indices_x = torch.bucketize(x_flat.contiguous(), boundaries_x) | |
| bin_indices_y = torch.bucketize(y_flat.contiguous(), boundaries_y) | |
| # Flatten 2-dimensional bin indices to 1 dimension | |
| bin_indices_flat = bin_indices_x * bins[1] + bin_indices_y | |
| # Flatten batch with offsets | |
| vector_offsets = torch.arange(num_vector_elements, device=x.device) * ( | |
| bins[0] * bins[1] | |
| ) | |
| bin_indices_flat = (bin_indices_flat + vector_offsets.unsqueeze(1)).flatten() | |
| # Count occurrences | |
| histogram_flat = torch.bincount( | |
| bin_indices_flat, minlength=num_vector_elements * bins[0] * bins[1] |
| **{"color": "black"} | (plot_kws or {}), | ||
| ) | ||
| ax.set_xlabel(f"{self.PRETTY_DIMENSION_LABELS[dimension]}") | ||
| smoothed_histogram = gaussian_filter(histogram, smoothing) |
There was a problem hiding this comment.
The gaussian_filter function from scipy.ndimage expects NumPy arrays, but histogram is a PyTorch tensor returned from distribution_histogram_and_confidence_1d. This tensor needs to be converted to a NumPy array before being passed to gaussian_filter.
| smoothed_histogram = gaussian_filter(mean_histogram, smoothing) | ||
|
|
||
| if style == "histogram": | ||
| ax.pcolormesh( | ||
| x_edges, | ||
| y_edges, | ||
| clipped_histogram.T / smoothed_histogram.max(), | ||
| **{"cmap": "rainbow"} | (pcolormesh_kws or {}), | ||
| bin_centers_x, | ||
| bin_centers_y, | ||
| smoothed_histogram.mT, | ||
| **({"cmap": "rainbow"} | (pcolormesh_kws or {})), | ||
| ) | ||
| elif style == "contour": | ||
| contour_histogram = gaussian_filter(histogram, contour_smoothing) | ||
|
|
||
| ax.contour( | ||
| x_centers, | ||
| y_centers, | ||
| contour_histogram.T / contour_histogram.max(), | ||
| **{"levels": 3} | (contour_kws or {}), | ||
| contour_set_of_mean = ax.contour( | ||
| bin_centers_x, | ||
| bin_centers_y, | ||
| smoothed_histogram.mT, | ||
| **({"levels": 3} | (distribution_contour_kws or {})), | ||
| ) | ||
|
|
||
| if lower_bound is not None and upper_bound is not None: | ||
| smoothed_lower_bound = gaussian_filter(lower_bound, smoothing) | ||
| smoothed_upper_bound = gaussian_filter(upper_bound, smoothing) |
There was a problem hiding this comment.
The gaussian_filter function from scipy.ndimage expects NumPy arrays, but mean_histogram, lower_bound, and upper_bound are PyTorch tensors returned from distribution_histogram_and_confidence_2d. These tensors need to be converted to NumPy arrays before being passed to gaussian_filter.
| bin_ranges = ( | ||
| (float(x.min()), float(x.max())), | ||
| (float(y.min()), float(y.max())), |
There was a problem hiding this comment.
When all x or y values are identical, x.min() equals x.max() (or y.min() equals y.max()), resulting in zero-width bin ranges. This could cause issues with torch.linspace and histogram binning. Consider adding a check to handle this edge case by expanding the range slightly or raising an informative error.
| bin_ranges = ( | |
| (float(x.min()), float(x.max())), | |
| (float(y.min()), float(y.max())), | |
| x_min = float(x.min()) | |
| x_max = float(x.max()) | |
| y_min = float(y.min()) | |
| y_max = float(y.max()) | |
| # Handle edge case where all x or all y values are identical, which would | |
| # otherwise produce zero-width bin ranges. | |
| if x_min == x_max: | |
| if torch.is_floating_point(x): | |
| eps_x = float(torch.finfo(x.dtype).eps) or 1e-6 | |
| else: | |
| # For integer types, expand by half a unit on each side. | |
| eps_x = 0.5 | |
| x_min -= eps_x | |
| x_max += eps_x | |
| if y_min == y_max: | |
| if torch.is_floating_point(y): | |
| eps_y = float(torch.finfo(y.dtype).eps) or 1e-6 | |
| else: | |
| # For integer types, expand by half a unit on each side. | |
| eps_y = 0.5 | |
| y_min -= eps_y | |
| y_max += eps_y | |
| bin_ranges = ( | |
| (x_min, x_max), | |
| (y_min, y_max), |
| ) # (num_vector_elements,) | ||
| bin_indicies_flat = ( | ||
| bin_indicies + vector_offsets.unsqueeze(-1) | ||
| ).flatten() # (num_vector_elements * num_bins,) |
There was a problem hiding this comment.
The comment states the shape is (num_vector_elements * num_bins,), but it should be (num_vector_elements * num_samples,) because we're flattening all the bin indices for all samples across all vector elements.
| ).flatten() # (num_vector_elements * num_bins,) | |
| ).flatten() # (num_vector_elements * num_samples,) |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>



Description
Add the following plotting functionalities for beams with vector dimensions:
Motivation and Context
Types of changes
Checklist
flake8(required).pytesttests pass (required).pyteston a machine with a CUDA GPU and made sure all tests pass (required).Note: We are using a maximum length of 88 characters per line.