@@ -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+
676687class 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