Source code for methcomp.blandaltman

import matplotlib.pyplot as plt
import matplotlib
import matplotlib.transforms as transforms
import numpy as np
from scipy import stats

__all__ = ["blandaltman"]


class _BlandAltman(object):
    """Internal class for drawing a Bland-Altman plot"""

    def __init__(self, method1, method2,
                 x_title, y_title, graph_title,
                 diff, limit_of_agreement, reference, CI,
                 xlim, ylim,
                 color_mean, color_loa, color_points,
                 point_kws):
        # variables assignment
        self.method1: np.array = np.asarray(method1)
        self.method2: np.array = np.asarray(method2)
        self.diff_method: str = diff
        self.graph_title: str = graph_title
        self.x_title: str = x_title
        self.y_title: str = y_title
        self.loa: float = limit_of_agreement
        self.reference: bool = reference
        self.CI: float = CI
        self.xlim: list = xlim
        self.ylim: list = ylim
        self.color_mean: str = color_mean
        self.color_loa: str = color_loa
        self.color_points: str = color_points
        self.point_kws: dict = {} if point_kws is None else point_kws.copy()

        # check provided parameters
        self._check_params()
        self._derive_params()

    def _derive_params(self):
        # perform necessary calculations and processing
        self.n: float = len(self.method1)
        self.mean: np.array = np.mean([self.method1, self.method1], axis=0)

        if self.diff_method == 'absolute':
            self.diff = self.method1 - self.method2
        elif self.diff_method == 'percentage':
            self.diff = ((self.method1 - self.method2) / self.mean) * 100
        else:
            self.diff = self.method1 - self.method2

        self.mean_diff = np.mean(self.diff)
        self.sd_diff = np.std(self.diff, axis=0)
        self.loa_sd = self.loa * self.sd_diff

        if self.CI is not None:
            self.CI_mean = stats.norm.interval(alpha=self.CI, loc=self.mean_diff,
                                               scale=self.sd_diff / np.sqrt(self.n))
            se_loa = (1.71 ** 2) * ((self.sd_diff**2) / self.n)
            conf_loa = np.sqrt(se_loa) * stats.t.ppf(q=(1 - self.CI) / 2., df=self.n - 1)
            self.CI_upper = [self.mean_diff + self.loa_sd + conf_loa,
                             self.mean_diff + self.loa_sd - conf_loa]
            self.CI_lower = [self.mean_diff - self.loa_sd + conf_loa,
                             self.mean_diff - self.loa_sd - conf_loa]

    def _check_params(self):
        if len(self.method1) != len(self.method2):
            raise ValueError('Length of method 1 and method 2 are not equal.')

        if self.CI is not None and (self.CI > 1 or self.CI < 0):
            raise ValueError('Confidence interval must be between 0 and 1.')

        if self.diff_method not in ['absolute', 'percentage']:
            raise ValueError('The provided difference method must be either absolute or percentage.')

        if any([not isinstance(x, str) for x in [self.x_title, self.y_title]]):
            raise ValueError('Axes labels arguments should be provided as a str.')

    def plot(self, ax: matplotlib.axes.Axes):
        # individual points
        ax.scatter(self.mean, self.diff, s=20, alpha=0.6, color=self.color_points,
                   **self.point_kws)

        # mean difference and SD lines
        ax.axhline(self.mean_diff, color=self.color_mean, linestyle='-')
        ax.axhline(self.mean_diff + self.loa_sd, color=self.color_loa, linestyle='--')
        ax.axhline(self.mean_diff - self.loa_sd, color=self.color_loa, linestyle='--')

        if self.reference:
            ax.axhline(0, color='grey', linestyle='-', alpha=0.4)

        # confidence intervals (if requested)
        if self.CI is not None:
            ax.axhspan(self.CI_mean[0],  self.CI_mean[1], color=self.color_mean, alpha=0.2)
            ax.axhspan(self.CI_upper[0], self.CI_upper[1], color=self.color_loa, alpha=0.2)
            ax.axhspan(self.CI_lower[0], self.CI_lower[1], color=self.color_loa, alpha=0.2)

        # text in graph
        trans: matplotlib.transform = transforms.blended_transform_factory(
            ax.transAxes, ax.transData)
        offset: float = (((self.loa * self.sd_diff) * 2) / 100) * 1.2
        ax.text(0.98, self.mean_diff + offset, 'Mean', ha="right", va="bottom", transform=trans)
        ax.text(0.98, self.mean_diff - offset, f'{self.mean_diff:.2f}', ha="right", va="top", transform=trans)
        ax.text(0.98, self.mean_diff + self.loa_sd + offset,
                f'+{self.loa:.2f} SD', ha="right", va="bottom", transform=trans)
        ax.text(0.98, self.mean_diff + self.loa_sd - offset,
                f'{self.mean_diff + self.loa_sd:.2f}', ha="right", va="top", transform=trans)
        ax.text(0.98, self.mean_diff - self.loa_sd - offset,
                f'-{self.loa:.2f} SD', ha="right", va="top", transform=trans)
        ax.text(0.98, self.mean_diff - self.loa_sd + offset,
                f'{self.mean_diff - self.loa_sd:.2f}', ha="right", va="bottom", transform=trans)

        # transform graphs
        ax.spines['right'].set_visible(False)
        ax.spines['top'].set_visible(False)

        # set X and Y limits
        if self.xlim is not None:
            ax.set_xlim(self.xlim[0], self.xlim[1])
        if self.ylim is not None:
            ax.set_ylim(self.ylim[0], self.ylim[1])

        # graph labels
        ax.set_ylabel(self.y_title)
        ax.set_xlabel(self.x_title)
        if self.graph_title is not None:
            ax.set_title(self.graph_title)


[docs]def blandaltman(method1, method2, x_label='Mean of methods', y_label='Difference between methods', title=None, diff='absolute', limit_of_agreement=1.96, reference=False, CI=0.95, xlim=None, ylim=None, color_mean='#008bff', color_loa='#FF7000', color_points='#000000', point_kws=None, ax=None): """Provide a method comparison using Bland-Altman plotting. This is an Axis-level function which will draw the Bland-Altman plot onto the current active Axis object unless ``ax`` is provided. Parameters ---------- method1, method2 : array, or list Values obtained from both methods, preferably provided in a np.array. x_label : str, optional The label which is added to the X-axis. If None is provided, a standard label will be added. y_label : str, optional The label which is added to the Y-axis. If None is provided, a standard label will be added. title : str, optional Title of the Bland-Altman plot. If None is provided, no title will be plotted. diff : "absolute" or "percentage" The difference to display, whether it is an absolute one or a percentual one. If None is provided, it defaults to absolute. limit_of_agreement : float, optional Multiples of the standard deviation to plot the limit of afgreement bounds at. This defaults to 1.96. reference : bool, optional If True, a grey reference line at y=0 will be plotted in the Bland-Altman. CI : float, optional The confidence interval employed in the mean difference and limit of agreement lines. Defaults to 0.95. xlim : list, optional Minimum and maximum limits for X-axis. Should be provided as list or tuple. If not set, matplotlib will decide its own bounds. ylim : list, optional Minimum and maximum limits for Y-axis. Should be provided as list or tuple. If not set, matplotlib will decide its own bounds. color_mean : str, optional Color of the mean difference line that will be plotted. color_loa : str, optional Color of the limit of agreement lines that will be plotted. color_points : str, optional Color of the individual differences that will be plotted. point_kws : dict of key, value mappings, optional Additional keyword arguments for `plt.scatter`. ax : matplotlib Axes, optional Axes in which to draw the plot, otherwise use the currently-active Axes. Returns ------- ax : matplotlib Axes Axes object with the Bland-Altman plot. See Also ------- pyCompare package on github Altman, D. G., and Bland, J. M. Series D (The Statistician), vol. 32, no. 3, 1983, pp. 307–317. Altman, D. G., and Bland, J. M. Statistical Methods in Medical Research, vol. 8, no. 2, 1999, pp. 135–160. """ plotter: _BlandAltman = _BlandAltman(method1, method2, x_label, y_label, title, diff, limit_of_agreement, reference, CI, xlim, ylim, color_mean, color_loa, color_points, point_kws) # Draw the plot and return the Axes if ax is None: ax = plt.gca() plotter.plot(ax) return ax