Belle II Software light-2609-luna
sysvar.py
1#!/usr/bin/env python3
2
3
10
11import ast
12import hashlib
13import warnings
14from dataclasses import dataclass
15
16import matplotlib.pyplot as plt
17import numpy as np
18import pandas as pd
19import pdg
20from pandas.errors import PerformanceWarning
21
22warnings.warn(
23 "This module will soon be deprecated and eventually removed in a future release. "
24 "Its functionality is being taken over by the standalone SysVar package: "
25 "https://gitlab.desy.de/belle2/software/sysvar",
26 FutureWarning,
27 stacklevel=2
28)
29
30"""
31A module that adds corrections to analysis dataframe.
32It adds weight variations according to the total uncertainty for easier error propagation.
33"""
34
35_weight_cols = ['data_MC_ratio',
36 'data_MC_uncertainty_stat_dn',
37 'data_MC_uncertainty_stat_up',
38 'data_MC_uncertainty_sys_dn',
39 'data_MC_uncertainty_sys_up'
40 ]
41
42_correction_types = ['PID', 'FEI']
43_fei_mode_col = 'dec_mode'
44
45
47_seed_key_cols = ['PDG', 'mcPDG', 'variable', 'threshold']
48
49
50def _table_key(table: pd.DataFrame) -> str:
51 """
52 Returns a stable identifier for a weight table, independent of the particle prefix.
53 """
54 cols = [col for col in _seed_key_cols if col in table.columns]
55 if not cols:
56 raise ValueError(
57 f'Cannot derive a seed from the weight table: it has none of the identifying '
58 f'columns {_seed_key_cols}, so distinct tables could not be told apart and would '
59 f'share their variations.'
60 )
61 ident = table[cols].drop_duplicates().sort_values(cols)
62 digest = pd.util.hash_pandas_object(ident, index=False).values.tobytes()
63 return hashlib.sha256(digest).hexdigest()
64
65
66def _derive_seed(base_seed: int, key: str) -> int:
67 """
68 Derives a reproducible 32-bit seed from a base seed and a key.
69 """
70 return int.from_bytes(hashlib.sha256(f'{base_seed}:{key}'.encode()).digest()[:4], 'big')
71
72
73def _check_seeds(sys_seed: int, seed: int) -> None:
74 """
75 Rejects ambiguous seed arguments and warns about the behaviour of sys_seed.
76 """
77 if sys_seed is None:
78 return
79 if seed is not None:
80 raise ValueError('Pass either seed or sys_seed, not both.')
81 warnings.warn(
82 'sys_seed only seeds the systematic variations; the statistical variations are '
83 'drawn anew on every call, so applying the same weight table to several dataframes '
84 'separately underestimates their contribution. Sharing sys_seed between weight '
85 'tables also correlates the systematic variations of tables with the same number of '
86 'rows. Use seed instead, which makes both components reproducible and independent '
87 'between tables.',
88 UserWarning,
89 stacklevel=3,
90 )
91
92
93@dataclass
95 """
96 Class that stores the information of a particle.
97 """
98
100 prefix: str
101
102
103 type: str
104
105
106 merged_table: pd.DataFrame
107
108
110 pdg_binning: dict
111
112
114 variable_aliases: dict
115
116
118 weight_name: str
119
120
121 column_names: list = None
122
123
124 sys_seed: int = None
125
126
127 cov: np.ndarray = None
128
129
130 syscorr: bool = True
131
132
133 coverage: float = None
134
135
136 plot_values: dict = None
137
138
139 seed: int = None
140
141 def get_varname(self, varname: str) -> str:
142 """
143 Returns the variable name with the prefix and use alias if defined.
144 """
145 name = varname
146 if self.variable_aliases and varname in self.variable_aliases:
147 name = self.variable_aliases[varname]
148 if name.startswith(self.prefix):
149 return name
150 return f'{self.prefix}{name}'
151
152 def get_binning_variables(self) -> list:
153 """
154 Returns the list of variables that are used for the binning
155 """
156 variables = set(sum([list(d.keys()) for d in self.pdg_binning.values()], []))
157 return [f'{self.get_varname(var)}' for var in variables]
158
159 def get_pdg_variables(self) -> list:
160 """
161 Returns the list of variables that are used for the PDG codes
162 """
163 pdg_vars = ['PDG']
164
165 if self.type == "PID":
166 pdg_vars += ['mcPDG']
167 return [f'{self.get_varname(var)}' for var in pdg_vars]
168
170 n_variations: int,
171 rho_sys: np.ndarray = None,
172 rho_stat: np.ndarray = None) -> None:
173 """
174 Generates variations of weights according to the uncertainties
175 """
176 self.merged_table['stat_error'] = self.merged_table[["data_MC_uncertainty_stat_up",
177 "data_MC_uncertainty_stat_dn"]].max(axis=1)
178 self.merged_table['sys_error'] = self.merged_table[["data_MC_uncertainty_sys_up",
179 "data_MC_uncertainty_sys_dn"]].max(axis=1)
180 self.merged_table["error"] = np.sqrt(self.merged_table["stat_error"] ** 2 + self.merged_table["sys_error"] ** 2)
181 means = self.merged_table["data_MC_ratio"].values
182
183 self.column_names = [f"{self.weight_name}_{i}" for i in range(n_variations)]
184 cov = self.get_covariance(n_variations, rho_sys, rho_stat)
185 weights = cov + means
186 self.merged_table[self.weight_name] = self.merged_table["data_MC_ratio"]
187 self.merged_table[self.column_names] = weights.T
188 self.column_names.insert(0, self.weight_name)
189
190 def get_seed(self, component: str) -> int:
191 """
192 Returns the seed for one component of the variations: 'sys', 'stat', or 'total'
193 for the combined draw when cov is set.
194
195 Derived from seed, the table identity and the component, so a table gives the
196 same variations in every dataframe it is applied to, while different tables and
197 components are independent.
198 """
199 if self.seed is None:
200 return None
201 return _derive_seed(self.seed, f'{component}:{_table_key(self.merged_table)}')
202
204 n_variations: int,
205 rho_sys: np.ndarray = None,
206 rho_stat: np.ndarray = None) -> np.ndarray:
207 """
208 Returns the covariance matrix of the weights
209 """
210 len_means = len(self.merged_table["data_MC_ratio"])
211 zeros = np.zeros(len_means)
212 if self.cov is None:
213 if rho_sys is None:
214 if self.syscorr:
215 rho_sys = np.ones((len_means, len_means))
216 else:
217 rho_sys = np.identity(len_means)
218 if rho_stat is None:
219 rho_stat = np.identity(len_means)
220 sys_cov = np.matmul(
221 np.matmul(np.diag(self.merged_table['sys_error']), rho_sys), np.diag(self.merged_table['sys_error'])
222 )
223 stat_cov = np.matmul(
224 np.matmul(np.diag(self.merged_table['stat_error']), rho_stat), np.diag(self.merged_table['stat_error'])
225 )
226 if self.seed is not None:
227 # Local generators, so the caller's global RNG state is left untouched
228 sys = np.random.default_rng(self.get_seed('sys')).multivariate_normal(zeros, sys_cov, n_variations)
229 stat = np.random.default_rng(self.get_seed('stat')).multivariate_normal(zeros, stat_cov, n_variations)
230 return sys + stat
231 # Legacy sys_seed behaviour
232 np.random.seed(self.sys_seed)
233 sys = np.random.multivariate_normal(zeros, sys_cov, n_variations)
234 np.random.seed(None)
235 stat = np.random.multivariate_normal(zeros, stat_cov, n_variations)
236 return sys + stat
237 # Total covariance: a single draw covers sys and stat
238 if self.seed is not None:
239 return np.random.default_rng(self.get_seed('total')).multivariate_normal(zeros, self.cov, n_variations)
240 errors = np.random.multivariate_normal(zeros, self.cov, n_variations)
241 return errors
242
243 def __str__(self) -> str:
244 """
245 Converts the object to a string.
246 """
247 separator = '------------------'
248 title = 'ReweighterParticle'
249 prefix_str = f'Type: {self.type} Prefix: {self.prefix}'
250 columns = _weight_cols
251 merged_table_str = f'Merged table:\n{self.merged_table[columns].describe()}'
252 pdg_binning_str = 'PDG binning:\n'
253 for pdgs in self.pdg_binning:
254 pdg_binning_str += f'{pdgs}: {self.pdg_binning[pdgs]}\n'
255 return '\n'.join([separator, title, prefix_str, merged_table_str, pdg_binning_str]) + separator
256
257 def plot_coverage(self, fig=None, axs=None):
258 """
259 Plots the coverage of the ntuple.
260 """
261 if self.plot_values is None:
262 return
263 vars = set(sum([list(d.keys()) for d in self.plot_values.values()], []))
264 if fig is None:
265 fig, axs = plt.subplots(len(self.plot_values), len(vars), figsize=(5 * len(vars), 3 * len(self.plot_values)), dpi=120)
266 axs = np.array(axs)
267 if len(axs.shape) < 2:
268 axs = axs.reshape(len(self.plot_values), len(vars))
269 bin_plt = {'linewidth': 3, 'linestyle': '--', 'color': '0.5'}
270 fig.suptitle(f'{self.type} particle {self.prefix.strip("_")}')
271 for (reco_pdg, mc_pdg), ax_row in zip(self.plot_values, axs):
272 for var, ax in zip(self.plot_values[(reco_pdg, mc_pdg)], ax_row):
273 ymin = 0
274 ymax = self.plot_values[(reco_pdg, mc_pdg)][var][1].max() * 1.1
275 # Plot binning
276 if self.type == 'PID':
277 ax.vlines(self.pdg_binning[(reco_pdg, mc_pdg)][var], ymin, ymax,
278 label='Binning',
279 alpha=0.8,
280 **bin_plt)
281 elif self.type == 'FEI':
282 values = np.array([int(val[4:]) for val in self.pdg_binning[(reco_pdg, mc_pdg)][var]])
283 ax.bar(values + 0.5,
284 np.ones(len(values)) * ymax,
285 width=1,
286 alpha=0.5,
287 label='Binning',
288 **bin_plt)
289 rest = np.setdiff1d(self.plot_values[(reco_pdg, mc_pdg)][var][0], values)
290 ax.bar(rest + 0.5,
291 np.ones(len(rest)) * ymax,
292 width=1,
293 alpha=0.2,
294 label='Rest category',
295 **bin_plt)
296 # Plot values
297 widths = (self.plot_values[(reco_pdg, mc_pdg)][var][0][1:] - self.plot_values[(reco_pdg, mc_pdg)][var][0][:-1])
298 centers = self.plot_values[(reco_pdg, mc_pdg)][var][0][:-1] + widths / 2
299 ax.bar(centers,
300 self.plot_values[(reco_pdg, mc_pdg)][var][1],
301 width=widths,
302 label='Values',
303 alpha=0.8)
304 ax.set_title(f'True {pdg.to_name(mc_pdg)} to reco {pdg.to_name(reco_pdg)} coverage')
305 ax.set_xlabel(var)
306 axs[-1][-1].legend()
307 fig.tight_layout()
308 return fig, axs
309
310
312 """
313 Class that reweights the dataframe.
314
315 Args:
316 n_variations (int): Number of weight variations to generate.
317 weight_name (str): Name of the weight column.
318 evaluate_plots (bool): Flag to indicate if the plots should be evaluated.
319 nbins (int): Number of bins for the plots.
320 """
321
322 def __init__(self,
323 n_variations: int = 100,
324 weight_name: str = "Weight",
325 evaluate_plots: bool = True,
326 nbins: int = 50,
327 fillna: float = 1.0) -> None:
328 """
329 Initializes the Reweighter class.
330 """
331
332 self.n_variations = n_variations
333
334 self.particles = []
335
336 self.correlations = []
337
338 self.weight_name = weight_name
339
340 self.weights_generated = False
341
342 self.evaluate_plots = evaluate_plots
343
344 self.nbins = nbins
345
346 self.fillna = fillna
347
348 def get_bin_columns(self, weight_df) -> list:
349 """
350 Returns the kinematic bin columns of the dataframe.
351 """
352 return [col for col in weight_df.columns if col.endswith('_min') or col.endswith('_max')]
353
354 def get_binning(self, weight_df) -> dict:
355 """
356 Returns the kinematic binning of the dataframe.
357 """
358 columns = self.get_bin_columns(weight_df)
359 var_names = {'_'.join(col.split('_')[:-1]) for col in columns}
360 bin_dict = {}
361 for var_name in var_names:
362 bin_dict[var_name] = []
363 for col in columns:
364 if col.startswith(var_name):
365 bin_dict[var_name] += list(weight_df[col].values)
366 bin_dict[var_name] = np.array(sorted(set(bin_dict[var_name])))
367 return bin_dict
368
369 def get_fei_binning(self, weight_df) -> dict:
370 """
371 Returns the irregular binning of the dataframe.
372 """
373 return {_fei_mode_col: weight_df.loc[weight_df[_fei_mode_col].str.startswith('mode'),
374 _fei_mode_col].value_counts().index.to_list()}
375
377 ntuple_df: pd.DataFrame,
378 particle: ReweighterParticle) -> None:
379 """
380 Checks if the variables are in the ntuple and returns them.
381
382 Args:
383 ntuple_df (pandas.DataFrame): Dataframe containing the analysis ntuple.
384 particle (ReweighterParticle): Particle object containing the necessary variables.
385 """
386 ntuple_variables = particle.get_binning_variables()
387 ntuple_variables += particle.get_pdg_variables()
388 for var in ntuple_variables:
389 if var not in ntuple_df.columns:
390 raise ValueError(f'Variable {var} is not in the ntuple! Required variables are {ntuple_variables}')
391 return ntuple_variables
392
394 weights_dict: dict,
395 pdg_pid_variable_dict: dict) -> pd.DataFrame:
396 """
397 Merges the efficiency and fake rate weight tables.
398
399 Args:
400 weights_dict (dict): Dictionary containing the weight tables.
401 pdg_pid_variable_dict (dict): Dictionary containing the PDG codes and variable names.
402 """
403 weight_dfs = []
404 for reco_pdg, mc_pdg in weights_dict:
405 if reco_pdg not in pdg_pid_variable_dict:
406 raise ValueError(f'Reconstructed PDG code {reco_pdg} not found in thresholds!')
407 weight_df = weights_dict[(reco_pdg, mc_pdg)]
408 weight_df['mcPDG'] = mc_pdg
409 weight_df['PDG'] = reco_pdg
410 # Check if these are legacy tables:
411 if 'charge' in weight_df.columns:
412 charge_dict = {'+': [0, 2], '-': [-2, 0]}
413 weight_df[['charge_min', 'charge_max']] = [charge_dict[val] for val in weight_df['charge'].values]
414 weight_df = weight_df.drop(columns=['charge'])
415 # If iso_score is a single value, drop the min and max columns
416 if 'iso_score_min' in weight_df.columns and len(weight_df['iso_score_min'].unique()) == 1:
417 weight_df = weight_df.drop(columns=['iso_score_min', 'iso_score_max'])
418 pid_variable_name = pdg_pid_variable_dict[reco_pdg][0]
419 threshold = pdg_pid_variable_dict[reco_pdg][1]
420 selected_weights = weight_df.query(f'variable == "{pid_variable_name}" and threshold == {threshold}')
421 if len(selected_weights) == 0:
422 available_variables = weight_df['variable'].unique()
423 available_thresholds = weight_df['threshold'].unique()
424 raise ValueError(f'No weights found for PDG code {reco_pdg}, mcPDG {mc_pdg},'
425 f' variable {pid_variable_name} and threshold {threshold}!\n'
426 f' Available variables: {available_variables}\n'
427 f' Available thresholds: {available_thresholds}')
428 weight_dfs.append(selected_weights)
429 return pd.concat(weight_dfs, ignore_index=True)
430
432 ntuple_df: pd.DataFrame,
433 particle: ReweighterParticle) -> None:
434 """
435 Adds a weight and uncertainty columns to the dataframe.
436
437 Args:
438 ntuple_df (pandas.DataFrame): Dataframe containing the analysis ntuple.
439 particle (ReweighterParticle): Particle object.
440 """
441 # Apply a weight value from the weight table to the ntuple, based on the binning
442 binning_df = pd.DataFrame(index=ntuple_df.index)
443 # Take absolute value of mcPDG for binning because we have charge already
444 binning_df['mcPDG'] = ntuple_df[f'{particle.get_varname("mcPDG")}'].abs()
445 binning_df['PDG'] = ntuple_df[f'{particle.get_varname("PDG")}'].abs()
446 plot_values = {}
447 for reco_pdg, mc_pdg in particle.pdg_binning:
448 ntuple_cut = f'abs({particle.get_varname("mcPDG")}) == {mc_pdg} and abs({particle.get_varname("PDG")}) == {reco_pdg}'
449 if ntuple_df.query(ntuple_cut).empty:
450 continue
451 plot_values[(reco_pdg, mc_pdg)] = {}
452 for var in particle.pdg_binning[(reco_pdg, mc_pdg)]:
453 labels = [(particle.pdg_binning[(reco_pdg, mc_pdg)][var][i - 1], particle.pdg_binning[(reco_pdg, mc_pdg)][var][i])
454 for i in range(1, len(particle.pdg_binning[(reco_pdg, mc_pdg)][var]))]
455 binning_df.loc[(binning_df['mcPDG'] == mc_pdg) & (binning_df['PDG'] == reco_pdg), var] = pd.cut(ntuple_df.query(
456 ntuple_cut)[f'{particle.get_varname(var)}'],
457 particle.pdg_binning[(reco_pdg, mc_pdg)][var], labels=labels)
458 binning_df.loc[(binning_df['mcPDG'] == mc_pdg) & (binning_df['PDG'] == reco_pdg),
459 f'{var}_min'] = binning_df.loc[(binning_df['mcPDG'] == mc_pdg) & (binning_df['PDG'] == reco_pdg),
460 var].str[0]
461 binning_df.loc[(binning_df['mcPDG'] == mc_pdg) & (binning_df['PDG'] == reco_pdg),
462 f'{var}_max'] = binning_df.loc[(binning_df['mcPDG'] == mc_pdg) & (binning_df['PDG'] == reco_pdg),
463 var].str[1]
464 binning_df.drop(var, axis=1, inplace=True)
465 if self.evaluate_plots:
466 values = ntuple_df.query(ntuple_cut)[f'{particle.get_varname(var)}']
467 if len(values.unique()) < 2:
468 print(f'Skip {var} for plotting!')
469 continue
470 x_range = np.linspace(values.min(), values.max(), self.nbins)
471 plot_values[(reco_pdg, mc_pdg)][var] = x_range, np.histogram(values, bins=x_range, density=True)[0]
472 # merge the weight table with the ntuple on binning columns
473 weight_cols = _weight_cols
474 if particle.column_names:
475 weight_cols = particle.column_names
476 binning_df = binning_df.merge(particle.merged_table[weight_cols + binning_df.columns.tolist()],
477 on=binning_df.columns.tolist(), how='left')
478 binning_df.index = ntuple_df.index
479 particle.coverage = 1 - binning_df[weight_cols[0]].isna().sum() / len(binning_df)
480 particle.plot_values = plot_values
481 for col in weight_cols:
482 ntuple_df[f'{particle.get_varname(col)}'] = binning_df[col]
483 ntuple_df[f'{particle.get_varname(col)}'] = ntuple_df[f'{particle.get_varname(col)}'].fillna(self.fillna)
484
486 prefix: str,
487 weights_dict: dict,
488 pdg_pid_variable_dict: dict,
489 variable_aliases: dict = None,
490 sys_seed: int = None,
491 syscorr: bool = True,
492 seed: int = None) -> None:
493 """
494 Adds weight variations according to the total uncertainty for easier error propagation.
495
496 Args:
497 prefix (str): Prefix for the new columns.
498 weights_dict (pandas.DataFrame): Dataframe containing the efficiency weights.
499 pdg_pid_variable_dict (dict): Dictionary containing the PID variables and thresholds.
500 variable_aliases (dict): Dictionary containing variable aliases.
501 sys_seed (int): Seed for the systematic variations only. Prefer seed.
502 syscorr (bool): When true assume systematics are 100% correlated defaults to
503 true. Note this is overridden by provision of a None value rho_sys
504 seed (int): Base seed for the systematic and statistical variations. Makes
505 them reproducible for a given weight table and independent between tables.
506 """
507 _check_seeds(sys_seed, seed)
508 # Empty prefix means no prefix
509 if prefix is None:
510 prefix = ''
511 # Add underscore if not present
512 if prefix and not prefix.endswith('_'):
513 prefix += '_'
514 if self.get_particle(prefix):
515 raise ValueError(f"Particle with prefix '{prefix}' already exists!")
516 if variable_aliases is None:
517 variable_aliases = {}
518 merged_weight_df = self.merge_pid_weight_tables(weights_dict, pdg_pid_variable_dict)
519 pdg_binning = {(reco_pdg, mc_pdg): self.get_binning(merged_weight_df.query(f'PDG == {reco_pdg} and mcPDG == {mc_pdg}'))
520 for reco_pdg, mc_pdg in merged_weight_df[['PDG', 'mcPDG']].value_counts().index.to_list()}
521 particle = ReweighterParticle(prefix,
522 type='PID',
523 merged_table=merged_weight_df,
524 pdg_binning=pdg_binning,
525 variable_aliases=variable_aliases,
526 weight_name=self.weight_name,
527 sys_seed=sys_seed,
528 syscorr=syscorr,
529 seed=seed)
530 self.particles += [particle]
531
532 def get_particle(self, prefix: str) -> ReweighterParticle:
533 """
534 Get a particle by its prefix.
535 """
536 cands = [particle for particle in self.particles if particle.prefix.strip('_') == prefix.strip('_')]
537 if len(cands) == 0:
538 return None
539 return cands[0]
540
541 def convert_fei_table(self, table: pd.DataFrame, threshold: float):
542 """
543 Checks if the tables are provided in a legacy format and converts them to the standard format.
544 """
545 result = None
546 str_to_pdg = {'B+': 521, 'B-': 521, 'B0': 511}
547 if 'cal' in table.columns:
548 result = pd.DataFrame(index=table.index)
549 result['data_MC_ratio'] = table['cal']
550 result['PDG'] = table['Btag'].apply(lambda x: str_to_pdg.get(x))
551 # Assume these are only efficiency tables
552 result['mcPDG'] = result['PDG']
553 result['threshold'] = table['sig_prob_threshold']
554 result[_fei_mode_col] = table[_fei_mode_col]
555 result['data_MC_uncertainty_stat_dn'] = table['cal_stat_error']
556 result['data_MC_uncertainty_stat_up'] = table['cal_stat_error']
557 result['data_MC_uncertainty_sys_dn'] = table['cal_sys_error']
558 result['data_MC_uncertainty_sys_up'] = table['cal_sys_error']
559 elif 'cal factor' in table.columns:
560 result = pd.DataFrame(index=table.index)
561 result['data_MC_ratio'] = table['cal factor']
562 result['PDG'] = table['Btype'].apply(lambda x: str_to_pdg.get(x))
563 result['mcPDG'] = result['PDG']
564 result['threshold'] = table['sig prob cut']
565 # Assign the total error to the stat uncertainty and set syst. one to 0
566 result['data_MC_uncertainty_stat_dn'] = table['error']
567 result['data_MC_uncertainty_stat_up'] = table['error']
568 result['data_MC_uncertainty_sys_dn'] = 0
569 result['data_MC_uncertainty_sys_up'] = 0
570 result[_fei_mode_col] = table['mode']
571 elif 'dmID' in table.columns:
572 result = pd.DataFrame(index=table.index)
573 result['data_MC_ratio'] = table['central_value']
574 result['PDG'] = table['PDG'].apply(ast.literal_eval).str[0].abs()
575 result['mcPDG'] = result['PDG']
576 result['threshold'] = table['sigProb']
577 # Assign the total error to the stat uncertainty and set syst. one to 0
578 result['data_MC_uncertainty_stat_dn'] = table['total_error']
579 result['data_MC_uncertainty_stat_up'] = table['total_error']
580 result['data_MC_uncertainty_sys_dn'] = 0
581 result['data_MC_uncertainty_sys_up'] = 0
582 result[_fei_mode_col] = ('mode' + table['dmID'].astype(str)).replace('mode999', 'rest')
583 else:
584 result = table
585 result = result.query(f'threshold == {threshold}')
586 if len(result) == 0:
587 raise ValueError(f'No weights found for threshold {threshold}!')
588 return result
589
590 def add_fei_particle(self, prefix: str,
591 table: pd.DataFrame,
592 threshold: float,
593 cov: np.ndarray = None,
594 variable_aliases: dict = None,
595 seed: int = None,
596 ) -> None:
597 """
598 Adds weight variations according to the total uncertainty for easier error propagation.
599
600 Args:
601 prefix (str): Prefix for the new columns.
602 table (pandas.DataFrame): Dataframe containing the efficiency weights.
603 threshold (float): Threshold for the efficiency weights.
604 cov (numpy.ndarray): Covariance matrix for the efficiency weights.
605 variable_aliases (dict): Dictionary containing variable aliases.
606 seed (int): Base seed for the variations, see :meth:`add_pid_particle`.
607 """
608 # Empty prefix means no prefix
609 if prefix is None:
610 prefix = ''
611 if prefix and not prefix.endswith('_'):
612 prefix += '_'
613 if self.get_particle(prefix):
614 raise ValueError(f"Particle with prefix '{prefix}' already exists!")
615 if variable_aliases is None:
616 variable_aliases = {}
617 if table is None or len(table) == 0:
618 raise ValueError('No weights provided!')
619 converted_table = self.convert_fei_table(table, threshold)
620 pdg_binning = {(reco_pdg, mc_pdg): self.get_fei_binning(converted_table.query(f'PDG == {reco_pdg} and mcPDG == {mc_pdg}'))
621 for reco_pdg, mc_pdg in converted_table[['PDG', 'mcPDG']].value_counts().index.to_list()}
622 particle = ReweighterParticle(prefix,
623 type='FEI',
624 merged_table=converted_table,
625 pdg_binning=pdg_binning,
626 variable_aliases=variable_aliases,
627 weight_name=self.weight_name,
628 cov=cov,
629 seed=seed)
630 self.particles += [particle]
631
632 def add_fei_weight_columns(self, ntuple_df: pd.DataFrame, particle: ReweighterParticle):
633 """
634 Adds weight columns according to the FEI calibration tables
635 """
636 rest_str = 'rest'
637 particle.merged_table[_fei_mode_col]
638 # Apply a weight value from the weight table to the ntuple, based on the binning
639 binning_df = pd.DataFrame(index=ntuple_df.index)
640 # Take absolute value of mcPDG for binning because we have charge already
641 binning_df['PDG'] = ntuple_df[f'{particle.get_varname("PDG")}'].abs()
642 # Copy the mode ID from the ntuple
643 binning_df['num_mode'] = ntuple_df[particle.get_varname(_fei_mode_col)].astype(int)
644 # Default value in case if reco PDG is not a B-meson PDG
645 # Object dtype, as string mode labels are assigned below
646 binning_df[_fei_mode_col] = pd.Series(np.nan, index=binning_df.index, dtype='object')
647 plot_values = {}
648 for reco_pdg, mc_pdg in particle.pdg_binning:
649 plot_values[(reco_pdg, mc_pdg)] = {}
650 binning_df.loc[binning_df['PDG'] == reco_pdg, _fei_mode_col] = particle.merged_table.query(
651 f'PDG == {reco_pdg} and {_fei_mode_col}.str.lower() == "{rest_str}"')[_fei_mode_col].values[0]
652 for mode in particle.pdg_binning[(reco_pdg, mc_pdg)][_fei_mode_col]:
653 binning_df.loc[(binning_df['PDG'] == reco_pdg) & (binning_df['num_mode'] == int(mode[4:])), _fei_mode_col] = mode
654 if self.evaluate_plots:
655 values = ntuple_df[f'{particle.get_varname(_fei_mode_col)}']
656 x_range = np.linspace(values.min(), values.max(), int(values.max()) + 1)
657 plot_values[(reco_pdg, mc_pdg)][_fei_mode_col] = x_range, np.histogram(values, bins=x_range, density=True)[0]
658
659 # merge the weight table with the ntuple on binning columns
660 weight_cols = _weight_cols
661 if particle.column_names:
662 weight_cols = particle.column_names
663 binning_df = binning_df.merge(particle.merged_table[weight_cols + ['PDG', _fei_mode_col]],
664 on=['PDG', _fei_mode_col], how='left')
665 binning_df.index = ntuple_df.index
666 particle.coverage = 1 - binning_df[weight_cols[0]].isna().sum() / len(binning_df)
667 particle.plot_values = plot_values
668 for col in weight_cols:
669 ntuple_df[f'{particle.get_varname(col)}'] = binning_df[col]
670
671 def reweight(self,
672 df: pd.DataFrame,
673 generate_variations: bool = True):
674 """
675 Reweights the dataframe according to the weight tables.
676
677 Args:
678 df (pandas.DataFrame): Dataframe containing the analysis ntuple.
679 generate_variations (bool): When true generate weight variations.
680 """
681 for particle in self.particles:
682 if particle.type not in _correction_types:
683 raise ValueError(f'Particle type {particle.type} not supported!')
684 print(f'Required variables: {self.get_ntuple_variables(df, particle)}')
685 if generate_variations:
686 particle.generate_variations(n_variations=self.n_variations)
687 if particle.type == 'PID':
688 self.add_pid_weight_columns(df, particle)
689 elif particle.type == 'FEI':
690 self.add_fei_weight_columns(df, particle)
691 return df
692
693 def print_coverage(self):
694 """
695 Prints the coverage of each particle.
696 """
697 print('Coverage:')
698 for particle in self.particles:
699 print(f'{particle.type} {particle.prefix.strip("_")}: {particle.coverage*100:0.1f}%')
700
701 def plot_coverage(self):
702 """
703 Plots the coverage of each particle.
704 """
705 for particle in self.particles:
706 particle.plot_coverage()
707
708
709def add_weights_to_dataframe(prefix: str,
710 df: pd.DataFrame,
711 systematic: str,
712 custom_tables: dict = None,
713 custom_thresholds: dict = None,
714 **kw_args) -> pd.DataFrame:
715 """
716 Helper method that adds weights to a dataframe.
717
718 Args:
719 prefix (str): Prefix for the new columns.
720 df (pandas.DataFrame): Dataframe containing the analysis ntuple.
721 systematic (str): Type of the systematic corrections, options: "custom_PID" and "custom_FEI".
722 MC_production (str): Name of the MC production.
723 custom_tables (dict): Dictionary containing the custom efficiency weights.
724 custom_thresholds (dict): Dictionary containing the custom thresholds for the custom efficiency weights.
725 n_variations (int): Number of variations to generate.
726 generate_variations (bool): When true generate weight variations.
727 weight_name (str): Name of the weight column.
728 show_plots (bool): When true show the coverage plots.
729 variable_aliases (dict): Dictionary containing variable aliases.
730 cov_matrix (numpy.ndarray): Covariance matrix for the custom efficiency weights.
731 fillna (int): Value to fill NaN values with.
732 sys_seed (int): Seed for the systematic variations only, custom_PID only. Prefer seed.
733 seed (int): Base seed for the variations, reproducible per weight table and independent between tables.
734 syscorr (bool): When true assume systematics are 100% correlated defaults to true.
735 **kw_args: Additional arguments for the Reweighter class.
736 """
737 generate_variations = kw_args.get('generate_variations', True)
738 n_variations = kw_args.get('n_variations', 100)
739 weight_name = kw_args.get('weight_name', "Weight")
740 fillna = kw_args.get('fillna', 1.0)
741 # Catch performance warnings from pandas
742 with warnings.catch_warnings():
743 warnings.simplefilter("ignore", category=PerformanceWarning)
744 reweighter = Reweighter(n_variations=n_variations,
745 weight_name=weight_name,
746 fillna=fillna)
747 variable_aliases = kw_args.get('variable_aliases')
748 seed = kw_args.get('seed')
749 if systematic.lower() == 'custom_fei':
750 if kw_args.get('sys_seed') is not None:
751 warnings.warn('sys_seed has no effect for custom_FEI. Use seed instead.', UserWarning, stacklevel=2)
752 cov_matrix = kw_args.get('cov_matrix')
753 reweighter.add_fei_particle(prefix=prefix,
754 table=custom_tables,
755 threshold=custom_thresholds,
756 variable_aliases=variable_aliases,
757 cov=cov_matrix,
758 seed=seed
759 )
760 elif systematic.lower() == 'custom_pid':
761 sys_seed = kw_args.get('sys_seed')
762 syscorr = kw_args.get('syscorr')
763 if syscorr is None:
764 syscorr = True
765 reweighter.add_pid_particle(prefix=prefix,
766 weights_dict=custom_tables,
767 pdg_pid_variable_dict=custom_thresholds,
768 variable_aliases=variable_aliases,
769 sys_seed=sys_seed,
770 syscorr=syscorr,
771 seed=seed
772 )
773 else:
774 raise ValueError(f'Systematic {systematic} is not supported!')
775
776 result = reweighter.reweight(df, generate_variations=generate_variations)
777 if kw_args.get('show_plots'):
778 reweighter.print_coverage()
779 reweighter.plot_coverage()
780 return result
str type
Add the mcPDG code requirement for PID particle.
Definition sysvar.py:165
variable_aliases
Variable aliases of the weight table.
Definition sysvar.py:146
pd merged_table
Type of the particle (PID or FEI)
Definition sysvar.py:106
int sys_seed
Random seed for systematics only (legacy, prefer seed)
Definition sysvar.py:124
str get_varname(self, str varname)
Definition sysvar.py:141
weight_name
Weight column name that will be added to the ntuple.
Definition sysvar.py:188
np cov
Covariance matrix corresponds to the total uncertainty.
Definition sysvar.py:127
plot_coverage(self, fig=None, axs=None)
Definition sysvar.py:257
list get_pdg_variables(self)
Definition sysvar.py:159
list get_binning_variables(self)
Definition sysvar.py:152
pdg_binning
Kinematic binning of the weight table per particle.
Definition sysvar.py:253
np.ndarray get_covariance(self, int n_variations, np.ndarray rho_sys=None, np.ndarray rho_stat=None)
Definition sysvar.py:206
int seed
Base seed for all variations, see get_seed.
Definition sysvar.py:139
bool syscorr
When true assume systematics are 100% correlated.
Definition sysvar.py:130
int get_seed(self, str component)
Definition sysvar.py:190
prefix
Prefix of the particle in the ntuple.
Definition sysvar.py:148
dict plot_values
Values for the plots.
Definition sysvar.py:136
None generate_variations(self, int n_variations, np.ndarray rho_sys=None, np.ndarray rho_stat=None)
Definition sysvar.py:172
list column_names
Internal list of the names of the weight columns.
Definition sysvar.py:121
n_variations
Number of weight variations to generate.
Definition sysvar.py:332
pd.DataFrame merge_pid_weight_tables(self, dict weights_dict, dict pdg_pid_variable_dict)
Definition sysvar.py:395
nbins
Number of bins for the plots.
Definition sysvar.py:344
list particles
List of particles.
Definition sysvar.py:334
add_fei_weight_columns(self, pd.DataFrame ntuple_df, ReweighterParticle particle)
Definition sysvar.py:632
None __init__(self, int n_variations=100, str weight_name="Weight", bool evaluate_plots=True, int nbins=50, float fillna=1.0)
Definition sysvar.py:327
print_coverage(self)
Definition sysvar.py:693
None add_pid_weight_columns(self, pd.DataFrame ntuple_df, ReweighterParticle particle)
Definition sysvar.py:433
weight_name
Name of the weight column.
Definition sysvar.py:338
None get_ntuple_variables(self, pd.DataFrame ntuple_df, ReweighterParticle particle)
Definition sysvar.py:378
None add_fei_particle(self, str prefix, pd.DataFrame table, float threshold, np.ndarray cov=None, dict variable_aliases=None, int seed=None)
Definition sysvar.py:596
None add_pid_particle(self, str prefix, dict weights_dict, dict pdg_pid_variable_dict, dict variable_aliases=None, int sys_seed=None, bool syscorr=True, int seed=None)
Definition sysvar.py:492
fillna
Value to fill NaN values.
Definition sysvar.py:346
dict get_fei_binning(self, weight_df)
Definition sysvar.py:369
convert_fei_table(self, pd.DataFrame table, float threshold)
Definition sysvar.py:541
bool weights_generated
Flag to indicate if the weights have been generated.
Definition sysvar.py:340
dict get_binning(self, weight_df)
Definition sysvar.py:354
list get_bin_columns(self, weight_df)
Definition sysvar.py:348
reweight(self, pd.DataFrame df, bool generate_variations=True)
Definition sysvar.py:673
plot_coverage(self)
Definition sysvar.py:701
ReweighterParticle get_particle(self, str prefix)
Definition sysvar.py:532
list correlations
Correlations between the particles.
Definition sysvar.py:336
evaluate_plots
Flag to indicate if the plots should be evaluated.
Definition sysvar.py:342