Download analysis/code/applications_plots.py from ekwan16/MagNET: direct link, hf CLI and curl.
- Browser
- Download file 32.6 kB
-
https://huggingface.co/ekwan16/MagNET/resolve/fe222d1894afb2d954fe8d2e67e70731a006c8d2/analysis/code/applications_plots.py
- Command line
-
hf download hf://ekwan16/MagNET@fe222d1894afb2d954fe8d2e67e70731a006c8d2/analysis/code/applications_plots.py
-
curl -L -o applications_plots.py https://huggingface.co/ekwan16/MagNET/resolve/fe222d1894afb2d954fe8d2e67e70731a006c8d2/analysis/code/applications_plots.py
32.6 kB
| """Plotting for the natural-products figures (main-text Figures 5C/5D, SI Figure S15). | |
| Holds every plotting engine for the applications (natural-products) notebooks: the delta-22-vs-test-set | |
| benefit bar chart (Figure 5C), the shared per-solute box-plot engine (Figure 5D and SI Figure S15's | |
| bootstrap-RMSE panels), and SI Figure S15's fitting-RMSE comparison bars, feature-space/residual scatter | |
| grids, and distribution-shift bars. Engines are carried over from the source notebooks with notebook | |
| globals turned into explicit arguments. The numbers come from applications.py; this module is kept | |
| separate so applications.py stays plotting-free. | |
| """ | |
| import os | |
| import numpy as np | |
| import pandas as pd | |
| import seaborn as sns | |
| import matplotlib.pyplot as plt | |
| def plot_nps_on_boxplot_delta22_simplified( | |
| regression_ss_df, | |
| nps_rmse_df, | |
| best_possible_df, # expects a 'rmse' column | |
| nucleus, | |
| formulas, | |
| colors, | |
| site_counts, | |
| solvents_filter=None, | |
| box_width=0.20, | |
| box_gap=0.05, | |
| formula_gap=0.15, | |
| figsize=(14, 8), | |
| formula_remap=None, | |
| solute_remap=None, | |
| solute_order=None, | |
| title=None, | |
| save_path=None, | |
| solute_color_remap=None, | |
| max_bar_height=0.05, | |
| # --- SELF-SCALING BASELINE CONTROLS --- | |
| show_baseline=True, | |
| baseline_annotation_text='Self-scaling baseline (dotted)', | |
| baseline_annotation_x=0.985, | |
| baseline_annotation_y=0.93, | |
| baseline_annotation_fontsize=9, | |
| # --- FULL FIT LINE CONTROLS --- | |
| show_full_fit_line=False, | |
| all_solute_fitting_results=None, # {nucleus: per-solvent fit df}; required when show_full_fit_line | |
| full_fit_line_label='Full Fit RMSE', | |
| full_fit_line_label_x=0.99, | |
| # --- INSET AXIS CONTROLS --- | |
| site_count_axis_mode='attached', | |
| site_count_inset_axis_offset=0.0, | |
| site_count_inset_area_frac=0.1, | |
| site_count_inset_axis_label_pad=0.04, | |
| site_count_alpha=0.25, | |
| site_count_label='Site count', | |
| site_count_preserve_visual_height=True, | |
| # ---- font sizes ---- | |
| xtick_fontsize=10, | |
| delta22_fontsize=10, | |
| ylabel_fontsize=13, | |
| title_fontsize=16, | |
| # ---- SAVE SVG ---- | |
| save_svg_path=None, | |
| default_svg_name="Figure_5C_extras.svg", | |
| svg_transparent=True, | |
| ): | |
| """ | |
| Simplified box and whisker plot: | |
| - Plots all solutes separately (no grouping) | |
| - Averages RMSE across solvents for each bootstrap seed | |
| - Shows "best possible" RMSE as dashed baseline for each solute | |
| Parameters: | |
| - regression_ss_df: Delta-22 regression samples (Bootstrap_RMSE) | |
| - nps_rmse_df: Should have columns: solvent, nucleus, formula, seed, solute, Bootstrap_RMSE | |
| - best_possible_df: DataFrame with best possible RMSEs per solute/solvent/formula | |
| Should have columns: formula, solvent, solute, rmse, params | |
| - nucleus: Which nucleus to filter for | |
| - formulas: List of formulas to compare | |
| - colors: Colors for each formula | |
| - site_counts: DataFrame indexed by solute with nucleus count columns (e.g., 'H', 'C') | |
| - solvents_filter: Optional list of solvents to include in averaging | |
| - show_baseline: Whether to show the 'best possible df' baseline | |
| - show_full_fit_line: Whether to draw a line representing the full-dataset fit RMSE across all solutes | |
| - full_fit_line_label: Label for the full fit line | |
| - full_fit_line_label_x: X-coordinate for the full fit line label | |
| - box_width: Width of each box | |
| - box_gap: Gap between boxes | |
| - formula_gap: Gap between formulas | |
| - figsize: Figure size | |
| - formula_remap: Optional dict to remap formula names | |
| - max_bar_height: Maximum visual height target for site-count bars relative to RMSE axis | |
| - site_count_axis_mode: One of {'attached', 'inset', 'off'} | |
| - site_count_inset_axis_offset: Horizontal inset-axis adjustment in axes coordinates (clipped to stay inside in inset mode) | |
| - site_count_inset_area_frac: Fraction of total figure area to allocate to inset axis (inset mode only) | |
| - site_count_alpha: Alpha for site-count bars | |
| - site_count_label: Label for site-count axis | |
| - site_count_preserve_visual_height: Keep the old visual bar footprint when using a right axis | |
| """ | |
| if formula_remap is None: | |
| formula_remap = {} | |
| if solute_remap is None: | |
| solute_remap = {} | |
| if solute_color_remap is None: | |
| solute_color_remap = {} | |
| valid_axis_modes = {'attached', 'inset', 'off'} | |
| if site_count_axis_mode not in valid_axis_modes: | |
| raise ValueError(f"site_count_axis_mode must be one of {sorted(valid_axis_modes)}") | |
| if site_counts is not None and not isinstance(site_counts, pd.DataFrame): | |
| raise TypeError("site_counts must be a pandas DataFrame or None") | |
| if site_count_inset_area_frac < 0 or site_count_inset_area_frac > 1: | |
| raise ValueError("site_count_inset_area_frac must be between 0 and 1") | |
| def get_rounded_count_ticks(max_count, target_ticks=7): | |
| if max_count <= 0: | |
| return np.array([0.0, 1.0]), 1.0 | |
| rough_step = max_count / max(target_ticks - 1, 1) | |
| magnitude = 10 ** np.floor(np.log10(rough_step)) if rough_step > 0 else 1.0 | |
| normalized = rough_step / magnitude | |
| if normalized <= 1: | |
| nice_base = 1 | |
| elif normalized <= 2: | |
| nice_base = 2 | |
| elif normalized <= 5: | |
| nice_base = 5 | |
| else: | |
| nice_base = 10 | |
| step = nice_base * magnitude | |
| rounded_top = np.ceil(max_count / step) * step | |
| ticks = np.arange(0, rounded_top + 0.5 * step, step) | |
| return ticks, rounded_top | |
| n_formulas = len(formulas) | |
| if solvents_filter is not None: | |
| regression_ss_df = regression_ss_df[regression_ss_df['solvent'].isin(solvents_filter)].copy() | |
| nps_rmse_df = nps_rmse_df[nps_rmse_df['solvent'].isin(solvents_filter)].copy() | |
| best_possible_df = best_possible_df[best_possible_df['solvent'].isin(solvents_filter)].copy() | |
| if regression_ss_df.empty: | |
| raise ValueError(f"No regression data found for solvents: {solvents_filter}") | |
| if nps_rmse_df.empty: | |
| raise ValueError(f"No NPS RMSE data found for solvents: {solvents_filter}") | |
| def get_delta22_averaged_rmse(regression_df, formula): | |
| df = regression_df[regression_df['formula'] == formula] | |
| return df.groupby('seed')['Bootstrap_RMSE'].mean().values | |
| def get_solute_bootstrap_averaged(nps_df, formula, nucleus): | |
| np_formula = formula_remap.get(formula, formula) | |
| df = nps_df[ | |
| (nps_df['formula'] == np_formula) & | |
| (nps_df['nucleus'] == nucleus) | |
| ] | |
| if df.empty: | |
| return {} | |
| # average over solvent per solute/seed, then collect each solute's per-seed array | |
| averaged_df = df.groupby(['solute', 'seed'])['Bootstrap_RMSE'].mean().reset_index() | |
| solute_dict = {} | |
| for solute in averaged_df['solute'].unique(): | |
| arr = averaged_df[averaged_df['solute'] == solute]['Bootstrap_RMSE'].values | |
| if arr.size > 0: | |
| solute_dict[solute] = arr | |
| return solute_dict | |
| def get_best_possible_averaged(best_df, solute, formula): | |
| np_formula = formula_remap.get(formula, formula) | |
| df = best_df[ | |
| (best_df['formula'] == np_formula) & | |
| (best_df['solute'] == solute) | |
| ] | |
| if df.empty: | |
| # fall back to the raw formula name in case best_possible_df wasn't remapped | |
| df = best_df[ | |
| (best_df['formula'] == formula) & | |
| (best_df['solute'] == solute) | |
| ] | |
| if df.empty: | |
| return None | |
| return df['rmse'].mean() | |
| all_solutes = set() | |
| for formula in formulas: | |
| np_formula = formula_remap.get(formula, formula) | |
| df_filtered = nps_rmse_df[ | |
| (nps_rmse_df['formula'] == np_formula) & | |
| (nps_rmse_df['nucleus'] == nucleus) | |
| ] | |
| all_solutes.update(df_filtered['solute'].unique()) | |
| all_solutes = sorted(list(all_solutes)) | |
| if solute_order is not None: # explicit ordering (e.g. main-text Figure 5D) | |
| ordered = [s for s in solute_order if s in all_solutes] | |
| all_solutes = ordered + [s for s in all_solutes if s not in ordered] | |
| delta22_by_formula = [get_delta22_averaged_rmse(regression_ss_df, f) for f in formulas] | |
| # each formula block is 1 delta-22 box plus one box per solute | |
| total_boxes_per_formula = 1 + len(all_solutes) | |
| all_data = [] | |
| all_positions = [] | |
| all_colors = [] | |
| all_alphas = [] | |
| tick_positions = [] | |
| tick_labels = [] | |
| baseline_segments = [] # Store (x_start, x_end, y_value, solute_label) for baseline segments | |
| count_bar_positions = [] | |
| count_bar_values = [] | |
| delta22_count_by_nucleus = {'H': 43.0, 'C': 50.0} | |
| current_position = 0.0 | |
| for j, formula in enumerate(formulas): | |
| # 1) Delta-22 box (solvent-averaged) | |
| box_pos = current_position + box_width / 2 | |
| all_data.append(delta22_by_formula[j]) | |
| all_positions.append(box_pos) | |
| all_colors.append(colors[j]) | |
| all_alphas.append(1.0) | |
| tick_positions.append(box_pos) | |
| tick_labels.append('delta-22') | |
| delta22_count = delta22_count_by_nucleus.get(nucleus) | |
| if delta22_count is not None: | |
| count_bar_positions.append(box_pos) | |
| count_bar_values.append(float(delta22_count)) | |
| current_position += box_width + box_gap | |
| # 2) Individual solute boxes (solvent-averaged, lighter alpha) | |
| solute_data = get_solute_bootstrap_averaged(nps_rmse_df, formula, nucleus) | |
| count_col = None | |
| if site_counts is not None: | |
| if nucleus in site_counts.columns: | |
| count_col = nucleus | |
| elif 'H' in site_counts.columns: | |
| count_col = 'H' | |
| for solute in all_solutes: | |
| box_pos = current_position + box_width / 2 | |
| if solute in solute_data and len(solute_data[solute]) > 0: | |
| all_data.append(solute_data[solute]) | |
| all_positions.append(box_pos) | |
| all_colors.append(solute_color_remap.get(solute, colors[j])) | |
| all_alphas.append(0.5) | |
| tick_positions.append(box_pos) | |
| tick_labels.append(solute_remap.get(solute, solute)) | |
| if ( | |
| count_col is not None | |
| and solute in site_counts.index | |
| and pd.notna(site_counts.loc[solute, count_col]) | |
| ): | |
| count_bar_positions.append(box_pos) | |
| count_bar_values.append(float(site_counts.loc[solute, count_col])) | |
| else: | |
| count_bar_positions.append(box_pos) | |
| count_bar_values.append(0.0) | |
| # best_possible_df has no nucleus column, so this doesn't filter by nucleus | |
| if show_baseline: | |
| best_rmse = get_best_possible_averaged(best_possible_df, solute, formula) | |
| if best_rmse is not None: | |
| # baseline segment spans the box's full width including its gap | |
| x_start = current_position - box_gap / 2 | |
| x_end = current_position + box_width + box_gap / 2 | |
| baseline_segments.append((x_start, x_end, best_rmse, solute)) | |
| current_position += box_width + box_gap | |
| # Add gap after formula (except last) | |
| if j < n_formulas - 1: | |
| current_position += formula_gap | |
| plot_figsize = figsize | |
| fig, ax = plt.subplots(figsize=plot_figsize) | |
| # skip boxes with no data | |
| valid_indices = [i for i, data in enumerate(all_data) if len(data) > 0] | |
| valid_data = [all_data[i] for i in valid_indices] | |
| valid_positions = [all_positions[i] for i in valid_indices] | |
| valid_colors = [all_colors[i] for i in valid_indices] | |
| valid_alphas = [all_alphas[i] for i in valid_indices] | |
| if valid_data: | |
| box = ax.boxplot( | |
| valid_data, | |
| positions=valid_positions, | |
| widths=box_width, | |
| showfliers=False, | |
| patch_artist=True, | |
| whiskerprops=dict(color='#404040'), | |
| capprops=dict(color='#404040') | |
| ) | |
| for patch, color, alpha in zip(box['boxes'], valid_colors, valid_alphas): | |
| patch.set_facecolor(color) | |
| patch.set_alpha(alpha) | |
| for median in box['medians']: | |
| median.set_color('#404040') | |
| if show_baseline: | |
| for i, (x_start, x_end, y_value, _) in enumerate(baseline_segments): | |
| ax.plot([x_start, x_end], [y_value, y_value], | |
| color='gray', linestyle='--', linewidth=1.5, alpha=0.7) | |
| # connect to the previous segment unless this is the first solute of a formula block | |
| # (connects across formula gaps too, not just within one) | |
| if len(formulas) > 0 and len(baseline_segments) > 0 and i % (len(baseline_segments) / len(formulas)) != 0: | |
| _, prev_x_end, prev_y, _ = baseline_segments[i - 1] | |
| ax.plot([prev_x_end, x_start], [prev_y, y_value], | |
| color='gray', linestyle='--', linewidth=1.5, alpha=0.7) | |
| if show_baseline and baseline_segments and baseline_annotation_text: | |
| ax.text( | |
| baseline_annotation_x, | |
| baseline_annotation_y, | |
| baseline_annotation_text, | |
| transform=ax.transAxes, | |
| ha='right', | |
| va='top', | |
| fontsize=baseline_annotation_fontsize, | |
| color='gray', | |
| style='italic', | |
| alpha=0.85, | |
| ) | |
| # the full-fit-line RMSE uses the first formula's delta-22 fit as its reference | |
| if show_full_fit_line: | |
| formula_remapped = formula_remap.get(formulas[0], formulas[0]) | |
| query = f'formula == "{formula_remapped}"' + (f' and solvent in {solvents_filter}' if solvents_filter is not None else '') | |
| full_fit_rmse = all_solute_fitting_results[nucleus].query(query)['rmse'].mean() | |
| ax.axhline(y=full_fit_rmse, color='red', linestyle='-.', linewidth=1.5, alpha=0.7) | |
| ax.text(full_fit_line_label_x, | |
| full_fit_rmse, f'{full_fit_line_label}: {full_fit_rmse:.3f} ppm', | |
| transform=ax.get_yaxis_transform(), | |
| ha='right', va='bottom', fontsize=9, color='red', style='italic', alpha=0.7) | |
| ax.set_xticks(tick_positions) | |
| ax.set_xticklabels(tick_labels, rotation=75, fontsize=xtick_fontsize, ha='center') | |
| for i, label_obj in enumerate(ax.get_xticklabels()): | |
| if tick_labels[i] == 'delta-22': | |
| label_obj.set_weight('bold') | |
| label_obj.set_fontsize(delta22_fontsize) | |
| label_obj.set_style('italic') | |
| # vertical separators between formula blocks | |
| for j in range(1, n_formulas): | |
| x_pos = j * (total_boxes_per_formula * box_width + formula_gap) - formula_gap / 2 | |
| ax.axvline(x=x_pos, color='gray', linestyle='--', linewidth=1, alpha=0.5) | |
| if solvents_filter is not None: | |
| solvents_str = ', '.join(solvents_filter) | |
| ylabel = f"1H RMSE (ppm)\n(Averaged across: {solvents_str})" | |
| else: | |
| ylabel = "1H RMSE (ppm)" | |
| ax.set_ylabel(ylabel, fontsize=ylabel_fontsize, fontweight='bold') | |
| ax.set_ylim(bottom=0) | |
| # caller may override via title=; the default text reflects the solvent filter | |
| if title is None: | |
| if solvents_filter is not None: | |
| title = f"Delta22 vs Individual Solute Bootstrap Sample RMSEs ({nucleus} nucleus; {solvents_filter})" | |
| else: | |
| title = f"Delta22 vs Individual Solute Bootstrap Sample RMSEs ({nucleus} nucleus, solvent-averaged)" | |
| ax.set_title(title, fontsize=title_fontsize, fontweight='bold') | |
| ax.set_xlim(-0.2, current_position + 0.2) | |
| # site-count bars use a dedicated right (twin) axis | |
| added_site_count_bars = False | |
| site_count_ax = None | |
| max_count = max(count_bar_values) if count_bar_values else 0.0 | |
| if site_count_axis_mode != 'off' and max_bar_height > 0 and len(count_bar_positions) > 0 and max_count > 0: | |
| site_count_ax = ax.twinx() | |
| tick_counts, rounded_count_top = get_rounded_count_ticks(max_count) | |
| if site_count_preserve_visual_height: | |
| rmse_top = ax.get_ylim()[1] | |
| rmse_top = max(rmse_top, 1e-6) | |
| bar_target = max(max_bar_height, 1e-6) | |
| # Keep bars in the lower band while right-axis tick labels remain true counts. | |
| axis_top = max(rounded_count_top * (rmse_top / bar_target), rounded_count_top * 1.05) | |
| else: | |
| axis_top = max(rounded_count_top, 1.0) | |
| site_count_ax.bar( | |
| count_bar_positions, | |
| count_bar_values, | |
| width=box_width + box_gap, | |
| bottom=0, | |
| color='#404040', | |
| alpha=site_count_alpha, | |
| edgecolor='none', | |
| zorder=0, | |
| ) | |
| site_count_ax.set_ylim(0, axis_top) | |
| if site_count_axis_mode == 'inset': | |
| # Hide inset twin-axis renderer and draw a short attached axis manually. | |
| for spine_name in ('left', 'right', 'top', 'bottom'): | |
| site_count_ax.spines[spine_name].set_visible(False) | |
| site_count_ax.patch.set_visible(False) | |
| site_count_ax.yaxis.set_visible(False) | |
| inset_axis_top_frac = min(rounded_count_top / axis_top, 1.0) | |
| # clip to keep the inset axis inside the plotting area near the right edge | |
| inset_x = float(np.clip(1.0 + site_count_inset_axis_offset, 0.0, 1.0)) | |
| ax.plot( | |
| [inset_x, inset_x], | |
| [0.0, inset_axis_top_frac], | |
| transform=ax.transAxes, | |
| color='#404040', | |
| linewidth=1.0, | |
| clip_on=False, | |
| zorder=3, | |
| ) | |
| # Manual ticks and tick labels, confined to the short segment. | |
| tick_len = 0.008 | |
| tick_label_pad = 0.012 | |
| for t in tick_counts: | |
| if t == 0: | |
| continue | |
| y_frac = t / axis_top | |
| if y_frac <= inset_axis_top_frac + 1e-9: | |
| ax.plot( | |
| [inset_x, inset_x + tick_len], | |
| [y_frac, y_frac], | |
| transform=ax.transAxes, | |
| color='#404040', | |
| linewidth=0.9, | |
| clip_on=False, | |
| zorder=3, | |
| ) | |
| ax.text( | |
| inset_x + tick_len + tick_label_pad, | |
| y_frac, | |
| f"{int(t)}", | |
| transform=ax.transAxes, | |
| va='center', | |
| ha='left', | |
| fontsize=10, | |
| color='#404040', | |
| clip_on=False, | |
| ) | |
| label_x = inset_x + site_count_inset_axis_label_pad | |
| ax.text( | |
| label_x, | |
| inset_axis_top_frac / 2, | |
| site_count_label, | |
| transform=ax.transAxes, | |
| rotation=90, | |
| va='center', | |
| ha='left', | |
| fontsize=11, | |
| fontweight='bold', | |
| color='#404040', | |
| clip_on=False, | |
| ) | |
| else: | |
| site_count_ax.set_yticks(tick_counts) | |
| site_count_ax.set_yticklabels([f"{int(t)}" for t in tick_counts]) | |
| site_count_ax.set_ylabel(site_count_label, fontsize=13, fontweight='bold', color='#404040') | |
| site_count_ax.tick_params(axis='y', labelcolor='#404040') | |
| site_count_ax.spines['right'].set_color('#404040') | |
| site_count_ax.grid(False) | |
| # Keep RMSE artists visually above site-count bars. | |
| ax.set_zorder(2) | |
| ax.patch.set_alpha(0) | |
| site_count_ax.set_zorder(1) | |
| added_site_count_bars = True | |
| if site_count_axis_mode == 'inset' and added_site_count_bars: | |
| ax.set_xlim(left=-box_width/2-box_gap/2, right=4.55) | |
| plt.tight_layout() | |
| # PNG is the release format; SVG is also supported for standalone vector export | |
| if save_path is not None: | |
| outdir = os.path.dirname(save_path) | |
| if outdir: | |
| os.makedirs(outdir, exist_ok=True) | |
| fig.savefig(save_path, dpi=200, bbox_inches="tight") | |
| if save_svg_path is not None: | |
| # If user passed a directory, save into it with default name | |
| if os.path.isdir(save_svg_path) or save_svg_path.endswith(os.sep): | |
| save_svg_path = os.path.join(save_svg_path, default_svg_name) | |
| if not save_svg_path.lower().endswith(".svg"): | |
| save_svg_path = save_svg_path + ".svg" | |
| outdir = os.path.dirname(save_svg_path) | |
| if outdir: | |
| os.makedirs(outdir, exist_ok=True) | |
| fig.savefig( | |
| save_svg_path, | |
| format="svg", | |
| bbox_inches="tight", | |
| pad_inches=0.02, | |
| transparent=svg_transparent, | |
| ) | |
| print(f"Saved SVG to: {os.path.abspath(save_svg_path)}") | |
| plt.show() | |
| def plot_nps_benefit_barplot(regression_ss_df, nps_rmse_df, nucleus, solvents, np_solute_groups, | |
| formulas, labels, colors, formula_remap=None, figsize=(6, 5), | |
| box_width=0.18, formula_gap=0.04, solvent_gap=0.35, spacer_width=0.0, | |
| y_min=0.0, y_max=None, save_path=None): | |
| """Grouped bar chart of mean bootstrap RMSE per (solvent, dataset, formula), with 2.5-97.5 | |
| percentile bootstrap error bars. Delta-22 is drawn first within each solvent, then the | |
| NP/complex bin. Used by main-text Figure 5C (implicit vs explicit correction, delta-22 vs the | |
| test set).""" | |
| sns.set_theme(style="ticks", context="paper") | |
| formula_remap = formula_remap or {} | |
| n_formulas = len(formulas) | |
| label_remap = {labels[0]: "Implicit", labels[1]: "Explicit"} | |
| label_to_color = {label_remap.get(labels[i], labels[i]): colors[i] for i in range(len(labels))} | |
| flat_solvents = [s for g in solvents for s in g] if isinstance(solvents[0], list) else list(solvents) | |
| solvent_groups_iter = solvents if isinstance(solvents[0], list) else [solvents] | |
| def d22_by_solvent(formula): | |
| df = regression_ss_df[regression_ss_df["formula"] == formula] | |
| return {s: df[df["solvent"] == s]["Bootstrap_RMSE"].dropna().values for s in flat_solvents} | |
| def np_grouped(formula, solvent): | |
| df = nps_rmse_df[(nps_rmse_df["formula"] == formula) & (nps_rmse_df["solvent"] == solvent) | |
| & (nps_rmse_df["nucleus"] == nucleus)] | |
| if df.empty: | |
| return {} | |
| out = {} | |
| for g in np_solute_groups.keys(): | |
| arr = df[df["solute_group"] == g]["Bootstrap_RMSE"].dropna().values | |
| if arr.size: | |
| out[g] = arr | |
| return out | |
| def summ(vals): | |
| vals = np.asarray(vals, float); vals = vals[~np.isnan(vals)] | |
| if vals.size == 0: | |
| return None | |
| m = np.mean(vals); lo, hi = np.percentile(vals, [2.5, 97.5]) | |
| return m, m - lo, hi - m | |
| d22 = [d22_by_solvent(f) for f in formulas] | |
| fig, ax = plt.subplots(figsize=figsize) | |
| category_gap = 0.55 | |
| x = 0.0 | |
| ticks, ticklabels, solvent_centers, seen = [], [], {}, {} | |
| def draw_pair(center, getter): | |
| drew = False | |
| for j, formula in enumerate(formulas): | |
| stats = getter(j, formula) | |
| if stats is None: | |
| continue | |
| mean, elo, ehi = stats | |
| disp = label_remap.get(labels[j], labels[j]) | |
| offset = (j - (n_formulas - 1) / 2) * (box_width + formula_gap) | |
| bar = ax.bar(center + offset, mean, width=box_width, | |
| yerr=np.array([[elo], [ehi]]), capsize=3, color=label_to_color[disp], | |
| edgecolor="black", linewidth=0.9, zorder=3, | |
| label=disp if disp not in seen else None) | |
| seen.setdefault(disp, bar) | |
| drew = True | |
| return drew | |
| for solvent_group in solvent_groups_iter: | |
| for solvent in solvent_group: | |
| centers = [x] | |
| draw_pair(x, lambda j, f: summ(d22[j].get(solvent, np.array([])))) | |
| ticks.append(x); ticklabels.append("delta-22"); x += category_gap | |
| for bin_label in np_solute_groups.keys(): | |
| if draw_pair(x, lambda j, f, s=solvent, b=bin_label: | |
| summ(np_grouped(formula_remap.get(f, f), s).get(b, [])) if | |
| np_grouped(formula_remap.get(f, f), s).get(b) is not None else None): | |
| centers.append(x); ticks.append(x) | |
| ticklabels.append("complex" if bin_label == "Test Set" else bin_label) | |
| x += category_gap | |
| solvent_centers[solvent] = np.mean(centers) | |
| x += solvent_gap | |
| x += spacer_width | |
| ax.set_xticks(ticks) | |
| ax.set_xticklabels(ticklabels, rotation=45, fontsize=9, style="italic", ha="center") | |
| for lab in ax.get_xticklabels(): | |
| if lab.get_text() == "delta-22": | |
| lab.set_fontweight("bold"); lab.set_fontsize(10); lab.set_style("normal") | |
| for solvent, center in solvent_centers.items(): | |
| ax.text(center, -0.17, solvent, transform=ax.get_xaxis_transform(), | |
| ha="center", va="top", fontsize=12, fontweight="bold") | |
| nuc = "^{1}\\mathrm{H}" if nucleus == "H" else "^{13}\\mathrm{C}" | |
| ax.set_ylabel(r"RMSE $(" + nuc + r"$ ppm)", fontsize=13) | |
| ax.set_ylim(bottom=y_min, top=y_max) | |
| disp_nuc = "1H" if nucleus == "H" else "13C" | |
| ax.set_title(f"Delta-22 vs NP/Isomer Bootstrap RMSE by Solvent ({disp_nuc} nucleus)", | |
| fontsize=14, fontweight="bold") | |
| ax.tick_params(axis="both", which="major", labelsize=10, length=4, width=0.8) | |
| ax.yaxis.grid(True, linestyle="-", linewidth=0.4, alpha=0.35, zorder=0) | |
| ax.xaxis.grid(False) | |
| sns.despine(ax=ax, top=True, right=True) | |
| for side in ["left", "bottom", "right", "top"]: | |
| ax.spines[side].set_linewidth(0.9) | |
| leg = ax.legend(frameon=False, fontsize=10) | |
| if leg is not None: | |
| leg.set_title("") | |
| fig.tight_layout() | |
| if save_path: | |
| fig.savefig(save_path, dpi=200, bbox_inches="tight") | |
| plt.show() | |
| def plot_fitting_rmse_comparison_bars(table, nucleus, solvent, figsize=(11, 4), save_path=None): | |
| """One group of three bars per test-set solute: fit on that solute alone ("Scaled to Solute"), | |
| fit once across the whole test set ("Scaled to Test Set"), and delta-22's bootstrap | |
| coefficients applied cold ("Extrapolated from delta22"). Used by SI Figure S15's "Fitting RMSE | |
| Comparisons" panel.""" | |
| colors = {"Scaled to Solute": "#4C72B0", "Scaled to Test Set": "#DD8452", | |
| "Extrapolated from delta22": "#55A868"} | |
| columns = list(colors.keys()) | |
| x = np.arange(len(table)) | |
| width = 0.27 | |
| fig, ax = plt.subplots(figsize=figsize) | |
| for i, col in enumerate(columns): | |
| ax.bar(x + (i - 1) * width, table[col].to_numpy(), width, | |
| label=col.replace("delta22", "Δ22"), color=colors[col]) | |
| ax.set_xticks(x) | |
| ax.set_xticklabels([str(s).replace("\n", " ") for s in table.index], rotation=30, ha="right") | |
| ax.set_ylabel(f"{'¹H' if nucleus == 'H' else '¹³C'} RMSE (ppm)") | |
| ax.set_title(f"Fitting RMSE Comparisons ({solvent}, {nucleus})") | |
| ax.legend() | |
| fig.tight_layout() | |
| if save_path: | |
| fig.savefig(save_path, dpi=200, bbox_inches="tight") | |
| return fig, ax | |
| # --- SI Figure S15 feature-space / residual 2x2 grids + distribution-shift bars --- | |
| # style constants private to the two _dataset_scatter_grid callers below. | |
| _DATASET_COLORS = {"Test Set": "#7e57c2", "Delta22": "#2ca02c"} # purple circles / green squares | |
| _DATASET_MARKERS = {"Test Set": "o", "Delta22": "s"} | |
| _SOLVENT_DISPLAY = {"chloroform": "Chloroform", "benzene": "Benzene", | |
| "methanol": "Methanol", "TIP4P": "Water (TIP4P)"} | |
| def _dataset_scatter_grid(table, x_col, y_col, solvents, title, subtitle, x_label, y_label, | |
| figsize, zero_lines): | |
| """2x2 solvent grid overlaying the Test Set and delta-22 point clouds, shared x/y across panels.""" | |
| fig, axes = plt.subplots(2, 2, figsize=figsize, sharex=True, sharey=True) | |
| axes = axes.ravel() | |
| for ax, solvent in zip(axes, solvents): | |
| sdf = table[table["solvent"] == solvent] | |
| for dataset in ["Test Set", "Delta22"]: | |
| sub = sdf[sdf["dataset"] == dataset] | |
| if not sub.empty: | |
| ax.scatter(sub[x_col], sub[y_col], s=32, alpha=0.75, marker=_DATASET_MARKERS[dataset], | |
| c=_DATASET_COLORS[dataset], edgecolors="white", linewidths=0.5, label=dataset) | |
| if zero_lines: | |
| ax.axhline(0, color="0.35", linestyle="--", linewidth=1.0, zorder=0) | |
| ax.axvline(0, color="0.35", linestyle="--", linewidth=1.0, zorder=0) | |
| else: | |
| ax.axhline(0, color="0.5", linestyle="--", linewidth=1.0, zorder=0) | |
| ax.set_title(_SOLVENT_DISPLAY.get(solvent, solvent), fontsize=11, fontweight="semibold") | |
| ax.grid(alpha=0.2) | |
| handles, labels = axes[0].get_legend_handles_labels() | |
| fig.legend(handles, labels, loc="center left", bbox_to_anchor=(0.88, 0.5), frameon=False, title="Dataset") | |
| fig.supxlabel(x_label, fontsize=11) | |
| fig.supylabel(y_label, fontsize=11) | |
| fig.suptitle(title, fontsize=13, fontweight="bold") | |
| fig.text(0.5, 0.94, subtitle, ha="center", fontsize=10, color="0.35") | |
| fig.tight_layout(rect=[0.02, 0.02, 0.86, 0.92]) | |
| return fig, axes | |
| def plot_feature_space_coverage_grid(table, nucleus, x_label, | |
| solvents=("chloroform", "benzene", "methanol", "TIP4P"), | |
| figsize=(11, 9), save_path=None): | |
| """SI Figure S15's "Feature Space Coverage by Solvent" panel.""" | |
| subtitle = f"{'Hydrogen' if nucleus == 'H' else 'Carbon'} sites: Test Set vs Delta22" | |
| fig, axes = _dataset_scatter_grid(table, "x", "y", solvents, "Feature Space Coverage by Solvent", | |
| subtitle, x_label, "OpenMM Correction (centered, ppm)", | |
| figsize, zero_lines=True) | |
| if save_path: | |
| fig.savefig(save_path, dpi=300, bbox_inches="tight") | |
| return fig, axes | |
| def plot_delta22_plane_residuals_grid(table, nucleus, | |
| solvents=("chloroform", "benzene", "methanol", "TIP4P"), | |
| figsize=(11, 9), save_path=None): | |
| """SI Figure S15's "Residuals for Delta22 Fitting Coefficients" panel.""" | |
| subtitle = f"{'Hydrogen' if nucleus == 'H' else 'Carbon'} sites: Test Set vs Delta22" | |
| fig, axes = _dataset_scatter_grid(table, "experimental", "residual", solvents, | |
| "Residuals for Delta22 Fitting Coefficients", subtitle, | |
| "Experimental Shielding (ppm)", | |
| "Residual vs Delta22 Plane (experimental - predicted)", | |
| figsize, zero_lines=False) | |
| if save_path: | |
| fig.savefig(save_path, dpi=300, bbox_inches="tight") | |
| return fig, axes | |
| def plot_distribution_shift_by_solvent_bars(table, nucleus, figsize=(7, 5), save_path=None): | |
| """One group of three bars per solvent: extrapolated-from-delta22 / scaled-to-test-set / | |
| scaled-to-solute RMSE. The three sitting close together (except water) is the transferability | |
| story. Used by SI Figure S15's "Distribution Shift by Solvent" panel.""" | |
| colors = {"Extrapolated from delta22": "#A72608", "Scaled to Test Set": "#C93240", | |
| "Scaled to Solute": "#61a89a"} | |
| columns = list(colors.keys()) | |
| x = np.arange(len(table)) | |
| width = 0.26 | |
| fig, ax = plt.subplots(figsize=figsize) | |
| for i, col in enumerate(columns): | |
| ax.bar(x + (i - 1) * width, table[col].to_numpy(), width, | |
| label=col.replace("delta22", "Δ22"), color=colors[col]) | |
| ax.set_xticks(x) | |
| ax.set_xticklabels([str(s) for s in table.index]) | |
| ax.set_ylabel(f"{'¹H' if nucleus == 'H' else '¹³C'} RMSE (ppm)") | |
| ax.set_title(f"Distribution Shift by Solvent ({nucleus})") | |
| ax.legend(fontsize=9) | |
| ax.grid(axis="y", alpha=0.3) | |
| fig.tight_layout() | |
| if save_path: | |
| fig.savefig(save_path, dpi=300, bbox_inches="tight") | |
| return fig, ax | |