Source code for smefit.analyze.coefficients_utils

# -*- coding: utf-8 -*-
import itertools
import pathlib
from collections.abc import Iterable

import arviz
import matplotlib.lines as mlines
import matplotlib.markers as markers
import matplotlib.patches as patches
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns

from .contours_2d import confidence_ellipse, plot_contours, split_solution
from .latex_tools import latex_packages, multicolum_table_header
from .spider import radar_factory


[docs] def get_confidence_values(dist, has_posterior=True): """ Get confidence level bounds given the distribution """ cl_vals = {} if has_posterior: cl_vals["low68"] = np.nanpercentile(dist, 16) cl_vals["high68"] = np.nanpercentile(dist, 84) cl_vals["low95"] = np.nanpercentile(dist, 2.5) cl_vals["high95"] = np.nanpercentile(dist, 97.5) cl_vals["mid"] = np.mean(dist, axis=0) else: cl_vals["low68"] = dist["68CL"][0] cl_vals["high68"] = dist["68CL"][1] cl_vals["low95"] = dist["95CL"][0] cl_vals["high95"] = dist["95CL"][1] cl_vals["mid"] = dist["bestfit"] for cl in [68, 95]: cl_vals[f"mean_err{cl}"] = (cl_vals[f"high{cl}"] - cl_vals[f"low{cl}"]) / 2.0 cl_vals[f"err{cl}_low"] = cl_vals["mid"] - cl_vals[f"low{cl}"] cl_vals[f"err{cl}_high"] = cl_vals[f"high{cl}"] - cl_vals["mid"] # highest density intervals hdi_widths = np.diff( arviz.hdi(dist.values, hdi=cl * 1e-2, multimodal=True), axis=1 ) cl_vals[f"hdi_{cl}"] = np.sum(hdi_widths.flatten()) cl_vals["pull"] = cl_vals["mid"] / cl_vals["mean_err68"] return cl_vals
[docs] def compute_confidence_level( posterior, coeff_info, has_posterior, disjointed_list=None ): """ Compute central value, 95 % and 68 % confidence levels and store the result in a dictionary given a posterior distribution Parameters ---------- posterior : dict posterior distributions per coefficient coeff_info : pandas.Series coefficients list for which the bounds are computed with latex names disjointed_list: list, optional list of coefficients with double solutions Returns ------- bounds: pandas.DataFrame confidence level bounds per coefficient Note: double solutions are appended under "2" """ disjointed_list = disjointed_list or [] bounds = {} for (group, name), latex_name in coeff_info.items(): if name not in posterior: bounds[(group, latex_name)] = {} else: posterior[name] = np.array(posterior[name]) # double soultion if name in disjointed_list: solution1, solution2 = split_solution(posterior[name]) bounds[(group, latex_name)] = pd.DataFrame( [get_confidence_values(solution1), get_confidence_values(solution2)] ).stack() # single solution else: bounds[(group, latex_name)] = pd.DataFrame( [get_confidence_values(posterior[name], has_posterior)] ).stack() return pd.DataFrame(bounds)
[docs] class CoefficientsPlotter: """ Plots central values + 95% CL errors, 95% CL bounds, probability distributions, residuals, residual distribution, and energy reach. Also writes a table displaying values for 68% CL bounds and central value + 95% errors. Takes into account parameter constraints and displays all non-zero parameters. Note: coefficients that are known to have disjoint probability distributions (i.e. multiple solutions) are manually separated by including the coefficient name in disjointed_list for disjointed_list2 for global and single fit results, respectively. Parameters ---------- report_path: pathlib.Path, str path to base folder, where the reports will be stored. coeff_config : pandas.DataFrame coefficients latex names by gropup type logo : bool if True dispaly the logo on scatter and bar plots """ def __init__(self, report_path, coeff_config, logo=False): self.report_folder = report_path self.coeff_info = coeff_config # SMEFiT logo if logo: self.logo = plt.imread( f"{pathlib.Path(__file__).absolute().parent}/logo.png" ) else: self.logo = None self.npar = self.coeff_info.shape[0] def _plot_logo(self, ax, extent=[0.8, 0.999, 0.001, 0.30]): if self.logo is not None: ax.imshow( self.logo, aspect="auto", transform=ax.transAxes, extent=extent, zorder=-1, ) def _get_suplblots(self, figsize): groups = self.coeff_info.groupby(level=0).count() _, axs = plt.subplots( groups.size, 1, gridspec_kw={"height_ratios": groups.values}, figsize=figsize, ) if not isinstance(axs, Iterable): axs = np.array([axs]) return groups, axs
[docs] def plot_coeffs( self, bounds, figsize=(10, 15), x_min=-400, x_max=400, x_log=True, lin_thr=1e-1 ): """ Plot central value + 95% CL errors Parameters ---------- bounds: dict confidence level bounds per fit and coefficient Note: double solutions are appended under "2" """ groups, axs = self._get_suplblots(figsize) bas10 = np.concatenate([-np.logspace(-4, 2, 7), np.logspace(-4, 2, 7)]) # Spacing between fit results nfits = len(bounds) y_shift = np.linspace(-0.2 * nfits, 0.2 * nfits, nfits) colors = plt.get_cmap("tab20") def plot_error_bars(ax, vals, cnt, i, label=None): ax.errorbar( x=vals.mid, y=Y[cnt] + y_shift[i], xerr=[[vals.err95_low], [vals.err95_high]], color=colors(2 * i + 1), ) ax.errorbar( x=vals.mid, y=Y[cnt] + y_shift[i], xerr=[[vals.err68_low], [vals.err68_high]], color=colors(2 * i), fmt=".", label=label, ) # loop on gropus cnt_plot = 0 for ax, (g, npar) in zip(axs, groups.items()): Y = 3 * np.arange(npar) # loop over fits for i, (fit_name, bound_df) in enumerate(bounds.items()): label = fit_name # loop on coeffs for cnt, (coeff_name, coeff) in enumerate(bound_df[g].items()): # maybe there are no double solutions key_not_found = f"{coeff_name} posterior is not found in {fit_name}" try: vals = coeff.dropna()[0] except KeyError as key_not_found: # fitted ? if coeff.dropna().empty: continue raise KeyError from key_not_found plot_error_bars(ax, vals, cnt, i, label=label) label = None # double solution try: vals_2 = coeff.dropna()[1] plot_error_bars(ax, vals_2, cnt, i) except KeyError: pass # y thicks ax.set_ylim(-2, Y[-1] + 2) ax.set_yticks(Y, self.coeff_info[g], fontsize=13) # x grid ax.vlines(0, -2, Y[-1] + 2, ls="dashed", color="black", alpha=0.7) if x_log: x_thicks = np.concatenate([bas * np.arange(1, 10) for bas in bas10]) if isinstance(lin_thr, dict): thr = lin_thr[g] else: thr = lin_thr x_thicks = x_thicks[np.abs(x_thicks) > thr / 10] ax.set_xscale("symlog", linthresh=thr) ax.set_xticks(x_thicks, minor=True) ax.grid(True, which="both", ls="dashed", axis="x", lw=0.5) if isinstance(x_max, dict): ax.set_xlim(x_min[g], x_max[g]) else: ax.set_xlim(x_min, x_max) ax.set_title(f"\\rm {g}", x=0.95, y=1.0) cnt_plot += npar self._plot_logo(axs[-1]) axs[-1].set_xlabel(r"$c_i/\Lambda^2\ ({\rm TeV}^{-2})$", fontsize=20) axs[0].legend(loc=0, frameon=False, prop={"size": 13}) plt.tight_layout() plt.savefig(f"{self.report_folder}/coefficient_central.pdf", dpi=500) plt.savefig(f"{self.report_folder}/coefficient_central.png")
[docs] def plot_coeffs_bar( self, error, figsize=(10, 15), plot_cutoff=400, x_log=True, x_min=1e-2, x_max=500, legend_loc="best", ): """ Plot error bars at given confidence level Parameters ---------- error: dict confidence level bounds per fit and coefficient figsize: list, optional Figure size, (10, 15) by default plot_cutoff: float Only show bounds up to here x_log: bool, optional Use a log scale on the x-axis, true by default x_min: float, optional Minimum x-value, 1e-2 by default x_max: float, optional Maximum x-value, 500 by default legend_loc: string, optional Legend location, "best" by default """ df = pd.DataFrame(error) groups, axs = self._get_suplblots(figsize) for ax, (g, bars) in zip(axs, df.groupby(level=0)): bars.droplevel(0).plot( kind="barh", width=0.6, ax=ax, legend=None, logx=x_log, xlim=(x_min, x_max), fontsize=13, ) ax.set_title(f"\\rm {g}", x=0.95, y=1.0) ax.grid(True, which="both", ls="dashed", axis="x", lw=0.5) # Hard cutoff if plot_cutoff is not None: ax.vlines( plot_cutoff, -2, 3 * groups[g] + 2, ls="dashed", color="black", alpha=0.7, ) self._plot_logo(axs[-1]) axs[-1].set_xlabel( r"$95\%\ {\rm Confidence\ Level\ Bounds}\ (1/{\rm TeV}^2)$", fontsize=20 ) axs[0].legend(loc=legend_loc, frameon=False, prop={"size": 13}) plt.tight_layout() plt.savefig(f"{self.report_folder}/coefficient_bar.pdf", dpi=500) plt.savefig(f"{self.report_folder}/coefficient_bar.png")
[docs] def plot_pull( self, pull, x_min=-3, x_max=3, figsize=(10, 15), legend_loc="best", ): """ Plot error bars at given confidence level Parameters ---------- pull: dict Fit residuals per fit and coefficient x_min: float, optional Minimum sigma to display, -3 by default x_max: float, optional Maximum sigma to display, +3 by default figsize: list, optional Figure size, (10, 15) by default legend_loc: string, optional Legend location, "best" by default """ df = pd.DataFrame(pull) groups, axs = self._get_suplblots(figsize) for ax, (g, bars) in zip(axs, df.groupby(level=0)): bars.droplevel(0).plot( kind="barh", width=0.6, ax=ax, legend=None, xlim=(x_min, x_max), fontsize=13, ) ax.set_title(f"\\rm {g}", x=0.95, y=1.0) ax.grid(True, which="both", ls="dashed", axis="x", lw=0.5) self._plot_logo(axs[-1]) axs[-1].set_xlabel(r"${\rm Fit\:Residual\:}(\sigma)$", fontsize=20) axs[0].legend(loc=legend_loc, frameon=False, prop={"size": 13}) plt.tight_layout() plt.savefig(f"{self.report_folder}/coefficient_pull.pdf", dpi=500) plt.savefig(f"{self.report_folder}/coefficient_pull.png")
[docs] def plot_spider( self, error, labels, title, marker_styles, ncol, ymax=100, log_scale=True, fontsize=12, figsize=(9, 9), legend_loc="best", radial_lines=None, class_order=None, ): """ Creates a spider plot that displays the ratio of uncertainties to a baseline fit, which is taken as the first fit specified in the report runcard Parameters ---------- error: dict confidence level bounds per fit and coefficient labels: list Fit labels, taken from the report runcard title: string Plot title marker_styles: list, optional Marker styles per fit ncol: int, optional Number of columns in the legend. Uses a single row by default. ymax: float, optional Radius in percentage log_scale: bool, optional Use a logarithmic radial scale, true by default fontsize: int, optional Font size figsize: list, optional Figure size, (9, 9) by default legend_loc: string, optional Location of the legend, "best" by default radial_lines: list, optional Location of radial lines in percentage class_order: list, optional Order of operator classes, starting at 12'o clock anticlockwise """ def log_transform(x, delta_shift): """ Log transform plus possible shift to map to semi-positive value Parameters ---------- x: array_like delta_shift: float Returns ------- Log transformed data """ return np.log10(x) + delta_shift df = pd.DataFrame(error) if radial_lines is None: radial_lines = [1, 5, 10, 20, 40, 60, 80] if class_order is None: class_order = df.index.get_level_values(0).unique() # check if more than one fit is loaded if df.shape[1] < 2: print("At least two fits are required for the spider plot") return theta = radar_factory(len(df), frame="circle") # normalise to first fit ratio = df.iloc[:, 1:].values / df.iloc[:, 0].values.reshape(-1, 1) * 100 delta = np.abs(np.log10(min(ratio.flatten()))) if log_scale: # in case the ratio < 1 %, its log transform is negative, so we add the absolute minimum data = log_transform(ratio, delta) else: data = ratio spoke_labels = df.index.get_level_values(1) fig = plt.figure(figsize=figsize) # margin settings outer_ax_width = 0.8 left_outer_ax = (1 - outer_ax_width) / 2 rect = [left_outer_ax, left_outer_ax, outer_ax_width, outer_ax_width] n_axis = 3 # number of spines with radial labels axes = [fig.add_axes(rect, projection="radar") for i in range(n_axis)] perc_labels = [rf"$\mathbf{{{(perc / 100):.3g}}}$" for perc in radial_lines] if log_scale: radial_lines = log_transform(radial_lines, delta) # take first axis as main, the rest only serve to show the remaining percentage axes ax = axes[0] # zero degrees is 12 o'clock start_angle = ( theta[np.argwhere(theta > 2 * np.pi - np.pi / 3).flatten()[0]] * 180 / np.pi ) angles = np.arange(start_angle, start_angle + 360, 360.0 / n_axis) prop_cycle = plt.rcParams["axes.prop_cycle"] colors = prop_cycle.by_key()["color"] if marker_styles is None: marker_styles = list(markers.MarkerStyle.markers.keys()) marker_styles = itertools.cycle(marker_styles) # add the ratios to the spider plot for i, data_fit_i in enumerate(data.T): ax.plot(theta, data_fit_i, color=colors[i], zorder=1) ax.scatter( theta, data_fit_i, marker=next(marker_styles), s=50, color=colors[i], zorder=1, ) ax.fill( theta, data_fit_i, alpha=0.25, label="_nolegend_", color=colors[i], zorder=1, ) for i, axis in enumerate(axes): if i > 0: axis.patch.set_visible(False) axis.xaxis.set_visible(False) angle = angles[i] text_alignment = "right" if angle % 360 > 180 else "left" axis.yaxis.set_tick_params(labelsize=11, zorder=100) if i == 0: axis.set_rgrids( radial_lines, angle=angle, labels=perc_labels, horizontalalignment=text_alignment, zorder=0, ) else: axis.set_rgrids( radial_lines[1:], angle=angle, labels=perc_labels[1:], horizontalalignment=text_alignment, zorder=0, ) if log_scale: axis.set_ylim(0, log_transform(ymax, delta)) else: axis.set_ylim(0, ymax) ax.set_varlabels(spoke_labels, fontsize=fontsize) ax.tick_params(axis="x", pad=17) ax2 = fig.add_axes(rect=[0, 0, 1, 1]) width_disk = 0.055 ax2.patch.set_visible(False) ax2.grid("off") ax2.xaxis.set_visible(False) ax2.yaxis.set_visible(False) delta_disk = 0.3 radius = outer_ax_width / 2 + (1 + delta_disk) * width_disk if title is not None: ax2.set_title(title, fontsize=18) # add coloured arcs along circle angle_sweep = [ sum(op_type in index for index in self.coeff_info.index) / len(self.coeff_info) for op_type in class_order ] # determine angles of the colored arcs prop_cycle = plt.rcParams["axes.prop_cycle"] colors = prop_cycle.by_key()["color"] filled_start_angle = 90 - 180 / len(self.coeff_info) for i, op_type in enumerate(class_order): filled_end_angle = ( angle_sweep[i] * 360 + filled_start_angle ) # End angle in degrees center = (0.5, 0.5) # Coordinates relative to the figure alpha = 0.3 ax2.axis("off") # Create the filled portion of the circular patch filled_wedge = patches.Wedge( center, radius, filled_start_angle, filled_end_angle, facecolor=colors[i], alpha=alpha, ec=None, width=width_disk, transform=ax2.transAxes, ) ax2.add_patch(filled_wedge) filled_start_angle += angle_sweep[i] * 360 handles = [ plt.Line2D( [0], [0], color=colors[i], linewidth=3, marker=next(marker_styles), markersize=10, ) for i in range(len(labels[1:])) ] ax2.legend( handles, labels[1:], frameon=False, fontsize=15, loc=legend_loc, ncol=ncol, bbox_to_anchor=(0.0, -0.05, 1.0, 0.05), bbox_transform=fig.transFigure, ) self._plot_logo(ax2, [0.75, 0.95, 0.001, 0.07]) plt.savefig( f"{self.report_folder}/spider_plot.pdf", dpi=500, bbox_inches="tight" ) plt.savefig(f"{self.report_folder}/spider_plot.png", bbox_inches="tight")
[docs] def plot_posteriors(self, posteriors, labels, disjointed_lists=None): """Plot posteriors histograms. Parameters ---------- posteriors : list posterior distributions per fit and coefficient labels : list list of fit names disjointed_list: list, optional list of coefficients with double solutions per fit """ colors = plt.rcParams["axes.prop_cycle"].by_key()["color"] grid_size = int(np.sqrt(self.npar)) + 1 fig = plt.figure(figsize=(grid_size * 4, grid_size * 4)) # loop on coefficients for idx, ((_, l), latex_name) in enumerate(self.coeff_info.items()): try: ax = plt.subplot(grid_size, grid_size, idx + 1) except ValueError: ax = plt.subplot(grid_size, grid_size, idx + 1) # loop on fits for clr_idx, posterior in enumerate(posteriors): if l not in posterior: continue solution = posterior[l] if ( disjointed_lists[clr_idx] is not None and l in disjointed_lists[clr_idx] ): solution1, solution2 = split_solution(posterior[l]) bins_solution1 = np.histogram_bin_edges(solution1, bins="fd") bins_solution2 = np.histogram_bin_edges(solution2, bins="fd") ax.hist( solution, bins=np.sort(np.concatenate((bins_solution1, bins_solution2))), density=True, color=colors[clr_idx], edgecolor="black", alpha=0.3, label=labels[clr_idx], ) else: ax.hist( solution, bins="fd", density=True, color=colors[clr_idx], edgecolor="black", alpha=0.3, label=labels[clr_idx], ) ax.text( 0.05, 0.85, latex_name, transform=ax.transAxes, fontsize=25, ) ax.tick_params(which="both", direction="in", labelsize=22.5) ax.tick_params(labelleft=False) lines, labels = fig.axes[0].get_legend_handles_labels() for axes in fig.axes: if len(axes.get_legend_handles_labels()[0]) > len(lines): lines, labels = axes.get_legend_handles_labels() fig.legend( lines, labels, ncol=len(posteriors), prop={"size": 25 * (grid_size * 4) / 20}, bbox_to_anchor=(0.5, 1.0), loc="upper center", frameon=False, ) if self.npar % grid_size == 0: ax_logo_nr = self.npar + grid_size else: ax_logo_nr = self.npar + (grid_size - self.npar % grid_size) ax_logo = plt.subplot(grid_size, grid_size, ax_logo_nr) plt.axis("off") self._plot_logo(ax_logo, [0, 1, 0.6, 1]) fig.tight_layout( rect=[0, 0.05 * (5.0 / grid_size), 1, 1 - 0.08 * (5.0 / grid_size)] ) plt.savefig(f"{self.report_folder}/coefficient_histo.pdf") plt.savefig(f"{self.report_folder}/coefficient_histo.png")
[docs] def plot_contours_2d( self, posteriors, labels, confidence_level=95, dofs_show=None, double_solution=None, ): """Plots 2D marginalised projections confidence level contours Parameters ---------- posteriors : list posterior distributions per fit and coefficient labels : list list of fit names dofs_show: list, optional List of coefficients to include in the cornerplot, set to ``None`` by default, i.e. all fitted coefficients are included. double_solution: dict, optional Dictionary of operators with double (disjoint) solution per fit """ if double_solution is None: double_solution = {f"fit{i+1}": [] for i in range(len(posteriors))} if dofs_show is not None: posteriors = [ (posterior[0][dofs_show], posterior[1]) for posterior in posteriors ] coeff = dofs_show n_par = len(dofs_show) else: coeff = self.coeff_info.index.levels[1] n_par = self.npar n_cols = n_par - 1 n_rows = n_cols if n_par > 2: fig = plt.figure(figsize=(n_cols * 4, n_rows * 4)) grid = plt.GridSpec(n_rows, n_cols, hspace=0.1, wspace=0.1) else: fig = plt.figure(figsize=(8, 8)) grid = plt.GridSpec(1, 1, hspace=0.1, wspace=0.1) c1_old = coeff[0] row_idx = -1 col_idx = -1 j = 1 # loop over coefficient pairs for c1, c2 in itertools.combinations(coeff, 2): if c1 != c1_old: row_idx += -1 col_idx = -1 - j j += 1 c1_old = c1 if n_par > 2: ax = fig.add_subplot(grid[row_idx, col_idx]) else: ax = fig.add_subplot(grid[0, 0]) # loop over fits hndls_all = [] fit_number = -1 for clr_idx, (posterior, kde) in enumerate(posteriors): fit_number = fit_number + 1 # case when confidence levels = [cl1, cl2] if isinstance(confidence_level, list): colors = plt.rcParams["axes.prop_cycle"].by_key()["color"] # plot the first one dashed if kde: sns.kdeplot( x=posterior[c2].values, y=posterior[c1].values, levels=[1 - confidence_level[0] / 100.0, 1.0], bw_adjust=1.2, ax=ax, linestyles="dashed", linewidths=2, color=colors[clr_idx], label=None, ) else: confidence_ellipse( posterior[c2].values, posterior[c1].values, ax, edgecolor=colors[clr_idx], confidence_level=confidence_level[0], linestyle="dashed", linewidth=2, ) cl = confidence_level[1] else: cl = confidence_level hndls_contours = plot_contours( ax, posterior, coeff1=c1, coeff2=c2, ax_labels=[ self.coeff_info[:, c1].values[0], self.coeff_info[:, c2].values[0], ], kde=kde, clr_idx=clr_idx, confidence_level=cl, double_solution=( list(double_solution.values())[clr_idx] if kde else None ), ) hndls_all.append(hndls_contours) if row_idx != -1: ax.set(xlabel=None) ax.tick_params( axis="x", # changes apply to the x-axis which="both", # both major and minor ticks are affected labelbottom=False, ) if (n_par > 2 and col_idx != -n_cols) or (n_par == 2 and col_idx != -1): ax.set(ylabel=None) ax.tick_params( axis="y", # changes apply to the y-axis which="both", # both major and minor ticks are affected labelleft=False, ) hndls_sm_point = ax.scatter(0, 0, c="k", marker="+", s=50, zorder=10) hndls_all.append(hndls_sm_point) col_idx -= 1 ax.locator_params(axis="x", nbins=5) ax.locator_params(axis="y", nbins=6) ax.minorticks_on() ax.grid(linestyle="dotted", linewidth=0.5) # in case n_par > 2, put legend outside subplot if n_par > 2: ax = fig.add_subplot(grid[0, -1]) ax.axis("off") ax.legend( labels=labels + [r"$\mathrm{SM}$"], handles=hndls_all, loc="lower left", frameon=False, fontsize=20, handlelength=1, borderpad=0.5, handletextpad=1, title_fontsize=24, ) ax.set_title( rf"$\mathrm{{Marginalised}}\:{cl}\:\%\:\mathrm{{C.L.\:intervals}}$", fontsize=18, ) grid.tight_layout(fig) fig.savefig(f"{self.report_folder}/contours_2d.pdf") fig.savefig(f"{self.report_folder}/contours_2d.png")
[docs] def write_cl_table(self, bounds, round_val=3): """Coefficients latex table""" nfits = len(bounds) L = latex_packages() L.extend( [ r"\begin{document}", "\n", "\n", r"\begin{table}[H]", r"\centering", r"\begin{tabular}{|c|c|" + "c|c|c|" * nfits + "}", ] ) L.extend(multicolum_table_header(bounds.keys(), ncolumn=3)) L.append( r"Class & Coefficients" + r" & best & 68\% CL Bounds & 95\% CL Bounds" * nfits + r"\\ \hline" ) for group, coeff_group in self.coeff_info.groupby(level=0): coeff_group = coeff_group.droplevel(0) L.append(f"\\multirow{{{coeff_group.shape[0]}}}{{*}}{{{group}}}") # loop on coefficients for latex_name in coeff_group.values: temp = f" & {latex_name}" temp2 = " &" # loop on fits for bound_df in bounds.values(): try: cl_vals = bound_df[(group, latex_name)].dropna()[0] except KeyError: # not fitted if bound_df[(group, latex_name)].dropna().empty: temp += r" & \textemdash & \textemdash & \textemdash " continue raise KeyError(f"{latex_name} is not found in posterior") temp += f" & {np.round(cl_vals['mid'],round_val)} \ & [{np.round(cl_vals['low68'],round_val)},{np.round(cl_vals['high68'],round_val)}] \ & [{np.round(cl_vals['low95'],round_val)},{np.round(cl_vals['high95'],round_val)}]" # double solution try: cl_vals_2 = bound_df[(group, latex_name)].dropna()[1] temp2 += f" & {np.round(cl_vals_2['mid'],round_val)} \ & [{np.round(cl_vals_2['low68'],round_val)},{np.round(cl_vals_2['high68'],round_val)}] \ & [{np.round(cl_vals_2['low95'],round_val)},{np.round(cl_vals_2['high95'],round_val)}]" except KeyError: temp2 += r" & & &" # append double solution if temp2 != " &" * (3 * nfits + 1): temp += f" \\\\ \\cline{{3-{(2 + 3 * nfits)}}}" temp += temp2 temp += f" \\\\ \\cline{{2-{(2 + 3 * nfits)}}}" L.append(temp) L.append(r"\hline") L.extend( [ r"\end{tabular}", r"\caption{Coefficient comparison}", r"\end{table}", "\n", "\n", ] ) return L