From cc1f751e14368df84912d056fa2a9319010f7db7 Mon Sep 17 00:00:00 2001 From: Yun-Fei Liu Date: Sun, 13 Jul 2025 13:59:42 -0400 Subject: [PATCH 01/18] display header row properly determine ylim to actually reflect the number of rows needed to display the header, especially when there are few rows in the dataframe (e.g., 3 rows) --- forestplot/plot.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/forestplot/plot.py b/forestplot/plot.py index 4b2191b..35fa54c 100644 --- a/forestplot/plot.py +++ b/forestplot/plot.py @@ -536,5 +536,6 @@ def _make_forestplot( ax=ax, ) negative_padding = 0.5 - ax.set_ylim(-0.5, ax.get_ylim()[1] - negative_padding) + # ax.set_ylim(-0.5, ax.get_ylim()[1] - negative_padding) # this doesn't reflect the number of actually required rows + ax.set_ylim(-0.5, dataframe.shape[0]) # 250713: added by Takua Liu return ax From 09fc9ca968f827db2ff2cef22b1aff084bfeb22e Mon Sep 17 00:00:00 2001 From: Yun-Fei Liu Date: Mon, 14 Jul 2025 20:13:42 -0400 Subject: [PATCH 02/18] Update graph_utils.py Handle dataframes with null values. --- forestplot/graph_utils.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/forestplot/graph_utils.py b/forestplot/graph_utils.py index 81b1a07..45ad89c 100644 --- a/forestplot/graph_utils.py +++ b/forestplot/graph_utils.py @@ -88,15 +88,16 @@ def draw_est_markers( markersize = kwargs.get("markersize", 40) markercolor = kwargs.get("markercolor", "darkslategray") markeralpha = kwargs.get("markeralpha", 0.8) - ax.scatter( - y=yticklabel, - x=estimate, - data=dataframe, - marker=marker, - s=markersize, - color=markercolor, - alpha=markeralpha, - ) + if not pd.isnull(dataframe[estimate]).all(): + ax.scatter( + y=yticklabel, + x=estimate, + data=dataframe, + marker=marker, + s=markersize, + color=markercolor, + alpha=markeralpha, + ) return ax @@ -587,7 +588,13 @@ def format_xticks( else: xlowerlimit = 1.1 * dataframe[estimate].min() xupperlimit = 1.1 * dataframe[estimate].max() - ax.set_xlim(xlowerlimit, xupperlimit) + + # 250714: handle the studies with unestimable CI + if not pd.isnull(xlowerlimit) and not pd.isnull(xupperlimit): + ax.set_xlim(xlowerlimit, xupperlimit) + else: + ax.set_xlim(-1, 1) + if xticks is not None: ax.set_xticks(xticks) ax.xaxis.set_tick_params(labelsize=xtick_size) From 2776d9a9329ff52923974e65adec93813a9be0e4 Mon Sep 17 00:00:00 2001 From: Yun-Fei Liu Date: Mon, 14 Jul 2025 20:14:35 -0400 Subject: [PATCH 03/18] Update graph_utils.py --- forestplot/graph_utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/forestplot/graph_utils.py b/forestplot/graph_utils.py index 45ad89c..76301a4 100644 --- a/forestplot/graph_utils.py +++ b/forestplot/graph_utils.py @@ -88,6 +88,8 @@ def draw_est_markers( markersize = kwargs.get("markersize", 40) markercolor = kwargs.get("markercolor", "darkslategray") markeralpha = kwargs.get("markeralpha", 0.8) + + # 250714: some dataframes are empty. In such cases, we still draw an empty graph. But of course we don't need markers on an empty graph! if not pd.isnull(dataframe[estimate]).all(): ax.scatter( y=yticklabel, From 212213065a1ed877f0f800f02d83eefda6eaf95f Mon Sep 17 00:00:00 2001 From: Yun-Fei Liu Date: Tue, 15 Jul 2025 20:05:45 -0400 Subject: [PATCH 04/18] proportional marker size Add a parameter to enable drawing marker sizes proportional to study weights. --- forestplot/graph_utils.py | 17 +++++++++++++++-- forestplot/plot.py | 8 ++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/forestplot/graph_utils.py b/forestplot/graph_utils.py index 76301a4..befbf7b 100644 --- a/forestplot/graph_utils.py +++ b/forestplot/graph_utils.py @@ -5,6 +5,7 @@ import matplotlib.pyplot as plt import pandas as pd +import numpy as np from matplotlib.pyplot import Axes warnings.filterwarnings("ignore") @@ -62,7 +63,7 @@ def draw_ci( def draw_est_markers( - dataframe: pd.core.frame.DataFrame, estimate: str, yticklabel: str, ax: Axes, **kwargs: Any + dataframe: pd.core.frame.DataFrame, estimate: str, yticklabel: str, proportional_size: bool, ax: Axes, **kwargs: Any ) -> Axes: """ Draws the markers of the estimates using the Matplotlib plt.scatter API. @@ -79,16 +80,28 @@ def draw_est_markers( Name of column in intermediate dataframe containing the formatted yticklabels. ax (Matplotlib Axes) Axes to operate on. + proportional_size (bool) + If true, specify marker size to be proportional to the weight of the study. Returns ------- Matplotlib Axes object. """ marker = kwargs.get("marker", "s") - markersize = kwargs.get("markersize", 40) + # markersize = kwargs.get("markersize", 40) markercolor = kwargs.get("markercolor", "darkslategray") markeralpha = kwargs.get("markeralpha", 0.8) + # 250715: draw marker sizes proportionally to study weights + if proportional_size: + if not pd.isnull(dataframe[estimate]).all(): + dataframe["markersize"] = np.power(2+0.125*dataframe["Weight"],2) + markersize = "markersize" + if not proportional_size: + markersize = kwargs.get("markersize", 40) + + + # 250714: some dataframes are empty. In such cases, we still draw an empty graph. But of course we don't need markers on an empty graph! if not pd.isnull(dataframe[estimate]).all(): ax.scatter( diff --git a/forestplot/plot.py b/forestplot/plot.py index 35fa54c..1ee4675 100644 --- a/forestplot/plot.py +++ b/forestplot/plot.py @@ -401,7 +401,8 @@ def _make_forestplot( ax: Axes, despine: bool = True, table: bool = False, - **kwargs: Any, + proportional_marker_size: bool=False, + **kwargs: Any ) -> Axes: """ Create and draw a forest plot using the given DataFrame and specified parameters. @@ -451,6 +452,8 @@ def _make_forestplot( Whether to remove the top and right spines of the plot. table : bool, default=False Whether to draw a table-like structure on the plot. + proportional_marker_size: bool, default=False + Whether to draw the marker size proportionally to the weight of the study. **kwargs : Any Additional keyword arguments for further customization. @@ -471,8 +474,9 @@ def _make_forestplot( ax=ax, **kwargs, ) + # 250715: draw marker sizes proportionally to study weights draw_est_markers( - dataframe=dataframe, estimate=estimate, yticklabel=yticklabel, ax=ax, **kwargs + dataframe=dataframe, estimate=estimate, yticklabel=yticklabel, ax=ax, proportional_size=proportional_marker_size, **kwargs ) format_xticks( dataframe=dataframe, estimate=estimate, ll=ll, hl=hl, xticks=xticks, ax=ax, **kwargs From ca31e2db7071f5b752d3ba588f75a4deb7518059 Mon Sep 17 00:00:00 2001 From: Yun-Fei Liu Date: Wed, 16 Jul 2025 20:25:50 -0400 Subject: [PATCH 05/18] total diamond Let user specify which row(s) contains subtotal information, and draw a horizontal diamond to indicate the CI of the subtotal stats, rather than square&whiskers. --- forestplot/graph_utils.py | 31 +++++++++++++++++++++++++++++-- forestplot/plot.py | 14 ++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/forestplot/graph_utils.py b/forestplot/graph_utils.py index befbf7b..37334ef 100644 --- a/forestplot/graph_utils.py +++ b/forestplot/graph_utils.py @@ -7,6 +7,7 @@ import pandas as pd import numpy as np from matplotlib.pyplot import Axes +from matplotlib.patches import Polygon warnings.filterwarnings("ignore") @@ -61,6 +62,9 @@ def draw_ci( ax.set_xscale("log", base=10) return ax +# Utility function, determine marker size. +def determine_marker_size(weight): + return np.power(2+0.125*weight,2) def draw_est_markers( dataframe: pd.core.frame.DataFrame, estimate: str, yticklabel: str, proportional_size: bool, ax: Axes, **kwargs: Any @@ -95,13 +99,12 @@ def draw_est_markers( # 250715: draw marker sizes proportionally to study weights if proportional_size: if not pd.isnull(dataframe[estimate]).all(): - dataframe["markersize"] = np.power(2+0.125*dataframe["Weight"],2) + dataframe["markersize"] = determine_marker_size(dataframe["Weight"]) markersize = "markersize" if not proportional_size: markersize = kwargs.get("markersize", 40) - # 250714: some dataframes are empty. In such cases, we still draw an empty graph. But of course we don't need markers on an empty graph! if not pd.isnull(dataframe[estimate]).all(): ax.scatter( @@ -115,6 +118,30 @@ def draw_est_markers( ) return ax +def draw_total_diamond( + dataframe: pd.core.frame.DataFrame, + total_col: str, + estimate: str, + ll: str, + hl: str, + ax: Axes, + **kwargs: Any +) -> Axes: + height = 0.8 # total height of the diamond from top to bottom + print(height) + for ii,row in dataframe.iterrows(): + if row[total_col]==1: + print(f"Row {ii} is total!") + ci_low = row[ll] + ci_high = row[hl] + val = row[estimate] + diamond = Polygon( + # left, top, right, bottom + [(ci_low, ii), (val, ii+height/2), (ci_high, ii), (val, ii-height/2)], + closed = True, facecolor="black", zorder=10 + ) + ax.add_patch(diamond) + return ax def draw_ref_xline( ax: Axes, diff --git a/forestplot/plot.py b/forestplot/plot.py index 1ee4675..c3f3744 100644 --- a/forestplot/plot.py +++ b/forestplot/plot.py @@ -17,6 +17,7 @@ draw_alt_row_colors, draw_ci, draw_est_markers, + draw_total_diamond, draw_pval_right, draw_ref_xline, draw_tablelines, @@ -80,6 +81,9 @@ def forestplot( preprocess: bool = True, table: bool = False, ax: Optional[Axes] = None, + proportional_marker_size: bool = False, + contains_total: bool = False, + total_col = None, **kwargs: Any, ) -> Axes: """ @@ -152,6 +156,10 @@ def forestplot( If True, in addition to the Matplotlib Axes object, returns the intermediate dataframe created from preprocess_dataframe(). A tuple of (preprocessed_dataframe, Ax) will be returned. + proportional_marker_size (bool) + Whether to draw the marker size proportionally to the weight of the study. + total_col (str) + Default is None. If specified, it should be the name of the column indicating which row is subtotal. The values in the column should be 0 (not a subtotal), or 1 (a subtotal row). A horizontal diamond will be drawn for subtotal rows rather than square&whiskers. Returns ------- @@ -221,6 +229,8 @@ def forestplot( color_alt_rows=color_alt_rows, table=table, ax=ax, + proportional_marker_size=proportional_marker_size, + total_col=total_col, **kwargs, ) return (_local_df, ax) if return_df else ax @@ -402,6 +412,7 @@ def _make_forestplot( despine: bool = True, table: bool = False, proportional_marker_size: bool=False, + total_col = None, **kwargs: Any ) -> Axes: """ @@ -478,6 +489,9 @@ def _make_forestplot( draw_est_markers( dataframe=dataframe, estimate=estimate, yticklabel=yticklabel, ax=ax, proportional_size=proportional_marker_size, **kwargs ) + if total_col is not None: + draw_total_diamond(dataframe=dataframe, total_col=total_col, ax=ax, estimate=estimate, ll=ll, hl=hl, **kwargs + ) format_xticks( dataframe=dataframe, estimate=estimate, ll=ll, hl=hl, xticks=xticks, ax=ax, **kwargs ) From 29b7596604c7fdf1641da64dc44c24ac335ad2ee Mon Sep 17 00:00:00 2001 From: Yun-Fei Liu Date: Thu, 17 Jul 2025 20:59:14 -0400 Subject: [PATCH 06/18] adding total stats info allows the user to specify stats of the total effect, and show underneath the total row. --- forestplot/graph_utils.py | 20 ++++++++++++-------- forestplot/plot.py | 24 +++++++++++++++--------- forestplot/text_utils.py | 18 ++++++++++++++++++ 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/forestplot/graph_utils.py b/forestplot/graph_utils.py index 37334ef..1d9082d 100644 --- a/forestplot/graph_utils.py +++ b/forestplot/graph_utils.py @@ -67,7 +67,7 @@ def determine_marker_size(weight): return np.power(2+0.125*weight,2) def draw_est_markers( - dataframe: pd.core.frame.DataFrame, estimate: str, yticklabel: str, proportional_size: bool, ax: Axes, **kwargs: Any + dataframe: pd.core.frame.DataFrame, estimate: str, yticklabel: str, ax: Axes, weight_col=None, total_col=None, **kwargs: Any ) -> Axes: """ Draws the markers of the estimates using the Matplotlib plt.scatter API. @@ -84,8 +84,10 @@ def draw_est_markers( Name of column in intermediate dataframe containing the formatted yticklabels. ax (Matplotlib Axes) Axes to operate on. - proportional_size (bool) - If true, specify marker size to be proportional to the weight of the study. + weight_col (str) + If specified, marker size will be drawn proportionally to the weight of the study. + total_col (str) + The column containing the indicator for whether a row is a subtotal row. If it is, a subtotal row, set the markersize to 0 despite the weight being 100. Returns ------- @@ -97,11 +99,13 @@ def draw_est_markers( markeralpha = kwargs.get("markeralpha", 0.8) # 250715: draw marker sizes proportionally to study weights - if proportional_size: + if weight_col!=None: if not pd.isnull(dataframe[estimate]).all(): - dataframe["markersize"] = determine_marker_size(dataframe["Weight"]) + dataframe["markersize"] = determine_marker_size(dataframe[weight_col]) + if total_col!=None: + dataframe.loc[dataframe[total_col]==1,"markersize"]=0 markersize = "markersize" - if not proportional_size: + if weight_col==None: markersize = kwargs.get("markersize", 40) @@ -128,10 +132,10 @@ def draw_total_diamond( **kwargs: Any ) -> Axes: height = 0.8 # total height of the diamond from top to bottom - print(height) + # print(height) for ii,row in dataframe.iterrows(): if row[total_col]==1: - print(f"Row {ii} is total!") + # print(f"Row {ii} is total!") ci_low = row[ll] ci_high = row[hl] val = row[estimate] diff --git a/forestplot/plot.py b/forestplot/plot.py index c3f3744..9b50554 100644 --- a/forestplot/plot.py +++ b/forestplot/plot.py @@ -81,9 +81,9 @@ def forestplot( preprocess: bool = True, table: bool = False, ax: Optional[Axes] = None, - proportional_marker_size: bool = False, - contains_total: bool = False, + weight_col = None, total_col = None, + total_stats_col = None, **kwargs: Any, ) -> Axes: """ @@ -156,10 +156,12 @@ def forestplot( If True, in addition to the Matplotlib Axes object, returns the intermediate dataframe created from preprocess_dataframe(). A tuple of (preprocessed_dataframe, Ax) will be returned. - proportional_marker_size (bool) - Whether to draw the marker size proportionally to the weight of the study. + weight_col (str) + Default is None. If specified, marker size will be proportaional to the weight of the study. total_col (str) Default is None. If specified, it should be the name of the column indicating which row is subtotal. The values in the column should be 0 (not a subtotal), or 1 (a subtotal row). A horizontal diamond will be drawn for subtotal rows rather than square&whiskers. + total_stats_col (str) + Default is None. If specified, it should be the name of the column indicating which row contains the stats info of the subtotal. The values in the column should be 0 (not such a row), or 1 (is such a row). In such a row, the stats info should be specified in the varlabel column using complete descriptions like "Test for overall effect: Z = 3.02 (P = 0.003)", "Heterogeneity: Tau² (DLb) = 0.00; Chi² = 2.86, df = 3 (P = 0.41); I² = 0%". Can add as many such rows as needed. Returns ------- @@ -206,6 +208,7 @@ def forestplot( sortby=sortby, flush=flush, decimal_precision=decimal_precision, + total_stats_col=total_stats_col, **kwargs, ) ax = _make_forestplot( @@ -229,7 +232,7 @@ def forestplot( color_alt_rows=color_alt_rows, table=table, ax=ax, - proportional_marker_size=proportional_marker_size, + weight_col=weight_col, total_col=total_col, **kwargs, ) @@ -258,6 +261,7 @@ def _preprocess_dataframe( sortascend: bool = True, flush: bool = True, decimal_precision: int = 2, + total_stats_col: Optional[str] = None, **kwargs: Any, ) -> pd.core.frame.DataFrame: """ @@ -367,6 +371,7 @@ def _preprocess_dataframe( varlabel=varlabel, annote=annote, annoteheaders=annoteheaders, + total_stats_col=total_stats_col, **kwargs, ) if rightannote is not None: @@ -384,6 +389,7 @@ def _preprocess_dataframe( annoteheaders=annoteheaders, rightannote=rightannote, right_annoteheaders=right_annoteheaders, + total_stats_col=total_stats_col, **kwargs, ) return reverse_dataframe(dataframe) # since plotting starts from bottom @@ -411,7 +417,7 @@ def _make_forestplot( ax: Axes, despine: bool = True, table: bool = False, - proportional_marker_size: bool=False, + weight_col = None, total_col = None, **kwargs: Any ) -> Axes: @@ -463,8 +469,8 @@ def _make_forestplot( Whether to remove the top and right spines of the plot. table : bool, default=False Whether to draw a table-like structure on the plot. - proportional_marker_size: bool, default=False - Whether to draw the marker size proportionally to the weight of the study. + weight_col: str, default=None + If weight column is specified, the marker size will be drawn proportionally to weight. **kwargs : Any Additional keyword arguments for further customization. @@ -487,7 +493,7 @@ def _make_forestplot( ) # 250715: draw marker sizes proportionally to study weights draw_est_markers( - dataframe=dataframe, estimate=estimate, yticklabel=yticklabel, ax=ax, proportional_size=proportional_marker_size, **kwargs + dataframe=dataframe, estimate=estimate, yticklabel=yticklabel, ax=ax, weight_col=weight_col, total_col=total_col, **kwargs ) if total_col is not None: draw_total_diamond(dataframe=dataframe, total_col=total_col, ax=ax, estimate=estimate, ll=ll, hl=hl, **kwargs diff --git a/forestplot/text_utils.py b/forestplot/text_utils.py index ccbf13a..ec3b7a6 100644 --- a/forestplot/text_utils.py +++ b/forestplot/text_utils.py @@ -330,6 +330,7 @@ def prep_annote( annoteheaders: Optional[Union[Sequence[str], None]], varlabel: str, groupvar: str, + total_stats_col=None, **kwargs: Any, ) -> pd.core.frame.DataFrame: """Prepare the additional columns to be printed as annotations. @@ -364,16 +365,26 @@ def prep_annote( for ix, annotation in enumerate(annote): # Get max len for padding _pad = _get_max_varlen(dataframe=dataframe, varlabel=annotation, extrapad=0) + if total_stats_col is not None: + _pad = _get_max_varlen(dataframe=dataframe[dataframe[total_stats_col]==0], varlabel=annotation, extrapad=0) + + if annoteheaders is not None: # Check that max len exceeds header length _header = annoteheaders[ix] _pad = max(_pad, len(_header)) lookup_annote_len[ix] = _pad for iy, row in dataframe.iterrows(): # Make individual formatted_annotations + if total_stats_col is not None: + if row[total_stats_col]==1: + dataframe.loc[iy, f"formatted_{annotation}"] = "" + continue _annotation = str(row[annotation]).ljust(_pad) dataframe.loc[iy, f"formatted_{annotation}"] = _annotation # get max length for variables pad = _get_max_varlen(dataframe=dataframe, varlabel=varlabel, extrapad=0) + if total_stats_col is not None: + pad = _get_max_varlen(dataframe=dataframe[dataframe[total_stats_col]==0], varlabel=varlabel, extrapad=0) if groupvar is not None: groups = [gr.lower() for gr in dataframe[groupvar].unique()] @@ -382,6 +393,10 @@ def prep_annote( for ix, row in dataframe.iterrows(): yticklabel = row[varlabel] + if total_stats_col is not None: + if row[total_stats_col] == 1: + dataframe.loc[ix, "yticklabel"] = yticklabel + continue if yticklabel.lower().strip() in groups: dataframe.loc[ix, "yticklabel"] = yticklabel else: @@ -473,6 +488,7 @@ def make_tableheaders( annoteheaders: Optional[Union[Sequence[str], None]], rightannote: Optional[Union[Sequence[str], None]], right_annoteheaders: Optional[Union[Sequence[str], None]], + total_stats_col=None, **kwargs: Any, ) -> pd.core.frame.DataFrame: """Make the table headers from 'annoteheaders' and 'right_annoteheaders' as a row in the dataframe. @@ -517,6 +533,8 @@ def make_tableheaders( dataframe = insert_empty_row(dataframe) pad = _get_max_varlen(dataframe=dataframe, varlabel=varlabel, extrapad=0) + if total_stats_col is not None: + pad = _get_max_varlen(dataframe=dataframe[dataframe[total_stats_col]==0], varlabel=varlabel, extrapad=0) left_headers = variable_header.ljust(pad) dataframe.loc[0, "yticklabel"] = left_headers if annoteheaders is not None: From 91fc1fe75c3bd9e829e59ec014526f9d1d2f97fa Mon Sep 17 00:00:00 2001 From: Yun-Fei Liu Date: Sat, 19 Jul 2025 10:31:48 -0400 Subject: [PATCH 07/18] formatting ignore capitalization for rows containing subtotal stats and info --- forestplot/graph_utils.py | 2 ++ forestplot/plot.py | 12 ++++++------ forestplot/text_utils.py | 36 +++++++++++++++++++++++++----------- 3 files changed, 33 insertions(+), 17 deletions(-) diff --git a/forestplot/graph_utils.py b/forestplot/graph_utils.py index 1d9082d..b2b2ebc 100644 --- a/forestplot/graph_utils.py +++ b/forestplot/graph_utils.py @@ -443,6 +443,8 @@ def draw_ylabel1(ylabel: str, pad: float, ax: Axes, **kwargs: Any) -> Axes: size=ylabel1_size, fontweight=ylabel1_fontweight, ) + pos = ax.get_position() + ax.set_position([pos.x0, pos.y0+10, pos.width, pos.height]) # shrink height by 10% return ax diff --git a/forestplot/plot.py b/forestplot/plot.py index 9b50554..5849257 100644 --- a/forestplot/plot.py +++ b/forestplot/plot.py @@ -211,7 +211,7 @@ def forestplot( total_stats_col=total_stats_col, **kwargs, ) - ax = _make_forestplot( + fig, ax = _make_forestplot( dataframe=_local_df, yticklabel="yticklabel", estimate=estimate, @@ -236,7 +236,7 @@ def forestplot( total_col=total_col, **kwargs, ) - return (_local_df, ax) if return_df else ax + return (_local_df, fig, ax) if return_df else (fig, ax) def _preprocess_dataframe( @@ -334,11 +334,11 @@ def _preprocess_dataframe( ) if groupvar is not None: # Make groups dataframe = normalize_varlabels( - dataframe=dataframe, varlabel=groupvar, capitalize=capitalize + dataframe=dataframe, varlabel=groupvar, capitalize=capitalize, total_stats_col=total_stats_col ) dataframe = insert_groups(dataframe=dataframe, groupvar=groupvar, varlabel=varlabel) dataframe = normalize_varlabels( - dataframe=dataframe, varlabel=varlabel, capitalize=capitalize + dataframe=dataframe, varlabel=varlabel, capitalize=capitalize, total_stats_col=total_stats_col ) dataframe = indent_nongroupvar(dataframe=dataframe, varlabel=varlabel, groupvar=groupvar) if form_ci_report: @@ -480,7 +480,7 @@ def _make_forestplot( The matplotlib Axes object with the forest plot. """ if not ax: - _, ax = plt.subplots(figsize=figsize, facecolor="white") + fig, ax = plt.subplots(figsize=figsize, facecolor="white") ax = draw_ci( dataframe=dataframe, estimate=estimate, @@ -562,4 +562,4 @@ def _make_forestplot( negative_padding = 0.5 # ax.set_ylim(-0.5, ax.get_ylim()[1] - negative_padding) # this doesn't reflect the number of actually required rows ax.set_ylim(-0.5, dataframe.shape[0]) # 250713: added by Takua Liu - return ax + return fig, ax diff --git a/forestplot/text_utils.py b/forestplot/text_utils.py index ec3b7a6..c7fdba1 100644 --- a/forestplot/text_utils.py +++ b/forestplot/text_utils.py @@ -167,6 +167,7 @@ def normalize_varlabels( dataframe: pd.core.frame.DataFrame, varlabel: str, capitalize: str = "capitalize", + total_stats_col = None, ) -> pd.core.frame.DataFrame: """ Normalize variable labels to capitalize or title form. @@ -181,22 +182,35 @@ def normalize_varlabels( capitalize (str) 'capitalize' or 'title' See https://pandas.pydata.org/docs/reference/api/pandas.Series.str.capitalize.html - + total_stats_col (str) + if such a column is specified, ignore the rows where total_stats_col is 1 Returns ------- pd.core.frame.DataFrame with the varlabel column normalized. """ if capitalize: - if capitalize == "title": - dataframe[varlabel] = dataframe[varlabel].str.title() - elif capitalize == "capitalize": - dataframe[varlabel] = dataframe[varlabel].str.capitalize() - elif capitalize == "lower": - dataframe[varlabel] = dataframe[varlabel].str.lower() - elif capitalize == "upper": - dataframe[varlabel] = dataframe[varlabel].str.upper() - elif capitalize == "swapcase": - dataframe[varlabel] = dataframe[varlabel].str.swapcase() + if total_stats_col != None: + if capitalize == "title": + dataframe[dataframe[total_stats_col]==0][varlabel] = dataframe[dataframe[total_stats_col]==0][varlabel].str.title() + elif capitalize == "capitalize": + dataframe[dataframe[total_stats_col]==0][varlabel] = dataframe[dataframe[total_stats_col]==0][varlabel].str.capitalize() + elif capitalize == "lower": + dataframe[dataframe[total_stats_col]==0][varlabel] = dataframe[dataframe[total_stats_col]==0][varlabel].str.lower() + elif capitalize == "upper": + dataframe[dataframe[total_stats_col]==0][varlabel] = dataframe[dataframe[total_stats_col]==0][varlabel].str.upper() + elif capitalize == "swapcase": + dataframe[dataframe[total_stats_col]==0][varlabel] = dataframe[dataframe[total_stats_col]==0][varlabel].str.swapcase() + else: + if capitalize == "title": + dataframe[varlabel] = dataframe[varlabel].str.title() + elif capitalize == "capitalize": + dataframe[varlabel] = dataframe[varlabel].str.capitalize() + elif capitalize == "lower": + dataframe[varlabel] = dataframe[varlabel].str.lower() + elif capitalize == "upper": + dataframe[varlabel] = dataframe[varlabel].str.upper() + elif capitalize == "swapcase": + dataframe[varlabel] = dataframe[varlabel].str.swapcase() return dataframe From 87e32a91b69cc5e8b1d7eb51a816d8cf5b49fd1c Mon Sep 17 00:00:00 2001 From: Yun-Fei Liu Date: Tue, 5 Aug 2025 19:56:48 -0400 Subject: [PATCH 08/18] handle long plot titles (ylabels) --- forestplot/graph_utils.py | 64 ++++++++++++++++++++++++++++++--------- forestplot/plot.py | 6 ++-- 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/forestplot/graph_utils.py b/forestplot/graph_utils.py index b2b2ebc..8d6e275 100644 --- a/forestplot/graph_utils.py +++ b/forestplot/graph_utils.py @@ -214,6 +214,12 @@ def right_flush_yticklabels( fontsize = kwargs.get("fontsize", 12) # plt.draw() fig = plt.gcf() + + # 250804: solve the mystery where the plot sometimes drop the header row! + # the number of rows in dataframe[yticklabel] is sometimes not equal to the number of y-ticks automatically generated by matplotlib. + # TakuaLiu: I still don't understand what triggers this mismatch, but we can eliminate it by forcing the number of yticks be the same as the number of yticklabels! + yticks = range(len(dataframe)) + ax.set_yticks(yticks) if flush: ax.set_yticklabels( dataframe[yticklabel], fontfamily=fontfamily, fontsize=fontsize, ha="left" @@ -222,6 +228,9 @@ def right_flush_yticklabels( ax.set_yticklabels( dataframe[yticklabel], fontfamily=fontfamily, fontsize=fontsize, ha="right" ) + # nlast = len(ax.get_yticklabels()) + # print(nlast) + # print(ax.get_yticklabels()) yax = ax.get_yaxis() try: pad = max( @@ -235,7 +244,6 @@ def right_flush_yticklabels( ) if flush: yax.set_tick_params(pad=pad) - return pad @@ -409,6 +417,23 @@ def draw_yticklabel2( righttext_width = max(righttext_width, x1) return ax, righttext_width +# utility for draw_ylabel1() +def shorten_label(txt, orig_txt, attempt=0): + if len(txt.split(":"))>1: + # have both analysis group and subgroup info + # in this case, keep only subgroup info + return txt.split(":")[-1] + if (len(txt.split(":"))==1 and len(orig_txt.split(":"))>1) or len(orig_txt.split(":"))==1: + # originally have both analysis group and subgroup info, tried keeping only the subgroup info, but still too long + # or, there is only analysis group to begin with + new_txt = "" + for tt in orig_txt.split(":"): + new_txt += " ".join(tt.split(" ")[:max(3,15-attempt)]) + " (...) " + txt = new_txt + if attempt > 10: + # unlikely, but if this happens, we don't care about what's being written, just reduce the length! + txt = orig_txt[:(max(20, len(orig_txt)-attempt))] + "(...)" + return txt def draw_ylabel1(ylabel: str, pad: float, ax: Axes, **kwargs: Any) -> Axes: """ @@ -427,22 +452,31 @@ def draw_ylabel1(ylabel: str, pad: float, ax: Axes, **kwargs: Any) -> Axes: ------- Matplotlib Axes object. """ + + ylabel_orig = ylabel + fig = plt.gcf() fontsize = kwargs.get("fontsize", 12) - ax.set_ylabel("") + decent_length = False + attempt = 0 if ylabel is not None: - # Retrieve settings from kwargs - ylabel1_size = kwargs.get("ylabel1_size", 1 + fontsize) - ylabel1_fontweight = kwargs.get("ylabel1_fontweight", "bold") - ylabel_loc = kwargs.get("ylabel_loc", "top") - ylabel_angle = kwargs.get("ylabel_angle", "horizontal") - ax.set_ylabel( - ylabel, - loc=ylabel_loc, - labelpad=-pad, - rotation=ylabel_angle, - size=ylabel1_size, - fontweight=ylabel1_fontweight, - ) + while not decent_length: + # Retrieve settings from kwargs + ylabel1_size = kwargs.get("ylabel1_size", 1 + fontsize) + ylabel1_fontweight = kwargs.get("ylabel1_fontweight", "bold") + ylabel_loc = kwargs.get("ylabel_loc", "top") + ylabel_angle = kwargs.get("ylabel_angle", "horizontal") + ax.set_ylabel( + ylabel, + loc=ylabel_loc, + labelpad=-pad, + rotation=ylabel_angle, + size=ylabel1_size, + fontweight=ylabel1_fontweight, + ) + label_w = ax.yaxis.label.get_window_extent(renderer=fig.canvas.get_renderer()).width + decent_length = label_w <= pad + ylabel = shorten_label(ylabel, ylabel_orig, attempt) + attempt += 1 pos = ax.get_position() ax.set_position([pos.x0, pos.y0+10, pos.width, pos.height]) # shrink height by 10% return ax diff --git a/forestplot/plot.py b/forestplot/plot.py index 5849257..0298f68 100644 --- a/forestplot/plot.py +++ b/forestplot/plot.py @@ -211,6 +211,7 @@ def forestplot( total_stats_col=total_stats_col, **kwargs, ) + fig, ax = _make_forestplot( dataframe=_local_df, yticklabel="yticklabel", @@ -511,6 +512,7 @@ def _make_forestplot( pad = right_flush_yticklabels( dataframe=dataframe, yticklabel=yticklabel, flush=flush, ax=ax, **kwargs ) + draw_ylabel1(ylabel=ylabel, pad=pad, ax=ax, **kwargs) if rightannote is None: ax, righttext_width = draw_pval_right( dataframe=dataframe, @@ -531,10 +533,10 @@ def _make_forestplot( ax=ax, **kwargs, ) - - draw_ylabel1(ylabel=ylabel, pad=pad, ax=ax, **kwargs) + remove_ticks(ax) format_grouplabels(dataframe=dataframe, groupvar=groupvar, ax=ax, **kwargs) + format_tableheader( annoteheaders=annoteheaders, right_annoteheaders=right_annoteheaders, ax=ax, **kwargs ) From 763d3070f0ecb9d6860635f1cf9aef71ac199428 Mon Sep 17 00:00:00 2001 From: Yun-Fei Liu Date: Fri, 8 Aug 2025 22:13:18 -0400 Subject: [PATCH 09/18] solve row mismatch problem riginally, the y is specified as dataframe[yticklabel]. This works until there are duplicate values in the yticklabel column. In this case, pyplot skips the duplicated values without yielding any warning. When plotting, we should always specify numerical x-y coordinates!!! --- forestplot/graph_utils.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/forestplot/graph_utils.py b/forestplot/graph_utils.py index 8d6e275..d1734a7 100644 --- a/forestplot/graph_utils.py +++ b/forestplot/graph_utils.py @@ -49,15 +49,17 @@ def draw_ci( if ll is not None: lw = kwargs.get("lw", 1.4) linecolor = kwargs.get("linecolor", ".6") + # 250808: originally, the y is specified as dataframe[yticklabel]. This works until there are duplicate values in the yticklabel column. In this case, pyplot skips the duplicated values without yielding any warning. This is very bad practice. When plotting, always specify numerical x-y coordinates!!! ax.errorbar( x=dataframe[estimate], - y=dataframe[yticklabel], + y=range(len(dataframe)), xerr=[dataframe[estimate] - dataframe[ll], dataframe[hl] - dataframe[estimate]], ecolor=linecolor, elinewidth=lw, ls="none", zorder=0, ) + if logscale: ax.set_xscale("log", base=10) return ax @@ -112,7 +114,7 @@ def draw_est_markers( # 250714: some dataframes are empty. In such cases, we still draw an empty graph. But of course we don't need markers on an empty graph! if not pd.isnull(dataframe[estimate]).all(): ax.scatter( - y=yticklabel, + y=range(len(dataframe)), x=estimate, data=dataframe, marker=marker, From be26def269796a1e81acbcbde8d3c8c765266d9f Mon Sep 17 00:00:00 2001 From: Yun-Fei Liu Date: Sat, 16 Aug 2025 16:31:41 -0400 Subject: [PATCH 10/18] flagging enable color flagging rows with suspicious values --- forestplot/graph_utils.py | 10 +++++++++- forestplot/plot.py | 9 ++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/forestplot/graph_utils.py b/forestplot/graph_utils.py index d1734a7..2a2bde7 100644 --- a/forestplot/graph_utils.py +++ b/forestplot/graph_utils.py @@ -189,7 +189,7 @@ def draw_ref_xline( def right_flush_yticklabels( - dataframe: pd.core.frame.DataFrame, yticklabel: str, flush: bool, ax: Axes, **kwargs: Any + dataframe: pd.core.frame.DataFrame, yticklabel: str, flush: bool, ax: Axes, flag_col: str, **kwargs: Any ) -> float: """Flushes the formatted ytickers to the left. Also returns the amount of max padding in the window width. @@ -230,6 +230,14 @@ def right_flush_yticklabels( ax.set_yticklabels( dataframe[yticklabel], fontfamily=fontfamily, fontsize=fontsize, ha="right" ) + if len(flag_col)>0 and (flag_col in dataframe.columns): + print(dataframe[flag_col]) + for label, fg in zip(ax.get_yticklabels(), dataframe[flag_col]): + if pd.isnull(fg): + continue + if fg>0: + print(f"Should set the color of {label}") + label.set_color("red") # nlast = len(ax.get_yticklabels()) # print(nlast) # print(ax.get_yticklabels()) diff --git a/forestplot/plot.py b/forestplot/plot.py index 0298f68..d115ed7 100644 --- a/forestplot/plot.py +++ b/forestplot/plot.py @@ -84,6 +84,7 @@ def forestplot( weight_col = None, total_col = None, total_stats_col = None, + flag_col = "", **kwargs: Any, ) -> Axes: """ @@ -162,7 +163,8 @@ def forestplot( Default is None. If specified, it should be the name of the column indicating which row is subtotal. The values in the column should be 0 (not a subtotal), or 1 (a subtotal row). A horizontal diamond will be drawn for subtotal rows rather than square&whiskers. total_stats_col (str) Default is None. If specified, it should be the name of the column indicating which row contains the stats info of the subtotal. The values in the column should be 0 (not such a row), or 1 (is such a row). In such a row, the stats info should be specified in the varlabel column using complete descriptions like "Test for overall effect: Z = 3.02 (P = 0.003)", "Heterogeneity: Tau² (DLb) = 0.00; Chi² = 2.86, df = 3 (P = 0.41); I² = 0%". Can add as many such rows as needed. - + flag_col (str) + the column based on which we color the yticklables to flag suspicious rows. Returns ------- Matplotlib Axes object. @@ -211,7 +213,6 @@ def forestplot( total_stats_col=total_stats_col, **kwargs, ) - fig, ax = _make_forestplot( dataframe=_local_df, yticklabel="yticklabel", @@ -235,6 +236,7 @@ def forestplot( ax=ax, weight_col=weight_col, total_col=total_col, + flag_col=flag_col, **kwargs, ) return (_local_df, fig, ax) if return_df else (fig, ax) @@ -420,6 +422,7 @@ def _make_forestplot( table: bool = False, weight_col = None, total_col = None, + flag_col = "", **kwargs: Any ) -> Axes: """ @@ -510,7 +513,7 @@ def _make_forestplot( **kwargs, ) pad = right_flush_yticklabels( - dataframe=dataframe, yticklabel=yticklabel, flush=flush, ax=ax, **kwargs + dataframe=dataframe, yticklabel=yticklabel, flush=flush, ax=ax, flag_col=flag_col, **kwargs ) draw_ylabel1(ylabel=ylabel, pad=pad, ax=ax, **kwargs) if rightannote is None: From a1fd9cf49d20591489adafafb4cb469c9c166078 Mon Sep 17 00:00:00 2001 From: Yun-Fei Liu Date: Sun, 17 Aug 2025 10:41:50 -0400 Subject: [PATCH 11/18] Update graph_utils.py --- forestplot/graph_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forestplot/graph_utils.py b/forestplot/graph_utils.py index 2a2bde7..2dfc75e 100644 --- a/forestplot/graph_utils.py +++ b/forestplot/graph_utils.py @@ -231,7 +231,7 @@ def right_flush_yticklabels( dataframe[yticklabel], fontfamily=fontfamily, fontsize=fontsize, ha="right" ) if len(flag_col)>0 and (flag_col in dataframe.columns): - print(dataframe[flag_col]) + # print(dataframe[flag_col]) for label, fg in zip(ax.get_yticklabels(), dataframe[flag_col]): if pd.isnull(fg): continue From c92c81d06ec2066686f0bbd1efb4a5217600704b Mon Sep 17 00:00:00 2001 From: Yun-Fei Liu Date: Sun, 24 Aug 2025 11:12:30 -0400 Subject: [PATCH 12/18] try to solve memory leak --- forestplot/graph_utils.py | 27 +++++++++++++++++++-------- forestplot/plot.py | 3 +++ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/forestplot/graph_utils.py b/forestplot/graph_utils.py index 2dfc75e..ca682b8 100644 --- a/forestplot/graph_utils.py +++ b/forestplot/graph_utils.py @@ -8,6 +8,8 @@ import numpy as np from matplotlib.pyplot import Axes from matplotlib.patches import Polygon +import matplotlib +matplotlib.use('Agg') warnings.filterwarnings("ignore") @@ -50,16 +52,21 @@ def draw_ci( lw = kwargs.get("lw", 1.4) linecolor = kwargs.get("linecolor", ".6") # 250808: originally, the y is specified as dataframe[yticklabel]. This works until there are duplicate values in the yticklabel column. In this case, pyplot skips the duplicated values without yielding any warning. This is very bad practice. When plotting, always specify numerical x-y coordinates!!! + x = dataframe[estimate].to_numpy(copy=False) # float + lo = dataframe[ll].to_numpy(copy=False) + hi = dataframe[hl].to_numpy(copy=False) + y = np.arange(len(x), dtype=float) + xerr = np.vstack([x - lo, hi - x]) + ax.errorbar( - x=dataframe[estimate], - y=range(len(dataframe)), - xerr=[dataframe[estimate] - dataframe[ll], dataframe[hl] - dataframe[estimate]], + x=x, + y=y, + xerr=xerr, ecolor=linecolor, elinewidth=lw, ls="none", zorder=0, ) - if logscale: ax.set_xscale("log", base=10) return ax @@ -215,7 +222,8 @@ def right_flush_yticklabels( fontfamily = kwargs.get("fontfamily", "monospace") fontsize = kwargs.get("fontsize", 12) # plt.draw() - fig = plt.gcf() + # fig = plt.gcf() + fig = ax.figure # 250804: solve the mystery where the plot sometimes drop the header row! # the number of rows in dataframe[yticklabel] is sometimes not equal to the number of y-ticks automatically generated by matplotlib. @@ -292,7 +300,8 @@ def draw_pval_right( if pval is not None: inv = ax.transData.inverted() righttext_width = 0 - fig = plt.gcf() + # fig = plt.gcf() + fig = ax.figure for _, row in dataframe.iterrows(): yticklabel1 = row[yticklabel] yticklabel2 = row["formatted_pval"] @@ -391,7 +400,8 @@ def draw_yticklabel2( top_row_ix = len(dataframe) - 1 inv = ax.transData.inverted() righttext_width = 0 - fig = plt.gcf() + # fig = plt.gcf() + fig = ax.figure for ix, row in dataframe.iterrows(): yticklabel1 = row["yticklabel"] yticklabel2 = row["yticklabel2"] @@ -464,7 +474,8 @@ def draw_ylabel1(ylabel: str, pad: float, ax: Axes, **kwargs: Any) -> Axes: """ ylabel_orig = ylabel - fig = plt.gcf() + # fig = plt.gcf() + fig = ax.figure fontsize = kwargs.get("fontsize", 12) decent_length = False attempt = 0 diff --git a/forestplot/plot.py b/forestplot/plot.py index d115ed7..a5bd22b 100644 --- a/forestplot/plot.py +++ b/forestplot/plot.py @@ -495,10 +495,13 @@ def _make_forestplot( ax=ax, **kwargs, ) + # 250715: draw marker sizes proportionally to study weights draw_est_markers( dataframe=dataframe, estimate=estimate, yticklabel=yticklabel, ax=ax, weight_col=weight_col, total_col=total_col, **kwargs ) + + if total_col is not None: draw_total_diamond(dataframe=dataframe, total_col=total_col, ax=ax, estimate=estimate, ll=ll, hl=hl, **kwargs ) From 088cbb752ef5ef39a5afed6a5432b410a7491625 Mon Sep 17 00:00:00 2001 From: Yun-Fei Liu Date: Sun, 24 Aug 2025 21:39:00 -0400 Subject: [PATCH 13/18] minor fix --- forestplot/graph_utils.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/forestplot/graph_utils.py b/forestplot/graph_utils.py index ca682b8..faf42a2 100644 --- a/forestplot/graph_utils.py +++ b/forestplot/graph_utils.py @@ -8,8 +8,6 @@ import numpy as np from matplotlib.pyplot import Axes from matplotlib.patches import Polygon -import matplotlib -matplotlib.use('Agg') warnings.filterwarnings("ignore") From ae9b7776939c11d1ad5bfce50b32e2669b056126 Mon Sep 17 00:00:00 2001 From: Yun-Fei Liu Date: Mon, 25 Aug 2025 08:02:37 -0400 Subject: [PATCH 14/18] padding problem enable consistent padding width across rendereres --- forestplot/graph_utils.py | 40 +++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/forestplot/graph_utils.py b/forestplot/graph_utils.py index faf42a2..f82e03c 100644 --- a/forestplot/graph_utils.py +++ b/forestplot/graph_utils.py @@ -50,16 +50,16 @@ def draw_ci( lw = kwargs.get("lw", 1.4) linecolor = kwargs.get("linecolor", ".6") # 250808: originally, the y is specified as dataframe[yticklabel]. This works until there are duplicate values in the yticklabel column. In this case, pyplot skips the duplicated values without yielding any warning. This is very bad practice. When plotting, always specify numerical x-y coordinates!!! - x = dataframe[estimate].to_numpy(copy=False) # float - lo = dataframe[ll].to_numpy(copy=False) - hi = dataframe[hl].to_numpy(copy=False) - y = np.arange(len(x), dtype=float) - xerr = np.vstack([x - lo, hi - x]) + # x = dataframe[estimate].to_numpy(copy=False) # float + # lo = dataframe[ll].to_numpy(copy=False) + # hi = dataframe[hl].to_numpy(copy=False) + # y = np.arange(len(x), dtype=float) + # xerr = np.vstack([x - lo, hi - x]) ax.errorbar( - x=x, - y=y, - xerr=xerr, + x=dataframe[estimate], + y=np.arange(dataframe.shape[0]), + xerr=[dataframe[estimate] - dataframe[ll], dataframe[hl] - dataframe[estimate]], ecolor=linecolor, elinewidth=lw, ls="none", @@ -220,8 +220,9 @@ def right_flush_yticklabels( fontfamily = kwargs.get("fontfamily", "monospace") fontsize = kwargs.get("fontsize", 12) # plt.draw() - # fig = plt.gcf() - fig = ax.figure + fig = plt.gcf() + # fig.canvas.draw() + # fig = ax.figure # 250804: solve the mystery where the plot sometimes drop the header row! # the number of rows in dataframe[yticklabel] is sometimes not equal to the number of y-ticks automatically generated by matplotlib. @@ -253,11 +254,17 @@ def right_flush_yticklabels( T.label.get_window_extent(renderer=fig.canvas.get_renderer()).width for T in yax.majorTicks ) + except AttributeError: pad = max( T.label1.get_window_extent(renderer=fig.canvas.get_renderer()).width for T in yax.majorTicks ) + # for T in yax.majorTicks: + # print(T.label1) + # print(T.label1.get_window_extent(renderer=fig.canvas.get_renderer()).width) + pad = pad* 72.0 / fig.dpi + print(pad) if flush: yax.set_tick_params(pad=pad) return pad @@ -298,8 +305,8 @@ def draw_pval_right( if pval is not None: inv = ax.transData.inverted() righttext_width = 0 - # fig = plt.gcf() - fig = ax.figure + fig = plt.gcf() + # fig = ax.figure for _, row in dataframe.iterrows(): yticklabel1 = row[yticklabel] yticklabel2 = row["formatted_pval"] @@ -398,8 +405,8 @@ def draw_yticklabel2( top_row_ix = len(dataframe) - 1 inv = ax.transData.inverted() righttext_width = 0 - # fig = plt.gcf() - fig = ax.figure + fig = plt.gcf() + # fig = ax.figure for ix, row in dataframe.iterrows(): yticklabel1 = row["yticklabel"] yticklabel2 = row["yticklabel2"] @@ -472,8 +479,8 @@ def draw_ylabel1(ylabel: str, pad: float, ax: Axes, **kwargs: Any) -> Axes: """ ylabel_orig = ylabel - # fig = plt.gcf() - fig = ax.figure + fig = plt.gcf() + # fig = ax.figure fontsize = kwargs.get("fontsize", 12) decent_length = False attempt = 0 @@ -493,6 +500,7 @@ def draw_ylabel1(ylabel: str, pad: float, ax: Axes, **kwargs: Any) -> Axes: fontweight=ylabel1_fontweight, ) label_w = ax.yaxis.label.get_window_extent(renderer=fig.canvas.get_renderer()).width + label_w = label_w * 72.0/fig.dpi decent_length = label_w <= pad ylabel = shorten_label(ylabel, ylabel_orig, attempt) attempt += 1 From 6f3e421225506f807e73fd6b376db60823a6b90a Mon Sep 17 00:00:00 2001 From: Yun-Fei Liu Date: Mon, 25 Aug 2025 08:06:23 -0400 Subject: [PATCH 15/18] minor --- forestplot/graph_utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/forestplot/graph_utils.py b/forestplot/graph_utils.py index f82e03c..f0202ce 100644 --- a/forestplot/graph_utils.py +++ b/forestplot/graph_utils.py @@ -264,7 +264,6 @@ def right_flush_yticklabels( # print(T.label1) # print(T.label1.get_window_extent(renderer=fig.canvas.get_renderer()).width) pad = pad* 72.0 / fig.dpi - print(pad) if flush: yax.set_tick_params(pad=pad) return pad From a19e617ff69f93f75ed2301e0c8ecee5dbf212c4 Mon Sep 17 00:00:00 2001 From: Yun-Fei Liu Date: Sat, 30 Aug 2025 16:15:22 -0400 Subject: [PATCH 16/18] minor --- forestplot/graph_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forestplot/graph_utils.py b/forestplot/graph_utils.py index f0202ce..032d4e1 100644 --- a/forestplot/graph_utils.py +++ b/forestplot/graph_utils.py @@ -243,7 +243,7 @@ def right_flush_yticklabels( if pd.isnull(fg): continue if fg>0: - print(f"Should set the color of {label}") + # print(f"Should set the color of {label}") label.set_color("red") # nlast = len(ax.get_yticklabels()) # print(nlast) From 529314b54b4f23c819083d501c21285513241853 Mon Sep 17 00:00:00 2001 From: Yun-Fei Liu Date: Sun, 7 Sep 2025 12:15:53 -0400 Subject: [PATCH 17/18] minor --- forestplot/graph_utils.py | 2 +- forestplot/plot.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/forestplot/graph_utils.py b/forestplot/graph_utils.py index 032d4e1..6fd2188 100644 --- a/forestplot/graph_utils.py +++ b/forestplot/graph_utils.py @@ -238,7 +238,7 @@ def right_flush_yticklabels( dataframe[yticklabel], fontfamily=fontfamily, fontsize=fontsize, ha="right" ) if len(flag_col)>0 and (flag_col in dataframe.columns): - # print(dataframe[flag_col]) + # print(dataframe.columns) for label, fg in zip(ax.get_yticklabels(), dataframe[flag_col]): if pd.isnull(fg): continue diff --git a/forestplot/plot.py b/forestplot/plot.py index a5bd22b..35e61fa 100644 --- a/forestplot/plot.py +++ b/forestplot/plot.py @@ -495,7 +495,6 @@ def _make_forestplot( ax=ax, **kwargs, ) - # 250715: draw marker sizes proportionally to study weights draw_est_markers( dataframe=dataframe, estimate=estimate, yticklabel=yticklabel, ax=ax, weight_col=weight_col, total_col=total_col, **kwargs From 4aa680c7368fdd8dbb8a10404917e8192283409e Mon Sep 17 00:00:00 2001 From: Yun-Fei Liu Date: Mon, 15 Sep 2025 21:44:12 -0400 Subject: [PATCH 18/18] minor debugging --- forestplot/graph_utils.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/forestplot/graph_utils.py b/forestplot/graph_utils.py index 6fd2188..5f39582 100644 --- a/forestplot/graph_utils.py +++ b/forestplot/graph_utils.py @@ -443,21 +443,21 @@ def draw_yticklabel2( # utility for draw_ylabel1() def shorten_label(txt, orig_txt, attempt=0): - if len(txt.split(":"))>1: - # have both analysis group and subgroup info - # in this case, keep only subgroup info - return txt.split(":")[-1] - if (len(txt.split(":"))==1 and len(orig_txt.split(":"))>1) or len(orig_txt.split(":"))==1: - # originally have both analysis group and subgroup info, tried keeping only the subgroup info, but still too long - # or, there is only analysis group to begin with - new_txt = "" - for tt in orig_txt.split(":"): - new_txt += " ".join(tt.split(" ")[:max(3,15-attempt)]) + " (...) " - txt = new_txt - if attempt > 10: - # unlikely, but if this happens, we don't care about what's being written, just reduce the length! - txt = orig_txt[:(max(20, len(orig_txt)-attempt))] + "(...)" - return txt + if len(txt.split(":"))>1: + # have both analysis group and subgroup info + # in this case, keep only subgroup info + return txt.split(":")[-1] + if (len(txt.split(":"))==1 and len(orig_txt.split(":"))>1) or len(orig_txt.split(":"))==1: + # originally have both analysis group and subgroup info, tried keeping only the subgroup info, but still too long + # or, there is only analysis group to begin with + new_txt = "" + for tt in orig_txt.split(":"): + new_txt += " ".join(tt.split(" ")[:max(3,15-attempt)]) + " (...) " + txt = new_txt + if attempt > 10: + # unlikely, but if this happens, we don't care about what's being written, just reduce the length! + txt = orig_txt[:(max(20, len(orig_txt)-attempt))] + "(...)" + return txt def draw_ylabel1(ylabel: str, pad: float, ax: Axes, **kwargs: Any) -> Axes: """