CoolFace
Apppublic

hanhou/patchseq

sourceHugging Faceupdated 7mo agoView on Hugging Face
0likes
scatter_plot.py1071 linesDownload Raw Back to components
1"""2Scatter plot component for the visualization app.3"""4 5import logging6from typing import Any, Dict, List, Tuple7 8import numpy as np9import matplotlib.pyplot as plt10import seaborn as sns11import pandas as pd12import panel as pn13from bokeh.layouts import gridplot14from bokeh.models import BoxZoomTool, ColumnDataSource, DatetimeTickFormatter, HoverTool, Legend15from bokeh.plotting import figure16from scipy import stats17from scipy.stats import mannwhitneyu18from sklearn.metrics import silhouette_score19from sklearn.mixture import GaussianMixture20from itertools import combinations21 22from LCNE_patchseq_analysis.pipeline_util.s3 import get_public_url_cell_summary23from LCNE_patchseq_analysis.pipeline_util.s3 import load_mesh_from_s324from LCNE_patchseq_analysis.data_util.mesh import trimesh_to_bokeh_data25 26from components.color_mapping import ColorMapping27from components.size_mapping import SizeMapping28 29# Set seaborn style30sns.set_context("paper")31 32logger = logging.getLogger(__name__)33 34# Define available color palettes35COLOR_PALETTES = [36    "Viridis256",37    "Plasma256",38    "Magma256",39    "Inferno256",40    "Cividis256",41    "Turbo256",42    "Set3",43    "Category10",44    "Category20",45    "Category20b",46    "Category20c",47]48 49 50class ScatterPlot:51    """Handles scatter plot creation and updates."""52 53    def __init__(self, df_meta: pd.DataFrame, data_holder: Any):54        """Initialize with metadata dataframe."""55        self.df_meta = df_meta56        self.color_mapping = ColorMapping(df_meta)57        self.size_mapping = SizeMapping(df_meta)58        self.data_holder = data_holder59        # Add cell summary URLs to dataframe60        self._add_cell_summary_urls()61        self.controls = self.create_plot_controls(width=300)62        self._latest_figures = {}63 64    def _add_cell_summary_urls(self):65        """Add cell summary URLs to the dataframe."""66        # Create a new column for cell summary URLs67        self.df_meta["cell_summary_url"] = None68 69        # Get URLs for each ephys_roi_id70        for idx, row in self.df_meta.iterrows():71            ephys_roi_id = str(int(row["ephys_roi_id"]))72            try:73                url = get_public_url_cell_summary(ephys_roi_id, if_check_exists=False)74                self.df_meta.at[idx, "cell_summary_url"] = url75            except Exception as e:76                logger.warning(f"Could not get URL for ephys_roi_id {ephys_roi_id}: {e}")77                self.df_meta.at[idx, "cell_summary_url"] = None78 79    def create_plot_controls(self, width: int = 180) -> Dict[str, Any]:80        """Create the control widgets for the scatter plot."""81        # Get numeric and categorical columns82        numeric_cols = self.df_meta.select_dtypes(include=["number"]).columns.tolist()83        categorical_cols = self.df_meta.select_dtypes(include=["object"]).columns.tolist()84        available_cols = sorted(numeric_cols + categorical_cols)85 86        # Append [valid N] to the available_cols for display purposes87        available_cols = [f"{col} [valid {self.df_meta[col].count()}]" for col in available_cols]88        all_cols = ["None"] + available_cols89 90        controls = {91            "x_axis_select": pn.widgets.Select(92                name="X Axis",93                options=all_cols,94                value=[col for col in all_cols if "Y" in col][0],95                sizing_mode="stretch_width",96            ),97            "y_axis_select": pn.widgets.Select(98                name="Y Axis",99                options=all_cols,100                value=[101                    col102                    for col in all_cols103                    if "ipfx_tau" in col104                ][0],105                sizing_mode="stretch_width",106            ),107            "color_col_select": pn.widgets.Select(108                name="Color By",109                options=all_cols,110                value=[col for col in all_cols if "injection region" in col][0],111                sizing_mode="stretch_width",112            ),113            "color_palette_select": pn.widgets.Select(114                name="Color Palette",115                options=COLOR_PALETTES,116                value="Viridis256",117                sizing_mode="stretch_width",118            ),119            "size_col_select": pn.widgets.Select(120                name="Size By",121                options=all_cols,122                value="None",123                sizing_mode="stretch_width",124            ),125            "size_range_slider": pn.widgets.RangeSlider(126                name="Size Range",127                start=5,128                end=40,129                value=(10, 30),130                step=1,131                sizing_mode="stretch_width",132            ),133            "size_gamma_slider": pn.widgets.FloatSlider(134                name="Size Gamma",135                start=0.1,136                end=5,137                value=1,138                step=0.1,139                sizing_mode="stretch_width",140            ),141            "alpha_slider": pn.widgets.FloatSlider(142                name="Alpha",143                start=0.1,144                end=1,145                value=0.7,146                step=0.1,147                sizing_mode="stretch_width",148            ),149            "width_slider": pn.widgets.IntSlider(150                name="Width",151                start=400,152                end=1200,153                value=800,154                step=50,155                sizing_mode="stretch_width",156            ),157            "height_slider": pn.widgets.IntSlider(158                name="Height",159                start=400,160                end=1200,161                value=600,162                step=50,163                sizing_mode="stretch_width",164            ),165            "bins_slider": pn.widgets.IntSlider(166                name="Histogram bins",167                start=10,168                end=100,169                value=50,170                step=1,171                sizing_mode="stretch_width",172            ),173            "show_gmm": pn.widgets.Checkbox(174                name="Show Gaussian Mixture Model",175                value=True,176                sizing_mode="stretch_width",177            ),178            "show_linear_fit": pn.widgets.Checkbox(179                name="Show Linear Fit",180                value=True,181                sizing_mode="stretch_width",182            ),183            "n_components_x": pn.widgets.IntSlider(184                name="Number of components (X)",185                start=1,186                end=5,187                value=2,188                step=1,189                disabled=False,190                sizing_mode="stretch_width",191            ),192            "n_components_y": pn.widgets.IntSlider(193                name="Number of components (Y)",194                start=1,195                end=5,196                value=1,197                step=1,198                disabled=False,199                sizing_mode="stretch_width",200            ),201            "hist_height_slider": pn.widgets.IntSlider(202                name="Distribution plot height",203                start=50,204                end=300,205                value=150,206                step=10,207                sizing_mode="stretch_width",208            ),209            "font_size_slider": pn.widgets.IntSlider(210                name="Font Size",211                start=10,212                end=30,213                value=15,214                sizing_mode="stretch_width",215            ),216        }217 218        # Link the GMM checkbox to enable/disable the component sliders219        def toggle_gmm_components(event):220            controls["n_components_x"].disabled = not event.new221            controls["n_components_y"].disabled = not event.new222 223        controls["show_gmm"].param.watch(toggle_gmm_components, "value")224 225        # Initialize the disabled state based on the initial checkbox value226        controls["n_components_x"].disabled = not controls["show_gmm"].value227        controls["n_components_y"].disabled = not controls["show_gmm"].value228 229        return controls230 231    def sync_controls_to_url(self):232        """Sync scatter plot controls to URL query parameters."""233 234        location = pn.state.location235        mapping = {236            "x_axis_select": ("value", "scatter_x"),237            "y_axis_select": ("value", "scatter_y"),238            "color_col_select": ("value", "scatter_color"),239            "color_palette_select": ("value", "scatter_palette"),240            "size_col_select": ("value", "scatter_size"),241            "size_range_slider": ("value", "scatter_size_range"),242            "size_gamma_slider": ("value", "scatter_gamma"),243            "alpha_slider": ("value", "scatter_alpha"),244            "width_slider": ("value", "scatter_width"),245            "height_slider": ("value", "scatter_height"),246            "bins_slider": ("value", "scatter_bins"),247            "show_gmm": ("value", "scatter_gmm"),248            "show_linear_fit": ("value", "scatter_linear_fit"),249            "n_components_x": ("value", "scatter_components_x"),250            "n_components_y": ("value", "scatter_components_y"),251            "hist_height_slider": ("value", "scatter_hist_height"),252            "font_size_slider": ("value", "scatter_font_size"),253        }254        for control_name, (param_name, url_param) in mapping.items():255            location.sync(self.controls[control_name], {param_name: url_param})256 257    def create_tooltips(258        self, x_col: str, y_col: str, color_col: str, size_col: str259    ) -> List[Tuple[str, str]]:260        """Create tooltips for the hover tool."""261 262        tooltips = f"""263             <div style="text-align: left; flex: auto; white-space: nowrap; margin: 0 10px;264                       border: 2px solid black; padding: 10px;">265                    <span style="font-size: 17px;">266                        <b>@Date_str, @{{injection region}}, @{{ephys_roi_id}},267                            @{{jem-id_cell_specimen}}</b><br>268                        <b>X = @{{{x_col}}}</b> [{x_col}]<br>269                        <b>Y = @{{{y_col}}}</b> [{y_col}]<br>270                        <b> Color = @{{{color_col}}}</b> [{color_col}]<br>271                        <b> Size = @{{{size_col}}}</b> [{size_col}]<br>272                    </span>273                 <img src="@cell_summary_url{{safe}}" alt="Cell Summary"274                        style="width: 800px; height: auto;">275             </div>276             """277 278        return tooltips279 280    def create_marginal_histogram(281        self,282        data: pd.Series,283        orientation: str,284        width: int,285        height: int,286        alpha: float,287        bins: int,288        show_gmm: bool = False,289        n_components: int = 1,290    ) -> figure:291        """Create a histogram for marginal distribution with optional GMM overlay."""292        # Remove NaN values and convert to numeric293        clean_data = pd.to_numeric(data, errors="coerce").dropna()294 295        # If no valid data, create an empty plot296        if clean_data.empty:297            p = figure(298                height=height,299                width=width,300                tools="",301                toolbar_location=None,302                x_range=(0, 1),303                y_range=(0, 1),304            )305            p.text(306                x=0.5,307                y=0.5,308                text=["No valid data"],309                text_align="center",310                text_baseline="middle",311            )312            return p313 314        # Calculate histogram data (independent of orientation)315        hist, edges = np.histogram(clean_data, bins=bins, density=True)316 317        # Set axis ranges and quad parameters based on orientation318        if orientation == "x":319            x_range = (edges[0], edges[-1])320            y_range = (0, hist.max() * 1.1)321        else:  # "y" orientation322            x_range = (0, hist.max() * 1.1)323            y_range = (edges[0], edges[-1])324 325        # Create the figure326        p = figure(327            height=height,328            width=width,329            tools="",330            toolbar_location=None,331            x_range=x_range,332            y_range=y_range,333        )334 335        # Plot the histogram using vbar/hbar (Bokeh's bar plot) instead of quad336        if orientation == "x":337            # Use vbar for x-orientation338            p.vbar(339                x=[(edges[i] + edges[i + 1]) / 2 for i in range(len(edges) - 1)],340                top=hist,341                width=(edges[1] - edges[0]) * 0.9,  # Slightly narrower than bin width342                fill_color="gray",343                line_color="white",344                alpha=0.9,345            )346        else:  # "y" orientation347            # Use hbar for y-orientation348            p.hbar(349                y=[(edges[i] + edges[i + 1]) / 2 for i in range(len(edges) - 1)],350                right=hist,351                height=(edges[1] - edges[0]) * 0.9,  # Slightly narrower than bin width352                fill_color="gray",353                line_color="white",354                alpha=0.9,355            )356 357        # Optional: Plot Gaussian Mixture Model overlay358        if show_gmm:359 360            gmm = GaussianMixture(n_components=n_components, random_state=42)361            gmm.fit(clean_data.values.reshape(-1, 1))362            domain = np.linspace(edges[0], edges[-1], 1000)363            density = np.exp(gmm.score_samples(domain.reshape(-1, 1)))364 365            # Calculate evaluation metrics366            if n_components > 1:367                labels = gmm.predict(clean_data.values.reshape(-1, 1))368                silhouette = silhouette_score(clean_data.values.reshape(-1, 1), labels)369 370                # Calculate BIC and AIC371                bic = gmm.bic(clean_data.values.reshape(-1, 1))372            else:373                bic = np.nan374                silhouette = np.nan375 376            p.line(377                *((domain, density) if orientation == "x" else (density, domain)),378                line_color="black",379                line_width=4,380                alpha=0.9,381            )382 383            # Plot individual components384            for i in range(n_components):385                mean = gmm.means_[i][0]386                std = np.sqrt(gmm.covariances_[i][0][0])387                weight = gmm.weights_[i]388                comp_density = (389                    weight390                    * np.exp(-0.5 * ((domain - mean) / std) ** 2)391                    / (std * np.sqrt(2 * np.pi))392                )393                p.line(394                    *((domain, comp_density) if orientation == "x" else (comp_density, domain)),395                    line_color="black",396                    line_width=2,397                    alpha=0.9,398                    line_dash="dashed",399                )400 401            # Add metrics to the plot title402            axis_to_show = p.xaxis if orientation == "x" else p.yaxis403 404            axis_to_show.axis_label = f"Silhouette: {silhouette:.3f}, " f"BIC: {bic:.3f}"405 406            # Font size407            axis_to_show.axis_label_text_font_size = "10pt"408            axis_to_show.major_label_text_font_size = "0pt"409 410        # Hide axes and grid411        axis_to_hide = p.xaxis if orientation == "y" else p.yaxis412        axis_to_hide.visible = False413        p.grid.visible = False414        return p415 416    def add_lc_mesh_overlay(self, p: figure, x_col: str, y_col: str) -> None:417        """Add LC mesh overlay to the plot based on axis column names.418        419        Args:420            p: Bokeh figure to add the mesh to421            x_col: X-axis column name422            y_col: Y-axis column name423        """424        # Determine mesh direction based on column names425        direction = None426        if x_col.startswith("X ") and y_col.startswith("Y "):427            direction = "sagittal"428        elif x_col.startswith("Z "):429            direction = "coronal"430        431        if direction is not None:432            try:433                # Load and add LC mesh overlay434                mesh = load_mesh_from_s3()435                lc_mesh_bokeh = trimesh_to_bokeh_data(mesh, direction=direction)436                mesh_source = ColumnDataSource(lc_mesh_bokeh)437                p.patches(438                    source=mesh_source,439                    xs="xs",440                    ys="ys",441                    fill_alpha=0.3,442                    line_color=None,443                    fill_color="lightgray",444                    level="underlay",445                    nonselection_fill_alpha=0.3,446                    nonselection_line_alpha=0,447                    selection_fill_alpha=0.3,448                    selection_line_alpha=0,449                    muted_alpha=0.3,450                )451            except Exception as e:452                logger.warning(f"Could not add LC mesh overlay: {e}")453 454    def update_scatter_plot(  # noqa: C901455        self,456        x_col: str,457        y_col: str,458        color_col: str,459        color_palette: str,460        size_col: str,461        size_range: tuple,462        size_gamma: float,463        alpha: float,464        width: int,465        height: int,466        font_size: int = 14,467        bins: int = 30,468        hist_height_slider: int = 100,469        show_gmm: bool = False,470        n_components_x: int = 2,471        n_components_y: int = 1,472        show_linear_fit: bool = True,473        df_meta: pd.DataFrame = None,474    ) -> gridplot:475        """Update the scatter plot with new parameters."""476        # Use provided dataframe if supplied, otherwise use instance df_meta477        df_to_use = df_meta if df_meta is not None else self.df_meta478 479        # Strip off [valid N] from the column name480        x_col = x_col.split(" [valid ")[0]481        y_col = y_col.split(" [valid ")[0]482        color_col = color_col.split(" [valid ")[0]483        size_col = size_col.split(" [valid ")[0]484 485        # Create a new figure for the main scatter plot486        p = figure(487            x_axis_label=x_col,488            y_axis_label=y_col,489            tools="pan,wheel_zoom,box_zoom,reset,tap",490            height=height,491            width=width,492        )493 494        # Create ColumnDataSource from the dataframe495        source = ColumnDataSource(df_to_use)496 497        # If any column is Date, convert it to datetime498        if x_col == "Date":499            source.data[x_col] = pd.to_datetime(pd.Series(source.data[x_col]), errors="coerce")500            p.xaxis.formatter = DatetimeTickFormatter(501                years="%Y",502                months="%Y-%m",503                days="%Y-%m-%d",504            )505 506        # Create temporary color mapping for this specific dataframe507        temp_color_mapping = ColorMapping(df_to_use)508        # Determine color mapping509        color = temp_color_mapping.determine_color_mapping(510            color_col, color_palette, p, font_size=font_size511        )512 513        # Create temporary size mapping for this specific dataframe514        temp_size_mapping = SizeMapping(df_to_use)515        # Determine size mapping516        size = temp_size_mapping.determine_size_mapping(517            size_col, source, min_size=size_range[0], max_size=size_range[1], gamma=size_gamma518        )519 520        # Add scatter glyph using the data source521        scatter_glyph = p.scatter(x=x_col, y=y_col, source=source, size=size, color=color, alpha=alpha)522 523        # Add linear regression if requested and both columns are numeric524        if show_linear_fit and x_col != "Date" and x_col != "None" and y_col != "None":525            # Get clean numeric data526            # Convert to numeric and drop rows where either x or y is NA527            df_clean = df_to_use[[x_col, y_col]].apply(pd.to_numeric, errors="coerce").dropna()528            x_data = df_clean[x_col]529            y_data = df_clean[y_col]530 531            # Only proceed if we have valid data532            if not x_data.empty and not y_data.empty:533                # Perform linear regression534                slope, intercept, r_value, p_value, std_err = stats.linregress(x_data, y_data)535 536                # Calculate fitted line points over dense grid for smooth CI curve537                x_min, x_max = x_data.min(), x_data.max()538                x_vals = np.linspace(x_min, x_max, 200)539                y_fit = slope * x_vals + intercept540 541                # Add confidence band for the regression line542                n_points = len(x_data)543                if n_points > 2:544                    residuals = y_data - (slope * x_data + intercept)545                    mse = np.sum(residuals ** 2) / (n_points - 2)546                    x_mean = x_data.mean()547                    Sxx = np.sum((x_data - x_mean) ** 2)548                    if Sxx > 0:549                        t_val = stats.t.ppf(0.975, n_points - 2)550                        se_fit = np.sqrt(551                            mse * (1 / n_points + (x_vals - x_mean) ** 2 / Sxx)552                        )553                        ci_upper = y_fit + t_val * se_fit554                        ci_lower = y_fit - t_val * se_fit555                        band_source = ColumnDataSource(556                            {557                                "x": np.concatenate([x_vals, x_vals[::-1]]),558                                "y": np.concatenate([ci_upper, ci_lower[::-1]]),559                            }560                        )561                        p.patch(562                            x="x",563                            y="y",564                            source=band_source,565                            fill_color="lightgray",566                            fill_alpha=0.3,567                            line_alpha=0,568                            level="underlay",569                        )570 571                # Add fitted line572                setting = (573                    {"line_width": 3, "line_dash": "solid"}574                    if p_value < 0.05575                    else {"line_width": 2, "line_dash": "dashed"}576                )577                line = p.line(x_vals, y_fit, line_color="black", **setting)578 579                # Add legend with R² and p-value580                legend_items = [581                    (f"Linear Fit (p = {p_value:.3e}, R² = {r_value**2:.3f})", [line]),582                ]583                legend = Legend(584                    items=legend_items, 585                    location="top_left", 586                    label_text_font_size=f"{font_size-2}pt"587                )588                legend.click_policy = "hide"589                p.add_layout(legend, 'above')590 591        # Flip the y-axis if y_col is depth592        if y_col == "Y (D --> V)":593            p.y_range.flipped = True594 595        # Add HoverTool with tooltips596        tooltips = self.create_tooltips(x_col, y_col, color_col, size_col)597        hovertool = HoverTool(598            tooltips=tooltips,599            attachment="right",  # Fix tooltip to the right of the plot600            formatters={"@Date": "datetime"},601            renderers=[scatter_glyph],  # Only apply hover to scatter points, not mesh patches602        )603 604        p.add_tools(hovertool)605 606        # Define callback to update ephys_roi_id on point tap607        def update_ephys_roi_id(attr, old, new):608            if new:609                selected_index = new[0]610                ephys_roi_id = str(int(df_to_use.iloc[selected_index]["ephys_roi_id"]))611                logger.info(f"Selected ephys_roi_id: {ephys_roi_id}")612                # Update the data holder's ephys_roi_id613                if hasattr(self, "data_holder"):614                    self.data_holder.ephys_roi_id_selected = ephys_roi_id615 616        # Attach the callback to the selection changes617        source.selected.on_change("indices", update_ephys_roi_id)618 619        # Set the default tool activated on drag to be box zoom620        p.toolbar.active_drag = p.select_one(BoxZoomTool)621 622        # Set axis label font sizes623        p.xaxis.axis_label_text_font_size = f"{font_size}pt"624        p.yaxis.axis_label_text_font_size = f"{font_size}pt"625 626        # Set major tick label font sizes627        p.xaxis.major_label_text_font_size = f"{font_size*0.9}pt"628        p.yaxis.major_label_text_font_size = f"{font_size*0.9}pt"629 630        # Add LC mesh overlay if appropriate columns are selected631        self.add_lc_mesh_overlay(p, x_col, y_col)632 633        # Create marginal histograms634        x_hist = None635        try:636            if x_col != "Date" and x_col != "None":  # Skip histogram for Date column637                x_hist = self.create_marginal_histogram(638                    df_to_use[x_col],639                    "x",640                    width=width,641                    height=hist_height_slider,642                    alpha=alpha,643                    bins=bins,644                    show_gmm=show_gmm,645                    n_components=n_components_x,646                )647                x_hist.x_range = p.x_range  # Link x ranges648        except Exception as e:649            logger.warning(f"Could not create x histogram: {e}")650            x_hist = None651 652        y_hist = None653        try:654            if y_col != "Date" and y_col != "None":  # Skip histogram for Date column655                y_hist = self.create_marginal_histogram(656                    df_to_use[y_col],657                    "y",658                    width=hist_height_slider,659                    height=height,660                    alpha=alpha,661                    bins=bins,662                    show_gmm=show_gmm,663                    n_components=n_components_y,664                )665                y_hist.y_range = p.y_range  # Link y ranges666        except Exception as e:667            logger.warning(f"Could not create y histogram: {e}")668            y_hist = None669 670        # Count non-NaN values grouped by "injection region"671        count_non_nan = df_to_use.groupby("injection region")[[x_col, y_col]].count().T672        count_non_nan.insert(0, "Total", count_non_nan.sum(axis=1))673        count_non_nan.index = pd.Index(["X", "Y"], name="Valid N")674 675        # Count NaN values (missing data) grouped by "injection region"676        count_nan = df_to_use.groupby("injection region")[[x_col, y_col]].apply(lambda x: x.isna().sum()).T677        count_nan.insert(0, "Total", count_nan.sum(axis=1))678        count_nan.index = pd.Index(["X", "Y"], name="Missing Data")679 680        # If the color column is categorical, generate violin plot and pairwise statistical tests681        if color_col in self.df_meta.select_dtypes(include=["object"]).columns:682            # --- Create marginalized histograms to compare across colors ---683            # marginalized_histograms, _ = self.create_marginalized_histograms(684            #     df_to_use, y_col, color_col, color_palette, temp_color_mapping, p, font_size685            # )686 687            # --- Create violin plot to compare across injection regions ---688            violin_plot = self.create_violin_plot(689                df_to_use, y_col, color_col, color_palette, temp_color_mapping, p, font_size690            )691 692            # Perform pairwise statistical tests693            # Drop NA for y_col and color_col for statistical tests694            plot_df_for_stats = df_to_use[[y_col, color_col]].dropna() if (y_col != "Date" and y_col != "None" and color_col != "None") else pd.DataFrame()695            pvalues_table = self.perform_pairwise_statistical_tests(plot_df_for_stats, y_col, color_col) if not plot_df_for_stats.empty else pn.pane.Markdown("**No statistical tests available**")696        else:697            violin_plot = pn.pane.Markdown("**Violin plot only available for categorical color columns**")698            pvalues_table = pn.pane.Markdown("")699 700        # Create grid layout701        layout = pn.Row(702            pn.Column(703                gridplot(704                    [[y_hist, p], [None, x_hist]],705                    toolbar_location="right",706                    merge_tools=False,707                    toolbar_options={"logo": None},708                ),709                pn.pane.Markdown(count_non_nan.to_markdown()),710                pn.pane.Markdown(count_nan.to_markdown()),711            ),712            pn.Column(713                violin_plot,714                pvalues_table,715                pn.Spacer(height=20),716                # marginalized_histograms,717            ),718        )719        720        # Store figures for export721        self._latest_figures = {722            "scatter_plot": p,723        }724        if x_hist is not None:725            self._latest_figures["x_histogram"] = x_hist726        if y_hist is not None:727            self._latest_figures["y_histogram"] = y_hist728        # Store violin plot if it's a matplotlib figure (not just a markdown pane)729        if hasattr(violin_plot, 'object') and hasattr(violin_plot.object, 'savefig'):730            self._latest_figures["violin_plot"] = violin_plot731            732        return layout733 734    def create_marginalized_histograms(735        self,736        df_to_use: pd.DataFrame,737        y_col: str,738        color_col: str,739        color_palette: str,740        temp_color_mapping: ColorMapping,741        p: figure,742        font_size: int743    ) -> Tuple[Any, Any]:744        """Create marginalized histograms to compare data across color groups.745        746        Args:747            df_to_use: DataFrame containing the data748            y_col: Column name for y-axis variable749            color_col: Column name for color grouping750            color_palette: Color palette name751            temp_color_mapping: ColorMapping instance for this data752            p: Bokeh figure (used for color mapping extraction)753            font_size: Font size for labels754            755        Returns:756            Tuple of (marginalized_histograms, None) - Creates KDE plots with mean±SEM757        """758        # Prepare marginalized histogram using seaborn's histplot (KDE) for y_col by color_col759        marginalized_histograms = pn.pane.Markdown("No marginalized histogram available.")760 761        try:762            if y_col != "Date" and y_col != "None" and color_col != "None":763                fig, ax = plt.subplots(figsize=(4, 3.5), dpi=300)764                # Drop NA for y_col and color_col765                plot_df = df_to_use[[y_col, color_col]].dropna()766                if not plot_df.empty:767                    # Extract color mapping from the scatter plot768                    color_palette_dict = None769                    color_mapping_result = temp_color_mapping.determine_color_mapping(770                        color_col, color_palette, p, font_size=font_size, if_add_color_bar=False771                    )772                    if isinstance(color_mapping_result, dict) and 'transform' in color_mapping_result:773                        color_mapper = color_mapping_result['transform']774                        if hasattr(color_mapper, 'factors') and hasattr(color_mapper, 'palette'):775                            color_palette_dict = dict(zip(color_mapper.factors, color_mapper.palette))776 777                    # Count number of samples per group (valid data)778                    group_counts = plot_df[color_col].value_counts().to_dict()779 780                    # Count missing data (NaN) per group from original dataframe781                    group_nan_counts = {}782                    for group in group_counts.keys():783                        # Get all rows for this group from original dataframe784                        group_mask = df_to_use[color_col] == group785                        # Count NaN values in y_col for this group786                        nan_count = df_to_use.loc[group_mask, y_col].isna().sum()787                        group_nan_counts[group] = nan_count788 789                    # Create a mapping from original group name to "group (n = valid, nan = missing)"790                    group_labels = {791                        group: f"{group} (n = {count}, missing {group_nan_counts.get(group, 0)})" 792                        for group, count in group_counts.items()793                    }794                    # Add a new column for legend labels795                    plot_df["_legend_label"] = plot_df[color_col].map(group_labels)796 797                    sns.kdeplot(798                        data=plot_df,799                        x=y_col,800                        hue="_legend_label",801                        common_norm=False,802                        fill=False,803                        ax=ax,804                        palette=color_palette_dict if color_palette_dict is None else {805                            group_labels[group]: color_palette_dict[group] for group in group_labels if group in color_palette_dict806                        },807                    )808                    sns.despine(trim=True)809                    ax.set_xlabel(y_col)810 811                    # Compute mean ± SEM for each group and add as dot + errorbar812                    y_positions = []  # Track y positions for staggering813                    for i, (group, data_subset) in enumerate(plot_df.groupby(color_col)):814                        values = data_subset[y_col].dropna()815                        if len(values) > 0:816                            # Ensure values are numeric and convert to float817                            try:818                                numeric_values = pd.to_numeric(values, errors='coerce').dropna()819                                if len(numeric_values) > 0:820                                    mean_val = float(np.mean(numeric_values))821                                    # Use numpy's std with ddof=1 to calculate SEM manually822                                    if len(numeric_values) > 1:823                                        sem_val = float(np.std(numeric_values, ddof=1) / np.sqrt(len(numeric_values)))824                                    else:825                                        sem_val = 0.0826 827                                    # Get color for this group828                                    group_color = color_palette_dict.get(group, 'black') if color_palette_dict else 'black'829 830                                    # Stagger y position slightly for each group831                                    # Compute y position as 10% + i * 10% of the current ylim range832                                    ylim = ax.get_ylim()833                                    y_pos = ylim[0] + 0.05 * (ylim[1] - ylim[0]) + i * 0.05 * (ylim[1] - ylim[0])834                                    y_positions.append(y_pos)835 836                                    # Add dot for mean837                                    ax.plot(mean_val, y_pos, 'o', color=group_color, markersize=4, 838                                            markeredgewidth=1)839 840                                    # Add error bar for SEM841                                    if sem_val > 0.0:842                                        ax.errorbar(mean_val, y_pos, xerr=sem_val, color=group_color, 843                                                  capsize=3, capthick=1.5, elinewidth=1.5, zorder=9)844                            except (ValueError, TypeError):845                                # Skip non-numeric data846                                continue847 848                    # Adjust y-axis limits to accommodate the error bars849                    current_ylim = ax.get_ylim()850                    if y_positions:851                        max_y_pos = max(y_positions)852                        ax.set_ylim(current_ylim[0], max(current_ylim[1], max_y_pos + 0.02))853 854                    # Move legend to top of the plot855                    y_lim = ax.get_ylim()856                    sns.move_legend(857                        ax,858                        loc="center left",859                        bbox_to_anchor=(1.01, 0.5),860                        ncol=1,861                        frameon=False,862                        fontsize="small",863                        title=color_col,864                    )865                    # Use Panel's matplotlib pane instead of manual base64 conversion866                    marginalized_histograms = pn.pane.Matplotlib(fig, dpi=300, tight=True, width=400)867 868                    # Close the figure to prevent memory leaks869                    plt.close(fig)870 871        except Exception as e:872            logger.warning(f"Could not create marginalized KDE histogram: {e}")873            marginalized_histograms = pn.pane.Markdown("Marginalized histogram error.")874 875        return marginalized_histograms, None876 877    def perform_pairwise_statistical_tests(878        self,879        plot_df: pd.DataFrame,880        y_col: str,881        color_col: str882    ) -> Any:883        """Perform pairwise Mann-Whitney U tests between groups.884        885        Args:886            plot_df: DataFrame containing the cleaned data887            y_col: Column name for y-axis variable888            color_col: Column name for color grouping889            890        Returns:891            Panel markdown object with statistical test results892        """893        pvalues_table = pn.pane.Markdown("**No statistical tests available**")894 895        try:896            # Perform pairwise Mann-Whitney U tests897            pairwise_tests = {}898            groups = list(plot_df[color_col].unique())899 900            for group1, group2 in combinations(groups, 2):901                data1 = pd.to_numeric(plot_df[plot_df[color_col] == group1][y_col], errors='coerce').dropna()902                data2 = pd.to_numeric(plot_df[plot_df[color_col] == group2][y_col], errors='coerce').dropna()903 904                if len(data1) > 0 and len(data2) > 0:905                    try:906                        statistic, p_value = mannwhitneyu(data1, data2, alternative='two-sided')907                        pairwise_tests[f"{group1} vs {group2}"] = p_value908                    except Exception as e:909                        logger.warning(f"Could not perform Mann-Whitney U test for {group1} vs {group2}: {e}")910                        pairwise_tests[f"{group1} vs {group2}"] = np.nan911 912            # Create a table of p-values913            if pairwise_tests:914                pvalues_df = pd.DataFrame(list(pairwise_tests.items()), columns=['Comparison', 'p-value'])915                pvalues_df['p-value'] = pvalues_df['p-value'].apply(lambda x: f"{x:.3e}" if not pd.isna(x) else "NaN")916                pvalues_table = pn.pane.Markdown(917                    f"**Mann-Whitney U Test (pairwise comparisons)**\n\n{pvalues_df.to_markdown(index=False)}"918                )919            else:920                pvalues_table = pn.pane.Markdown("**No pairwise comparisons available**")921 922        except Exception as e:923            logger.warning(f"Could not perform pairwise statistical tests: {e}")924            pvalues_table = pn.pane.Markdown("**Statistical test error**")925 926        return pvalues_table927 928    def create_violin_plot(929        self,930        df_to_use: pd.DataFrame,931        y_col: str,932        color_col: str,933        color_palette: str,934        temp_color_mapping: ColorMapping,935        p: figure,936        font_size: int937    ) -> Any:938        """Create violin plot to compare data distributions across injection regions.939        940        Args:941            df_to_use: DataFrame containing the data942            y_col: Column name for y-axis variable943            color_col: Column name for color grouping (injection regions)944            color_palette: Color palette name945            temp_color_mapping: ColorMapping instance for this data946            p: Bokeh figure (used for color mapping extraction)947            font_size: Font size for labels948            949        Returns:950            Panel matplotlib object with violin plot951        """952        violin_plot = pn.pane.Markdown("No violin plot available.")953 954        try:955            if y_col != "Date" and y_col != "None" and color_col != "None":956                fig, ax = plt.subplots(figsize=(5, 4), dpi=300)957                # Drop NA for y_col and color_col958                plot_df = df_to_use[[y_col, color_col]].dropna()959                if not plot_df.empty:960                    # Extract color mapping from the scatter plot961                    color_palette_dict = None962                    color_mapping_result = temp_color_mapping.determine_color_mapping(963                        color_col, color_palette, p, font_size=font_size, if_add_color_bar=False964                    )965                    if isinstance(color_mapping_result, dict) and 'transform' in color_mapping_result:966                        color_mapper = color_mapping_result['transform']967                        if hasattr(color_mapper, 'factors') and hasattr(color_mapper, 'palette'):968                            color_palette_dict = dict(zip(color_mapper.factors, color_mapper.palette))969 970                    # Count number of samples per group (valid data)971                    group_counts = plot_df[color_col].value_counts().to_dict()972 973                    # Count missing data (NaN) per group from original dataframe974                    group_nan_counts = {}975                    for group in group_counts.keys():976                        # Get all rows for this group from original dataframe977                        group_mask = df_to_use[color_col] == group978                        # Count NaN values in y_col for this group979                        nan_count = df_to_use.loc[group_mask, y_col].isna().sum()980                        group_nan_counts[group] = nan_count981 982                    # Convert y_col to numeric983                    plot_df[y_col] = pd.to_numeric(plot_df[y_col], errors='coerce')984                    plot_df = plot_df.dropna(subset=[y_col])985 986                    if not plot_df.empty:987                        # Get the order of groups to ensure consistency between violin plot and overlays988                        # Use sorted order to match seaborn's default behavior989                        groups_order = sorted(plot_df[color_col].unique())990 991                        # Create violin plot using seaborn with explicit order992                        sns.violinplot(993                            data=plot_df,994                            x=color_col,995                            y=y_col,996                            hue=color_col,997                            ax=ax,998                            palette=color_palette_dict,999                            inner="quart",  # Show quartiles as inner elements1000                            alpha=0.6,1001                            cut=0,  # No extension beyond the data range1002                            order=groups_order,  # Explicitly set the order1003                            width=0.5,1004                            legend=False1005                        )1006 1007                        # Overlay raw data points with strip plot using the same order1008                        sns.stripplot(1009                            data=plot_df,1010                            x=color_col,1011                            y=y_col,1012                            ax=ax,1013                            color='black',1014                            size=2,1015                            alpha=0.5,1016                            jitter=True,1017                            order=groups_order  # Use the same order1018                        )1019 1020                        # Calculate and plot mean ± SEM for each group using the same order1021                        groups = groups_order  # Use the same order as seaborn plots1022                        x_positions = np.arange(len(groups))1023 1024                        for i, group in enumerate(groups):1025                            group_data = pd.to_numeric(plot_df[plot_df[color_col] == group][y_col], errors='coerce').dropna()1026                            if len(group_data) > 0:1027                                mean_val = float(np.mean(group_data))1028                                if len(group_data) > 1:1029                                    sem_val = float(np.std(group_data, ddof=1) / np.sqrt(len(group_data)))1030                                else:1031                                    sem_val = 0.01032 1033                                # Plot mean as a larger point1034                                group_color = color_palette_dict.get(group, 'black') if color_palette_dict else 'black'1035                                ax.plot(i+0.45, mean_val, 'o', color=group_color, markersize=5, 1036                                       markeredgecolor='black', markeredgewidth=1, zorder=10)1037 1038                                # Add error bar for SEM1039                                if sem_val > 0.0:1040                                    ax.errorbar(i+0.45, mean_val, yerr=sem_val, color='black', 1041                                              capsize=5, capthick=1, elinewidth=1, zorder=9,1042                                              fmt='none')1043 1044                        # Set x-axis labels with sample counts1045                        group_labels_with_counts = [1046                            f"{group}\n(n={group_counts.get(group, 0)}, missing {group_nan_counts.get(group, 0)})" 1047                            for group in groups1048                        ]1049                        ax.set_xticks(x_positions)1050                        ax.set_xticklabels(group_labels_with_counts, rotation=30, ha='right')1051                        ax.set_ylabel(y_col)1052                        ax.set_xlabel(color_col)1053 1054                        # Add grid and styling1055                        sns.despine(trim=True)1056 1057                        # Adjust layout to prevent label cutoff1058                        plt.tight_layout()1059 1060                        # Use Panel's matplotlib pane1061                        violin_plot = pn.pane.Matplotlib(fig, dpi=300, tight=True, width=400)1062 1063                        # Close the figure to prevent memory leaks1064                        plt.close(fig)1065 1066        except Exception as e:1067            logger.warning(f"Could not create violin plot: {e}")1068            violin_plot = pn.pane.Markdown("Violin plot error.")1069 1070        return violin_plot1071