petra-catalogs 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Aaron D. Johnson, Javier Roulete, Katerina Chatziioannou, Michele Vallisneri, Kyle Gersbach, Chris Trejo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.3
2
+ Name: petra-catalogs
3
+ Version: 0.1.0
4
+ Summary: From LISA global fit to catalogs
5
+ Author: Aaron D. Johnson
6
+ Requires-Python: >=3.11,<4.0
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.11
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Requires-Dist: numpy (>=2.2.3,<3.0.0)
12
+ Requires-Dist: pandas (>=2.2.3,<3.0.0)
13
+ Requires-Dist: scipy (>=1.15.2,<2.0.0)
14
+ Description-Content-Type: text/markdown
15
+
16
+ # petra
17
+ From the LISA global fit to catalogs of single sources
18
+
@@ -0,0 +1,2 @@
1
+ # petra
2
+ From the LISA global fit to catalogs of single sources
@@ -0,0 +1,2 @@
1
+ from .make_catalog import make_catalog_mv_normal
2
+ from .posterior_chain import PosteriorChain
@@ -0,0 +1,76 @@
1
+ import numpy as np
2
+ from typing import List, Tuple
3
+
4
+
5
+ def mv_normal_aux_distribution(sample: np.ndarray,
6
+ aux_parameters: Tuple[List[np.ndarray]],
7
+ source_index) -> np.ndarray:
8
+ """
9
+ Vectorized: Compute the logpdf of a multivariate normal distribution evaluated at each source in sample.
10
+ Accepts source_index as an int or array-like.
11
+
12
+ Parameters:
13
+ sample: numpy array of shape (num_sources, num_params_per_source)
14
+ aux_parameters: Tuple of lists (means, cov_matrices) where each is of length num_distributions.
15
+ source_index: int or array-like indices specifying which distributions to evaluate.
16
+
17
+ Returns:
18
+ If source_index is a scalar, returns an array of shape (num_sources,).
19
+ Otherwise, returns an array of shape (len(source_index), num_sources).
20
+ """
21
+ means, cov_matrices = aux_parameters
22
+ # Ensure source_index is array-like.
23
+ source_indices = np.atleast_1d(source_index)
24
+ means = np.array(means)[source_indices] # shape: (n, d)
25
+ cov_matrices = np.array(cov_matrices)[source_indices] # shape: (n, d, d)
26
+
27
+ num_sources, d = sample.shape
28
+ # Broadcast sample to match the number of distributions.
29
+ diff = sample[np.newaxis, :, :] - means[:, np.newaxis, :] # shape: (n, num_sources, d)
30
+ inv_cov = np.linalg.inv(cov_matrices) # shape: (n, d, d)
31
+ # Compute log determinant for each covariance matrix.
32
+ _, logdet = np.linalg.slogdet(cov_matrices) # shape: (n,)
33
+ # Compute the Mahalanobis term over distributions and sources.
34
+ mahal = np.einsum('nsi, nij, nsj -> ns', diff, inv_cov, diff) # shape: (n, num_sources)
35
+ logpdf = -0.5 * (d * np.log(2 * np.pi) + logdet[:, None] + mahal) # shape: (n, num_sources)
36
+
37
+ # Return result: squeeze out axis if only one distribution was requested.
38
+ if logpdf.shape[0] == 1:
39
+ return logpdf[0]
40
+ return logpdf
41
+
42
+
43
+ def uni_normal_aux_distribution_single_parameter(sample: np.ndarray,
44
+ aux_parameters: Tuple[List[np.ndarray]],
45
+ source_index,
46
+ single_parameter: int) -> np.ndarray:
47
+ """
48
+ Vectorized: Compute the logpdf of a univariate normal distribution on a single parameter for each source.
49
+ Accepts source_index as an int or array-like.
50
+
51
+ Parameters:
52
+ sample: numpy array of shape (num_sources, num_params_per_source)
53
+ aux_parameters: Tuple of lists (means, stds) for the univariate case.
54
+ source_index: int or array-like indices specifying which distributions to evaluate.
55
+ single_parameter: the parameter index to process.
56
+
57
+ Returns:
58
+ If source_index is a scalar, returns an array of shape (num_sources,).
59
+ Otherwise, returns an array of shape (len(source_index), num_sources).
60
+ """
61
+ values = sample[:, single_parameter] # shape: (num_sources,)
62
+ means, stds = aux_parameters
63
+ source_indices = np.atleast_1d(source_index)
64
+ means = np.array(means)[source_indices] # shape: (n,)
65
+ stds = np.array(stds)[source_indices] # shape: (n,)
66
+
67
+ # Compute logpdf in a vectorized way. Broadcasting: (n,1) vs (num_sources,)
68
+ logpdf = (
69
+ -0.5 * np.log(2 * np.pi)
70
+ - np.log(stds)[:, None]
71
+ - 0.5 * (((values - means[:, None]) / stds[:, None]) ** 2)
72
+ ) # shape: (n, num_sources)
73
+
74
+ if logpdf.shape[0] == 1:
75
+ return logpdf[0]
76
+ return logpdf
@@ -0,0 +1,54 @@
1
+ import numpy as np
2
+ from typing import Callable, List
3
+ from functools import partial
4
+
5
+
6
+ def create_compute_cost_matrix(aux_distribution: Callable, single_parameter: int = None) -> Callable:
7
+ """
8
+ Creates the function to compute the cost matrix for a given auxiliary parametric distribution.
9
+
10
+ Parameters
11
+ ----------
12
+ aux_distribution: The function to compute the cost matrix elements for the auxiliary distribution
13
+
14
+ Return
15
+ ------
16
+ compute_cost_matrix: The function to compute the cost matrix
17
+ """
18
+ if single_parameter is not None:
19
+ aux_distribution = partial(aux_distribution, single_parameter=single_parameter)
20
+
21
+ def compute_cost_matrix(sample: np.ndarray, aux_parameters: List[np.ndarray],
22
+ prob_in_model: np.ndarray, num_distributions: int) -> np.ndarray:
23
+ """
24
+ Takes in a single sample and a set of parameters for all fitting distributions,
25
+ and returns the cost matrix.
26
+
27
+ Parameters
28
+ ----------
29
+ sample: A numpy array of shape (num_sources, num_params_per_source)
30
+ aux_parameters: A tuple of lists containing the auxiliary distribution parameters
31
+ prob_in_model: A numpy array of shape (num_distributions,) that contains the probability of each source being in the catalog
32
+ num_distributions: The number of distributions to use in the cost matrix
33
+
34
+ Return
35
+ ------
36
+ cost_matrix: A numpy array of shape (num_sources, num_distributions)
37
+ """
38
+ # Precompute logarithms.
39
+ with np.errstate(divide='ignore'): # avoid divide by zero warnings, they are expected when we don't clip prob_in_model
40
+ log_prob = np.log(prob_in_model) # shape: (num_distributions,)
41
+ log_prob_not = np.log1p(-prob_in_model)[np.newaxis, :] # shape: (1, num_distributions)
42
+
43
+ # Vectorize over distribution indices.
44
+ distribution_indices = np.arange(num_distributions)
45
+ # Assume that aux_distribution is vectorized so that it returns an array of shape (num_distributions, num_sources)
46
+ cost_matrix = aux_distribution(sample, aux_parameters, distribution_indices)
47
+ cost_matrix = log_prob[:, np.newaxis] + cost_matrix
48
+
49
+ # Replace any NaN values with log(1 - prob_in_model)
50
+ cost_matrix = np.where(np.isnan(cost_matrix), log_prob_not, cost_matrix)
51
+
52
+ return cost_matrix
53
+
54
+ return compute_cost_matrix
@@ -0,0 +1,149 @@
1
+ import numpy as np
2
+ from petra.posterior_chain import PosteriorChain
3
+ from petra.parametric_fits import uni_normal_fit_single_parameter
4
+ from petra.aux_distributions import uni_normal_aux_distribution_single_parameter
5
+ from petra.relabel import create_relabel_samples
6
+
7
+
8
+ def relabel_by_sorting(posterior_chain: PosteriorChain, init_parameter_index: int = 0):
9
+ """
10
+ Relabel sources by simply sorting each row by the chosen parameter.
11
+
12
+ NaNs are replaced by infinity so that they come last, then after sorting
13
+ we revert infinity back to NaN.
14
+ """
15
+ chain = posterior_chain.get_chain().copy() # shape: (n_samples, n_sources, n_params)
16
+ n_samples, n_sources, _ = chain.shape
17
+
18
+ # Work on the parameter we want to sort on.
19
+ param = chain[:, :, init_parameter_index].copy()
20
+ # Replace NaNs with infinity so they appear at the end when sorting.
21
+ param[np.isnan(param)] = np.inf
22
+
23
+ # For each row, get the permutation that sorts the parameter values.
24
+ ordering = np.argsort(param, axis=1)
25
+
26
+ # Reorder the entire chain.
27
+ reordered_chain = np.take_along_axis(chain, ordering[:, :, np.newaxis], axis=1)
28
+
29
+ # Convert any inf back to NaN in the parameter.
30
+ sorted_param = reordered_chain[:, :, init_parameter_index]
31
+ sorted_param[np.isinf(sorted_param)] = np.nan
32
+ reordered_chain[:, :, init_parameter_index] = sorted_param
33
+
34
+ return PosteriorChain(
35
+ reordered_chain,
36
+ posterior_chain.num_sources,
37
+ posterior_chain.num_params_per_source,
38
+ posterior_chain.trans_dimensional,
39
+ posterior_chain.prob_in_model,
40
+ posterior_chain.cost_dict,
41
+ )
42
+
43
+
44
+ def relabel_by_histogram(posterior_chain: PosteriorChain, init_parameter_index: int = 0):
45
+ """
46
+ Assign labels to sources based on samples for one parameter,
47
+ ensuring that each original column (source) is used exactly once per row.
48
+
49
+ The algorithm:
50
+ 1. Extract the parameter values (for ordering) and replace NaNs with ∞ so they’re
51
+ chosen only after all finite values.
52
+ 2. For each ordering column (i.e. for each source position):
53
+ a. Compute a histogram from all remaining finite values (across all rows).
54
+ b. Find the histogram’s mode.
55
+ c. For each row, choose the index (from the not-yet-assigned values) whose value is
56
+ closest to the mode.
57
+ d. Mark that chosen value as “used” by setting it to NaN.
58
+ 3. Reorder the full chain according to the computed ordering.
59
+ 4. Convert any ∞ values in the sorted parameter back to NaN.
60
+
61
+ Parameters
62
+ ----------
63
+ posterior_chain : PosteriorChain
64
+ Contains a 3D array of samples with shape (n_samples, n_sources, n_params).
65
+ init_parameter_index : int, optional
66
+ The parameter index used for sorting (default 0).
67
+
68
+ Returns
69
+ -------
70
+ PosteriorChain
71
+ A new PosteriorChain with sources reordered so that each original column is used once per row.
72
+ """
73
+ # Get a copy of the full chain (shape: [n_samples, n_sources, n_params])
74
+ chain = posterior_chain.get_chain().copy()
75
+ n_samples, n_sources, _ = chain.shape
76
+
77
+ # Extract the parameter values we are sorting on.
78
+ # Work on a separate copy so that we can mark chosen entries.
79
+ param_values = chain[:, :, init_parameter_index].copy()
80
+ # Replace NaNs with infinity so they are assigned last.
81
+ param_values[np.isnan(param_values)] = np.inf
82
+
83
+ # Prepare an array to store the new ordering (each row should be a permutation of 0..n_sources-1).
84
+ reordered_indices = np.empty((n_samples, n_sources), dtype=int)
85
+
86
+ # Iterate for each new column in the ordering.
87
+ for new_col in range(n_sources):
88
+ # Compute the global histogram mode from all remaining finite values.
89
+ remaining = param_values[np.isfinite(param_values)]
90
+ if remaining.size > 0:
91
+ hist, edges = np.histogram(remaining, bins="auto")
92
+ centers = (edges[:-1] + edges[1:]) / 2
93
+ mode = centers[np.argmax(hist)]
94
+ else:
95
+ # Fallback if no finite values remain (should not occur given our assumptions).
96
+ mode = 0.0
97
+
98
+ # Now, for each row, pick the index (from values not yet chosen) that is closest to the mode.
99
+ for i in range(n_samples):
100
+ row = param_values[i, :]
101
+ # Find the positions that have not been chosen yet (i.e. are not NaN).
102
+ available = np.where(~np.isnan(row))[0]
103
+ if available.size > 0:
104
+ # Compute differences only over available values.
105
+ diffs = np.abs(row[available] - mode)
106
+ best_idx = available[np.argmin(diffs)]
107
+ else:
108
+ # Should not happen; every row originally has at least one finite value.
109
+ best_idx = 0
110
+ # Record the chosen index for this new column.
111
+ reordered_indices[i, new_col] = best_idx
112
+ # Mark the chosen value as used by setting it to NaN.
113
+ param_values[i, best_idx] = np.nan
114
+
115
+ # Reorder the entire chain using the computed ordering.
116
+ # This reorders along the source dimension.
117
+ reordered_chain = np.take_along_axis(chain, reordered_indices[:, :, np.newaxis], axis=1)
118
+
119
+ # In the sorted parameter, convert any ∞ values back to NaN.
120
+ sorted_param = reordered_chain[:, :, init_parameter_index]
121
+ sorted_param[np.isinf(sorted_param)] = np.nan
122
+ reordered_chain[:, :, init_parameter_index] = sorted_param
123
+
124
+ return PosteriorChain(
125
+ reordered_chain,
126
+ posterior_chain.num_sources,
127
+ posterior_chain.num_params_per_source,
128
+ posterior_chain.trans_dimensional,
129
+ posterior_chain.prob_in_model,
130
+ posterior_chain.cost_dict,
131
+ )
132
+
133
+
134
+ def relabel_uni_normal_one_parameter(posterior_chain: PosteriorChain,
135
+ max_num_sources: int = None,
136
+ num_iterations: int = 20,
137
+ init_parameter_index: int = 0,
138
+ eps=1e-2):
139
+ # create single parameter function to relabel samples
140
+ relabel_samples = create_relabel_samples(uni_normal_fit_single_parameter,
141
+ uni_normal_aux_distribution_single_parameter,
142
+ single_parameter=init_parameter_index,
143
+ eps=eps)
144
+
145
+ return relabel_samples(
146
+ posterior_chain,
147
+ max_num_sources=max_num_sources,
148
+ num_iterations=num_iterations
149
+ )
@@ -0,0 +1,113 @@
1
+ import numpy as np
2
+ import os
3
+ import glob
4
+ from petra.utils import sort_by_number
5
+ from petra.posterior_chain import PosteriorChain
6
+
7
+
8
+ def load_samples_product_space(filepath, num_params_per_source, fill_value=np.nan):
9
+ """
10
+ Load chains from a file and reshape them into a 3D array
11
+ with dimensions (num_samples, num_sources, num_params)
12
+
13
+ Assumes the chain is organized as (param1_source1, param2_source1, ..., paramN_source1, paramN_source2, ...)
14
+ """
15
+ with open(filepath, "r") as f:
16
+ chain = np.loadtxt(f)
17
+ num_sources_vector = np.rint(chain[:, -5] + 1).astype(int)
18
+ num_sources = np.max(num_sources_vector)
19
+
20
+ only_samples_chain = (
21
+ np.zeros((chain.shape[0], num_sources * num_params_per_source)) + fill_value
22
+ )
23
+ for i in range(chain.shape[0]):
24
+ only_samples_chain[i, : num_sources_vector[i] * num_params_per_source] = chain[
25
+ i, : num_sources_vector[i] * num_params_per_source
26
+ ]
27
+ samples = only_samples_chain.reshape(-1, num_sources, num_params_per_source)
28
+ return PosteriorChain(
29
+ samples, num_sources, num_params_per_source, trans_dimensional=True
30
+ )
31
+
32
+
33
+ def load_samples_fixed_num_sources(filepath, num_params_per_source, burn=0, thin=1):
34
+ """
35
+ Load chains from a file and reshape them into a 3D array
36
+ with dimensions (num_samples, num_sources, num_params)
37
+
38
+ Assumes the chain is organized as (param1_source1, param2_source1, ..., paramN_source1, paramN_source2, ...)
39
+ """
40
+ with open(filepath, "r") as f:
41
+ chain = np.loadtxt(f)
42
+ chain = chain[burn::thin, :-4] # remove metadata columns
43
+ num_sources = (chain.shape[1]) // num_params_per_source
44
+ samples = chain.reshape(-1, num_sources, num_params_per_source)
45
+ return PosteriorChain(samples, num_sources, num_params_per_source)
46
+
47
+
48
+ def load_samples_ucbmcmc(chain_folder, fill_value=np.nan, burn=0, thin=1, remove_low_numbers=False):
49
+ """
50
+ Load chains from files and reshape them into a 3D array
51
+ with dimensions (num_samples, num_sources, num_params).
52
+
53
+ Only files with more than 8 samples (i.e. more samples than the number
54
+ of dimensions in the model) are loaded.
55
+
56
+ Assumes the chain is organized as:
57
+ (param1_source1, param2_source1, ...,
58
+ param1_source2, param2_source2, ...,
59
+ ...,
60
+ paramN_source1, paramN_source2, ...)
61
+ """
62
+
63
+ # Find and sort all matching filepaths.
64
+ filepaths = sort_by_number(
65
+ glob.glob(os.path.join(chain_folder, "dimension_chain.dat.*"))
66
+ )
67
+
68
+ # Build a list of valid files that have more samples than the model's 8 dimensions.
69
+ valid_files = []
70
+ for filepath in filepaths:
71
+ # Determine the number of sources from the filename.
72
+ nsources = int(filepath.split(".")[-1])
73
+ if nsources == 0:
74
+ continue # Skip files with no sources
75
+
76
+ # Count the total number of lines in the file.
77
+ with open(filepath, "r") as f:
78
+ total_lines = sum(1 for line in f)
79
+ # Calculate the number of samples by dividing by the number of sources.
80
+ # (Assumes that the file's total line count is an integer multiple of nsources.)
81
+ nsamples = total_lines // nsources
82
+
83
+ # Only accept files with more than 8 samples.
84
+ if not remove_low_numbers:
85
+ valid_files.append((filepath, nsamples, nsources))
86
+ elif remove_low_numbers and nsamples > 8:
87
+ valid_files.append((filepath, nsamples, nsources))
88
+
89
+ if not valid_files:
90
+ raise ValueError("No files with more than 8 samples found.")
91
+
92
+ # Compute the total number of samples and maximum number of sources among valid files.
93
+ total_samples = sum(nsamples for (_, nsamples, _) in valid_files)
94
+ max_sources = max(nsources for (_, _, nsources) in valid_files)
95
+
96
+ # Initialize the array to hold the samples.
97
+ samples = np.full((total_samples, max_sources, 8), fill_value)
98
+
99
+ current_total = 0
100
+ # Loop over valid files and load their data.
101
+ for filepath, nsamples, nsources in valid_files:
102
+ # Load the chain data from the file.
103
+ chain = np.loadtxt(filepath)
104
+ # Reshape and place the data into the samples array.
105
+ samples[current_total: current_total + nsamples, :nsources, :] = chain.reshape((nsamples, nsources, 8))
106
+ current_total += nsamples
107
+
108
+ return PosteriorChain(
109
+ samples[burn::thin],
110
+ max_sources,
111
+ trans_dimensional=True,
112
+ num_params_per_source=8,
113
+ )
@@ -0,0 +1,54 @@
1
+ from petra.posterior_chain import PosteriorChain
2
+ from petra.relabel import create_relabel_samples
3
+ from petra.aux_distributions import mv_normal_aux_distribution
4
+ from petra.parametric_fits import mv_normal_fit
5
+ from petra.initialization import relabel_uni_normal_one_parameter, relabel_by_histogram
6
+
7
+
8
+ def relabel_mv_normal(posterior_chain: PosteriorChain,
9
+ max_num_sources: int = None,
10
+ num_iterations: int = 20,
11
+ eps=1e-2):
12
+
13
+ relabel_samples = create_relabel_samples(mv_normal_fit,
14
+ mv_normal_aux_distribution,
15
+ eps=eps)
16
+
17
+ return relabel_samples(
18
+ posterior_chain,
19
+ max_num_sources=max_num_sources,
20
+ num_iterations=num_iterations
21
+ )
22
+
23
+
24
+ def make_catalog_mv_normal(posterior_chain: PosteriorChain,
25
+ max_num_sources: int,
26
+ num_iterations: int = 200,
27
+ initialization_param_index: int = None,
28
+ shuffle_seed: int = None):
29
+ # shuffle the entries
30
+ posterior_chain.randomize_entries(shuffle_seed)
31
+
32
+ # make sure that posterior_chain has the right shape
33
+ if posterior_chain.num_sources > max_num_sources:
34
+ posterior_chain.expand_chain(max_num_sources)
35
+
36
+ # initialize here:
37
+ if initialization_param_index is not None:
38
+ print("Initializing with univariate normal distribution.")
39
+ # initial_posterior_chain = relabel_uni_normal_one_parameter(posterior_chain,
40
+ # max_num_sources=max_num_sources,
41
+ # num_iterations=init_num_iterations,
42
+ # init_parameter_index=initialization_param_index)
43
+ initial_posterior_chain = relabel_by_histogram(posterior_chain,
44
+ init_parameter_index=initialization_param_index)
45
+ else:
46
+ initial_posterior_chain = posterior_chain
47
+
48
+ # relabel using mv normal
49
+ print("Relabeling with multivariate normal distribution.")
50
+ relabeled_chain = relabel_mv_normal(initial_posterior_chain,
51
+ max_num_sources=max_num_sources,
52
+ num_iterations=num_iterations)
53
+
54
+ return relabeled_chain
@@ -0,0 +1,131 @@
1
+ import pandas as pd
2
+ import numpy as np
3
+ from functools import partial
4
+ from typing import Callable
5
+ from petra.utils import find_prob_in_model
6
+
7
+
8
+ def create_parametric_fit(fit_function, single_parameter=None):
9
+ """
10
+ Create a function to fit a parametric distribution to the chain of samples. Extensible to any parametric distribution.
11
+
12
+ Parameters
13
+ ----------
14
+ fit_function: Callable function with the signature (chain, max_num_sources, single_parameter (optional))
15
+ The function to fit the parametric distribution to the chain of samples
16
+
17
+ Return
18
+ ------
19
+ parametric_fit: Callable function with the signature (chain, max_num_sources)
20
+ The function to fit the parametric distribution to the chain of samples
21
+ """
22
+
23
+ if single_parameter is not None:
24
+ fit_function = partial(fit_function, fit_parameter=single_parameter)
25
+
26
+ def parametric_fit(chain, max_num_sources):
27
+ """
28
+ Fit a parametric distribution to the chain of samples .
29
+
30
+ Parameters
31
+ ----------
32
+ chain: A numpy array of shape (num_samples, num_entries, num_params_per_source)
33
+ max_num_sources: The maximum number of sources to consider in the catalog
34
+
35
+ Return
36
+ ------
37
+ fit: The fit to the chain of samples
38
+ """
39
+ return fit_function(chain, max_num_sources)
40
+
41
+ return parametric_fit
42
+
43
+
44
+ def mv_normal_fit(chain, max_num_sources):
45
+ """
46
+ Get a MV Gaussian fit (mu, cov) to use in the cost matrix. This function takes in a chain of samples and returns the
47
+ mean and covariance matrix. NaN values are removed by pandas before the calculation.
48
+
49
+ Parameters
50
+ ----------
51
+ chain: A numpy array of shape (num_samples, num_entries, num_params_per_source)
52
+ max_num_sources: The maximum number of sources to consider in the catalog
53
+
54
+ Return
55
+ -------
56
+ means: A list of length max_num_sources of numpy arrays of shape (num_params_per_source,)
57
+ cov_matrices: A list of length max_num_sources of numpy arrays of shape (num_params_per_source, num_params_per_source)
58
+ """
59
+
60
+ means = []
61
+ cov_matrices = []
62
+ for source in range(max_num_sources):
63
+ sample_i = chain[:, source, :] # shape: (num_samples, num_params)
64
+ valid = ~np.isnan(sample_i).any(axis=1)
65
+ valid_samples = sample_i[valid]
66
+ if valid_samples.shape[0] < 8:
67
+ print('Fewer than 8 values in source index {}. Appending a standard normal distribution.'.format(source))
68
+ means.append(np.zeros(chain.shape[2]))
69
+ cov_matrices.append(np.identity(chain.shape[2]))
70
+ continue
71
+ df = pd.DataFrame(chain[:, source, :])
72
+ means.append(np.array(df.dropna().mean()))
73
+ cov_matrices.append(np.array(df.dropna().cov()))
74
+ return np.array(means), np.array(cov_matrices)
75
+
76
+
77
+ def uni_normal_fit_single_parameter(chain, max_num_sources, fit_parameter):
78
+ """
79
+ Get a univariate Gaussian fit (mu, std) to use in the cost matrix. This function takes in a chain of samples and returns the
80
+ mean and standard deviation. NaN values are removed by pandas before the calculation.
81
+
82
+ Parameters
83
+ ----------
84
+ chain: A numpy array of shape (num_samples, num_entries, num_params_per_source)
85
+ max_num_sources: The maximum number of sources to consider in the catalog
86
+ fit_parameter: The index of the parameter to fit
87
+
88
+ Return
89
+ ------
90
+ means: A list of length max_num_sources of numpy arrays of shape (num_params_per_source,)
91
+ stds: A list of length max_num_sources of numpy arrays of shape (num_params_per_source,)
92
+ """
93
+ means = []
94
+ stds = []
95
+ for source in range(max_num_sources):
96
+ sample_i = chain[:, source, fit_parameter] # shape: (num_samples, num_params)
97
+ valid = np.where(~np.isnan(sample_i))
98
+ valid_samples = sample_i[valid]
99
+ if valid_samples.shape[0] < 8:
100
+ print('Fewer than 8 values in source index {}. Appending a standard normal distribution.'.format(source))
101
+ means.append(0)
102
+ stds.append(1)
103
+ continue
104
+ else:
105
+ df = pd.DataFrame(chain[:, source, fit_parameter])
106
+ mean = np.nanmean(df)
107
+ std = np.nanstd(df)
108
+ means.append(mean)
109
+ stds.append(std)
110
+ return np.array(means), np.array(stds)
111
+
112
+
113
+ def update_parametric_fit_and_prob_in_model(posterior_chain, max_num_sources, parametric_fit_function: Callable, eps=1e-2):
114
+ """
115
+ Update the parametric fit and the probability of each source being in the model.
116
+
117
+ Parameters
118
+ ----------
119
+ chain: A numpy array of shape (num_samples, num_entries, num_params_per_source)
120
+ max_num_sources: The maximum number of sources to consider in the catalog
121
+ fit_function: A function that fits a parametric distribution to the chain of samples
122
+ prob_in_model: A numpy array of shape (max_num_sources,) that contains the probability of each source being in the model
123
+
124
+ Return
125
+ ------
126
+ parametric_fit: The fit to the chain of samples
127
+ prob_in_model: A numpy array of shape (max_num_sources,)
128
+ """
129
+ aux_params = parametric_fit_function(posterior_chain.get_chain(), max_num_sources)
130
+ prob_in_model = find_prob_in_model(posterior_chain.get_chain(), max_num_sources, eps=eps)
131
+ return aux_params, prob_in_model
@@ -0,0 +1,70 @@
1
+ import numpy as np
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass
6
+ class PosteriorChain:
7
+ """
8
+ A dataclass to store the chains, number of sources, and number of parameters per source
9
+
10
+ Parameters
11
+ ----------
12
+ chain: np.ndarray
13
+ The chain of samples
14
+ num_sources: int
15
+ The number of sources
16
+ num_params_per_source: int
17
+ The number of parameters per source
18
+ trans_dimensional: bool
19
+ Whether the chain has variable number of sources
20
+ prob_in_model: ndarray
21
+ Probability of a source being in the model
22
+ cost_dict: dict
23
+ A dictionary of total cost for the labeling
24
+ """
25
+
26
+ chain: np.ndarray
27
+ num_sources: int
28
+ num_params_per_source: int
29
+ trans_dimensional: bool = False
30
+ prob_in_model: np.ndarray = None
31
+ cost_dict: dict = None
32
+
33
+ def __post_init__(self):
34
+ if self.cost_dict is None:
35
+ self.cost_dict = {}
36
+ self.chain = self.chain.reshape(-1, self.num_sources, self.num_params_per_source)
37
+
38
+ def __repr__(self):
39
+ return self.chain.view().__repr__()
40
+
41
+ @property
42
+ def shape(self):
43
+ return self.chain.shape
44
+
45
+ def __getitem__(self, index):
46
+ return self.chain[index]
47
+
48
+ def __setitem__(self, index, value):
49
+ self.chain[index] = value
50
+
51
+ def get_chain(self, burn=0, thin=1):
52
+ return self.chain[burn::thin]
53
+
54
+ def expand_chain(self, max_num_sources):
55
+ """
56
+ Expand the second dimension of the chain to the maximum number of sources.
57
+ """
58
+ expanded_chain = np.zeros((self.chain.shape[0], max_num_sources, self.num_params_per_source)) + np.nan
59
+ expanded_chain[:, :self.num_sources, :] = self.chain
60
+ return PosteriorChain(expanded_chain, max_num_sources, self.num_params_per_source, True, self.prob_in_model, self.cost_dict)
61
+
62
+ def randomize_entries(self, seed=None):
63
+ """
64
+ Randomize the entries (second dimension) of the chain without repetition.
65
+ """
66
+ # Shuffle along axis=1 (the second dimension) for each index in the first dimension
67
+ if seed is not None:
68
+ np.random.seed(seed)
69
+ for i in range(self.chain.shape[0]):
70
+ np.random.shuffle(self.chain[i])
@@ -0,0 +1,146 @@
1
+ import numpy as np
2
+ from scipy import optimize
3
+ from typing import Callable
4
+
5
+ from petra.utils import fill_missing_indices
6
+ from petra.posterior_chain import PosteriorChain
7
+ from petra.parametric_fits import update_parametric_fit_and_prob_in_model, create_parametric_fit
8
+ from petra.cost_matrix import create_compute_cost_matrix
9
+
10
+
11
+ def relabel_samples_one_iteration(chain, aux_parameters, prob_in_model, max_num_sources, compute_cost_matrix: Callable):
12
+ """
13
+ Takes in a chain of samples and a MV Gaussian fit and returns the sorted chain
14
+
15
+ Parameters
16
+ ----------
17
+ chain: A numpy array of shape (num_chains, num_sources, num_params_per_source)
18
+ parameters: A set of parameters that fit the aux distribution to the chain
19
+ prob_in_model: A numpy array of shape (max_num_sources,) that contains the probability of each source being in the model
20
+ max_num_sources: The maximum number of sources to consider in the catalog (can be less than, equal to, or greater than the number of entries in the chain)
21
+
22
+ Return
23
+ ------
24
+ relabeled_chain: A numpy array of shape (num_chains, num_sources, num_params_per_source)
25
+ cost: Cost of the optimal assignment
26
+ """
27
+ # TODO(Aaron): Parallelize this for loop over samples
28
+ relabeled_list = []
29
+ total_cost = 0
30
+ for sample in chain:
31
+ cost_matrix = compute_cost_matrix(sample, aux_parameters, prob_in_model, max_num_sources)
32
+ total_entries = max(len(sample), max_num_sources) # pick the larger of number of aux distributions or sample entries
33
+ row_ind, col_ind = optimize.linear_sum_assignment(cost_matrix, maximize=True) # solve the linear sum assignment problem
34
+ total_cost -= cost_matrix[row_ind, col_ind].sum()
35
+ # TODO(Aaron): Is the following line still necessary? It shouldn't change the result either way.
36
+ relabeled_list.append(fill_missing_indices(total_entries, col_ind)) # sum assignment only outputs 1 number for each labeling distribution input
37
+ relabeled_array = np.array(relabeled_list)
38
+
39
+ relabeled_samples = np.array([row[m] for row, m in zip(chain, relabeled_array)]) # reorder the original samples
40
+ total_cost /= len(chain)
41
+ return relabeled_samples, total_cost
42
+
43
+
44
+ def relabel_posterior_chain_one_iteration(posterior_chain: PosteriorChain, aux_parameters, prob_in_model, max_num_sources, compute_cost_matrix: Callable):
45
+ """
46
+ Takes in a chain of samples and a MV Gaussian fit and returns the sorted chain
47
+
48
+ Parameters
49
+ ----------
50
+ chain: A PosteriorChain object
51
+ parameters: A set of parameters that fit the aux distribution to the chain
52
+ prob_in_model: A numpy array of shape (max_num_sources,) that contains the probability of each source being in the model
53
+ max_num_sources: The maximum number of sources to consider in the catalog (can be less than, equal to, or greater than the number of entries in the chain)
54
+
55
+ Return
56
+ ------
57
+ relabeled_chain: A PosteriorChain object
58
+ cost: Cost of the optimal assignment
59
+ """
60
+ cost_dict = posterior_chain.cost_dict
61
+ relabeled_chain, total_cost = relabel_samples_one_iteration(posterior_chain.get_chain(), aux_parameters, prob_in_model, max_num_sources, compute_cost_matrix)
62
+ if cost_dict is None:
63
+ cost_dict = {}
64
+ cost_dict[max_num_sources] = total_cost
65
+ return PosteriorChain(relabeled_chain, posterior_chain.num_sources, posterior_chain.num_params_per_source, prob_in_model=prob_in_model, cost_dict=cost_dict)
66
+
67
+
68
+ def create_relabel_samples(parametric_fit_function: Callable,
69
+ aux_distribution: Callable,
70
+ single_parameter: int = None,
71
+ eps: float = 1e-2):
72
+
73
+ # make all the necessary pieces
74
+ param_fit = create_parametric_fit(parametric_fit_function, single_parameter=single_parameter)
75
+ compute_cost_matrix = create_compute_cost_matrix(aux_distribution, single_parameter=single_parameter)
76
+
77
+ def relabel_samples(posterior_chain: PosteriorChain,
78
+ max_num_sources: int = None,
79
+ num_iterations: int = 200):
80
+ """
81
+ Takes in a posterior_chain and relabels the samples using a multivariate normal auxiliary fitting distribution
82
+
83
+ Parameters
84
+ ----------
85
+ posterior_chain: A PosteriorChain object
86
+ max_num_sources: The maximum number of sources to consider in the catalog (can be less than, equal to, or greater than the number of entries in the chain)
87
+ num_iterations: How many tries to use before stopping the algorithm
88
+ init_parameter_index: Which parameter index to use in the initialization procedures
89
+ shuffle_entries: Whether to shuffle the entries in the chain at the start
90
+ shuffle_seed: Seed for the random number generator
91
+
92
+ Return
93
+ ------
94
+ relabeled_chain: A PosteriorChain object
95
+ cost: Cost of the optimal assignment
96
+ """
97
+
98
+ # if shuffle_entries:
99
+ # posterior_chain.randomize_entries(shuffle_seed)
100
+ if max_num_sources is None:
101
+ max_num_sources = posterior_chain.num_sources
102
+ if max_num_sources > posterior_chain.num_sources:
103
+ # increase the width of the posterior chain to the maximum number of sources
104
+ posterior_chain = posterior_chain.expand_chain(max_num_sources)
105
+ if max_num_sources < posterior_chain.num_sources:
106
+ raise ValueError("The maximum number of sources cannot be less than the number of entries in the chain.")
107
+
108
+ print()
109
+ print(f"Sorting the posterior chain:\n\tMaximum number of iterations: {num_iterations}\n\tMaximum number of source labels:{max_num_sources}\n")
110
+
111
+ # set up the for loop
112
+ old_posterior_chain = posterior_chain
113
+ old_parametric_fit, old_prob_in_model = update_parametric_fit_and_prob_in_model(posterior_chain, max_num_sources, param_fit, eps=eps)
114
+ old_cost_of_assignment = 0
115
+
116
+ for iteration in range(num_iterations):
117
+ # get the new values
118
+ new_posterior_chain = relabel_posterior_chain_one_iteration(old_posterior_chain, old_parametric_fit, old_prob_in_model, max_num_sources, compute_cost_matrix)
119
+ new_parametric_fit, new_prob_in_model = update_parametric_fit_and_prob_in_model(new_posterior_chain, max_num_sources, param_fit, eps=eps)
120
+ new_cost_of_assignment = new_posterior_chain.cost_dict[max_num_sources]
121
+
122
+ # print out the results
123
+ delta_cost_of_assignment = new_cost_of_assignment - old_cost_of_assignment
124
+ print(f"Iteration {iteration + 1}: Difference in cost of assignment is {delta_cost_of_assignment} with total cost of {new_cost_of_assignment}.")
125
+ print(f"\tProbabilities in model: {new_prob_in_model}")
126
+
127
+ # break if converged
128
+ if (delta_cost_of_assignment == 0):
129
+ print(f"Stopped after {iteration + 1} iterations because the cost didn't change from the previous iteration.")
130
+ new_parametric_fit = old_parametric_fit
131
+ new_prob_in_model = old_prob_in_model
132
+ new_posterior_chain = old_posterior_chain
133
+ break
134
+
135
+ # update the old values to the new values
136
+ old_posterior_chain = new_posterior_chain
137
+ old_parametric_fit = new_parametric_fit
138
+ old_prob_in_model = new_prob_in_model
139
+ old_cost_of_assignment = new_cost_of_assignment
140
+
141
+ if iteration == num_iterations - 1:
142
+ print(f"Final cost of assignment: {new_cost_of_assignment} after the maximum number ({num_iterations}) of iterations.")
143
+
144
+ return new_posterior_chain
145
+
146
+ return relabel_samples
@@ -0,0 +1,61 @@
1
+ import numpy as np
2
+
3
+
4
+ def find_prob_in_model(chain, max_num_sources, eps=1e-2):
5
+ """
6
+ Find the number of samples for each entry in the chain and divide by the total number of samples.
7
+
8
+ Parameters
9
+ ----------
10
+ chains: A numpy array of shape (num_samples, num_entries, num_params_per_source)
11
+ max_num_sources: The maximum number of sources to consider in the catalog
12
+ eps: A small number to avoid division by zero
13
+
14
+ Return
15
+ ------
16
+ prob_in_model: A numpy array of shape (max_num_sources,)
17
+ """
18
+ num_samples = chain.shape[0]
19
+ prob_in_model = np.zeros(max_num_sources)
20
+ for i in range(max_num_sources):
21
+ prob_in_model[i] = np.sum(~np.isnan(chain[:, i, 0])) / num_samples
22
+ # clip the probabilities to avoid division by zero in np.log
23
+ prob_in_model = np.clip(prob_in_model, eps, 1 - eps)
24
+ return prob_in_model
25
+
26
+
27
+ def fill_missing_indices(total_sources, given_indices):
28
+ # Step 1: Generate a list of all indices from 0 to total_sources - 1
29
+ all_indices = np.arange(total_sources)
30
+ # Step 2: Convert given_indices to a set for faster operations
31
+ given_indices_set = set(given_indices)
32
+ # Step 3: Filter out the given indices from all_indices to get missing indices
33
+ missing_indices = [index for index in all_indices if index not in given_indices_set]
34
+ # Step 4: Combine the given indices with the missing indices
35
+ filled_indices = list(given_indices) + missing_indices
36
+
37
+ return np.array(filled_indices)
38
+
39
+
40
+ def count_swaps(arr):
41
+ """
42
+ Counts the number of swaps needed to sort an array.
43
+
44
+ Args:
45
+ arr (np.array): The input array.
46
+
47
+ Returns:
48
+ int: The number of swaps.
49
+ """
50
+ sorted_arr = np.sort(arr)
51
+ swaps = np.sum(arr != sorted_arr)
52
+ return swaps // 2
53
+
54
+
55
+ def sort_by_number(filenames):
56
+ # Extract the number after the dot and convert it to an integer
57
+ def extract_number(filename):
58
+ return int(filename.split(".")[-1])
59
+
60
+ # Sort the filenames using the extracted number
61
+ return sorted(filenames, key=extract_number)
@@ -0,0 +1,18 @@
1
+ [tool.poetry]
2
+ name = "petra-catalogs"
3
+ version = "0.1.0"
4
+ description = "From LISA global fit to catalogs"
5
+ authors = ["Aaron D. Johnson", "Javier Roulete", "Katerina Chatziioannou", "Michele Vallisneri", "Kyle Gersbach", "Chris Trejo"]
6
+ readme = "README.md"
7
+ packages = [{ include = "petra"}]
8
+
9
+ [tool.poetry.dependencies]
10
+ python = "^3.11"
11
+ numpy = "^2.2.3"
12
+ scipy = "^1.15.2"
13
+ pandas = "^2.2.3"
14
+
15
+
16
+ [build-system]
17
+ requires = ["poetry-core"]
18
+ build-backend = "poetry.core.masonry.api"