Skip to content

Commit bfa22b7

Browse files
authored
Update utility.py
1 parent c2ca548 commit bfa22b7

1 file changed

Lines changed: 157 additions & 97 deletions

File tree

metbit/utility.py

Lines changed: 157 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -673,65 +673,102 @@ def project_name_generator():
673673
project_name = time_format + '_' + random.choice(project_names)
674674
return project_name
675675

676+
import pandas as pd
677+
import numpy as np
678+
import plotly.express as px
679+
import plotly.graph_objects as go
680+
from scipy.stats import ttest_ind, f_oneway, mannwhitneyu
681+
from statsmodels.stats.multitest import multipletests
682+
from itertools import combinations
683+
import warnings
684+
from typing import List, Optional, Dict
685+
686+
676687
class univar_stats:
677688
"""
678-
A class for creating univariate box or violin plots with statistical annotations.
679-
680-
This class supports group comparisons using t-tests, ANOVA, or nonparametric tests,
681-
along with effect size computation and multiple testing correction. It outputs
682-
interactive Plotly figures.
689+
A class for generating univariate box or violin plots with statistical annotations
690+
using Plotly. Supports group-wise comparisons using t-tests, ANOVA, or nonparametric
691+
tests, along with effect size calculation and multiple testing correction.
683692
684693
Parameters
685694
----------
686-
df : pd.DataFrame
687-
Input DataFrame containing the data to plot.
695+
df : pandas.DataFrame
696+
The input dataframe containing numeric and grouping columns.
697+
688698
x_col : str
689-
Column name to use for grouping (x-axis categories).
699+
Column name used for group/category (x-axis).
700+
690701
y_col : str
691-
Column name for numeric values (y-axis).
692-
group_order : list, optional
693-
Custom group ordering (default: inferred from data).
702+
Column name used for values (y-axis).
703+
704+
group_order : list of str, optional
705+
Custom ordering of groups on the x-axis. Defaults to the order in the dataframe.
706+
694707
custom_colors : dict, optional
695-
Mapping of group names to Plotly color codes.
708+
Dictionary mapping group names to Plotly color codes.
709+
696710
stats_options : list of str, optional
697-
Statistical options to apply. Choices: ['t-test', 'anova', 'nonparametric', 'effect-size'].
711+
Statistical tests to perform. Choices:
712+
- 't-test' : independent two-sample t-test
713+
- 'anova' : one-way ANOVA
714+
- 'nonparametric' : Mann-Whitney U test
715+
- 'effect-size' : Computes Cohen's d
716+
698717
p_value_threshold : float, default=0.05
699-
Significance threshold for annotations.
700-
annotate_style : str, default='value'
701-
Annotation format. 'value' shows p-values, 'symbol' shows *, **, ***.
718+
Threshold for marking comparisons as significant.
719+
720+
annotate_style : str, default="value"
721+
How to display p-values. Options:
722+
- 'value' : show exact p-values (e.g., p=0.0031)
723+
- 'symbol' : show significance level (*, **, ***) or 'ns'
724+
702725
y_offset_factor : float, default=0.35
703-
Controls vertical spacing of annotation lines.
726+
Controls spacing between stacked annotation lines (relative to y-axis range).
727+
704728
show_non_significant : bool, default=True
705-
Show non-significant comparisons or not.
706-
correct_p : str or None, default='bonferroni'
707-
Correction method for multiple comparisons (e.g., 'bonferroni', 'fdr_bh').
729+
If False, non-significant comparisons are hidden from the plot.
730+
731+
correct_p : str or None, default="bonferroni"
732+
Method for multiple testing correction (e.g., "bonferroni", "fdr_bh", or None).
733+
708734
title_ : str, optional
709-
Plot title. Defaults to y_col if not set.
735+
Title for the plot. Defaults to y_col.
736+
710737
y_label : str, optional
711-
Label for y-axis. Defaults to y_col.
738+
Custom label for the y-axis. Defaults to y_col.
739+
712740
x_label : str, optional
713-
Label for x-axis. Defaults to x_col.
741+
Custom label for the x-axis. Defaults to x_col.
742+
714743
fig_height : int, default=800
715-
Height of the plot in pixels.
744+
Height of the figure in pixels.
745+
716746
fig_width : int, default=600
717-
Width of the plot in pixels.
718-
plot_type : str, default='box'
719-
Type of plot: 'box' or 'violin'.
747+
Width of the figure in pixels.
748+
749+
plot_type : str, default="box"
750+
Type of plot. Choices:
751+
- "box"
752+
- "violin"
753+
720754
show_axis_lines : bool, default=True
721-
Whether to show axis lines around the plot.
755+
Whether to show border lines on axes.
756+
757+
Attributes
758+
----------
759+
df : pandas.DataFrame
760+
The input data.
722761
723-
Returns
724-
-------
725-
plotly.graph_objects.Figure
726-
Interactive Plotly figure.
762+
plot() : plotly.graph_objects.Figure
763+
Generates the interactive annotated plot.
727764
728765
Examples
729766
--------
730767
>>> import pandas as pd
731-
>>> from univar_stats import univar_stats
732768
>>> import numpy as np
769+
>>> from univar_stats import univar_stats
733770
734-
>>> # Create example data
771+
>>> # Create mock data
735772
>>> df = pd.DataFrame({
736773
... "group": np.repeat(["A", "B", "C"], 30),
737774
... "value": np.concatenate([
@@ -741,45 +778,37 @@ class univar_stats:
741778
... ])
742779
... })
743780
744-
>>> # Initialize the plotting object
781+
>>> # Initialize and plot
745782
>>> plotter = univar_stats(
746783
... df, x_col="group", y_col="value",
747784
... stats_options=["t-test", "effect-size"],
748-
... annotate_style="symbol", plot_type="box"
785+
... annotate_style="symbol", plot_type="box",
786+
... show_non_significant=False
749787
... )
750-
751-
>>> # Generate and show the plot
752788
>>> fig = plotter.plot()
753789
>>> fig.show()
754790
"""
755-
#include packages
756-
import pandas as pd
757-
import numpy as np
758-
import plotly.express as px
759-
import plotly.graph_objects as go
760-
from scipy.stats import ttest_ind, f_oneway, mannwhitneyu
761-
from statsmodels.stats.multitest import multipletests
762-
from itertools import combinations
763-
import warnings
764-
from typing import List, Optional, Dict, Any
765-
import plotly.io as pio
766-
#pio.templates.default = "plotly_white"
767-
import plotly.figure_factory as ff
768-
import plotly.express as px
769-
import plotly.graph_objects as go
770-
import statsmodels.api as sm
771-
772-
773791

774792
def __init__(
775-
self, df, x_col, y_col,
776-
group_order=None, custom_colors=None,
777-
stats_options=None, p_value_threshold=0.05,
778-
annotate_style="value", y_offset_factor=0.35,
779-
show_non_significant=True, correct_p="bonferroni",
780-
title_=None, y_label=None, x_label=None,
781-
fig_height=800, fig_width=600,
782-
plot_type="box", show_axis_lines=True # ← NEW PARAMETERS
793+
self,
794+
df: pd.DataFrame,
795+
x_col: str,
796+
y_col: str,
797+
group_order: Optional[List[str]] = None,
798+
custom_colors: Optional[Dict[str, str]] = None,
799+
stats_options: Optional[List[str]] = None,
800+
p_value_threshold: float = 0.05,
801+
annotate_style: str = "value",
802+
y_offset_factor: float = 0.35,
803+
show_non_significant: bool = True,
804+
correct_p: Optional[str] = "bonferroni",
805+
title_: Optional[str] = None,
806+
y_label: Optional[str] = None,
807+
x_label: Optional[str] = None,
808+
fig_height: int = 800,
809+
fig_width: int = 600,
810+
plot_type: str = "box",
811+
show_axis_lines: bool = True,
783812
):
784813
self.df = df
785814
self.x_col = x_col
@@ -800,41 +829,40 @@ def __init__(
800829
self.plot_type = plot_type
801830
self.show_axis_lines = show_axis_lines
802831

832+
@staticmethod
833+
def compute_effsize(a, b, eftype: str = "cohen") -> float:
834+
"""Compute effect size (Cohen's d)."""
835+
if eftype == "cohen":
836+
pooled_std = np.sqrt((np.std(a, ddof=1)**2 + np.std(b, ddof=1)**2) / 2)
837+
return (np.mean(a) - np.mean(b)) / pooled_std
838+
raise ValueError("Unsupported effect size type.")
803839

804-
def plot(self):
805-
806-
#import packages
807-
import pandas as pd
808-
import numpy as np
809-
import plotly.express as px
810-
import plotly.graph_objects as go
811-
from scipy.stats import ttest_ind, f_oneway, mannwhitneyu
812-
from statsmodels.stats.multitest import multipletests
813-
from itertools import combinations
814-
from typing import List, Optional, Dict, Any
815-
import plotly.io as pio
816-
#pio.templates.default = "plotly_white"
817-
import statsmodels.api as sm
818-
import warnings
840+
def plot(self) -> go.Figure:
819841
warnings.filterwarnings("ignore")
820-
821842
df = self.df
822843
if df.empty:
823844
raise ValueError("The DataFrame is empty.")
824845

825846
grouped = df.groupby(self.x_col)[self.y_col]
826847
group_order = self.group_order or list(grouped.groups.keys())
827-
comparisons = list(combinations(group_order, 2))
828848

849+
if self.custom_colors:
850+
missing = set(group_order) - set(self.custom_colors)
851+
if missing:
852+
raise ValueError(f"Missing colors for groups: {missing}")
853+
854+
comparisons = list(combinations(group_order, 2))
829855
y_range = df[self.y_col].max() - df[self.y_col].min()
830856
y_offset = self.y_offset_factor * y_range
831857
max_y = df[self.y_col].max()
832858

833-
p_values, effect_sizes, annotations, lines = [], [], [], []
859+
raw_p_values = []
860+
effect_sizes = []
834861

862+
# Statistical testing
835863
if "anova" in self.stats_options and len(group_order) > 2:
836864
f_stat, anova_p = f_oneway(*(grouped.get_group(g).values for g in group_order))
837-
p_values = [anova_p] * len(comparisons)
865+
raw_p_values = [anova_p] * len(comparisons)
838866
else:
839867
for g1, g2 in comparisons:
840868
group1 = grouped.get_group(g1).values
@@ -847,40 +875,52 @@ def plot(self):
847875
else:
848876
raise ValueError("Invalid stats_options.")
849877

850-
p_values.append(p_val)
878+
raw_p_values.append(p_val)
851879

852880
if "effect-size" in self.stats_options:
853-
effect_sizes.append(compute_effsize(group1, group2, eftype="cohen"))
881+
d = self.compute_effsize(group1, group2)
882+
effect_sizes.append(d)
854883

884+
# Apply correction if needed
855885
if self.correct_p and "anova" not in self.stats_options:
856-
_, corrected, _, _ = multipletests(p_values, method=self.correct_p)
857-
p_values = corrected
886+
_, corrected_p_values, _, _ = multipletests(raw_p_values, method=self.correct_p)
887+
else:
888+
corrected_p_values = raw_p_values
858889

859-
# Plot selection
890+
891+
# Create base plot
860892
if self.plot_type == "box":
861893
fig = px.box(
862894
df, x=self.x_col, y=self.y_col, color=self.x_col,
863895
points="all", category_orders={self.x_col: group_order},
864-
color_discrete_map=self.custom_colors
896+
color_discrete_map=self.custom_colors,
865897
)
866898
elif self.plot_type == "violin":
867899
fig = px.violin(
868900
df, x=self.x_col, y=self.y_col, color=self.x_col,
869901
box=True, points="all", category_orders={self.x_col: group_order},
870-
color_discrete_map=self.custom_colors
902+
color_discrete_map=self.custom_colors,
871903
)
872904
else:
873905
raise ValueError("Invalid plot_type. Use 'box' or 'violin'.")
874906

875907
# Annotations
876-
for i, ((g1, g2), p_val) in enumerate(zip(comparisons, p_values)):
877-
if not self.show_non_significant and p_val > self.p_value_threshold:
908+
annotations = []
909+
lines = []
910+
for i, ((g1, g2), p_val) in enumerate(zip(comparisons, corrected_p_values)):
911+
if p_val > self.p_value_threshold and not self.show_non_significant:
878912
continue
879-
913+
# if p_val is nan replace it with 1
914+
if np.isnan(p_val):
915+
p_val = 1.0
916+
x1 = group_order.index(g1)
917+
x2 = group_order.index(g2)
918+
x_center = (x1 + x2) / 2
880919
y_pos = max_y + 0.15 + (i + 1) * y_offset
881920

921+
# Build p-value text
882922
if self.annotate_style == "value":
883-
p_text = f"p={p_val:.4f}" if p_val >= 0.0001 else "p<0.0001"
923+
p_text = f"p={p_val:.4f}"
884924
elif self.annotate_style == "symbol":
885925
if p_val < 0.001:
886926
p_text = "***"
@@ -890,14 +930,16 @@ def plot(self):
890930
p_text = "*"
891931
else:
892932
p_text = "ns"
933+
if not self.show_non_significant:
934+
continue
893935
else:
894936
raise ValueError("Invalid annotate_style.")
895937

896938
if "effect-size" in self.stats_options and "anova" not in self.stats_options:
897939
p_text += f", d={effect_sizes[i]:.2f}"
898940

899941
annotations.append(dict(
900-
x=(group_order.index(g1) + group_order.index(g2)) / 2,
942+
x=x_center,
901943
y=y_pos + y_offset * 0.75,
902944
text=p_text,
903945
showarrow=False,
@@ -916,7 +958,6 @@ def plot(self):
916958
for line in lines:
917959
fig.add_trace(line)
918960

919-
# Axis styling
920961
axis_line_config = dict(
921962
showline=self.show_axis_lines,
922963
linewidth=2,
@@ -938,4 +979,23 @@ def plot(self):
938979
plot_bgcolor='rgba(0,0,0,0)',
939980
)
940981

941-
return fig
982+
#Add text to under the plot if style is value show legend of *
983+
if self.annotate_style == "symbol":
984+
if self.show_non_significant:
985+
fig.add_annotation(
986+
text="* p < 0.05, ** p < 0.01, *** p < 0.001, ns = not significant",
987+
xref="paper", yref="paper",
988+
x=0.5, y=-0.1,
989+
showarrow=False,
990+
font=dict(size=12)
991+
)
992+
else:
993+
fig.add_annotation(
994+
text="* p < 0.05, ** p < 0.01, *** p < 0.001",
995+
xref="paper", yref="paper",
996+
x=0.5, y=-0.1,
997+
showarrow=False,
998+
font=dict(size=12)
999+
)
1000+
1001+
return fig

0 commit comments

Comments
 (0)