# -*- coding: utf-8 -*-
import warnings
from typing import Dict, List, Tuple, Union
import matplotlib
import matplotlib.pyplot as plt
import matplotlib.transforms as transforms
import numpy as np
from .comparer import Comparer
with warnings.catch_warnings():
warnings.simplefilter("ignore")
from scipy.stats import norm, t
__all__ = ["blandaltman", "BlandAltman"]
[docs]class BlandAltman(Comparer):
"""Class for drawing a Bland-Altman plot"""
DEFAULT_POINTS_KWS = {"s": 20, "alpha": 0.6, "color": "#000000"}
def __init__(
self,
method1: Union[List[float], np.ndarray],
method2: Union[List[float], np.ndarray],
diff: str = "absolute",
limit_of_agreement: float = 1.96,
CI: float = 0.95,
):
"""
Initialize a Bland-Altman class object. This class is center to the calculate
and compute functionality of a Bland-Altman comparison.
Parameters
----------
method1, method2: Union[List[float], np.ndarray]
Values obtained from both methods, preferably provided in a np.array.
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.
CI : float, optional
The confidence interval employed in the mean difference and limit of
agreement lines. Defaults to 0.95.
"""
# variables assignment
self.computed = False
self.diff_method: str = diff
self.CI: float = CI
self.loa: float = limit_of_agreement
super().__init__(method1, method2)
def _check_params(self):
super()._check_params()
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."
)
def _calculate_impl(self):
"""Calculates the statistics for method comparison using
Bland-Altman plotting. Returns a dictionary with the results.
Parameters
----------
None
Returns
----------
Dict[str, Any] :
Dictionary containing the mean and limit of agreement
values and their confidence intervals
"""
self.mean: np.array = np.mean([self.method1, self.method2], axis=0)
self.diff = self.method1 - self.method2
if self.diff_method == "percentage":
self.diff = self.diff * 100 / self.mean
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 = 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) * t.ppf(q=(1 - self.CI) / 2.0, 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,
]
self._result = {
"mean": self.mean_diff,
"mean_CI": self.CI_mean if self.CI else None,
"loa_lower": self.mean_diff - self.loa_sd,
"loa_lower_CI": self.CI_lower if self.CI else None,
"loa_upper": self.mean_diff + self.loa_sd,
"loa_upper_CI": self.CI_upper if self.CI else None,
"sd_diff": self.sd_diff,
}
[docs] def plot(
self,
x_label: str = "Mean of methods",
y_label: str = "Difference between methods",
graph_title: str = None,
reference: bool = False,
xlim: Tuple = None,
ylim: Tuple = None,
color_mean: str = "#008bff",
color_loa: str = "#FF7000",
color_points: str = "#000000",
point_kws: Dict = None,
ci_alpha: float = 0.2,
loa_linestyle: str = "--",
ax: matplotlib.axes.Axes = 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
----------
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.
graph_title : str, optional
Title of the Bland-Altman plot.
If None is provided, no title will be plotted.
reference : bool, optional
If True, a grey reference line at y=0 will be plotted in the Bland-Altman.
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`.
ci_alpha: float, optional
Alpha value of the confidence interval.
loa_linestyle: str, optional
Linestyle of the limit of agreement lines.
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.
"""
ax = ax or plt.gca()
pkws = self.DEFAULT_POINTS_KWS.copy()
pkws.update(point_kws or {})
# Get parameters
mean, mean_CI = self.result["mean"], self.result["mean_CI"]
loa_upper, loa_upper_CI = self.result["loa_upper"], self.result["loa_upper_CI"]
loa_lower, loa_lower_CI = self.result["loa_lower"], self.result["loa_lower_CI"]
sd_diff = self.result["sd_diff"]
# individual points
ax.scatter(self.mean, self.diff, **pkws)
# mean difference and SD lines
ax.axhline(mean, color=color_mean, linestyle=loa_linestyle)
ax.axhline(loa_upper, color=color_loa, linestyle=loa_linestyle)
ax.axhline(loa_lower, color=color_loa, linestyle=loa_linestyle)
if reference:
ax.axhline(0, color="grey", linestyle="-", alpha=0.4)
# confidence intervals (if requested)
if self.CI is not None:
ax.axhspan(*mean_CI, color=color_mean, alpha=ci_alpha)
ax.axhspan(*loa_upper_CI, color=color_loa, alpha=ci_alpha)
ax.axhspan(*loa_lower_CI, color=color_loa, alpha=ci_alpha)
# text in graph
trans: matplotlib.transform = transforms.blended_transform_factory(
ax.transAxes, ax.transData
)
offset: float = (((self.loa * sd_diff) * 2) / 100) * 1.2
ax.text(
0.98,
mean + offset,
"Mean",
ha="right",
va="bottom",
transform=trans,
)
ax.text(
0.98,
mean - offset,
f"{mean:.2f}",
ha="right",
va="top",
transform=trans,
)
ax.text(
0.98,
loa_upper + offset,
f"+{self.loa:.2f} SD",
ha="right",
va="bottom",
transform=trans,
)
ax.text(
0.98,
loa_upper - offset,
f"{loa_upper:.2f}",
ha="right",
va="top",
transform=trans,
)
ax.text(
0.98,
loa_lower - offset,
f"-{self.loa:.2f} SD",
ha="right",
va="top",
transform=trans,
)
ax.text(
0.98,
loa_lower + offset,
f"{loa_lower:.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 xlim is not None:
ax.set_xlim(xlim[0], xlim[1])
if ylim is not None:
ax.set_ylim(ylim[0], ylim[1])
# graph labels
ax.set(xlabel=x_label, ylabel=y_label, title=graph_title)
return ax
[docs]def blandaltman(
method1,
method2,
diff="absolute",
limit_of_agreement=1.96,
CI=0.95,
x_label: str = "Mean of methods",
y_label: str = "Difference between methods",
graph_title: str = None,
reference: bool = False,
xlim: Tuple = None,
ylim: Tuple = None,
color_mean: str = "#008bff",
color_loa: str = "#FF7000",
color_points: str = "#000000",
point_kws: Dict = None,
ci_alpha: float = 0.2,
loa_linestyle: str = "--",
ax: matplotlib.axes.Axes = None,
) -> BlandAltman:
"""Provide a method comparison using Bland-Altman.
This functions creates a BlandAltman class which can be used to
access the statistics (using .statistics()) or to generate a plot
with additional arguments (using .plots()).
Parameters
----------
method1, method2 : array, or list
Values obtained from both methods, preferably provided in a np.array.
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.
CI : float, optional
The confidence interval employed in the mean difference and limit of agreement
lines. Defaults to 0.95.
Returns
-------
BlandAltman : class object containing the statistics and plot functionality
See Also
-------
pyCompare package on github
[altman_1983] Altman, D. G., and Bland, J. M.
Series D (The Statistician), vol. 32, no. 3, 1983, pp. 307–317.
[altman_1999] Altman, D. G., and Bland, J. M.
Statistical Methods in Medical Research,
vol. 8, no. 2, 1999, pp. 135–160.
"""
return BlandAltman(method1, method2, diff, limit_of_agreement, CI).plot(
x_label=x_label,
y_label=y_label,
graph_title=graph_title,
reference=reference,
xlim=xlim,
ylim=ylim,
color_mean=color_mean,
color_loa=color_loa,
color_points=color_points,
point_kws=point_kws,
ci_alpha=ci_alpha,
loa_linestyle=loa_linestyle,
ax=ax,
)