This commit is contained in:
2025-02-22 14:05:13 +01:00
commit 15d46490c6
5374 changed files with 1772337 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
# Import seaborn objects
from .rcmod import * # noqa: F401,F403
from .utils import * # noqa: F401,F403
from .palettes import * # noqa: F401,F403
from .relational import * # noqa: F401,F403
from .regression import * # noqa: F401,F403
from .categorical import * # noqa: F401,F403
from .distributions import * # noqa: F401,F403
from .matrix import * # noqa: F401,F403
from .miscplot import * # noqa: F401,F403
from .axisgrid import * # noqa: F401,F403
from .widgets import * # noqa: F401,F403
from .colors import xkcd_rgb, crayons # noqa: F401
from . import cm # noqa: F401
# Capture the original matplotlib rcParams
import matplotlib as mpl
_orig_rc_params = mpl.rcParams.copy()
# Define the seaborn version
__version__ = "0.13.2"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,123 @@
from __future__ import annotations
from typing import Literal
import numpy as np
import pandas as pd
import matplotlib as mpl
from matplotlib.figure import Figure
from seaborn.utils import _version_predates
def norm_from_scale(scale, norm):
"""Produce a Normalize object given a Scale and min/max domain limits."""
# This is an internal maplotlib function that simplifies things to access
# It is likely to become part of the matplotlib API at some point:
# https://github.com/matplotlib/matplotlib/issues/20329
if isinstance(norm, mpl.colors.Normalize):
return norm
if scale is None:
return None
if norm is None:
vmin = vmax = None
else:
vmin, vmax = norm # TODO more helpful error if this fails?
class ScaledNorm(mpl.colors.Normalize):
def __call__(self, value, clip=None):
# From github.com/matplotlib/matplotlib/blob/v3.4.2/lib/matplotlib/colors.py
# See github.com/matplotlib/matplotlib/tree/v3.4.2/LICENSE
value, is_scalar = self.process_value(value)
self.autoscale_None(value)
if self.vmin > self.vmax:
raise ValueError("vmin must be less or equal to vmax")
if self.vmin == self.vmax:
return np.full_like(value, 0)
if clip is None:
clip = self.clip
if clip:
value = np.clip(value, self.vmin, self.vmax)
# ***** Seaborn changes start ****
t_value = self.transform(value).reshape(np.shape(value))
t_vmin, t_vmax = self.transform([self.vmin, self.vmax])
# ***** Seaborn changes end *****
if not np.isfinite([t_vmin, t_vmax]).all():
raise ValueError("Invalid vmin or vmax")
t_value -= t_vmin
t_value /= (t_vmax - t_vmin)
t_value = np.ma.masked_invalid(t_value, copy=False)
return t_value[0] if is_scalar else t_value
new_norm = ScaledNorm(vmin, vmax)
new_norm.transform = scale.get_transform().transform
return new_norm
def get_colormap(name):
"""Handle changes to matplotlib colormap interface in 3.6."""
try:
return mpl.colormaps[name]
except AttributeError:
return mpl.cm.get_cmap(name)
def register_colormap(name, cmap):
"""Handle changes to matplotlib colormap interface in 3.6."""
try:
if name not in mpl.colormaps:
mpl.colormaps.register(cmap, name=name)
except AttributeError:
mpl.cm.register_cmap(name, cmap)
def set_layout_engine(
fig: Figure,
engine: Literal["constrained", "compressed", "tight", "none"],
) -> None:
"""Handle changes to auto layout engine interface in 3.6"""
if hasattr(fig, "set_layout_engine"):
fig.set_layout_engine(engine)
else:
# _version_predates(mpl, 3.6)
if engine == "tight":
fig.set_tight_layout(True) # type: ignore # predates typing
elif engine == "constrained":
fig.set_constrained_layout(True) # type: ignore
elif engine == "none":
fig.set_tight_layout(False) # type: ignore
fig.set_constrained_layout(False) # type: ignore
def get_layout_engine(fig: Figure) -> mpl.layout_engine.LayoutEngine | None:
"""Handle changes to auto layout engine interface in 3.6"""
if hasattr(fig, "get_layout_engine"):
return fig.get_layout_engine()
else:
# _version_predates(mpl, 3.6)
return None
def share_axis(ax0, ax1, which):
"""Handle changes to post-hoc axis sharing."""
if _version_predates(mpl, "3.5"):
group = getattr(ax0, f"get_shared_{which}_axes")()
group.join(ax1, ax0)
else:
getattr(ax1, f"share{which}")(ax0)
def get_legend_handles(legend):
"""Handle legendHandles attribute rename."""
if _version_predates(mpl, "3.7"):
return legend.legendHandles
else:
return legend.legend_handles
def groupby_apply_include_groups(val):
if _version_predates(pd, "2.2.0"):
return {}
return {"include_groups": val}

View File

@@ -0,0 +1,319 @@
"""
Components for parsing variable assignments and internally representing plot data.
"""
from __future__ import annotations
from collections.abc import Mapping, Sized
from typing import cast
import warnings
import pandas as pd
from pandas import DataFrame
from seaborn._core.typing import DataSource, VariableSpec, ColumnName
from seaborn.utils import _version_predates
class PlotData:
"""
Data table with plot variable schema and mapping to original names.
Contains logic for parsing variable specification arguments and updating
the table with layer-specific data and/or mappings.
Parameters
----------
data
Input data where variable names map to vector values.
variables
Keys are names of plot variables (x, y, ...) each value is one of:
- name of a column (or index level, or dictionary entry) in `data`
- vector in any format that can construct a :class:`pandas.DataFrame`
Attributes
----------
frame
Data table with column names having defined plot variables.
names
Dictionary mapping plot variable names to names in source data structure(s).
ids
Dictionary mapping plot variable names to unique data source identifiers.
"""
frame: DataFrame
frames: dict[tuple, DataFrame]
names: dict[str, str | None]
ids: dict[str, str | int]
source_data: DataSource
source_vars: dict[str, VariableSpec]
def __init__(
self,
data: DataSource,
variables: dict[str, VariableSpec],
):
data = handle_data_source(data)
frame, names, ids = self._assign_variables(data, variables)
self.frame = frame
self.names = names
self.ids = ids
# The reason we possibly have a dictionary of frames is to support the
# Plot.pair operation, post scaling, where each x/y variable needs its
# own frame. This feels pretty clumsy and there are a bunch of places in
# the client code with awkard if frame / elif frames constructions.
# It would be great to have a cleaner abstraction here.
self.frames = {}
self.source_data = data
self.source_vars = variables
def __contains__(self, key: str) -> bool:
"""Boolean check on whether a variable is defined in this dataset."""
if self.frame is None:
return any(key in df for df in self.frames.values())
return key in self.frame
def join(
self,
data: DataSource,
variables: dict[str, VariableSpec] | None,
) -> PlotData:
"""Add, replace, or drop variables and return as a new dataset."""
# Inherit the original source of the upstream data by default
if data is None:
data = self.source_data
# TODO allow `data` to be a function (that is called on the source data?)
if not variables:
variables = self.source_vars
# Passing var=None implies that we do not want that variable in this layer
disinherit = [k for k, v in variables.items() if v is None]
# Create a new dataset with just the info passed here
new = PlotData(data, variables)
# -- Update the inherited DataSource with this new information
drop_cols = [k for k in self.frame if k in new.frame or k in disinherit]
parts = [self.frame.drop(columns=drop_cols), new.frame]
# Because we are combining distinct columns, this is perhaps more
# naturally thought of as a "merge"/"join". But using concat because
# some simple testing suggests that it is marginally faster.
frame = pd.concat(parts, axis=1, sort=False, copy=False)
names = {k: v for k, v in self.names.items() if k not in disinherit}
names.update(new.names)
ids = {k: v for k, v in self.ids.items() if k not in disinherit}
ids.update(new.ids)
new.frame = frame
new.names = names
new.ids = ids
# Multiple chained operations should always inherit from the original object
new.source_data = self.source_data
new.source_vars = self.source_vars
return new
def _assign_variables(
self,
data: DataFrame | Mapping | None,
variables: dict[str, VariableSpec],
) -> tuple[DataFrame, dict[str, str | None], dict[str, str | int]]:
"""
Assign values for plot variables given long-form data and/or vector inputs.
Parameters
----------
data
Input data where variable names map to vector values.
variables
Keys are names of plot variables (x, y, ...) each value is one of:
- name of a column (or index level, or dictionary entry) in `data`
- vector in any format that can construct a :class:`pandas.DataFrame`
Returns
-------
frame
Table mapping seaborn variables (x, y, color, ...) to data vectors.
names
Keys are defined seaborn variables; values are names inferred from
the inputs (or None when no name can be determined).
ids
Like the `names` dict, but `None` values are replaced by the `id()`
of the data object that defined the variable.
Raises
------
TypeError
When data source is not a DataFrame or Mapping.
ValueError
When variables are strings that don't appear in `data`, or when they are
non-indexed vector datatypes that have a different length from `data`.
"""
source_data: Mapping | DataFrame
frame: DataFrame
names: dict[str, str | None]
ids: dict[str, str | int]
plot_data = {}
names = {}
ids = {}
given_data = data is not None
if data is None:
# Data is optional; all variables can be defined as vectors
# But simplify downstream code by always having a usable source data object
source_data = {}
else:
source_data = data
# Variables can also be extracted from the index of a DataFrame
if isinstance(source_data, pd.DataFrame):
index = source_data.index.to_frame().to_dict("series")
else:
index = {}
for key, val in variables.items():
# Simply ignore variables with no specification
if val is None:
continue
# Try to treat the argument as a key for the data collection.
# But be flexible about what can be used as a key.
# Usually it will be a string, but allow other hashables when
# taking from the main data object. Allow only strings to reference
# fields in the index, because otherwise there is too much ambiguity.
# TODO this will be rendered unnecessary by the following pandas fix:
# https://github.com/pandas-dev/pandas/pull/41283
try:
hash(val)
val_is_hashable = True
except TypeError:
val_is_hashable = False
val_as_data_key = (
# See https://github.com/pandas-dev/pandas/pull/41283
# (isinstance(val, abc.Hashable) and val in source_data)
(val_is_hashable and val in source_data)
or (isinstance(val, str) and val in index)
)
if val_as_data_key:
val = cast(ColumnName, val)
if val in source_data:
plot_data[key] = source_data[val]
elif val in index:
plot_data[key] = index[val]
names[key] = ids[key] = str(val)
elif isinstance(val, str):
# This looks like a column name but, lookup failed.
err = f"Could not interpret value `{val}` for `{key}`. "
if not given_data:
err += "Value is a string, but `data` was not passed."
else:
err += "An entry with this name does not appear in `data`."
raise ValueError(err)
else:
# Otherwise, assume the value somehow represents data
# Ignore empty data structures
if isinstance(val, Sized) and len(val) == 0:
continue
# If vector has no index, it must match length of data table
if isinstance(data, pd.DataFrame) and not isinstance(val, pd.Series):
if isinstance(val, Sized) and len(data) != len(val):
val_cls = val.__class__.__name__
err = (
f"Length of {val_cls} vectors must match length of `data`"
f" when both are used, but `data` has length {len(data)}"
f" and the vector passed to `{key}` has length {len(val)}."
)
raise ValueError(err)
plot_data[key] = val
# Try to infer the original name using pandas-like metadata
if hasattr(val, "name"):
names[key] = ids[key] = str(val.name) # type: ignore # mypy/1424
else:
names[key] = None
ids[key] = id(val)
# Construct a tidy plot DataFrame. This will convert a number of
# types automatically, aligning on index in case of pandas objects
# TODO Note: this fails when variable specs *only* have scalars!
frame = pd.DataFrame(plot_data)
return frame, names, ids
def handle_data_source(data: object) -> pd.DataFrame | Mapping | None:
"""Convert the data source object to a common union representation."""
if isinstance(data, pd.DataFrame) or hasattr(data, "__dataframe__"):
# Check for pd.DataFrame inheritance could be removed once
# minimal pandas version supports dataframe interchange (1.5.0).
data = convert_dataframe_to_pandas(data)
elif data is not None and not isinstance(data, Mapping):
err = f"Data source must be a DataFrame or Mapping, not {type(data)!r}."
raise TypeError(err)
return data
def convert_dataframe_to_pandas(data: object) -> pd.DataFrame:
"""Use the DataFrame exchange protocol, or fail gracefully."""
if isinstance(data, pd.DataFrame):
return data
if not hasattr(pd.api, "interchange"):
msg = (
"Support for non-pandas DataFrame objects requires a version of pandas "
"that implements the DataFrame interchange protocol. Please upgrade "
"your pandas version or coerce your data to pandas before passing "
"it to seaborn."
)
raise TypeError(msg)
if _version_predates(pd, "2.0.2"):
msg = (
"DataFrame interchange with pandas<2.0.2 has some known issues. "
f"You are using pandas {pd.__version__}. "
"Continuing, but it is recommended to carefully inspect the results and to "
"consider upgrading."
)
warnings.warn(msg, stacklevel=2)
try:
# This is going to convert all columns in the input dataframe, even though
# we may only need one or two of them. It would be more efficient to select
# the columns that are going to be used in the plot prior to interchange.
# Solving that in general is a hard problem, especially with the objects
# interface where variables passed in Plot() may only be referenced later
# in Plot.add(). But noting here in case this seems to be a bottleneck.
return pd.api.interchange.from_dataframe(data)
except Exception as err:
msg = (
"Encountered an exception when converting data source "
"to a pandas DataFrame. See traceback above for details."
)
raise RuntimeError(msg) from err

View File

@@ -0,0 +1,32 @@
"""
Custom exceptions for the seaborn.objects interface.
This is very lightweight, but it's a separate module to avoid circular imports.
"""
from __future__ import annotations
class PlotSpecError(RuntimeError):
"""
Error class raised from seaborn.objects.Plot for compile-time failures.
In the declarative Plot interface, exceptions may not be triggered immediately
by bad user input (and validation at input time may not be possible). This class
is used to signal that indirect dependency. It should be raised in an exception
chain when compile-time operations fail with an error message providing useful
context (e.g., scaling errors could specify the variable that failed.)
"""
@classmethod
def _during(cls, step: str, var: str = "") -> PlotSpecError:
"""
Initialize the class to report the failure of a specific operation.
"""
message = []
if var:
message.append(f"{step} failed for the `{var}` variable.")
else:
message.append(f"{step} failed.")
message.append("See the traceback above for more information.")
return cls(" ".join(message))

View File

@@ -0,0 +1,129 @@
"""Simplified split-apply-combine paradigm on dataframes for internal use."""
from __future__ import annotations
from typing import cast, Iterable
import pandas as pd
from seaborn._core.rules import categorical_order
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Callable
from pandas import DataFrame, MultiIndex, Index
class GroupBy:
"""
Interface for Pandas GroupBy operations allowing specified group order.
Writing our own class to do this has a few advantages:
- It constrains the interface between Plot and Stat/Move objects
- It allows control over the row order of the GroupBy result, which is
important when using in the context of some Move operations (dodge, stack, ...)
- It simplifies some complexities regarding the return type and Index contents
one encounters with Pandas, especially for DataFrame -> DataFrame applies
- It increases future flexibility regarding alternate DataFrame libraries
"""
def __init__(self, order: list[str] | dict[str, list | None]):
"""
Initialize the GroupBy from grouping variables and optional level orders.
Parameters
----------
order
List of variable names or dict mapping names to desired level orders.
Level order values can be None to use default ordering rules. The
variables can include names that are not expected to appear in the
data; these will be dropped before the groups are defined.
"""
if not order:
raise ValueError("GroupBy requires at least one grouping variable")
if isinstance(order, list):
order = {k: None for k in order}
self.order = order
def _get_groups(
self, data: DataFrame
) -> tuple[str | list[str], Index | MultiIndex]:
"""Return index with Cartesian product of ordered grouping variable levels."""
levels = {}
for var, order in self.order.items():
if var in data:
if order is None:
order = categorical_order(data[var])
levels[var] = order
grouper: str | list[str]
groups: Index | MultiIndex
if not levels:
grouper = []
groups = pd.Index([])
elif len(levels) > 1:
grouper = list(levels)
groups = pd.MultiIndex.from_product(levels.values(), names=grouper)
else:
grouper, = list(levels)
groups = pd.Index(levels[grouper], name=grouper)
return grouper, groups
def _reorder_columns(self, res, data):
"""Reorder result columns to match original order with new columns appended."""
cols = [c for c in data if c in res]
cols += [c for c in res if c not in data]
return res.reindex(columns=pd.Index(cols))
def agg(self, data: DataFrame, *args, **kwargs) -> DataFrame:
"""
Reduce each group to a single row in the output.
The output will have a row for each unique combination of the grouping
variable levels with null values for the aggregated variable(s) where
those combinations do not appear in the dataset.
"""
grouper, groups = self._get_groups(data)
if not grouper:
# We will need to see whether there are valid usecases that end up here
raise ValueError("No grouping variables are present in dataframe")
res = (
data
.groupby(grouper, sort=False, observed=False)
.agg(*args, **kwargs)
.reindex(groups)
.reset_index()
.pipe(self._reorder_columns, data)
)
return res
def apply(
self, data: DataFrame, func: Callable[..., DataFrame],
*args, **kwargs,
) -> DataFrame:
"""Apply a DataFrame -> DataFrame mapping to each group."""
grouper, groups = self._get_groups(data)
if not grouper:
return self._reorder_columns(func(data, *args, **kwargs), data)
parts = {}
for key, part_df in data.groupby(grouper, sort=False, observed=False):
parts[key] = func(part_df, *args, **kwargs)
stack = []
for key in groups:
if key in parts:
if isinstance(grouper, list):
# Implies that we had a MultiIndex so key is iterable
group_ids = dict(zip(grouper, cast(Iterable, key)))
else:
group_ids = {grouper: key}
stack.append(parts[key].assign(**group_ids))
res = pd.concat(stack, ignore_index=True)
return self._reorder_columns(res, data)

View File

@@ -0,0 +1,274 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import ClassVar, Callable, Optional, Union, cast
import numpy as np
from pandas import DataFrame
from seaborn._core.groupby import GroupBy
from seaborn._core.scales import Scale
from seaborn._core.typing import Default
default = Default()
@dataclass
class Move:
"""Base class for objects that apply simple positional transforms."""
group_by_orient: ClassVar[bool] = True
def __call__(
self, data: DataFrame, groupby: GroupBy, orient: str, scales: dict[str, Scale],
) -> DataFrame:
raise NotImplementedError
@dataclass
class Jitter(Move):
"""
Random displacement along one or both axes to reduce overplotting.
Parameters
----------
width : float
Magnitude of jitter, relative to mark width, along the orientation axis.
If not provided, the default value will be 0 when `x` or `y` are set, otherwise
there will be a small amount of jitter applied by default.
x : float
Magnitude of jitter, in data units, along the x axis.
y : float
Magnitude of jitter, in data units, along the y axis.
Examples
--------
.. include:: ../docstrings/objects.Jitter.rst
"""
width: float | Default = default
x: float = 0
y: float = 0
seed: int | None = None
def __call__(
self, data: DataFrame, groupby: GroupBy, orient: str, scales: dict[str, Scale],
) -> DataFrame:
data = data.copy()
rng = np.random.default_rng(self.seed)
def jitter(data, col, scale):
noise = rng.uniform(-.5, +.5, len(data))
offsets = noise * scale
return data[col] + offsets
if self.width is default:
width = 0.0 if self.x or self.y else 0.2
else:
width = cast(float, self.width)
if self.width:
data[orient] = jitter(data, orient, width * data["width"])
if self.x:
data["x"] = jitter(data, "x", self.x)
if self.y:
data["y"] = jitter(data, "y", self.y)
return data
@dataclass
class Dodge(Move):
"""
Displacement and narrowing of overlapping marks along orientation axis.
Parameters
----------
empty : {'keep', 'drop', 'fill'}
gap : float
Size of gap between dodged marks.
by : list of variable names
Variables to apply the movement to, otherwise use all.
Examples
--------
.. include:: ../docstrings/objects.Dodge.rst
"""
empty: str = "keep" # Options: keep, drop, fill
gap: float = 0
# TODO accept just a str here?
# TODO should this always be present?
# TODO should the default be an "all" singleton?
by: Optional[list[str]] = None
def __call__(
self, data: DataFrame, groupby: GroupBy, orient: str, scales: dict[str, Scale],
) -> DataFrame:
grouping_vars = [v for v in groupby.order if v in data]
groups = groupby.agg(data, {"width": "max"})
if self.empty == "fill":
groups = groups.dropna()
def groupby_pos(s):
grouper = [groups[v] for v in [orient, "col", "row"] if v in data]
return s.groupby(grouper, sort=False, observed=True)
def scale_widths(w):
# TODO what value to fill missing widths??? Hard problem...
# TODO short circuit this if outer widths has no variance?
empty = 0 if self.empty == "fill" else w.mean()
filled = w.fillna(empty)
scale = filled.max()
norm = filled.sum()
if self.empty == "keep":
w = filled
return w / norm * scale
def widths_to_offsets(w):
return w.shift(1).fillna(0).cumsum() + (w - w.sum()) / 2
new_widths = groupby_pos(groups["width"]).transform(scale_widths)
offsets = groupby_pos(new_widths).transform(widths_to_offsets)
if self.gap:
new_widths *= 1 - self.gap
groups["_dodged"] = groups[orient] + offsets
groups["width"] = new_widths
out = (
data
.drop("width", axis=1)
.merge(groups, on=grouping_vars, how="left")
.drop(orient, axis=1)
.rename(columns={"_dodged": orient})
)
return out
@dataclass
class Stack(Move):
"""
Displacement of overlapping bar or area marks along the value axis.
Examples
--------
.. include:: ../docstrings/objects.Stack.rst
"""
# TODO center? (or should this be a different move, eg. Stream())
def _stack(self, df, orient):
# TODO should stack do something with ymin/ymax style marks?
# Should there be an upstream conversion to baseline/height parameterization?
if df["baseline"].nunique() > 1:
err = "Stack move cannot be used when baselines are already heterogeneous"
raise RuntimeError(err)
other = {"x": "y", "y": "x"}[orient]
stacked_lengths = (df[other] - df["baseline"]).dropna().cumsum()
offsets = stacked_lengths.shift(1).fillna(0)
df[other] = stacked_lengths
df["baseline"] = df["baseline"] + offsets
return df
def __call__(
self, data: DataFrame, groupby: GroupBy, orient: str, scales: dict[str, Scale],
) -> DataFrame:
# TODO where to ensure that other semantic variables are sorted properly?
# TODO why are we not using the passed in groupby here?
groupers = ["col", "row", orient]
return GroupBy(groupers).apply(data, self._stack, orient)
@dataclass
class Shift(Move):
"""
Displacement of all marks with the same magnitude / direction.
Parameters
----------
x, y : float
Magnitude of shift, in data units, along each axis.
Examples
--------
.. include:: ../docstrings/objects.Shift.rst
"""
x: float = 0
y: float = 0
def __call__(
self, data: DataFrame, groupby: GroupBy, orient: str, scales: dict[str, Scale],
) -> DataFrame:
data = data.copy(deep=False)
data["x"] = data["x"] + self.x
data["y"] = data["y"] + self.y
return data
@dataclass
class Norm(Move):
"""
Divisive scaling on the value axis after aggregating within groups.
Parameters
----------
func : str or callable
Function called on each group to define the comparison value.
where : str
Query string defining the subset used to define the comparison values.
by : list of variables
Variables used to define aggregation groups.
percent : bool
If True, multiply the result by 100.
Examples
--------
.. include:: ../docstrings/objects.Norm.rst
"""
func: Union[Callable, str] = "max"
where: Optional[str] = None
by: Optional[list[str]] = None
percent: bool = False
group_by_orient: ClassVar[bool] = False
def _norm(self, df, var):
if self.where is None:
denom_data = df[var]
else:
denom_data = df.query(self.where)[var]
df[var] = df[var] / denom_data.agg(self.func)
if self.percent:
df[var] = df[var] * 100
return df
def __call__(
self, data: DataFrame, groupby: GroupBy, orient: str, scales: dict[str, Scale],
) -> DataFrame:
other = {"x": "y", "y": "x"}[orient]
return groupby.apply(data, self._norm, other)
# TODO
# @dataclass
# class Ridge(Move):
# ...

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,834 @@
from __future__ import annotations
import itertools
import warnings
import numpy as np
from numpy.typing import ArrayLike
from pandas import Series
import matplotlib as mpl
from matplotlib.colors import to_rgb, to_rgba, to_rgba_array
from matplotlib.markers import MarkerStyle
from matplotlib.path import Path
from seaborn._core.scales import Scale, Boolean, Continuous, Nominal, Temporal
from seaborn._core.rules import categorical_order, variable_type
from seaborn.palettes import QUAL_PALETTES, color_palette, blend_palette
from seaborn.utils import get_color_cycle
from typing import Any, Callable, Tuple, List, Union, Optional
RGBTuple = Tuple[float, float, float]
RGBATuple = Tuple[float, float, float, float]
ColorSpec = Union[RGBTuple, RGBATuple, str]
DashPattern = Tuple[float, ...]
DashPatternWithOffset = Tuple[float, Optional[DashPattern]]
MarkerPattern = Union[
float,
str,
Tuple[int, int, float],
List[Tuple[float, float]],
Path,
MarkerStyle,
]
Mapping = Callable[[ArrayLike], ArrayLike]
# =================================================================================== #
# Base classes
# =================================================================================== #
class Property:
"""Base class for visual properties that can be set directly or be data scaling."""
# When True, scales for this property will populate the legend by default
legend = False
# When True, scales for this property normalize data to [0, 1] before mapping
normed = False
def __init__(self, variable: str | None = None):
"""Initialize the property with the name of the corresponding plot variable."""
if not variable:
variable = self.__class__.__name__.lower()
self.variable = variable
def default_scale(self, data: Series) -> Scale:
"""Given data, initialize appropriate scale class."""
var_type = variable_type(data, boolean_type="boolean", strict_boolean=True)
if var_type == "numeric":
return Continuous()
elif var_type == "datetime":
return Temporal()
elif var_type == "boolean":
return Boolean()
else:
return Nominal()
def infer_scale(self, arg: Any, data: Series) -> Scale:
"""Given data and a scaling argument, initialize appropriate scale class."""
# TODO put these somewhere external for validation
# TODO putting this here won't pick it up if subclasses define infer_scale
# (e.g. color). How best to handle that? One option is to call super after
# handling property-specific possibilities (e.g. for color check that the
# arg is not a valid palette name) but that could get tricky.
trans_args = ["log", "symlog", "logit", "pow", "sqrt"]
if isinstance(arg, str):
if any(arg.startswith(k) for k in trans_args):
# TODO validate numeric type? That should happen centrally somewhere
return Continuous(trans=arg)
else:
msg = f"Unknown magic arg for {self.variable} scale: '{arg}'."
raise ValueError(msg)
else:
arg_type = type(arg).__name__
msg = f"Magic arg for {self.variable} scale must be str, not {arg_type}."
raise TypeError(msg)
def get_mapping(self, scale: Scale, data: Series) -> Mapping:
"""Return a function that maps from data domain to property range."""
def identity(x):
return x
return identity
def standardize(self, val: Any) -> Any:
"""Coerce flexible property value to standardized representation."""
return val
def _check_dict_entries(self, levels: list, values: dict) -> None:
"""Input check when values are provided as a dictionary."""
missing = set(levels) - set(values)
if missing:
formatted = ", ".join(map(repr, sorted(missing, key=str)))
err = f"No entry in {self.variable} dictionary for {formatted}"
raise ValueError(err)
def _check_list_length(self, levels: list, values: list) -> list:
"""Input check when values are provided as a list."""
message = ""
if len(levels) > len(values):
message = " ".join([
f"\nThe {self.variable} list has fewer values ({len(values)})",
f"than needed ({len(levels)}) and will cycle, which may",
"produce an uninterpretable plot."
])
values = [x for _, x in zip(levels, itertools.cycle(values))]
elif len(values) > len(levels):
message = " ".join([
f"The {self.variable} list has more values ({len(values)})",
f"than needed ({len(levels)}), which may not be intended.",
])
values = values[:len(levels)]
# TODO look into custom PlotSpecWarning with better formatting
if message:
warnings.warn(message, UserWarning)
return values
# =================================================================================== #
# Properties relating to spatial position of marks on the plotting axes
# =================================================================================== #
class Coordinate(Property):
"""The position of visual marks with respect to the axes of the plot."""
legend = False
normed = False
# =================================================================================== #
# Properties with numeric values where scale range can be defined as an interval
# =================================================================================== #
class IntervalProperty(Property):
"""A numeric property where scale range can be defined as an interval."""
legend = True
normed = True
_default_range: tuple[float, float] = (0, 1)
@property
def default_range(self) -> tuple[float, float]:
"""Min and max values used by default for semantic mapping."""
return self._default_range
def _forward(self, values: ArrayLike) -> ArrayLike:
"""Transform applied to native values before linear mapping into interval."""
return values
def _inverse(self, values: ArrayLike) -> ArrayLike:
"""Transform applied to results of mapping that returns to native values."""
return values
def infer_scale(self, arg: Any, data: Series) -> Scale:
"""Given data and a scaling argument, initialize appropriate scale class."""
# TODO infer continuous based on log/sqrt etc?
var_type = variable_type(data, boolean_type="boolean", strict_boolean=True)
if var_type == "boolean":
return Boolean(arg)
elif isinstance(arg, (list, dict)):
return Nominal(arg)
elif var_type == "categorical":
return Nominal(arg)
elif var_type == "datetime":
return Temporal(arg)
# TODO other variable types
else:
return Continuous(arg)
def get_mapping(self, scale: Scale, data: Series) -> Mapping:
"""Return a function that maps from data domain to property range."""
if isinstance(scale, Nominal):
return self._get_nominal_mapping(scale, data)
elif isinstance(scale, Boolean):
return self._get_boolean_mapping(scale, data)
if scale.values is None:
vmin, vmax = self._forward(self.default_range)
elif isinstance(scale.values, tuple) and len(scale.values) == 2:
vmin, vmax = self._forward(scale.values)
else:
if isinstance(scale.values, tuple):
actual = f"{len(scale.values)}-tuple"
else:
actual = str(type(scale.values))
scale_class = scale.__class__.__name__
err = " ".join([
f"Values for {self.variable} variables with {scale_class} scale",
f"must be 2-tuple; not {actual}.",
])
raise TypeError(err)
def mapping(x):
return self._inverse(np.multiply(x, vmax - vmin) + vmin)
return mapping
def _get_nominal_mapping(self, scale: Nominal, data: Series) -> Mapping:
"""Identify evenly-spaced values using interval or explicit mapping."""
levels = categorical_order(data, scale.order)
values = self._get_values(scale, levels)
def mapping(x):
ixs = np.asarray(x, np.intp)
out = np.full(len(x), np.nan)
use = np.isfinite(x)
out[use] = np.take(values, ixs[use])
return out
return mapping
def _get_boolean_mapping(self, scale: Boolean, data: Series) -> Mapping:
"""Identify evenly-spaced values using interval or explicit mapping."""
values = self._get_values(scale, [True, False])
def mapping(x):
out = np.full(len(x), np.nan)
use = np.isfinite(x)
out[use] = np.where(x[use], *values)
return out
return mapping
def _get_values(self, scale: Scale, levels: list) -> list:
"""Validate scale.values and identify a value for each level."""
if isinstance(scale.values, dict):
self._check_dict_entries(levels, scale.values)
values = [scale.values[x] for x in levels]
elif isinstance(scale.values, list):
values = self._check_list_length(levels, scale.values)
else:
if scale.values is None:
vmin, vmax = self.default_range
elif isinstance(scale.values, tuple):
vmin, vmax = scale.values
else:
scale_class = scale.__class__.__name__
err = " ".join([
f"Values for {self.variable} variables with {scale_class} scale",
f"must be a dict, list or tuple; not {type(scale.values)}",
])
raise TypeError(err)
vmin, vmax = self._forward([vmin, vmax])
values = list(self._inverse(np.linspace(vmax, vmin, len(levels))))
return values
class PointSize(IntervalProperty):
"""Size (diameter) of a point mark, in points, with scaling by area."""
_default_range = 2, 8 # TODO use rcparams?
def _forward(self, values):
"""Square native values to implement linear scaling of point area."""
return np.square(values)
def _inverse(self, values):
"""Invert areal values back to point diameter."""
return np.sqrt(values)
class LineWidth(IntervalProperty):
"""Thickness of a line mark, in points."""
@property
def default_range(self) -> tuple[float, float]:
"""Min and max values used by default for semantic mapping."""
base = mpl.rcParams["lines.linewidth"]
return base * .5, base * 2
class EdgeWidth(IntervalProperty):
"""Thickness of the edges on a patch mark, in points."""
@property
def default_range(self) -> tuple[float, float]:
"""Min and max values used by default for semantic mapping."""
base = mpl.rcParams["patch.linewidth"]
return base * .5, base * 2
class Stroke(IntervalProperty):
"""Thickness of lines that define point glyphs."""
_default_range = .25, 2.5
class Alpha(IntervalProperty):
"""Opacity of the color values for an arbitrary mark."""
_default_range = .3, .95
# TODO validate / enforce that output is in [0, 1]
class Offset(IntervalProperty):
"""Offset for edge-aligned text, in point units."""
_default_range = 0, 5
_legend = False
class FontSize(IntervalProperty):
"""Font size for textual marks, in points."""
_legend = False
@property
def default_range(self) -> tuple[float, float]:
"""Min and max values used by default for semantic mapping."""
base = mpl.rcParams["font.size"]
return base * .5, base * 2
# =================================================================================== #
# Properties defined by arbitrary objects with inherently nominal scaling
# =================================================================================== #
class ObjectProperty(Property):
"""A property defined by arbitrary an object, with inherently nominal scaling."""
legend = True
normed = False
# Object representing null data, should appear invisible when drawn by matplotlib
# Note that we now drop nulls in Plot._plot_layer and thus may not need this
null_value: Any = None
def _default_values(self, n: int) -> list:
raise NotImplementedError()
def default_scale(self, data: Series) -> Scale:
var_type = variable_type(data, boolean_type="boolean", strict_boolean=True)
return Boolean() if var_type == "boolean" else Nominal()
def infer_scale(self, arg: Any, data: Series) -> Scale:
var_type = variable_type(data, boolean_type="boolean", strict_boolean=True)
return Boolean(arg) if var_type == "boolean" else Nominal(arg)
def get_mapping(self, scale: Scale, data: Series) -> Mapping:
"""Define mapping as lookup into list of object values."""
boolean_scale = isinstance(scale, Boolean)
order = getattr(scale, "order", [True, False] if boolean_scale else None)
levels = categorical_order(data, order)
values = self._get_values(scale, levels)
if boolean_scale:
values = values[::-1]
def mapping(x):
ixs = np.asarray(np.nan_to_num(x), np.intp)
return [
values[ix] if np.isfinite(x_i) else self.null_value
for x_i, ix in zip(x, ixs)
]
return mapping
def _get_values(self, scale: Scale, levels: list) -> list:
"""Validate scale.values and identify a value for each level."""
n = len(levels)
if isinstance(scale.values, dict):
self._check_dict_entries(levels, scale.values)
values = [scale.values[x] for x in levels]
elif isinstance(scale.values, list):
values = self._check_list_length(levels, scale.values)
elif scale.values is None:
values = self._default_values(n)
else:
msg = " ".join([
f"Scale values for a {self.variable} variable must be provided",
f"in a dict or list; not {type(scale.values)}."
])
raise TypeError(msg)
values = [self.standardize(x) for x in values]
return values
class Marker(ObjectProperty):
"""Shape of points in scatter-type marks or lines with data points marked."""
null_value = MarkerStyle("")
# TODO should we have named marker "palettes"? (e.g. see d3 options)
# TODO need some sort of "require_scale" functionality
# to raise when we get the wrong kind explicitly specified
def standardize(self, val: MarkerPattern) -> MarkerStyle:
return MarkerStyle(val)
def _default_values(self, n: int) -> list[MarkerStyle]:
"""Build an arbitrarily long list of unique marker styles.
Parameters
----------
n : int
Number of unique marker specs to generate.
Returns
-------
markers : list of string or tuples
Values for defining :class:`matplotlib.markers.MarkerStyle` objects.
All markers will be filled.
"""
# Start with marker specs that are well distinguishable
markers = [
"o", "X", (4, 0, 45), "P", (4, 0, 0), (4, 1, 0), "^", (4, 1, 45), "v",
]
# Now generate more from regular polygons of increasing order
s = 5
while len(markers) < n:
a = 360 / (s + 1) / 2
markers.extend([(s + 1, 1, a), (s + 1, 0, a), (s, 1, 0), (s, 0, 0)])
s += 1
markers = [MarkerStyle(m) for m in markers[:n]]
return markers
class LineStyle(ObjectProperty):
"""Dash pattern for line-type marks."""
null_value = ""
def standardize(self, val: str | DashPattern) -> DashPatternWithOffset:
return self._get_dash_pattern(val)
def _default_values(self, n: int) -> list[DashPatternWithOffset]:
"""Build an arbitrarily long list of unique dash styles for lines.
Parameters
----------
n : int
Number of unique dash specs to generate.
Returns
-------
dashes : list of strings or tuples
Valid arguments for the ``dashes`` parameter on
:class:`matplotlib.lines.Line2D`. The first spec is a solid
line (``""``), the remainder are sequences of long and short
dashes.
"""
# Start with dash specs that are well distinguishable
dashes: list[str | DashPattern] = [
"-", (4, 1.5), (1, 1), (3, 1.25, 1.5, 1.25), (5, 1, 1, 1),
]
# Now programmatically build as many as we need
p = 3
while len(dashes) < n:
# Take combinations of long and short dashes
a = itertools.combinations_with_replacement([3, 1.25], p)
b = itertools.combinations_with_replacement([4, 1], p)
# Interleave the combinations, reversing one of the streams
segment_list = itertools.chain(*zip(list(a)[1:-1][::-1], list(b)[1:-1]))
# Now insert the gaps
for segments in segment_list:
gap = min(segments)
spec = tuple(itertools.chain(*((seg, gap) for seg in segments)))
dashes.append(spec)
p += 1
return [self._get_dash_pattern(x) for x in dashes]
@staticmethod
def _get_dash_pattern(style: str | DashPattern) -> DashPatternWithOffset:
"""Convert linestyle arguments to dash pattern with offset."""
# Copied and modified from Matplotlib 3.4
# go from short hand -> full strings
ls_mapper = {"-": "solid", "--": "dashed", "-.": "dashdot", ":": "dotted"}
if isinstance(style, str):
style = ls_mapper.get(style, style)
# un-dashed styles
if style in ["solid", "none", "None"]:
offset = 0
dashes = None
# dashed styles
elif style in ["dashed", "dashdot", "dotted"]:
offset = 0
dashes = tuple(mpl.rcParams[f"lines.{style}_pattern"])
else:
options = [*ls_mapper.values(), *ls_mapper.keys()]
msg = f"Linestyle string must be one of {options}, not {repr(style)}."
raise ValueError(msg)
elif isinstance(style, tuple):
if len(style) > 1 and isinstance(style[1], tuple):
offset, dashes = style
elif len(style) > 1 and style[1] is None:
offset, dashes = style
else:
offset = 0
dashes = style
else:
val_type = type(style).__name__
msg = f"Linestyle must be str or tuple, not {val_type}."
raise TypeError(msg)
# Normalize offset to be positive and shorter than the dash cycle
if dashes is not None:
try:
dsum = sum(dashes)
except TypeError as err:
msg = f"Invalid dash pattern: {dashes}"
raise TypeError(msg) from err
if dsum:
offset %= dsum
return offset, dashes
class TextAlignment(ObjectProperty):
legend = False
class HorizontalAlignment(TextAlignment):
def _default_values(self, n: int) -> list:
vals = itertools.cycle(["left", "right"])
return [next(vals) for _ in range(n)]
class VerticalAlignment(TextAlignment):
def _default_values(self, n: int) -> list:
vals = itertools.cycle(["top", "bottom"])
return [next(vals) for _ in range(n)]
# =================================================================================== #
# Properties with RGB(A) color values
# =================================================================================== #
class Color(Property):
"""Color, as RGB(A), scalable with nominal palettes or continuous gradients."""
legend = True
normed = True
def standardize(self, val: ColorSpec) -> RGBTuple | RGBATuple:
# Return color with alpha channel only if the input spec has it
# This is so that RGBA colors can override the Alpha property
if to_rgba(val) != to_rgba(val, 1):
return to_rgba(val)
else:
return to_rgb(val)
def _standardize_color_sequence(self, colors: ArrayLike) -> ArrayLike:
"""Convert color sequence to RGB(A) array, preserving but not adding alpha."""
def has_alpha(x):
return to_rgba(x) != to_rgba(x, 1)
if isinstance(colors, np.ndarray):
needs_alpha = colors.shape[1] == 4
else:
needs_alpha = any(has_alpha(x) for x in colors)
if needs_alpha:
return to_rgba_array(colors)
else:
return to_rgba_array(colors)[:, :3]
def infer_scale(self, arg: Any, data: Series) -> Scale:
# TODO when inferring Continuous without data, verify type
# TODO need to rethink the variable type system
# (e.g. boolean, ordered categories as Ordinal, etc)..
var_type = variable_type(data, boolean_type="boolean", strict_boolean=True)
if var_type == "boolean":
return Boolean(arg)
if isinstance(arg, (dict, list)):
return Nominal(arg)
if isinstance(arg, tuple):
if var_type == "categorical":
# TODO It seems reasonable to allow a gradient mapping for nominal
# scale but it also feels "technically" wrong. Should this infer
# Ordinal with categorical data and, if so, verify orderedness?
return Nominal(arg)
return Continuous(arg)
if callable(arg):
return Continuous(arg)
# TODO Do we accept str like "log", "pow", etc. for semantics?
if not isinstance(arg, str):
msg = " ".join([
f"A single scale argument for {self.variable} variables must be",
f"a string, dict, tuple, list, or callable, not {type(arg)}."
])
raise TypeError(msg)
if arg in QUAL_PALETTES:
return Nominal(arg)
elif var_type == "numeric":
return Continuous(arg)
# TODO implement scales for date variables and any others.
else:
return Nominal(arg)
def get_mapping(self, scale: Scale, data: Series) -> Mapping:
"""Return a function that maps from data domain to color values."""
# TODO what is best way to do this conditional?
# Should it be class-based or should classes have behavioral attributes?
if isinstance(scale, Nominal):
return self._get_nominal_mapping(scale, data)
elif isinstance(scale, Boolean):
return self._get_boolean_mapping(scale, data)
if scale.values is None:
# TODO Rethink best default continuous color gradient
mapping = color_palette("ch:", as_cmap=True)
elif isinstance(scale.values, tuple):
# TODO blend_palette will strip alpha, but we should support
# interpolation on all four channels
mapping = blend_palette(scale.values, as_cmap=True)
elif isinstance(scale.values, str):
# TODO for matplotlib colormaps this will clip extremes, which is
# different from what using the named colormap directly would do
# This may or may not be desireable.
mapping = color_palette(scale.values, as_cmap=True)
elif callable(scale.values):
mapping = scale.values
else:
scale_class = scale.__class__.__name__
msg = " ".join([
f"Scale values for {self.variable} with a {scale_class} mapping",
f"must be string, tuple, or callable; not {type(scale.values)}."
])
raise TypeError(msg)
def _mapping(x):
# Remove alpha channel so it does not override alpha property downstream
# TODO this will need to be more flexible to support RGBA tuples (see above)
invalid = ~np.isfinite(x)
out = mapping(x)[:, :3]
out[invalid] = np.nan
return out
return _mapping
def _get_nominal_mapping(self, scale: Nominal, data: Series) -> Mapping:
levels = categorical_order(data, scale.order)
colors = self._get_values(scale, levels)
def mapping(x):
ixs = np.asarray(np.nan_to_num(x), np.intp)
use = np.isfinite(x)
out = np.full((len(ixs), colors.shape[1]), np.nan)
out[use] = np.take(colors, ixs[use], axis=0)
return out
return mapping
def _get_boolean_mapping(self, scale: Boolean, data: Series) -> Mapping:
colors = self._get_values(scale, [True, False])
def mapping(x):
use = np.isfinite(x)
x = np.asarray(np.nan_to_num(x)).astype(bool)
out = np.full((len(x), colors.shape[1]), np.nan)
out[x & use] = colors[0]
out[~x & use] = colors[1]
return out
return mapping
def _get_values(self, scale: Scale, levels: list) -> ArrayLike:
"""Validate scale.values and identify a value for each level."""
n = len(levels)
values = scale.values
if isinstance(values, dict):
self._check_dict_entries(levels, values)
colors = [values[x] for x in levels]
elif isinstance(values, list):
colors = self._check_list_length(levels, values)
elif isinstance(values, tuple):
colors = blend_palette(values, n)
elif isinstance(values, str):
colors = color_palette(values, n)
elif values is None:
if n <= len(get_color_cycle()):
# Use current (global) default palette
colors = color_palette(n_colors=n)
else:
colors = color_palette("husl", n)
else:
scale_class = scale.__class__.__name__
msg = " ".join([
f"Scale values for {self.variable} with a {scale_class} mapping",
f"must be string, list, tuple, or dict; not {type(scale.values)}."
])
raise TypeError(msg)
return self._standardize_color_sequence(colors)
# =================================================================================== #
# Properties that can take only two states
# =================================================================================== #
class Fill(Property):
"""Boolean property of points/bars/patches that can be solid or outlined."""
legend = True
normed = False
def default_scale(self, data: Series) -> Scale:
var_type = variable_type(data, boolean_type="boolean", strict_boolean=True)
return Boolean() if var_type == "boolean" else Nominal()
def infer_scale(self, arg: Any, data: Series) -> Scale:
var_type = variable_type(data, boolean_type="boolean", strict_boolean=True)
return Boolean(arg) if var_type == "boolean" else Nominal(arg)
def standardize(self, val: Any) -> bool:
return bool(val)
def _default_values(self, n: int) -> list:
"""Return a list of n values, alternating True and False."""
if n > 2:
msg = " ".join([
f"The variable assigned to {self.variable} has more than two levels,",
f"so {self.variable} values will cycle and may be uninterpretable",
])
# TODO fire in a "nice" way (see above)
warnings.warn(msg, UserWarning)
return [x for x, _ in zip(itertools.cycle([True, False]), range(n))]
def get_mapping(self, scale: Scale, data: Series) -> Mapping:
"""Return a function that maps each data value to True or False."""
boolean_scale = isinstance(scale, Boolean)
order = getattr(scale, "order", [True, False] if boolean_scale else None)
levels = categorical_order(data, order)
values = self._get_values(scale, levels)
if boolean_scale:
values = values[::-1]
def mapping(x):
ixs = np.asarray(np.nan_to_num(x), np.intp)
return [
values[ix] if np.isfinite(x_i) else False
for x_i, ix in zip(x, ixs)
]
return mapping
def _get_values(self, scale: Scale, levels: list) -> list:
"""Validate scale.values and identify a value for each level."""
if isinstance(scale.values, list):
values = [bool(x) for x in scale.values]
elif isinstance(scale.values, dict):
values = [bool(scale.values[x]) for x in levels]
elif scale.values is None:
values = self._default_values(len(levels))
else:
msg = " ".join([
f"Scale values for {self.variable} must be passed in",
f"a list or dict; not {type(scale.values)}."
])
raise TypeError(msg)
return values
# =================================================================================== #
# Enumeration of properties for use by Plot and Mark classes
# =================================================================================== #
# TODO turn this into a property registry with hooks, etc.
# TODO Users do not interact directly with properties, so how to document them?
PROPERTY_CLASSES = {
"x": Coordinate,
"y": Coordinate,
"color": Color,
"alpha": Alpha,
"fill": Fill,
"marker": Marker,
"pointsize": PointSize,
"stroke": Stroke,
"linewidth": LineWidth,
"linestyle": LineStyle,
"fillcolor": Color,
"fillalpha": Alpha,
"edgewidth": EdgeWidth,
"edgestyle": LineStyle,
"edgecolor": Color,
"edgealpha": Alpha,
"text": Property,
"halign": HorizontalAlignment,
"valign": VerticalAlignment,
"offset": Offset,
"fontsize": FontSize,
"xmin": Coordinate,
"xmax": Coordinate,
"ymin": Coordinate,
"ymax": Coordinate,
"group": Property,
# TODO pattern?
# TODO gradient?
}
PROPERTIES = {var: cls(var) for var, cls in PROPERTY_CLASSES.items()}

View File

@@ -0,0 +1,173 @@
from __future__ import annotations
import warnings
from collections import UserString
from numbers import Number
from datetime import datetime
import numpy as np
import pandas as pd
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Literal
from pandas import Series
class VarType(UserString):
"""
Prevent comparisons elsewhere in the library from using the wrong name.
Errors are simple assertions because users should not be able to trigger
them. If that changes, they should be more verbose.
"""
# TODO VarType is an awfully overloaded name, but so is DataType ...
# TODO adding unknown because we are using this in for scales, is that right?
allowed = "numeric", "datetime", "categorical", "boolean", "unknown"
def __init__(self, data):
assert data in self.allowed, data
super().__init__(data)
def __eq__(self, other):
assert other in self.allowed, other
return self.data == other
def variable_type(
vector: Series,
boolean_type: Literal["numeric", "categorical", "boolean"] = "numeric",
strict_boolean: bool = False,
) -> VarType:
"""
Determine whether a vector contains numeric, categorical, or datetime data.
This function differs from the pandas typing API in a few ways:
- Python sequences or object-typed PyData objects are considered numeric if
all of their entries are numeric.
- String or mixed-type data are considered categorical even if not
explicitly represented as a :class:`pandas.api.types.CategoricalDtype`.
- There is some flexibility about how to treat binary / boolean data.
Parameters
----------
vector : :func:`pandas.Series`, :func:`numpy.ndarray`, or Python sequence
Input data to test.
boolean_type : 'numeric', 'categorical', or 'boolean'
Type to use for vectors containing only 0s and 1s (and NAs).
strict_boolean : bool
If True, only consider data to be boolean when the dtype is bool or Boolean.
Returns
-------
var_type : 'numeric', 'categorical', or 'datetime'
Name identifying the type of data in the vector.
"""
# If a categorical dtype is set, infer categorical
if isinstance(getattr(vector, 'dtype', None), pd.CategoricalDtype):
return VarType("categorical")
# Special-case all-na data, which is always "numeric"
if pd.isna(vector).all():
return VarType("numeric")
# Now drop nulls to simplify further type inference
vector = vector.dropna()
# Special-case binary/boolean data, allow caller to determine
# This triggers a numpy warning when vector has strings/objects
# https://github.com/numpy/numpy/issues/6784
# Because we reduce with .all(), we are agnostic about whether the
# comparison returns a scalar or vector, so we will ignore the warning.
# It triggers a separate DeprecationWarning when the vector has datetimes:
# https://github.com/numpy/numpy/issues/13548
# This is considered a bug by numpy and will likely go away.
with warnings.catch_warnings():
warnings.simplefilter(
action='ignore',
category=(FutureWarning, DeprecationWarning) # type: ignore # mypy bug?
)
if strict_boolean:
if isinstance(vector.dtype, pd.core.dtypes.base.ExtensionDtype):
boolean_dtypes = ["bool", "boolean"]
else:
boolean_dtypes = ["bool"]
boolean_vector = vector.dtype in boolean_dtypes
else:
try:
boolean_vector = bool(np.isin(vector, [0, 1]).all())
except TypeError:
# .isin comparison is not guaranteed to be possible under NumPy
# casting rules, depending on the (unknown) dtype of 'vector'
boolean_vector = False
if boolean_vector:
return VarType(boolean_type)
# Defer to positive pandas tests
if pd.api.types.is_numeric_dtype(vector):
return VarType("numeric")
if pd.api.types.is_datetime64_dtype(vector):
return VarType("datetime")
# --- If we get to here, we need to check the entries
# Check for a collection where everything is a number
def all_numeric(x):
for x_i in x:
if not isinstance(x_i, Number):
return False
return True
if all_numeric(vector):
return VarType("numeric")
# Check for a collection where everything is a datetime
def all_datetime(x):
for x_i in x:
if not isinstance(x_i, (datetime, np.datetime64)):
return False
return True
if all_datetime(vector):
return VarType("datetime")
# Otherwise, our final fallback is to consider things categorical
return VarType("categorical")
def categorical_order(vector: Series, order: list | None = None) -> list:
"""
Return a list of unique data values using seaborn's ordering rules.
Parameters
----------
vector : Series
Vector of "categorical" values
order : list
Desired order of category levels to override the order determined
from the `data` object.
Returns
-------
order : list
Ordered list of category levels not including null values.
"""
if order is not None:
return order
if vector.dtype.name == "category":
order = list(vector.cat.categories)
else:
order = list(filter(pd.notnull, vector.unique()))
if variable_type(pd.Series(order)) == "numeric":
order.sort()
return order

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,263 @@
from __future__ import annotations
from collections.abc import Generator
import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt
from matplotlib.axes import Axes
from matplotlib.figure import Figure
from typing import TYPE_CHECKING
if TYPE_CHECKING: # TODO move to seaborn._core.typing?
from seaborn._core.plot import FacetSpec, PairSpec
from matplotlib.figure import SubFigure
class Subplots:
"""
Interface for creating and using matplotlib subplots based on seaborn parameters.
Parameters
----------
subplot_spec : dict
Keyword args for :meth:`matplotlib.figure.Figure.subplots`.
facet_spec : dict
Parameters that control subplot faceting.
pair_spec : dict
Parameters that control subplot pairing.
data : PlotData
Data used to define figure setup.
"""
def __init__(
self,
subplot_spec: dict, # TODO define as TypedDict
facet_spec: FacetSpec,
pair_spec: PairSpec,
):
self.subplot_spec = subplot_spec
self._check_dimension_uniqueness(facet_spec, pair_spec)
self._determine_grid_dimensions(facet_spec, pair_spec)
self._handle_wrapping(facet_spec, pair_spec)
self._determine_axis_sharing(pair_spec)
def _check_dimension_uniqueness(
self, facet_spec: FacetSpec, pair_spec: PairSpec
) -> None:
"""Reject specs that pair and facet on (or wrap to) same figure dimension."""
err = None
facet_vars = facet_spec.get("variables", {})
if facet_spec.get("wrap") and {"col", "row"} <= set(facet_vars):
err = "Cannot wrap facets when specifying both `col` and `row`."
elif (
pair_spec.get("wrap")
and pair_spec.get("cross", True)
and len(pair_spec.get("structure", {}).get("x", [])) > 1
and len(pair_spec.get("structure", {}).get("y", [])) > 1
):
err = "Cannot wrap subplots when pairing on both `x` and `y`."
collisions = {"x": ["columns", "rows"], "y": ["rows", "columns"]}
for pair_axis, (multi_dim, wrap_dim) in collisions.items():
if pair_axis not in pair_spec.get("structure", {}):
continue
elif multi_dim[:3] in facet_vars:
err = f"Cannot facet the {multi_dim} while pairing on `{pair_axis}``."
elif wrap_dim[:3] in facet_vars and facet_spec.get("wrap"):
err = f"Cannot wrap the {wrap_dim} while pairing on `{pair_axis}``."
elif wrap_dim[:3] in facet_vars and pair_spec.get("wrap"):
err = f"Cannot wrap the {multi_dim} while faceting the {wrap_dim}."
if err is not None:
raise RuntimeError(err) # TODO what err class? Define PlotSpecError?
def _determine_grid_dimensions(
self, facet_spec: FacetSpec, pair_spec: PairSpec
) -> None:
"""Parse faceting and pairing information to define figure structure."""
self.grid_dimensions: dict[str, list] = {}
for dim, axis in zip(["col", "row"], ["x", "y"]):
facet_vars = facet_spec.get("variables", {})
if dim in facet_vars:
self.grid_dimensions[dim] = facet_spec["structure"][dim]
elif axis in pair_spec.get("structure", {}):
self.grid_dimensions[dim] = [
None for _ in pair_spec.get("structure", {})[axis]
]
else:
self.grid_dimensions[dim] = [None]
self.subplot_spec[f"n{dim}s"] = len(self.grid_dimensions[dim])
if not pair_spec.get("cross", True):
self.subplot_spec["nrows"] = 1
self.n_subplots = self.subplot_spec["ncols"] * self.subplot_spec["nrows"]
def _handle_wrapping(
self, facet_spec: FacetSpec, pair_spec: PairSpec
) -> None:
"""Update figure structure parameters based on facet/pair wrapping."""
self.wrap = wrap = facet_spec.get("wrap") or pair_spec.get("wrap")
if not wrap:
return
wrap_dim = "row" if self.subplot_spec["nrows"] > 1 else "col"
flow_dim = {"row": "col", "col": "row"}[wrap_dim]
n_subplots = self.subplot_spec[f"n{wrap_dim}s"]
flow = int(np.ceil(n_subplots / wrap))
if wrap < self.subplot_spec[f"n{wrap_dim}s"]:
self.subplot_spec[f"n{wrap_dim}s"] = wrap
self.subplot_spec[f"n{flow_dim}s"] = flow
self.n_subplots = n_subplots
self.wrap_dim = wrap_dim
def _determine_axis_sharing(self, pair_spec: PairSpec) -> None:
"""Update subplot spec with default or specified axis sharing parameters."""
axis_to_dim = {"x": "col", "y": "row"}
key: str
val: str | bool
for axis in "xy":
key = f"share{axis}"
# Always use user-specified value, if present
if key not in self.subplot_spec:
if axis in pair_spec.get("structure", {}):
# Paired axes are shared along one dimension by default
if self.wrap is None and pair_spec.get("cross", True):
val = axis_to_dim[axis]
else:
val = False
else:
# This will pick up faceted plots, as well as single subplot
# figures, where the value doesn't really matter
val = True
self.subplot_spec[key] = val
def init_figure(
self,
pair_spec: PairSpec,
pyplot: bool = False,
figure_kws: dict | None = None,
target: Axes | Figure | SubFigure | None = None,
) -> Figure:
"""Initialize matplotlib objects and add seaborn-relevant metadata."""
# TODO reduce need to pass pair_spec here?
if figure_kws is None:
figure_kws = {}
if isinstance(target, mpl.axes.Axes):
if max(self.subplot_spec["nrows"], self.subplot_spec["ncols"]) > 1:
err = " ".join([
"Cannot create multiple subplots after calling `Plot.on` with",
f"a {mpl.axes.Axes} object.",
f" You may want to use a {mpl.figure.SubFigure} instead.",
])
raise RuntimeError(err)
self._subplot_list = [{
"ax": target,
"left": True,
"right": True,
"top": True,
"bottom": True,
"col": None,
"row": None,
"x": "x",
"y": "y",
}]
self._figure = target.figure
return self._figure
elif isinstance(target, mpl.figure.SubFigure):
figure = target.figure
elif isinstance(target, mpl.figure.Figure):
figure = target
else:
if pyplot:
figure = plt.figure(**figure_kws)
else:
figure = mpl.figure.Figure(**figure_kws)
target = figure
self._figure = figure
axs = target.subplots(**self.subplot_spec, squeeze=False)
if self.wrap:
# Remove unused Axes and flatten the rest into a (2D) vector
axs_flat = axs.ravel({"col": "C", "row": "F"}[self.wrap_dim])
axs, extra = np.split(axs_flat, [self.n_subplots])
for ax in extra:
ax.remove()
if self.wrap_dim == "col":
axs = axs[np.newaxis, :]
else:
axs = axs[:, np.newaxis]
# Get i, j coordinates for each Axes object
# Note that i, j are with respect to faceting/pairing,
# not the subplot grid itself, (which only matters in the case of wrapping).
iter_axs: np.ndenumerate | zip
if not pair_spec.get("cross", True):
indices = np.arange(self.n_subplots)
iter_axs = zip(zip(indices, indices), axs.flat)
else:
iter_axs = np.ndenumerate(axs)
self._subplot_list = []
for (i, j), ax in iter_axs:
info = {"ax": ax}
nrows, ncols = self.subplot_spec["nrows"], self.subplot_spec["ncols"]
if not self.wrap:
info["left"] = j % ncols == 0
info["right"] = (j + 1) % ncols == 0
info["top"] = i == 0
info["bottom"] = i == nrows - 1
elif self.wrap_dim == "col":
info["left"] = j % ncols == 0
info["right"] = ((j + 1) % ncols == 0) or ((j + 1) == self.n_subplots)
info["top"] = j < ncols
info["bottom"] = j >= (self.n_subplots - ncols)
elif self.wrap_dim == "row":
info["left"] = i < nrows
info["right"] = i >= self.n_subplots - nrows
info["top"] = i % nrows == 0
info["bottom"] = ((i + 1) % nrows == 0) or ((i + 1) == self.n_subplots)
if not pair_spec.get("cross", True):
info["top"] = j < ncols
info["bottom"] = j >= self.n_subplots - ncols
for dim in ["row", "col"]:
idx = {"row": i, "col": j}[dim]
info[dim] = self.grid_dimensions[dim][idx]
for axis in "xy":
idx = {"x": j, "y": i}[axis]
if axis in pair_spec.get("structure", {}):
key = f"{axis}{idx}"
else:
key = axis
info[axis] = key
self._subplot_list.append(info)
return figure
def __iter__(self) -> Generator[dict, None, None]: # TODO TypedDict?
"""Yield each subplot dictionary with Axes object and metadata."""
yield from self._subplot_list
def __len__(self) -> int:
"""Return the number of subplots in this figure."""
return len(self._subplot_list)

View File

@@ -0,0 +1,49 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping
from datetime import date, datetime, timedelta
from typing import Any, Optional, Union, Tuple, List, Dict
from numpy import ndarray # TODO use ArrayLike?
from pandas import Series, Index, Timestamp, Timedelta
from matplotlib.colors import Colormap, Normalize
ColumnName = Union[
str, bytes, date, datetime, timedelta, bool, complex, Timestamp, Timedelta
]
Vector = Union[Series, Index, ndarray]
VariableSpec = Union[ColumnName, Vector, None]
VariableSpecList = Union[List[VariableSpec], Index, None]
# A DataSource can be an object implementing __dataframe__, or a Mapping
# (and is optional in all contexts where it is used).
# I don't think there's an abc for "has __dataframe__", so we type as object
# but keep the (slightly odd) Union alias for better user-facing annotations.
DataSource = Union[object, Mapping, None]
OrderSpec = Union[Iterable, None] # TODO technically str is iterable
NormSpec = Union[Tuple[Optional[float], Optional[float]], Normalize, None]
# TODO for discrete mappings, it would be ideal to use a parameterized type
# as the dict values / list entries should be of specific type(s) for each method
PaletteSpec = Union[str, list, dict, Colormap, None]
DiscreteValueSpec = Union[dict, list, None]
ContinuousValueSpec = Union[
Tuple[float, float], List[float], Dict[Any, float], None,
]
class Default:
def __repr__(self):
return "<default>"
class Deprecated:
def __repr__(self):
return "<deprecated>"
default = Default()
deprecated = Deprecated()

View File

@@ -0,0 +1,198 @@
import re
import pydoc
from .external.docscrape import NumpyDocString
class DocstringComponents:
regexp = re.compile(r"\n((\n|.)+)\n\s*", re.MULTILINE)
def __init__(self, comp_dict, strip_whitespace=True):
"""Read entries from a dict, optionally stripping outer whitespace."""
if strip_whitespace:
entries = {}
for key, val in comp_dict.items():
m = re.match(self.regexp, val)
if m is None:
entries[key] = val
else:
entries[key] = m.group(1)
else:
entries = comp_dict.copy()
self.entries = entries
def __getattr__(self, attr):
"""Provide dot access to entries for clean raw docstrings."""
if attr in self.entries:
return self.entries[attr]
else:
try:
return self.__getattribute__(attr)
except AttributeError as err:
# If Python is run with -OO, it will strip docstrings and our lookup
# from self.entries will fail. We check for __debug__, which is actually
# set to False by -O (it is True for normal execution).
# But we only want to see an error when building the docs;
# not something users should see, so this slight inconsistency is fine.
if __debug__:
raise err
else:
pass
@classmethod
def from_nested_components(cls, **kwargs):
"""Add multiple sub-sets of components."""
return cls(kwargs, strip_whitespace=False)
@classmethod
def from_function_params(cls, func):
"""Use the numpydoc parser to extract components from existing func."""
params = NumpyDocString(pydoc.getdoc(func))["Parameters"]
comp_dict = {}
for p in params:
name = p.name
type = p.type
desc = "\n ".join(p.desc)
comp_dict[name] = f"{name} : {type}\n {desc}"
return cls(comp_dict)
# TODO is "vector" the best term here? We mean to imply 1D data with a variety
# of types?
# TODO now that we can parse numpydoc style strings, do we need to define dicts
# of docstring components, or just write out a docstring?
_core_params = dict(
data="""
data : :class:`pandas.DataFrame`, :class:`numpy.ndarray`, mapping, or sequence
Input data structure. Either a long-form collection of vectors that can be
assigned to named variables or a wide-form dataset that will be internally
reshaped.
""", # TODO add link to user guide narrative when exists
xy="""
x, y : vectors or keys in ``data``
Variables that specify positions on the x and y axes.
""",
hue="""
hue : vector or key in ``data``
Semantic variable that is mapped to determine the color of plot elements.
""",
palette="""
palette : string, list, dict, or :class:`matplotlib.colors.Colormap`
Method for choosing the colors to use when mapping the ``hue`` semantic.
String values are passed to :func:`color_palette`. List or dict values
imply categorical mapping, while a colormap object implies numeric mapping.
""", # noqa: E501
hue_order="""
hue_order : vector of strings
Specify the order of processing and plotting for categorical levels of the
``hue`` semantic.
""",
hue_norm="""
hue_norm : tuple or :class:`matplotlib.colors.Normalize`
Either a pair of values that set the normalization range in data units
or an object that will map from data units into a [0, 1] interval. Usage
implies numeric mapping.
""",
color="""
color : :mod:`matplotlib color <matplotlib.colors>`
Single color specification for when hue mapping is not used. Otherwise, the
plot will try to hook into the matplotlib property cycle.
""",
ax="""
ax : :class:`matplotlib.axes.Axes`
Pre-existing axes for the plot. Otherwise, call :func:`matplotlib.pyplot.gca`
internally.
""", # noqa: E501
)
_core_returns = dict(
ax="""
:class:`matplotlib.axes.Axes`
The matplotlib axes containing the plot.
""",
facetgrid="""
:class:`FacetGrid`
An object managing one or more subplots that correspond to conditional data
subsets with convenient methods for batch-setting of axes attributes.
""",
jointgrid="""
:class:`JointGrid`
An object managing multiple subplots that correspond to joint and marginal axes
for plotting a bivariate relationship or distribution.
""",
pairgrid="""
:class:`PairGrid`
An object managing multiple subplots that correspond to joint and marginal axes
for pairwise combinations of multiple variables in a dataset.
""",
)
_seealso_blurbs = dict(
# Relational plots
scatterplot="""
scatterplot : Plot data using points.
""",
lineplot="""
lineplot : Plot data using lines.
""",
# Distribution plots
displot="""
displot : Figure-level interface to distribution plot functions.
""",
histplot="""
histplot : Plot a histogram of binned counts with optional normalization or smoothing.
""",
kdeplot="""
kdeplot : Plot univariate or bivariate distributions using kernel density estimation.
""",
ecdfplot="""
ecdfplot : Plot empirical cumulative distribution functions.
""",
rugplot="""
rugplot : Plot a tick at each observation value along the x and/or y axes.
""",
# Categorical plots
stripplot="""
stripplot : Plot a categorical scatter with jitter.
""",
swarmplot="""
swarmplot : Plot a categorical scatter with non-overlapping points.
""",
violinplot="""
violinplot : Draw an enhanced boxplot using kernel density estimation.
""",
pointplot="""
pointplot : Plot point estimates and CIs using markers and lines.
""",
# Multiples
jointplot="""
jointplot : Draw a bivariate plot with univariate marginal distributions.
""",
pairplot="""
jointplot : Draw multiple bivariate plots with univariate marginal distributions.
""",
jointgrid="""
JointGrid : Set up a figure with joint and marginal views on bivariate data.
""",
pairgrid="""
PairGrid : Set up a figure with joint and marginal views on multiple variables.
""",
)
_core_docs = dict(
params=DocstringComponents(_core_params),
returns=DocstringComponents(_core_returns),
seealso=DocstringComponents(_seealso_blurbs),
)

View File

@@ -0,0 +1,170 @@
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
import numpy as np
import matplotlib as mpl
from seaborn._marks.base import (
Mark,
Mappable,
MappableBool,
MappableFloat,
MappableColor,
MappableStyle,
resolve_properties,
resolve_color,
document_properties,
)
class AreaBase:
def _plot(self, split_gen, scales, orient):
patches = defaultdict(list)
for keys, data, ax in split_gen():
kws = {}
data = self._standardize_coordinate_parameters(data, orient)
resolved = resolve_properties(self, keys, scales)
verts = self._get_verts(data, orient)
ax.update_datalim(verts)
# TODO should really move this logic into resolve_color
fc = resolve_color(self, keys, "", scales)
if not resolved["fill"]:
fc = mpl.colors.to_rgba(fc, 0)
kws["facecolor"] = fc
kws["edgecolor"] = resolve_color(self, keys, "edge", scales)
kws["linewidth"] = resolved["edgewidth"]
kws["linestyle"] = resolved["edgestyle"]
patches[ax].append(mpl.patches.Polygon(verts, **kws))
for ax, ax_patches in patches.items():
for patch in ax_patches:
self._postprocess_artist(patch, ax, orient)
ax.add_patch(patch)
def _standardize_coordinate_parameters(self, data, orient):
return data
def _postprocess_artist(self, artist, ax, orient):
pass
def _get_verts(self, data, orient):
dv = {"x": "y", "y": "x"}[orient]
data = data.sort_values(orient, kind="mergesort")
verts = np.concatenate([
data[[orient, f"{dv}min"]].to_numpy(),
data[[orient, f"{dv}max"]].to_numpy()[::-1],
])
if orient == "y":
verts = verts[:, ::-1]
return verts
def _legend_artist(self, variables, value, scales):
keys = {v: value for v in variables}
resolved = resolve_properties(self, keys, scales)
fc = resolve_color(self, keys, "", scales)
if not resolved["fill"]:
fc = mpl.colors.to_rgba(fc, 0)
return mpl.patches.Patch(
facecolor=fc,
edgecolor=resolve_color(self, keys, "edge", scales),
linewidth=resolved["edgewidth"],
linestyle=resolved["edgestyle"],
**self.artist_kws,
)
@document_properties
@dataclass
class Area(AreaBase, Mark):
"""
A fill mark drawn from a baseline to data values.
See also
--------
Band : A fill mark representing an interval between values.
Examples
--------
.. include:: ../docstrings/objects.Area.rst
"""
color: MappableColor = Mappable("C0", )
alpha: MappableFloat = Mappable(.2, )
fill: MappableBool = Mappable(True, )
edgecolor: MappableColor = Mappable(depend="color")
edgealpha: MappableFloat = Mappable(1, )
edgewidth: MappableFloat = Mappable(rc="patch.linewidth", )
edgestyle: MappableStyle = Mappable("-", )
# TODO should this be settable / mappable?
baseline: MappableFloat = Mappable(0, grouping=False)
def _standardize_coordinate_parameters(self, data, orient):
dv = {"x": "y", "y": "x"}[orient]
return data.rename(columns={"baseline": f"{dv}min", dv: f"{dv}max"})
def _postprocess_artist(self, artist, ax, orient):
# TODO copying a lot of code from Bar, let's abstract this
# See comments there, I am not going to repeat them too
artist.set_linewidth(artist.get_linewidth() * 2)
linestyle = artist.get_linestyle()
if linestyle[1]:
linestyle = (linestyle[0], tuple(x / 2 for x in linestyle[1]))
artist.set_linestyle(linestyle)
artist.set_clip_path(artist.get_path(), artist.get_transform() + ax.transData)
if self.artist_kws.get("clip_on", True):
artist.set_clip_box(ax.bbox)
val_idx = ["y", "x"].index(orient)
artist.sticky_edges[val_idx][:] = (0, np.inf)
@document_properties
@dataclass
class Band(AreaBase, Mark):
"""
A fill mark representing an interval between values.
See also
--------
Area : A fill mark drawn from a baseline to data values.
Examples
--------
.. include:: ../docstrings/objects.Band.rst
"""
color: MappableColor = Mappable("C0", )
alpha: MappableFloat = Mappable(.2, )
fill: MappableBool = Mappable(True, )
edgecolor: MappableColor = Mappable(depend="color", )
edgealpha: MappableFloat = Mappable(1, )
edgewidth: MappableFloat = Mappable(0, )
edgestyle: MappableFloat = Mappable("-", )
def _standardize_coordinate_parameters(self, data, orient):
# dv = {"x": "y", "y": "x"}[orient]
# TODO assert that all(ymax >= ymin)?
# TODO what if only one exist?
other = {"x": "y", "y": "x"}[orient]
if not set(data.columns) & {f"{other}min", f"{other}max"}:
agg = {f"{other}min": (other, "min"), f"{other}max": (other, "max")}
data = data.groupby(orient).agg(**agg).reset_index()
return data

View File

@@ -0,0 +1,252 @@
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
import numpy as np
import matplotlib as mpl
from seaborn._marks.base import (
Mark,
Mappable,
MappableBool,
MappableColor,
MappableFloat,
MappableStyle,
resolve_properties,
resolve_color,
document_properties
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from matplotlib.artist import Artist
from seaborn._core.scales import Scale
class BarBase(Mark):
def _make_patches(self, data, scales, orient):
transform = scales[orient]._matplotlib_scale.get_transform()
forward = transform.transform
reverse = transform.inverted().transform
other = {"x": "y", "y": "x"}[orient]
pos = reverse(forward(data[orient]) - data["width"] / 2)
width = reverse(forward(data[orient]) + data["width"] / 2) - pos
val = (data[other] - data["baseline"]).to_numpy()
base = data["baseline"].to_numpy()
kws = self._resolve_properties(data, scales)
if orient == "x":
kws.update(x=pos, y=base, w=width, h=val)
else:
kws.update(x=base, y=pos, w=val, h=width)
kws.pop("width", None)
kws.pop("baseline", None)
val_dim = {"x": "h", "y": "w"}[orient]
bars, vals = [], []
for i in range(len(data)):
row = {k: v[i] for k, v in kws.items()}
# Skip bars with no value. It's possible we'll want to make this
# an option (i.e so you have an artist for animating or annotating),
# but let's keep things simple for now.
if not np.nan_to_num(row[val_dim]):
continue
bar = mpl.patches.Rectangle(
xy=(row["x"], row["y"]),
width=row["w"],
height=row["h"],
facecolor=row["facecolor"],
edgecolor=row["edgecolor"],
linestyle=row["edgestyle"],
linewidth=row["edgewidth"],
**self.artist_kws,
)
bars.append(bar)
vals.append(row[val_dim])
return bars, vals
def _resolve_properties(self, data, scales):
resolved = resolve_properties(self, data, scales)
resolved["facecolor"] = resolve_color(self, data, "", scales)
resolved["edgecolor"] = resolve_color(self, data, "edge", scales)
fc = resolved["facecolor"]
if isinstance(fc, tuple):
resolved["facecolor"] = fc[0], fc[1], fc[2], fc[3] * resolved["fill"]
else:
fc[:, 3] = fc[:, 3] * resolved["fill"] # TODO Is inplace mod a problem?
resolved["facecolor"] = fc
return resolved
def _legend_artist(
self, variables: list[str], value: Any, scales: dict[str, Scale],
) -> Artist:
# TODO return some sensible default?
key = {v: value for v in variables}
key = self._resolve_properties(key, scales)
artist = mpl.patches.Patch(
facecolor=key["facecolor"],
edgecolor=key["edgecolor"],
linewidth=key["edgewidth"],
linestyle=key["edgestyle"],
)
return artist
@document_properties
@dataclass
class Bar(BarBase):
"""
A bar mark drawn between baseline and data values.
See also
--------
Bars : A faster bar mark with defaults more suitable for histograms.
Examples
--------
.. include:: ../docstrings/objects.Bar.rst
"""
color: MappableColor = Mappable("C0", grouping=False)
alpha: MappableFloat = Mappable(.7, grouping=False)
fill: MappableBool = Mappable(True, grouping=False)
edgecolor: MappableColor = Mappable(depend="color", grouping=False)
edgealpha: MappableFloat = Mappable(1, grouping=False)
edgewidth: MappableFloat = Mappable(rc="patch.linewidth", grouping=False)
edgestyle: MappableStyle = Mappable("-", grouping=False)
# pattern: MappableString = Mappable(None) # TODO no Property yet
width: MappableFloat = Mappable(.8, grouping=False)
baseline: MappableFloat = Mappable(0, grouping=False) # TODO *is* this mappable?
def _plot(self, split_gen, scales, orient):
val_idx = ["y", "x"].index(orient)
for _, data, ax in split_gen():
bars, vals = self._make_patches(data, scales, orient)
for bar in bars:
# Because we are clipping the artist (see below), the edges end up
# looking half as wide as they actually are. I don't love this clumsy
# workaround, which is going to cause surprises if you work with the
# artists directly. We may need to revisit after feedback.
bar.set_linewidth(bar.get_linewidth() * 2)
linestyle = bar.get_linestyle()
if linestyle[1]:
linestyle = (linestyle[0], tuple(x / 2 for x in linestyle[1]))
bar.set_linestyle(linestyle)
# This is a bit of a hack to handle the fact that the edge lines are
# centered on the actual extents of the bar, and overlap when bars are
# stacked or dodged. We may discover that this causes problems and needs
# to be revisited at some point. Also it should be faster to clip with
# a bbox than a path, but I cant't work out how to get the intersection
# with the axes bbox.
bar.set_clip_path(bar.get_path(), bar.get_transform() + ax.transData)
if self.artist_kws.get("clip_on", True):
# It seems the above hack undoes the default axes clipping
bar.set_clip_box(ax.bbox)
bar.sticky_edges[val_idx][:] = (0, np.inf)
ax.add_patch(bar)
# Add a container which is useful for, e.g. Axes.bar_label
orientation = {"x": "vertical", "y": "horizontal"}[orient]
container_kws = dict(datavalues=vals, orientation=orientation)
container = mpl.container.BarContainer(bars, **container_kws)
ax.add_container(container)
@document_properties
@dataclass
class Bars(BarBase):
"""
A faster bar mark with defaults more suitable for histograms.
See also
--------
Bar : A bar mark drawn between baseline and data values.
Examples
--------
.. include:: ../docstrings/objects.Bars.rst
"""
color: MappableColor = Mappable("C0", grouping=False)
alpha: MappableFloat = Mappable(.7, grouping=False)
fill: MappableBool = Mappable(True, grouping=False)
edgecolor: MappableColor = Mappable(rc="patch.edgecolor", grouping=False)
edgealpha: MappableFloat = Mappable(1, grouping=False)
edgewidth: MappableFloat = Mappable(auto=True, grouping=False)
edgestyle: MappableStyle = Mappable("-", grouping=False)
# pattern: MappableString = Mappable(None) # TODO no Property yet
width: MappableFloat = Mappable(1, grouping=False)
baseline: MappableFloat = Mappable(0, grouping=False) # TODO *is* this mappable?
def _plot(self, split_gen, scales, orient):
ori_idx = ["x", "y"].index(orient)
val_idx = ["y", "x"].index(orient)
patches = defaultdict(list)
for _, data, ax in split_gen():
bars, _ = self._make_patches(data, scales, orient)
patches[ax].extend(bars)
collections = {}
for ax, ax_patches in patches.items():
col = mpl.collections.PatchCollection(ax_patches, match_original=True)
col.sticky_edges[val_idx][:] = (0, np.inf)
ax.add_collection(col, autolim=False)
collections[ax] = col
# Workaround for matplotlib autoscaling bug
# https://github.com/matplotlib/matplotlib/issues/11898
# https://github.com/matplotlib/matplotlib/issues/23129
xys = np.vstack([path.vertices for path in col.get_paths()])
ax.update_datalim(xys)
if "edgewidth" not in scales and isinstance(self.edgewidth, Mappable):
for ax in collections:
ax.autoscale_view()
def get_dimensions(collection):
edges, widths = [], []
for verts in (path.vertices for path in collection.get_paths()):
edges.append(min(verts[:, ori_idx]))
widths.append(np.ptp(verts[:, ori_idx]))
return np.array(edges), np.array(widths)
min_width = np.inf
for ax, col in collections.items():
edges, widths = get_dimensions(col)
points = 72 / ax.figure.dpi * abs(
ax.transData.transform([edges + widths] * 2)
- ax.transData.transform([edges] * 2)
)
min_width = min(min_width, min(points[:, ori_idx]))
linewidth = min(.1 * min_width, mpl.rcParams["patch.linewidth"])
for _, col in collections.items():
col.set_linewidth(linewidth)

View File

@@ -0,0 +1,317 @@
from __future__ import annotations
from dataclasses import dataclass, fields, field
import textwrap
from typing import Any, Callable, Union
from collections.abc import Generator
import numpy as np
import pandas as pd
import matplotlib as mpl
from numpy import ndarray
from pandas import DataFrame
from matplotlib.artist import Artist
from seaborn._core.scales import Scale
from seaborn._core.properties import (
PROPERTIES,
Property,
RGBATuple,
DashPattern,
DashPatternWithOffset,
)
from seaborn._core.exceptions import PlotSpecError
class Mappable:
def __init__(
self,
val: Any = None,
depend: str | None = None,
rc: str | None = None,
auto: bool = False,
grouping: bool = True,
):
"""
Property that can be mapped from data or set directly, with flexible defaults.
Parameters
----------
val : Any
Use this value as the default.
depend : str
Use the value of this feature as the default.
rc : str
Use the value of this rcParam as the default.
auto : bool
The default value will depend on other parameters at compile time.
grouping : bool
If True, use the mapped variable to define groups.
"""
if depend is not None:
assert depend in PROPERTIES
if rc is not None:
assert rc in mpl.rcParams
self._val = val
self._rc = rc
self._depend = depend
self._auto = auto
self._grouping = grouping
def __repr__(self):
"""Nice formatting for when object appears in Mark init signature."""
if self._val is not None:
s = f"<{repr(self._val)}>"
elif self._depend is not None:
s = f"<depend:{self._depend}>"
elif self._rc is not None:
s = f"<rc:{self._rc}>"
elif self._auto:
s = "<auto>"
else:
s = "<undefined>"
return s
@property
def depend(self) -> Any:
"""Return the name of the feature to source a default value from."""
return self._depend
@property
def grouping(self) -> bool:
return self._grouping
@property
def default(self) -> Any:
"""Get the default value for this feature, or access the relevant rcParam."""
if self._val is not None:
return self._val
elif self._rc is not None:
return mpl.rcParams.get(self._rc)
# TODO where is the right place to put this kind of type aliasing?
MappableBool = Union[bool, Mappable]
MappableString = Union[str, Mappable]
MappableFloat = Union[float, Mappable]
MappableColor = Union[str, tuple, Mappable]
MappableStyle = Union[str, DashPattern, DashPatternWithOffset, Mappable]
@dataclass
class Mark:
"""Base class for objects that visually represent data."""
artist_kws: dict = field(default_factory=dict)
@property
def _mappable_props(self):
return {
f.name: getattr(self, f.name) for f in fields(self)
if isinstance(f.default, Mappable)
}
@property
def _grouping_props(self):
# TODO does it make sense to have variation within a Mark's
# properties about whether they are grouping?
return [
f.name for f in fields(self)
if isinstance(f.default, Mappable) and f.default.grouping
]
# TODO make this method private? Would extender every need to call directly?
def _resolve(
self,
data: DataFrame | dict[str, Any],
name: str,
scales: dict[str, Scale] | None = None,
) -> Any:
"""Obtain default, specified, or mapped value for a named feature.
Parameters
----------
data : DataFrame or dict with scalar values
Container with data values for features that will be semantically mapped.
name : string
Identity of the feature / semantic.
scales: dict
Mapping from variable to corresponding scale object.
Returns
-------
value or array of values
Outer return type depends on whether `data` is a dict (implying that
we want a single value) or DataFrame (implying that we want an array
of values with matching length).
"""
feature = self._mappable_props[name]
prop = PROPERTIES.get(name, Property(name))
directly_specified = not isinstance(feature, Mappable)
return_multiple = isinstance(data, pd.DataFrame)
return_array = return_multiple and not name.endswith("style")
# Special case width because it needs to be resolved and added to the dataframe
# during layer prep (so the Move operations use it properly).
# TODO how does width *scaling* work, e.g. for violin width by count?
if name == "width":
directly_specified = directly_specified and name not in data
if directly_specified:
feature = prop.standardize(feature)
if return_multiple:
feature = [feature] * len(data)
if return_array:
feature = np.array(feature)
return feature
if name in data:
if scales is None or name not in scales:
# TODO Might this obviate the identity scale? Just don't add a scale?
feature = data[name]
else:
scale = scales[name]
value = data[name]
try:
feature = scale(value)
except Exception as err:
raise PlotSpecError._during("Scaling operation", name) from err
if return_array:
feature = np.asarray(feature)
return feature
if feature.depend is not None:
# TODO add source_func or similar to transform the source value?
# e.g. set linewidth as a proportion of pointsize?
return self._resolve(data, feature.depend, scales)
default = prop.standardize(feature.default)
if return_multiple:
default = [default] * len(data)
if return_array:
default = np.array(default)
return default
def _infer_orient(self, scales: dict) -> str: # TODO type scales
# TODO The original version of this (in seaborn._base) did more checking.
# Paring that down here for the prototype to see what restrictions make sense.
# TODO rethink this to map from scale type to "DV priority" and use that?
# e.g. Nominal > Discrete > Continuous
x = 0 if "x" not in scales else scales["x"]._priority
y = 0 if "y" not in scales else scales["y"]._priority
if y > x:
return "y"
else:
return "x"
def _plot(
self,
split_generator: Callable[[], Generator],
scales: dict[str, Scale],
orient: str,
) -> None:
"""Main interface for creating a plot."""
raise NotImplementedError()
def _legend_artist(
self, variables: list[str], value: Any, scales: dict[str, Scale],
) -> Artist | None:
return None
def resolve_properties(
mark: Mark, data: DataFrame, scales: dict[str, Scale]
) -> dict[str, Any]:
props = {
name: mark._resolve(data, name, scales) for name in mark._mappable_props
}
return props
def resolve_color(
mark: Mark,
data: DataFrame | dict,
prefix: str = "",
scales: dict[str, Scale] | None = None,
) -> RGBATuple | ndarray:
"""
Obtain a default, specified, or mapped value for a color feature.
This method exists separately to support the relationship between a
color and its corresponding alpha. We want to respect alpha values that
are passed in specified (or mapped) color values but also make use of a
separate `alpha` variable, which can be mapped. This approach may also
be extended to support mapping of specific color channels (i.e.
luminance, chroma) in the future.
Parameters
----------
mark :
Mark with the color property.
data :
Container with data values for features that will be semantically mapped.
prefix :
Support "color", "fillcolor", etc.
"""
color = mark._resolve(data, f"{prefix}color", scales)
if f"{prefix}alpha" in mark._mappable_props:
alpha = mark._resolve(data, f"{prefix}alpha", scales)
else:
alpha = mark._resolve(data, "alpha", scales)
def visible(x, axis=None):
"""Detect "invisible" colors to set alpha appropriately."""
# TODO First clause only needed to handle non-rgba arrays,
# which we are trying to handle upstream
return np.array(x).dtype.kind != "f" or np.isfinite(x).all(axis)
# Second check here catches vectors of strings with identity scale
# It could probably be handled better upstream. This is a tricky problem
if np.ndim(color) < 2 and all(isinstance(x, float) for x in color):
if len(color) == 4:
return mpl.colors.to_rgba(color)
alpha = alpha if visible(color) else np.nan
return mpl.colors.to_rgba(color, alpha)
else:
if np.ndim(color) == 2 and color.shape[1] == 4:
return mpl.colors.to_rgba_array(color)
alpha = np.where(visible(color, axis=1), alpha, np.nan)
return mpl.colors.to_rgba_array(color, alpha)
# TODO should we be implementing fill here too?
# (i.e. set fillalpha to 0 when fill=False)
def document_properties(mark):
properties = [f.name for f in fields(mark) if isinstance(f.default, Mappable)]
text = [
"",
" This mark defines the following properties:",
textwrap.fill(
", ".join([f"|{p}|" for p in properties]),
width=78, initial_indent=" " * 8, subsequent_indent=" " * 8,
),
]
docstring_lines = mark.__doc__.split("\n")
new_docstring = "\n".join([
*docstring_lines[:2],
*text,
*docstring_lines[2:],
])
mark.__doc__ = new_docstring
return mark

View File

@@ -0,0 +1,200 @@
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
import matplotlib as mpl
from seaborn._marks.base import (
Mark,
Mappable,
MappableBool,
MappableFloat,
MappableString,
MappableColor,
MappableStyle,
resolve_properties,
resolve_color,
document_properties,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from matplotlib.artist import Artist
from seaborn._core.scales import Scale
class DotBase(Mark):
def _resolve_paths(self, data):
paths = []
path_cache = {}
marker = data["marker"]
def get_transformed_path(m):
return m.get_path().transformed(m.get_transform())
if isinstance(marker, mpl.markers.MarkerStyle):
return get_transformed_path(marker)
for m in marker:
if m not in path_cache:
path_cache[m] = get_transformed_path(m)
paths.append(path_cache[m])
return paths
def _resolve_properties(self, data, scales):
resolved = resolve_properties(self, data, scales)
resolved["path"] = self._resolve_paths(resolved)
resolved["size"] = resolved["pointsize"] ** 2
if isinstance(data, dict): # Properties for single dot
filled_marker = resolved["marker"].is_filled()
else:
filled_marker = [m.is_filled() for m in resolved["marker"]]
resolved["fill"] = resolved["fill"] * filled_marker
return resolved
def _plot(self, split_gen, scales, orient):
# TODO Not backcompat with allowed (but nonfunctional) univariate plots
# (That should be solved upstream by defaulting to "" for unset x/y?)
# (Be mindful of xmin/xmax, etc!)
for _, data, ax in split_gen():
offsets = np.column_stack([data["x"], data["y"]])
data = self._resolve_properties(data, scales)
points = mpl.collections.PathCollection(
offsets=offsets,
paths=data["path"],
sizes=data["size"],
facecolors=data["facecolor"],
edgecolors=data["edgecolor"],
linewidths=data["linewidth"],
linestyles=data["edgestyle"],
transOffset=ax.transData,
transform=mpl.transforms.IdentityTransform(),
**self.artist_kws,
)
ax.add_collection(points)
def _legend_artist(
self, variables: list[str], value: Any, scales: dict[str, Scale],
) -> Artist:
key = {v: value for v in variables}
res = self._resolve_properties(key, scales)
return mpl.collections.PathCollection(
paths=[res["path"]],
sizes=[res["size"]],
facecolors=[res["facecolor"]],
edgecolors=[res["edgecolor"]],
linewidths=[res["linewidth"]],
linestyles=[res["edgestyle"]],
transform=mpl.transforms.IdentityTransform(),
**self.artist_kws,
)
@document_properties
@dataclass
class Dot(DotBase):
"""
A mark suitable for dot plots or less-dense scatterplots.
See also
--------
Dots : A dot mark defined by strokes to better handle overplotting.
Examples
--------
.. include:: ../docstrings/objects.Dot.rst
"""
marker: MappableString = Mappable("o", grouping=False)
pointsize: MappableFloat = Mappable(6, grouping=False) # TODO rcParam?
stroke: MappableFloat = Mappable(.75, grouping=False) # TODO rcParam?
color: MappableColor = Mappable("C0", grouping=False)
alpha: MappableFloat = Mappable(1, grouping=False)
fill: MappableBool = Mappable(True, grouping=False)
edgecolor: MappableColor = Mappable(depend="color", grouping=False)
edgealpha: MappableFloat = Mappable(depend="alpha", grouping=False)
edgewidth: MappableFloat = Mappable(.5, grouping=False) # TODO rcParam?
edgestyle: MappableStyle = Mappable("-", grouping=False)
def _resolve_properties(self, data, scales):
resolved = super()._resolve_properties(data, scales)
filled = resolved["fill"]
main_stroke = resolved["stroke"]
edge_stroke = resolved["edgewidth"]
resolved["linewidth"] = np.where(filled, edge_stroke, main_stroke)
main_color = resolve_color(self, data, "", scales)
edge_color = resolve_color(self, data, "edge", scales)
if not np.isscalar(filled):
# Expand dims to use in np.where with rgba arrays
filled = filled[:, None]
resolved["edgecolor"] = np.where(filled, edge_color, main_color)
filled = np.squeeze(filled)
if isinstance(main_color, tuple):
# TODO handle this in resolve_color
main_color = tuple([*main_color[:3], main_color[3] * filled])
else:
main_color = np.c_[main_color[:, :3], main_color[:, 3] * filled]
resolved["facecolor"] = main_color
return resolved
@document_properties
@dataclass
class Dots(DotBase):
"""
A dot mark defined by strokes to better handle overplotting.
See also
--------
Dot : A mark suitable for dot plots or less-dense scatterplots.
Examples
--------
.. include:: ../docstrings/objects.Dots.rst
"""
# TODO retype marker as MappableMarker
marker: MappableString = Mappable(rc="scatter.marker", grouping=False)
pointsize: MappableFloat = Mappable(4, grouping=False) # TODO rcParam?
stroke: MappableFloat = Mappable(.75, grouping=False) # TODO rcParam?
color: MappableColor = Mappable("C0", grouping=False)
alpha: MappableFloat = Mappable(1, grouping=False) # TODO auto alpha?
fill: MappableBool = Mappable(True, grouping=False)
fillcolor: MappableColor = Mappable(depend="color", grouping=False)
fillalpha: MappableFloat = Mappable(.2, grouping=False)
def _resolve_properties(self, data, scales):
resolved = super()._resolve_properties(data, scales)
resolved["linewidth"] = resolved.pop("stroke")
resolved["facecolor"] = resolve_color(self, data, "fill", scales)
resolved["edgecolor"] = resolve_color(self, data, "", scales)
resolved.setdefault("edgestyle", (0, None))
fc = resolved["facecolor"]
if isinstance(fc, tuple):
resolved["facecolor"] = fc[0], fc[1], fc[2], fc[3] * resolved["fill"]
else:
fc[:, 3] = fc[:, 3] * resolved["fill"] # TODO Is inplace mod a problem?
resolved["facecolor"] = fc
return resolved

View File

@@ -0,0 +1,285 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import ClassVar
import numpy as np
import matplotlib as mpl
from seaborn._marks.base import (
Mark,
Mappable,
MappableFloat,
MappableString,
MappableColor,
resolve_properties,
resolve_color,
document_properties,
)
@document_properties
@dataclass
class Path(Mark):
"""
A mark connecting data points in the order they appear.
See also
--------
Line : A mark connecting data points with sorting along the orientation axis.
Paths : A faster but less-flexible mark for drawing many paths.
Examples
--------
.. include:: ../docstrings/objects.Path.rst
"""
color: MappableColor = Mappable("C0")
alpha: MappableFloat = Mappable(1)
linewidth: MappableFloat = Mappable(rc="lines.linewidth")
linestyle: MappableString = Mappable(rc="lines.linestyle")
marker: MappableString = Mappable(rc="lines.marker")
pointsize: MappableFloat = Mappable(rc="lines.markersize")
fillcolor: MappableColor = Mappable(depend="color")
edgecolor: MappableColor = Mappable(depend="color")
edgewidth: MappableFloat = Mappable(rc="lines.markeredgewidth")
_sort: ClassVar[bool] = False
def _plot(self, split_gen, scales, orient):
for keys, data, ax in split_gen(keep_na=not self._sort):
vals = resolve_properties(self, keys, scales)
vals["color"] = resolve_color(self, keys, scales=scales)
vals["fillcolor"] = resolve_color(self, keys, prefix="fill", scales=scales)
vals["edgecolor"] = resolve_color(self, keys, prefix="edge", scales=scales)
if self._sort:
data = data.sort_values(orient, kind="mergesort")
artist_kws = self.artist_kws.copy()
self._handle_capstyle(artist_kws, vals)
line = mpl.lines.Line2D(
data["x"].to_numpy(),
data["y"].to_numpy(),
color=vals["color"],
linewidth=vals["linewidth"],
linestyle=vals["linestyle"],
marker=vals["marker"],
markersize=vals["pointsize"],
markerfacecolor=vals["fillcolor"],
markeredgecolor=vals["edgecolor"],
markeredgewidth=vals["edgewidth"],
**artist_kws,
)
ax.add_line(line)
def _legend_artist(self, variables, value, scales):
keys = {v: value for v in variables}
vals = resolve_properties(self, keys, scales)
vals["color"] = resolve_color(self, keys, scales=scales)
vals["fillcolor"] = resolve_color(self, keys, prefix="fill", scales=scales)
vals["edgecolor"] = resolve_color(self, keys, prefix="edge", scales=scales)
artist_kws = self.artist_kws.copy()
self._handle_capstyle(artist_kws, vals)
return mpl.lines.Line2D(
[], [],
color=vals["color"],
linewidth=vals["linewidth"],
linestyle=vals["linestyle"],
marker=vals["marker"],
markersize=vals["pointsize"],
markerfacecolor=vals["fillcolor"],
markeredgecolor=vals["edgecolor"],
markeredgewidth=vals["edgewidth"],
**artist_kws,
)
def _handle_capstyle(self, kws, vals):
# Work around for this matplotlib issue:
# https://github.com/matplotlib/matplotlib/issues/23437
if vals["linestyle"][1] is None:
capstyle = kws.get("solid_capstyle", mpl.rcParams["lines.solid_capstyle"])
kws["dash_capstyle"] = capstyle
@document_properties
@dataclass
class Line(Path):
"""
A mark connecting data points with sorting along the orientation axis.
See also
--------
Path : A mark connecting data points in the order they appear.
Lines : A faster but less-flexible mark for drawing many lines.
Examples
--------
.. include:: ../docstrings/objects.Line.rst
"""
_sort: ClassVar[bool] = True
@document_properties
@dataclass
class Paths(Mark):
"""
A faster but less-flexible mark for drawing many paths.
See also
--------
Path : A mark connecting data points in the order they appear.
Examples
--------
.. include:: ../docstrings/objects.Paths.rst
"""
color: MappableColor = Mappable("C0")
alpha: MappableFloat = Mappable(1)
linewidth: MappableFloat = Mappable(rc="lines.linewidth")
linestyle: MappableString = Mappable(rc="lines.linestyle")
_sort: ClassVar[bool] = False
def __post_init__(self):
# LineCollection artists have a capstyle property but don't source its value
# from the rc, so we do that manually here. Unfortunately, because we add
# only one LineCollection, we have the use the same capstyle for all lines
# even when they are dashed. It's a slight inconsistency, but looks fine IMO.
self.artist_kws.setdefault("capstyle", mpl.rcParams["lines.solid_capstyle"])
def _plot(self, split_gen, scales, orient):
line_data = {}
for keys, data, ax in split_gen(keep_na=not self._sort):
if ax not in line_data:
line_data[ax] = {
"segments": [],
"colors": [],
"linewidths": [],
"linestyles": [],
}
segments = self._setup_segments(data, orient)
line_data[ax]["segments"].extend(segments)
n = len(segments)
vals = resolve_properties(self, keys, scales)
vals["color"] = resolve_color(self, keys, scales=scales)
line_data[ax]["colors"].extend([vals["color"]] * n)
line_data[ax]["linewidths"].extend([vals["linewidth"]] * n)
line_data[ax]["linestyles"].extend([vals["linestyle"]] * n)
for ax, ax_data in line_data.items():
lines = mpl.collections.LineCollection(**ax_data, **self.artist_kws)
# Handle datalim update manually
# https://github.com/matplotlib/matplotlib/issues/23129
ax.add_collection(lines, autolim=False)
if ax_data["segments"]:
xy = np.concatenate(ax_data["segments"])
ax.update_datalim(xy)
def _legend_artist(self, variables, value, scales):
key = resolve_properties(self, {v: value for v in variables}, scales)
artist_kws = self.artist_kws.copy()
capstyle = artist_kws.pop("capstyle")
artist_kws["solid_capstyle"] = capstyle
artist_kws["dash_capstyle"] = capstyle
return mpl.lines.Line2D(
[], [],
color=key["color"],
linewidth=key["linewidth"],
linestyle=key["linestyle"],
**artist_kws,
)
def _setup_segments(self, data, orient):
if self._sort:
data = data.sort_values(orient, kind="mergesort")
# Column stack to avoid block consolidation
xy = np.column_stack([data["x"], data["y"]])
return [xy]
@document_properties
@dataclass
class Lines(Paths):
"""
A faster but less-flexible mark for drawing many lines.
See also
--------
Line : A mark connecting data points with sorting along the orientation axis.
Examples
--------
.. include:: ../docstrings/objects.Lines.rst
"""
_sort: ClassVar[bool] = True
@document_properties
@dataclass
class Range(Paths):
"""
An oriented line mark drawn between min/max values.
Examples
--------
.. include:: ../docstrings/objects.Range.rst
"""
def _setup_segments(self, data, orient):
# TODO better checks on what variables we have
# TODO what if only one exist?
val = {"x": "y", "y": "x"}[orient]
if not set(data.columns) & {f"{val}min", f"{val}max"}:
agg = {f"{val}min": (val, "min"), f"{val}max": (val, "max")}
data = data.groupby(orient).agg(**agg).reset_index()
cols = [orient, f"{val}min", f"{val}max"]
data = data[cols].melt(orient, value_name=val)[["x", "y"]]
segments = [d.to_numpy() for _, d in data.groupby(orient)]
return segments
@document_properties
@dataclass
class Dash(Paths):
"""
A line mark drawn as an oriented segment for each datapoint.
Examples
--------
.. include:: ../docstrings/objects.Dash.rst
"""
width: MappableFloat = Mappable(.8, grouping=False)
def _setup_segments(self, data, orient):
ori = ["x", "y"].index(orient)
xys = data[["x", "y"]].to_numpy().astype(float)
segments = np.stack([xys, xys], axis=1)
segments[:, 0, ori] -= data["width"] / 2
segments[:, 1, ori] += data["width"] / 2
return segments

View File

@@ -0,0 +1,76 @@
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
import numpy as np
import matplotlib as mpl
from matplotlib.transforms import ScaledTranslation
from seaborn._marks.base import (
Mark,
Mappable,
MappableFloat,
MappableString,
MappableColor,
resolve_properties,
resolve_color,
document_properties,
)
@document_properties
@dataclass
class Text(Mark):
"""
A textual mark to annotate or represent data values.
Examples
--------
.. include:: ../docstrings/objects.Text.rst
"""
text: MappableString = Mappable("")
color: MappableColor = Mappable("k")
alpha: MappableFloat = Mappable(1)
fontsize: MappableFloat = Mappable(rc="font.size")
halign: MappableString = Mappable("center")
valign: MappableString = Mappable("center_baseline")
offset: MappableFloat = Mappable(4)
def _plot(self, split_gen, scales, orient):
ax_data = defaultdict(list)
for keys, data, ax in split_gen():
vals = resolve_properties(self, keys, scales)
color = resolve_color(self, keys, "", scales)
halign = vals["halign"]
valign = vals["valign"]
fontsize = vals["fontsize"]
offset = vals["offset"] / 72
offset_trans = ScaledTranslation(
{"right": -offset, "left": +offset}.get(halign, 0),
{"top": -offset, "bottom": +offset, "baseline": +offset}.get(valign, 0),
ax.figure.dpi_scale_trans,
)
for row in data.to_dict("records"):
artist = mpl.text.Text(
x=row["x"],
y=row["y"],
text=str(row.get("text", vals["text"])),
color=color,
fontsize=fontsize,
horizontalalignment=halign,
verticalalignment=valign,
transform=ax.transData + offset_trans,
**self.artist_kws,
)
ax.add_artist(artist)
ax_data[ax].append([row["x"], row["y"]])
for ax, ax_vals in ax_data.items():
ax.update_datalim(np.array(ax_vals))

View File

@@ -0,0 +1,698 @@
"""Statistical transformations for visualization.
This module is currently private, but is being written to eventually form part
of the public API.
The classes should behave roughly in the style of scikit-learn.
- All data-independent parameters should be passed to the class constructor.
- Each class should implement a default transformation that is exposed through
__call__. These are currently written for vector arguments, but I think
consuming a whole `plot_data` DataFrame and return it with transformed
variables would make more sense.
- Some class have data-dependent preprocessing that should be cached and used
multiple times (think defining histogram bins off all data and then counting
observations within each bin multiple times per data subsets). These currently
have unique names, but it would be good to have a common name. Not quite
`fit`, but something similar.
- Alternatively, the transform interface could take some information about grouping
variables and do a groupby internally.
- Some classes should define alternate transforms that might make the most sense
with a different function. For example, KDE usually evaluates the distribution
on a regular grid, but it would be useful for it to transform at the actual
datapoints. Then again, this could be controlled by a parameter at the time of
class instantiation.
"""
from numbers import Number
from statistics import NormalDist
import numpy as np
import pandas as pd
try:
from scipy.stats import gaussian_kde
_no_scipy = False
except ImportError:
from .external.kde import gaussian_kde
_no_scipy = True
from .algorithms import bootstrap
from .utils import _check_argument
class KDE:
"""Univariate and bivariate kernel density estimator."""
def __init__(
self, *,
bw_method=None,
bw_adjust=1,
gridsize=200,
cut=3,
clip=None,
cumulative=False,
):
"""Initialize the estimator with its parameters.
Parameters
----------
bw_method : string, scalar, or callable, optional
Method for determining the smoothing bandwidth to use; passed to
:class:`scipy.stats.gaussian_kde`.
bw_adjust : number, optional
Factor that multiplicatively scales the value chosen using
``bw_method``. Increasing will make the curve smoother. See Notes.
gridsize : int, optional
Number of points on each dimension of the evaluation grid.
cut : number, optional
Factor, multiplied by the smoothing bandwidth, that determines how
far the evaluation grid extends past the extreme datapoints. When
set to 0, truncate the curve at the data limits.
clip : pair of numbers or None, or a pair of such pairs
Do not evaluate the density outside of these limits.
cumulative : bool, optional
If True, estimate a cumulative distribution function. Requires scipy.
"""
if clip is None:
clip = None, None
self.bw_method = bw_method
self.bw_adjust = bw_adjust
self.gridsize = gridsize
self.cut = cut
self.clip = clip
self.cumulative = cumulative
if cumulative and _no_scipy:
raise RuntimeError("Cumulative KDE evaluation requires scipy")
self.support = None
def _define_support_grid(self, x, bw, cut, clip, gridsize):
"""Create the grid of evaluation points depending for vector x."""
clip_lo = -np.inf if clip[0] is None else clip[0]
clip_hi = +np.inf if clip[1] is None else clip[1]
gridmin = max(x.min() - bw * cut, clip_lo)
gridmax = min(x.max() + bw * cut, clip_hi)
return np.linspace(gridmin, gridmax, gridsize)
def _define_support_univariate(self, x, weights):
"""Create a 1D grid of evaluation points."""
kde = self._fit(x, weights)
bw = np.sqrt(kde.covariance.squeeze())
grid = self._define_support_grid(
x, bw, self.cut, self.clip, self.gridsize
)
return grid
def _define_support_bivariate(self, x1, x2, weights):
"""Create a 2D grid of evaluation points."""
clip = self.clip
if clip[0] is None or np.isscalar(clip[0]):
clip = (clip, clip)
kde = self._fit([x1, x2], weights)
bw = np.sqrt(np.diag(kde.covariance).squeeze())
grid1 = self._define_support_grid(
x1, bw[0], self.cut, clip[0], self.gridsize
)
grid2 = self._define_support_grid(
x2, bw[1], self.cut, clip[1], self.gridsize
)
return grid1, grid2
def define_support(self, x1, x2=None, weights=None, cache=True):
"""Create the evaluation grid for a given data set."""
if x2 is None:
support = self._define_support_univariate(x1, weights)
else:
support = self._define_support_bivariate(x1, x2, weights)
if cache:
self.support = support
return support
def _fit(self, fit_data, weights=None):
"""Fit the scipy kde while adding bw_adjust logic and version check."""
fit_kws = {"bw_method": self.bw_method}
if weights is not None:
fit_kws["weights"] = weights
kde = gaussian_kde(fit_data, **fit_kws)
kde.set_bandwidth(kde.factor * self.bw_adjust)
return kde
def _eval_univariate(self, x, weights=None):
"""Fit and evaluate a univariate on univariate data."""
support = self.support
if support is None:
support = self.define_support(x, cache=False)
kde = self._fit(x, weights)
if self.cumulative:
s_0 = support[0]
density = np.array([
kde.integrate_box_1d(s_0, s_i) for s_i in support
])
else:
density = kde(support)
return density, support
def _eval_bivariate(self, x1, x2, weights=None):
"""Fit and evaluate a univariate on bivariate data."""
support = self.support
if support is None:
support = self.define_support(x1, x2, cache=False)
kde = self._fit([x1, x2], weights)
if self.cumulative:
grid1, grid2 = support
density = np.zeros((grid1.size, grid2.size))
p0 = grid1.min(), grid2.min()
for i, xi in enumerate(grid1):
for j, xj in enumerate(grid2):
density[i, j] = kde.integrate_box(p0, (xi, xj))
else:
xx1, xx2 = np.meshgrid(*support)
density = kde([xx1.ravel(), xx2.ravel()]).reshape(xx1.shape)
return density, support
def __call__(self, x1, x2=None, weights=None):
"""Fit and evaluate on univariate or bivariate data."""
if x2 is None:
return self._eval_univariate(x1, weights)
else:
return self._eval_bivariate(x1, x2, weights)
# Note: we no longer use this for univariate histograms in histplot,
# preferring _stats.Hist. We'll deprecate this once we have a bivariate Stat class.
class Histogram:
"""Univariate and bivariate histogram estimator."""
def __init__(
self,
stat="count",
bins="auto",
binwidth=None,
binrange=None,
discrete=False,
cumulative=False,
):
"""Initialize the estimator with its parameters.
Parameters
----------
stat : str
Aggregate statistic to compute in each bin.
- `count`: show the number of observations in each bin
- `frequency`: show the number of observations divided by the bin width
- `probability` or `proportion`: normalize such that bar heights sum to 1
- `percent`: normalize such that bar heights sum to 100
- `density`: normalize such that the total area of the histogram equals 1
bins : str, number, vector, or a pair of such values
Generic bin parameter that can be the name of a reference rule,
the number of bins, or the breaks of the bins.
Passed to :func:`numpy.histogram_bin_edges`.
binwidth : number or pair of numbers
Width of each bin, overrides ``bins`` but can be used with
``binrange``.
binrange : pair of numbers or a pair of pairs
Lowest and highest value for bin edges; can be used either
with ``bins`` or ``binwidth``. Defaults to data extremes.
discrete : bool or pair of bools
If True, set ``binwidth`` and ``binrange`` such that bin
edges cover integer values in the dataset.
cumulative : bool
If True, return the cumulative statistic.
"""
stat_choices = [
"count", "frequency", "density", "probability", "proportion", "percent",
]
_check_argument("stat", stat_choices, stat)
self.stat = stat
self.bins = bins
self.binwidth = binwidth
self.binrange = binrange
self.discrete = discrete
self.cumulative = cumulative
self.bin_kws = None
def _define_bin_edges(self, x, weights, bins, binwidth, binrange, discrete):
"""Inner function that takes bin parameters as arguments."""
if binrange is None:
start, stop = x.min(), x.max()
else:
start, stop = binrange
if discrete:
bin_edges = np.arange(start - .5, stop + 1.5)
elif binwidth is not None:
step = binwidth
bin_edges = np.arange(start, stop + step, step)
# Handle roundoff error (maybe there is a less clumsy way?)
if bin_edges.max() < stop or len(bin_edges) < 2:
bin_edges = np.append(bin_edges, bin_edges.max() + step)
else:
bin_edges = np.histogram_bin_edges(
x, bins, binrange, weights,
)
return bin_edges
def define_bin_params(self, x1, x2=None, weights=None, cache=True):
"""Given data, return numpy.histogram parameters to define bins."""
if x2 is None:
bin_edges = self._define_bin_edges(
x1, weights, self.bins, self.binwidth, self.binrange, self.discrete,
)
if isinstance(self.bins, (str, Number)):
n_bins = len(bin_edges) - 1
bin_range = bin_edges.min(), bin_edges.max()
bin_kws = dict(bins=n_bins, range=bin_range)
else:
bin_kws = dict(bins=bin_edges)
else:
bin_edges = []
for i, x in enumerate([x1, x2]):
# Resolve out whether bin parameters are shared
# or specific to each variable
bins = self.bins
if not bins or isinstance(bins, (str, Number)):
pass
elif isinstance(bins[i], str):
bins = bins[i]
elif len(bins) == 2:
bins = bins[i]
binwidth = self.binwidth
if binwidth is None:
pass
elif not isinstance(binwidth, Number):
binwidth = binwidth[i]
binrange = self.binrange
if binrange is None:
pass
elif not isinstance(binrange[0], Number):
binrange = binrange[i]
discrete = self.discrete
if not isinstance(discrete, bool):
discrete = discrete[i]
# Define the bins for this variable
bin_edges.append(self._define_bin_edges(
x, weights, bins, binwidth, binrange, discrete,
))
bin_kws = dict(bins=tuple(bin_edges))
if cache:
self.bin_kws = bin_kws
return bin_kws
def _eval_bivariate(self, x1, x2, weights):
"""Inner function for histogram of two variables."""
bin_kws = self.bin_kws
if bin_kws is None:
bin_kws = self.define_bin_params(x1, x2, cache=False)
density = self.stat == "density"
hist, *bin_edges = np.histogram2d(
x1, x2, **bin_kws, weights=weights, density=density
)
area = np.outer(
np.diff(bin_edges[0]),
np.diff(bin_edges[1]),
)
if self.stat == "probability" or self.stat == "proportion":
hist = hist.astype(float) / hist.sum()
elif self.stat == "percent":
hist = hist.astype(float) / hist.sum() * 100
elif self.stat == "frequency":
hist = hist.astype(float) / area
if self.cumulative:
if self.stat in ["density", "frequency"]:
hist = (hist * area).cumsum(axis=0).cumsum(axis=1)
else:
hist = hist.cumsum(axis=0).cumsum(axis=1)
return hist, bin_edges
def _eval_univariate(self, x, weights):
"""Inner function for histogram of one variable."""
bin_kws = self.bin_kws
if bin_kws is None:
bin_kws = self.define_bin_params(x, weights=weights, cache=False)
density = self.stat == "density"
hist, bin_edges = np.histogram(
x, **bin_kws, weights=weights, density=density,
)
if self.stat == "probability" or self.stat == "proportion":
hist = hist.astype(float) / hist.sum()
elif self.stat == "percent":
hist = hist.astype(float) / hist.sum() * 100
elif self.stat == "frequency":
hist = hist.astype(float) / np.diff(bin_edges)
if self.cumulative:
if self.stat in ["density", "frequency"]:
hist = (hist * np.diff(bin_edges)).cumsum()
else:
hist = hist.cumsum()
return hist, bin_edges
def __call__(self, x1, x2=None, weights=None):
"""Count the occurrences in each bin, maybe normalize."""
if x2 is None:
return self._eval_univariate(x1, weights)
else:
return self._eval_bivariate(x1, x2, weights)
class ECDF:
"""Univariate empirical cumulative distribution estimator."""
def __init__(self, stat="proportion", complementary=False):
"""Initialize the class with its parameters
Parameters
----------
stat : {{"proportion", "percent", "count"}}
Distribution statistic to compute.
complementary : bool
If True, use the complementary CDF (1 - CDF)
"""
_check_argument("stat", ["count", "percent", "proportion"], stat)
self.stat = stat
self.complementary = complementary
def _eval_bivariate(self, x1, x2, weights):
"""Inner function for ECDF of two variables."""
raise NotImplementedError("Bivariate ECDF is not implemented")
def _eval_univariate(self, x, weights):
"""Inner function for ECDF of one variable."""
sorter = x.argsort()
x = x[sorter]
weights = weights[sorter]
y = weights.cumsum()
if self.stat in ["percent", "proportion"]:
y = y / y.max()
if self.stat == "percent":
y = y * 100
x = np.r_[-np.inf, x]
y = np.r_[0, y]
if self.complementary:
y = y.max() - y
return y, x
def __call__(self, x1, x2=None, weights=None):
"""Return proportion or count of observations below each sorted datapoint."""
x1 = np.asarray(x1)
if weights is None:
weights = np.ones_like(x1)
else:
weights = np.asarray(weights)
if x2 is None:
return self._eval_univariate(x1, weights)
else:
return self._eval_bivariate(x1, x2, weights)
class EstimateAggregator:
def __init__(self, estimator, errorbar=None, **boot_kws):
"""
Data aggregator that produces an estimate and error bar interval.
Parameters
----------
estimator : callable or string
Function (or method name) that maps a vector to a scalar.
errorbar : string, (string, number) tuple, or callable
Name of errorbar method (either "ci", "pi", "se", or "sd"), or a tuple
with a method name and a level parameter, or a function that maps from a
vector to a (min, max) interval, or None to hide errorbar. See the
:doc:`errorbar tutorial </tutorial/error_bars>` for more information.
boot_kws
Additional keywords are passed to bootstrap when error_method is "ci".
"""
self.estimator = estimator
method, level = _validate_errorbar_arg(errorbar)
self.error_method = method
self.error_level = level
self.boot_kws = boot_kws
def __call__(self, data, var):
"""Aggregate over `var` column of `data` with estimate and error interval."""
vals = data[var]
if callable(self.estimator):
# You would think we could pass to vals.agg, and yet:
# https://github.com/mwaskom/seaborn/issues/2943
estimate = self.estimator(vals)
else:
estimate = vals.agg(self.estimator)
# Options that produce no error bars
if self.error_method is None:
err_min = err_max = np.nan
elif len(data) <= 1:
err_min = err_max = np.nan
# Generic errorbars from user-supplied function
elif callable(self.error_method):
err_min, err_max = self.error_method(vals)
# Parametric options
elif self.error_method == "sd":
half_interval = vals.std() * self.error_level
err_min, err_max = estimate - half_interval, estimate + half_interval
elif self.error_method == "se":
half_interval = vals.sem() * self.error_level
err_min, err_max = estimate - half_interval, estimate + half_interval
# Nonparametric options
elif self.error_method == "pi":
err_min, err_max = _percentile_interval(vals, self.error_level)
elif self.error_method == "ci":
units = data.get("units", None)
boots = bootstrap(vals, units=units, func=self.estimator, **self.boot_kws)
err_min, err_max = _percentile_interval(boots, self.error_level)
return pd.Series({var: estimate, f"{var}min": err_min, f"{var}max": err_max})
class WeightedAggregator:
def __init__(self, estimator, errorbar=None, **boot_kws):
"""
Data aggregator that produces a weighted estimate and error bar interval.
Parameters
----------
estimator : string
Function (or method name) that maps a vector to a scalar. Currently
supports only "mean".
errorbar : string or (string, number) tuple
Name of errorbar method or a tuple with a method name and a level parameter.
Currently the only supported method is "ci".
boot_kws
Additional keywords are passed to bootstrap when error_method is "ci".
"""
if estimator != "mean":
# Note that, while other weighted estimators may make sense (e.g. median),
# I'm not aware of an implementation in our dependencies. We can add one
# in seaborn later, if there is sufficient interest. For now, limit to mean.
raise ValueError(f"Weighted estimator must be 'mean', not {estimator!r}.")
self.estimator = estimator
method, level = _validate_errorbar_arg(errorbar)
if method is not None and method != "ci":
# As with the estimator, weighted 'sd' or 'pi' error bars may make sense.
# But we'll keep things simple for now and limit to (bootstrap) CI.
raise ValueError(f"Error bar method must be 'ci', not {method!r}.")
self.error_method = method
self.error_level = level
self.boot_kws = boot_kws
def __call__(self, data, var):
"""Aggregate over `var` column of `data` with estimate and error interval."""
vals = data[var]
weights = data["weight"]
estimate = np.average(vals, weights=weights)
if self.error_method == "ci" and len(data) > 1:
def error_func(x, w):
return np.average(x, weights=w)
boots = bootstrap(vals, weights, func=error_func, **self.boot_kws)
err_min, err_max = _percentile_interval(boots, self.error_level)
else:
err_min = err_max = np.nan
return pd.Series({var: estimate, f"{var}min": err_min, f"{var}max": err_max})
class LetterValues:
def __init__(self, k_depth, outlier_prop, trust_alpha):
"""
Compute percentiles of a distribution using various tail stopping rules.
Parameters
----------
k_depth: "tukey", "proportion", "trustworthy", or "full"
Stopping rule for choosing tail percentiled to show:
- tukey: Show a similar number of outliers as in a conventional boxplot.
- proportion: Show approximately `outlier_prop` outliers.
- trust_alpha: Use `trust_alpha` level for most extreme tail percentile.
outlier_prop: float
Parameter for `k_depth="proportion"` setting the expected outlier rate.
trust_alpha: float
Parameter for `k_depth="trustworthy"` setting the confidence threshold.
Notes
-----
Based on the proposal in this paper:
https://vita.had.co.nz/papers/letter-value-plot.pdf
"""
k_options = ["tukey", "proportion", "trustworthy", "full"]
if isinstance(k_depth, str):
_check_argument("k_depth", k_options, k_depth)
elif not isinstance(k_depth, int):
err = (
"The `k_depth` parameter must be either an integer or string "
f"(one of {k_options}), not {k_depth!r}."
)
raise TypeError(err)
self.k_depth = k_depth
self.outlier_prop = outlier_prop
self.trust_alpha = trust_alpha
def _compute_k(self, n):
# Select the depth, i.e. number of boxes to draw, based on the method
if self.k_depth == "full":
# extend boxes to 100% of the data
k = int(np.log2(n)) + 1
elif self.k_depth == "tukey":
# This results with 5-8 points in each tail
k = int(np.log2(n)) - 3
elif self.k_depth == "proportion":
k = int(np.log2(n)) - int(np.log2(n * self.outlier_prop)) + 1
elif self.k_depth == "trustworthy":
normal_quantile_func = np.vectorize(NormalDist().inv_cdf)
point_conf = 2 * normal_quantile_func(1 - self.trust_alpha / 2) ** 2
k = int(np.log2(n / point_conf)) + 1
else:
# Allow having k directly specified as input
k = int(self.k_depth)
return max(k, 1)
def __call__(self, x):
"""Evaluate the letter values."""
k = self._compute_k(len(x))
exp = np.arange(k + 1, 1, -1), np.arange(2, k + 2)
levels = k + 1 - np.concatenate([exp[0], exp[1][1:]])
percentiles = 100 * np.concatenate([0.5 ** exp[0], 1 - 0.5 ** exp[1]])
if self.k_depth == "full":
percentiles[0] = 0
percentiles[-1] = 100
values = np.percentile(x, percentiles)
fliers = np.asarray(x[(x < values.min()) | (x > values.max())])
median = np.percentile(x, 50)
return {
"k": k,
"levels": levels,
"percs": percentiles,
"values": values,
"fliers": fliers,
"median": median,
}
def _percentile_interval(data, width):
"""Return a percentile interval from data of a given width."""
edge = (100 - width) / 2
percentiles = edge, 100 - edge
return np.nanpercentile(data, percentiles)
def _validate_errorbar_arg(arg):
"""Check type and value of errorbar argument and assign default level."""
DEFAULT_LEVELS = {
"ci": 95,
"pi": 95,
"se": 1,
"sd": 1,
}
usage = "`errorbar` must be a callable, string, or (string, number) tuple"
if arg is None:
return None, None
elif callable(arg):
return arg, None
elif isinstance(arg, str):
method = arg
level = DEFAULT_LEVELS.get(method, None)
else:
try:
method, level = arg
except (ValueError, TypeError) as err:
raise err.__class__(usage) from err
_check_argument("errorbar", list(DEFAULT_LEVELS), method)
if level is not None and not isinstance(level, Number):
raise TypeError(usage)
return method, level

View File

@@ -0,0 +1,130 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import ClassVar, Callable
import pandas as pd
from pandas import DataFrame
from seaborn._core.scales import Scale
from seaborn._core.groupby import GroupBy
from seaborn._stats.base import Stat
from seaborn._statistics import (
EstimateAggregator,
WeightedAggregator,
)
from seaborn._core.typing import Vector
@dataclass
class Agg(Stat):
"""
Aggregate data along the value axis using given method.
Parameters
----------
func : str or callable
Name of a :class:`pandas.Series` method or a vector -> scalar function.
See Also
--------
objects.Est : Aggregation with error bars.
Examples
--------
.. include:: ../docstrings/objects.Agg.rst
"""
func: str | Callable[[Vector], float] = "mean"
group_by_orient: ClassVar[bool] = True
def __call__(
self, data: DataFrame, groupby: GroupBy, orient: str, scales: dict[str, Scale],
) -> DataFrame:
var = {"x": "y", "y": "x"}.get(orient)
res = (
groupby
.agg(data, {var: self.func})
.dropna(subset=[var])
.reset_index(drop=True)
)
return res
@dataclass
class Est(Stat):
"""
Calculate a point estimate and error bar interval.
For more information about the various `errorbar` choices, see the
:doc:`errorbar tutorial </tutorial/error_bars>`.
Additional variables:
- **weight**: When passed to a layer that uses this stat, a weighted estimate
will be computed. Note that use of weights currently limits the choice of
function and error bar method to `"mean"` and `"ci"`, respectively.
Parameters
----------
func : str or callable
Name of a :class:`numpy.ndarray` method or a vector -> scalar function.
errorbar : str, (str, float) tuple, or callable
Name of errorbar method (one of "ci", "pi", "se" or "sd"), or a tuple
with a method name ane a level parameter, or a function that maps from a
vector to a (min, max) interval.
n_boot : int
Number of bootstrap samples to draw for "ci" errorbars.
seed : int
Seed for the PRNG used to draw bootstrap samples.
Examples
--------
.. include:: ../docstrings/objects.Est.rst
"""
func: str | Callable[[Vector], float] = "mean"
errorbar: str | tuple[str, float] = ("ci", 95)
n_boot: int = 1000
seed: int | None = None
group_by_orient: ClassVar[bool] = True
def _process(
self, data: DataFrame, var: str, estimator: EstimateAggregator
) -> DataFrame:
# Needed because GroupBy.apply assumes func is DataFrame -> DataFrame
# which we could probably make more general to allow Series return
res = estimator(data, var)
return pd.DataFrame([res])
def __call__(
self, data: DataFrame, groupby: GroupBy, orient: str, scales: dict[str, Scale],
) -> DataFrame:
boot_kws = {"n_boot": self.n_boot, "seed": self.seed}
if "weight" in data:
engine = WeightedAggregator(self.func, self.errorbar, **boot_kws)
else:
engine = EstimateAggregator(self.func, self.errorbar, **boot_kws)
var = {"x": "y", "y": "x"}[orient]
res = (
groupby
.apply(data, self._process, var, engine)
.dropna(subset=[var])
.reset_index(drop=True)
)
res = res.fillna({f"{var}min": res[var], f"{var}max": res[var]})
return res
@dataclass
class Rolling(Stat):
...
def __call__(self, data, groupby, orient, scales):
...

View File

@@ -0,0 +1,65 @@
"""Base module for statistical transformations."""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from typing import ClassVar, Any
import warnings
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pandas import DataFrame
from seaborn._core.groupby import GroupBy
from seaborn._core.scales import Scale
@dataclass
class Stat:
"""Base class for objects that apply statistical transformations."""
# The class supports a partial-function application pattern. The object is
# initialized with desired parameters and the result is a callable that
# accepts and returns dataframes.
# The statistical transformation logic should not add any state to the instance
# beyond what is defined with the initialization parameters.
# Subclasses can declare whether the orient dimension should be used in grouping
# TODO consider whether this should be a parameter. Motivating example:
# use the same KDE class violin plots and univariate density estimation.
# In the former case, we would expect separate densities for each unique
# value on the orient axis, but we would not in the latter case.
group_by_orient: ClassVar[bool] = False
def _check_param_one_of(self, param: str, options: Iterable[Any]) -> None:
"""Raise when parameter value is not one of a specified set."""
value = getattr(self, param)
if value not in options:
*most, last = options
option_str = ", ".join(f"{x!r}" for x in most[:-1]) + f" or {last!r}"
err = " ".join([
f"The `{param}` parameter for `{self.__class__.__name__}` must be",
f"one of {option_str}; not {value!r}.",
])
raise ValueError(err)
def _check_grouping_vars(
self, param: str, data_vars: list[str], stacklevel: int = 2,
) -> None:
"""Warn if vars are named in parameter without being present in the data."""
param_vars = getattr(self, param)
undefined = set(param_vars) - set(data_vars)
if undefined:
param = f"{self.__class__.__name__}.{param}"
names = ", ".join(f"{x!r}" for x in undefined)
msg = f"Undefined variable(s) passed for {param}: {names}."
warnings.warn(msg, stacklevel=stacklevel)
def __call__(
self,
data: DataFrame,
groupby: GroupBy,
orient: str,
scales: dict[str, Scale],
) -> DataFrame:
"""Apply statistical transform to data subgroups and return combined result."""
return data

View File

@@ -0,0 +1,232 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import ClassVar
import numpy as np
import pandas as pd
from pandas import DataFrame
from seaborn._core.groupby import GroupBy
from seaborn._core.scales import Scale
from seaborn._stats.base import Stat
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from numpy.typing import ArrayLike
@dataclass
class Count(Stat):
"""
Count distinct observations within groups.
See Also
--------
Hist : A more fully-featured transform including binning and/or normalization.
Examples
--------
.. include:: ../docstrings/objects.Count.rst
"""
group_by_orient: ClassVar[bool] = True
def __call__(
self, data: DataFrame, groupby: GroupBy, orient: str, scales: dict[str, Scale],
) -> DataFrame:
var = {"x": "y", "y": "x"}[orient]
res = (
groupby
.agg(data.assign(**{var: data[orient]}), {var: len})
.dropna(subset=["x", "y"])
.reset_index(drop=True)
)
return res
@dataclass
class Hist(Stat):
"""
Bin observations, count them, and optionally normalize or cumulate.
Parameters
----------
stat : str
Aggregate statistic to compute in each bin:
- `count`: the number of observations
- `density`: normalize so that the total area of the histogram equals 1
- `percent`: normalize so that bar heights sum to 100
- `probability` or `proportion`: normalize so that bar heights sum to 1
- `frequency`: divide the number of observations by the bin width
bins : str, int, or ArrayLike
Generic parameter that can be the name of a reference rule, the number
of bins, or the bin breaks. Passed to :func:`numpy.histogram_bin_edges`.
binwidth : float
Width of each bin; overrides `bins` but can be used with `binrange`.
Note that if `binwidth` does not evenly divide the bin range, the actual
bin width used will be only approximately equal to the parameter value.
binrange : (min, max)
Lowest and highest value for bin edges; can be used with either
`bins` (when a number) or `binwidth`. Defaults to data extremes.
common_norm : bool or list of variables
When not `False`, the normalization is applied across groups. Use
`True` to normalize across all groups, or pass variable name(s) that
define normalization groups.
common_bins : bool or list of variables
When not `False`, the same bins are used for all groups. Use `True` to
share bins across all groups, or pass variable name(s) to share within.
cumulative : bool
If True, cumulate the bin values.
discrete : bool
If True, set `binwidth` and `binrange` so that bins have unit width and
are centered on integer values
Notes
-----
The choice of bins for computing and plotting a histogram can exert
substantial influence on the insights that one is able to draw from the
visualization. If the bins are too large, they may erase important features.
On the other hand, bins that are too small may be dominated by random
variability, obscuring the shape of the true underlying distribution. The
default bin size is determined using a reference rule that depends on the
sample size and variance. This works well in many cases, (i.e., with
"well-behaved" data) but it fails in others. It is always a good to try
different bin sizes to be sure that you are not missing something important.
This function allows you to specify bins in several different ways, such as
by setting the total number of bins to use, the width of each bin, or the
specific locations where the bins should break.
Examples
--------
.. include:: ../docstrings/objects.Hist.rst
"""
stat: str = "count"
bins: str | int | ArrayLike = "auto"
binwidth: float | None = None
binrange: tuple[float, float] | None = None
common_norm: bool | list[str] = True
common_bins: bool | list[str] = True
cumulative: bool = False
discrete: bool = False
def __post_init__(self):
stat_options = [
"count", "density", "percent", "probability", "proportion", "frequency"
]
self._check_param_one_of("stat", stat_options)
def _define_bin_edges(self, vals, weight, bins, binwidth, binrange, discrete):
"""Inner function that takes bin parameters as arguments."""
vals = vals.replace(-np.inf, np.nan).replace(np.inf, np.nan).dropna()
if binrange is None:
start, stop = vals.min(), vals.max()
else:
start, stop = binrange
if discrete:
bin_edges = np.arange(start - .5, stop + 1.5)
else:
if binwidth is not None:
bins = int(round((stop - start) / binwidth))
bin_edges = np.histogram_bin_edges(vals, bins, binrange, weight)
# TODO warning or cap on too many bins?
return bin_edges
def _define_bin_params(self, data, orient, scale_type):
"""Given data, return numpy.histogram parameters to define bins."""
vals = data[orient]
weights = data.get("weight", None)
# TODO We'll want this for ordinal / discrete scales too
# (Do we need discrete as a parameter or just infer from scale?)
discrete = self.discrete or scale_type == "nominal"
bin_edges = self._define_bin_edges(
vals, weights, self.bins, self.binwidth, self.binrange, discrete,
)
if isinstance(self.bins, (str, int)):
n_bins = len(bin_edges) - 1
bin_range = bin_edges.min(), bin_edges.max()
bin_kws = dict(bins=n_bins, range=bin_range)
else:
bin_kws = dict(bins=bin_edges)
return bin_kws
def _get_bins_and_eval(self, data, orient, groupby, scale_type):
bin_kws = self._define_bin_params(data, orient, scale_type)
return groupby.apply(data, self._eval, orient, bin_kws)
def _eval(self, data, orient, bin_kws):
vals = data[orient]
weights = data.get("weight", None)
density = self.stat == "density"
hist, edges = np.histogram(vals, **bin_kws, weights=weights, density=density)
width = np.diff(edges)
center = edges[:-1] + width / 2
return pd.DataFrame({orient: center, "count": hist, "space": width})
def _normalize(self, data):
hist = data["count"]
if self.stat == "probability" or self.stat == "proportion":
hist = hist.astype(float) / hist.sum()
elif self.stat == "percent":
hist = hist.astype(float) / hist.sum() * 100
elif self.stat == "frequency":
hist = hist.astype(float) / data["space"]
if self.cumulative:
if self.stat in ["density", "frequency"]:
hist = (hist * data["space"]).cumsum()
else:
hist = hist.cumsum()
return data.assign(**{self.stat: hist})
def __call__(
self, data: DataFrame, groupby: GroupBy, orient: str, scales: dict[str, Scale],
) -> DataFrame:
scale_type = scales[orient].__class__.__name__.lower()
grouping_vars = [str(v) for v in data if v in groupby.order]
if not grouping_vars or self.common_bins is True:
bin_kws = self._define_bin_params(data, orient, scale_type)
data = groupby.apply(data, self._eval, orient, bin_kws)
else:
if self.common_bins is False:
bin_groupby = GroupBy(grouping_vars)
else:
bin_groupby = GroupBy(self.common_bins)
self._check_grouping_vars("common_bins", grouping_vars)
data = bin_groupby.apply(
data, self._get_bins_and_eval, orient, groupby, scale_type,
)
if not grouping_vars or self.common_norm is True:
data = self._normalize(data)
else:
if self.common_norm is False:
norm_groupby = GroupBy(grouping_vars)
else:
norm_groupby = GroupBy(self.common_norm)
self._check_grouping_vars("common_norm", grouping_vars)
data = norm_groupby.apply(data, self._normalize)
other = {"x": "y", "y": "x"}[orient]
return data.assign(**{other: data[self.stat]})

View File

@@ -0,0 +1,214 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable
import numpy as np
from numpy import ndarray
import pandas as pd
from pandas import DataFrame
try:
from scipy.stats import gaussian_kde
_no_scipy = False
except ImportError:
from seaborn.external.kde import gaussian_kde
_no_scipy = True
from seaborn._core.groupby import GroupBy
from seaborn._core.scales import Scale
from seaborn._stats.base import Stat
@dataclass
class KDE(Stat):
"""
Compute a univariate kernel density estimate.
Parameters
----------
bw_adjust : float
Factor that multiplicatively scales the value chosen using
`bw_method`. Increasing will make the curve smoother. See Notes.
bw_method : string, scalar, or callable
Method for determining the smoothing bandwidth to use. Passed directly
to :class:`scipy.stats.gaussian_kde`; see there for options.
common_norm : bool or list of variables
If `True`, normalize so that the areas of all curves sums to 1.
If `False`, normalize each curve independently. If a list, defines
variable(s) to group by and normalize within.
common_grid : bool or list of variables
If `True`, all curves will share the same evaluation grid.
If `False`, each evaluation grid is independent. If a list, defines
variable(s) to group by and share a grid within.
gridsize : int or None
Number of points in the evaluation grid. If None, the density is
evaluated at the original datapoints.
cut : float
Factor, multiplied by the kernel bandwidth, that determines how far
the evaluation grid extends past the extreme datapoints. When set to 0,
the curve is truncated at the data limits.
cumulative : bool
If True, estimate a cumulative distribution function. Requires scipy.
Notes
-----
The *bandwidth*, or standard deviation of the smoothing kernel, is an
important parameter. Much like histogram bin width, using the wrong
bandwidth can produce a distorted representation. Over-smoothing can erase
true features, while under-smoothing can create false ones. The default
uses a rule-of-thumb that works best for distributions that are roughly
bell-shaped. It is a good idea to check the default by varying `bw_adjust`.
Because the smoothing is performed with a Gaussian kernel, the estimated
density curve can extend to values that may not make sense. For example, the
curve may be drawn over negative values when data that are naturally
positive. The `cut` parameter can be used to control the evaluation range,
but datasets that have many observations close to a natural boundary may be
better served by a different method.
Similar distortions may arise when a dataset is naturally discrete or "spiky"
(containing many repeated observations of the same value). KDEs will always
produce a smooth curve, which could be misleading.
The units on the density axis are a common source of confusion. While kernel
density estimation produces a probability distribution, the height of the curve
at each point gives a density, not a probability. A probability can be obtained
only by integrating the density across a range. The curve is normalized so
that the integral over all possible values is 1, meaning that the scale of
the density axis depends on the data values.
If scipy is installed, its cython-accelerated implementation will be used.
Examples
--------
.. include:: ../docstrings/objects.KDE.rst
"""
bw_adjust: float = 1
bw_method: str | float | Callable[[gaussian_kde], float] = "scott"
common_norm: bool | list[str] = True
common_grid: bool | list[str] = True
gridsize: int | None = 200
cut: float = 3
cumulative: bool = False
def __post_init__(self):
if self.cumulative and _no_scipy:
raise RuntimeError("Cumulative KDE evaluation requires scipy")
def _check_var_list_or_boolean(self, param: str, grouping_vars: Any) -> None:
"""Do input checks on grouping parameters."""
value = getattr(self, param)
if not (
isinstance(value, bool)
or (isinstance(value, list) and all(isinstance(v, str) for v in value))
):
param_name = f"{self.__class__.__name__}.{param}"
raise TypeError(f"{param_name} must be a boolean or list of strings.")
self._check_grouping_vars(param, grouping_vars, stacklevel=3)
def _fit(self, data: DataFrame, orient: str) -> gaussian_kde:
"""Fit and return a KDE object."""
# TODO need to handle singular data
fit_kws: dict[str, Any] = {"bw_method": self.bw_method}
if "weight" in data:
fit_kws["weights"] = data["weight"]
kde = gaussian_kde(data[orient], **fit_kws)
kde.set_bandwidth(kde.factor * self.bw_adjust)
return kde
def _get_support(self, data: DataFrame, orient: str) -> ndarray:
"""Define the grid that the KDE will be evaluated on."""
if self.gridsize is None:
return data[orient].to_numpy()
kde = self._fit(data, orient)
bw = np.sqrt(kde.covariance.squeeze())
gridmin = data[orient].min() - bw * self.cut
gridmax = data[orient].max() + bw * self.cut
return np.linspace(gridmin, gridmax, self.gridsize)
def _fit_and_evaluate(
self, data: DataFrame, orient: str, support: ndarray
) -> DataFrame:
"""Transform single group by fitting a KDE and evaluating on a support grid."""
empty = pd.DataFrame(columns=[orient, "weight", "density"], dtype=float)
if len(data) < 2:
return empty
try:
kde = self._fit(data, orient)
except np.linalg.LinAlgError:
return empty
if self.cumulative:
s_0 = support[0]
density = np.array([kde.integrate_box_1d(s_0, s_i) for s_i in support])
else:
density = kde(support)
weight = data["weight"].sum()
return pd.DataFrame({orient: support, "weight": weight, "density": density})
def _transform(
self, data: DataFrame, orient: str, grouping_vars: list[str]
) -> DataFrame:
"""Transform multiple groups by fitting KDEs and evaluating."""
empty = pd.DataFrame(columns=[*data.columns, "density"], dtype=float)
if len(data) < 2:
return empty
try:
support = self._get_support(data, orient)
except np.linalg.LinAlgError:
return empty
grouping_vars = [x for x in grouping_vars if data[x].nunique() > 1]
if not grouping_vars:
return self._fit_and_evaluate(data, orient, support)
groupby = GroupBy(grouping_vars)
return groupby.apply(data, self._fit_and_evaluate, orient, support)
def __call__(
self, data: DataFrame, groupby: GroupBy, orient: str, scales: dict[str, Scale],
) -> DataFrame:
if "weight" not in data:
data = data.assign(weight=1)
data = data.dropna(subset=[orient, "weight"])
# Transform each group separately
grouping_vars = [str(v) for v in data if v in groupby.order]
if not grouping_vars or self.common_grid is True:
res = self._transform(data, orient, grouping_vars)
else:
if self.common_grid is False:
grid_vars = grouping_vars
else:
self._check_var_list_or_boolean("common_grid", grouping_vars)
grid_vars = [v for v in self.common_grid if v in grouping_vars]
res = (
GroupBy(grid_vars)
.apply(data, self._transform, orient, grouping_vars)
)
# Normalize, potentially within groups
if not grouping_vars or self.common_norm is True:
res = res.assign(group_weight=data["weight"].sum())
else:
if self.common_norm is False:
norm_vars = grouping_vars
else:
self._check_var_list_or_boolean("common_norm", grouping_vars)
norm_vars = [v for v in self.common_norm if v in grouping_vars]
res = res.join(
data.groupby(norm_vars)["weight"].sum().rename("group_weight"),
on=norm_vars,
)
res["density"] *= res.eval("weight / group_weight")
value = {"x": "y", "y": "x"}[orient]
res[value] = res["density"]
return res.drop(["weight", "group_weight"], axis=1)

View File

@@ -0,0 +1,78 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import ClassVar, cast
try:
from typing import Literal
except ImportError:
from typing_extensions import Literal # type: ignore
import numpy as np
from pandas import DataFrame
from seaborn._core.scales import Scale
from seaborn._core.groupby import GroupBy
from seaborn._stats.base import Stat
from seaborn.utils import _version_predates
# From https://github.com/numpy/numpy/blob/main/numpy/lib/function_base.pyi
_MethodKind = Literal[
"inverted_cdf",
"averaged_inverted_cdf",
"closest_observation",
"interpolated_inverted_cdf",
"hazen",
"weibull",
"linear",
"median_unbiased",
"normal_unbiased",
"lower",
"higher",
"midpoint",
"nearest",
]
@dataclass
class Perc(Stat):
"""
Replace observations with percentile values.
Parameters
----------
k : list of numbers or int
If a list of numbers, this gives the percentiles (in [0, 100]) to compute.
If an integer, compute `k` evenly-spaced percentiles between 0 and 100.
For example, `k=5` computes the 0, 25, 50, 75, and 100th percentiles.
method : str
Method for interpolating percentiles between observed datapoints.
See :func:`numpy.percentile` for valid options and more information.
Examples
--------
.. include:: ../docstrings/objects.Perc.rst
"""
k: int | list[float] = 5
method: str = "linear"
group_by_orient: ClassVar[bool] = True
def _percentile(self, data: DataFrame, var: str) -> DataFrame:
k = list(np.linspace(0, 100, self.k)) if isinstance(self.k, int) else self.k
method = cast(_MethodKind, self.method)
values = data[var].dropna()
if _version_predates(np, "1.22"):
res = np.percentile(values, k, interpolation=method) # type: ignore
else:
res = np.percentile(data[var].dropna(), k, method=method)
return DataFrame({var: res, "percentile": k})
def __call__(
self, data: DataFrame, groupby: GroupBy, orient: str, scales: dict[str, Scale],
) -> DataFrame:
var = {"x": "y", "y": "x"}[orient]
return groupby.apply(data, self._percentile, var)

View File

@@ -0,0 +1,50 @@
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
import pandas as pd
from seaborn._stats.base import Stat
@dataclass
class PolyFit(Stat):
"""
Fit a polynomial of the given order and resample data onto predicted curve.
"""
# This is a provisional class that is useful for building out functionality.
# It may or may not change substantially in form or dissappear as we think
# through the organization of the stats subpackage.
order: int = 2
gridsize: int = 100
def _fit_predict(self, data):
x = data["x"]
y = data["y"]
if x.nunique() <= self.order:
# TODO warn?
xx = yy = []
else:
p = np.polyfit(x, y, self.order)
xx = np.linspace(x.min(), x.max(), self.gridsize)
yy = np.polyval(p, xx)
return pd.DataFrame(dict(x=xx, y=yy))
# TODO we should have a way of identifying the method that will be applied
# and then only define __call__ on a base-class of stats with this pattern
def __call__(self, data, groupby, orient, scales):
return (
groupby
.apply(data.dropna(subset=["x", "y"]), self._fit_predict)
)
@dataclass
class OLSFit(Stat):
...

View File

@@ -0,0 +1,90 @@
import numpy as np
import matplotlib as mpl
from matplotlib.colors import to_rgb, to_rgba
from numpy.testing import assert_array_equal
USE_PROPS = [
"alpha",
"edgecolor",
"facecolor",
"fill",
"hatch",
"height",
"linestyle",
"linewidth",
"paths",
"xy",
"xydata",
"sizes",
"zorder",
]
def assert_artists_equal(list1, list2):
assert len(list1) == len(list2)
for a1, a2 in zip(list1, list2):
assert a1.__class__ == a2.__class__
prop1 = a1.properties()
prop2 = a2.properties()
for key in USE_PROPS:
if key not in prop1:
continue
v1 = prop1[key]
v2 = prop2[key]
if key == "paths":
for p1, p2 in zip(v1, v2):
assert_array_equal(p1.vertices, p2.vertices)
assert_array_equal(p1.codes, p2.codes)
elif key == "color":
v1 = mpl.colors.to_rgba(v1)
v2 = mpl.colors.to_rgba(v2)
assert v1 == v2
elif isinstance(v1, np.ndarray):
assert_array_equal(v1, v2)
else:
assert v1 == v2
def assert_legends_equal(leg1, leg2):
assert leg1.get_title().get_text() == leg2.get_title().get_text()
for t1, t2 in zip(leg1.get_texts(), leg2.get_texts()):
assert t1.get_text() == t2.get_text()
assert_artists_equal(
leg1.get_patches(), leg2.get_patches(),
)
assert_artists_equal(
leg1.get_lines(), leg2.get_lines(),
)
def assert_plots_equal(ax1, ax2, labels=True):
assert_artists_equal(ax1.patches, ax2.patches)
assert_artists_equal(ax1.lines, ax2.lines)
assert_artists_equal(ax1.collections, ax2.collections)
if labels:
assert ax1.get_xlabel() == ax2.get_xlabel()
assert ax1.get_ylabel() == ax2.get_ylabel()
def assert_colors_equal(a, b, check_alpha=True):
def handle_array(x):
if isinstance(x, np.ndarray):
if x.ndim > 1:
x = np.unique(x, axis=0).squeeze()
if x.ndim > 1:
raise ValueError("Color arrays must be 1 dimensional")
return x
a = handle_array(a)
b = handle_array(b)
f = to_rgba if check_alpha else to_rgb
assert f(a) == f(b)

View File

@@ -0,0 +1,120 @@
"""Algorithms to support fitting routines in seaborn plotting functions."""
import numpy as np
import warnings
def bootstrap(*args, **kwargs):
"""Resample one or more arrays with replacement and store aggregate values.
Positional arguments are a sequence of arrays to bootstrap along the first
axis and pass to a summary function.
Keyword arguments:
n_boot : int, default=10000
Number of iterations
axis : int, default=None
Will pass axis to ``func`` as a keyword argument.
units : array, default=None
Array of sampling unit IDs. When used the bootstrap resamples units
and then observations within units instead of individual
datapoints.
func : string or callable, default="mean"
Function to call on the args that are passed in. If string, uses as
name of function in the numpy namespace. If nans are present in the
data, will try to use nan-aware version of named function.
seed : Generator | SeedSequence | RandomState | int | None
Seed for the random number generator; useful if you want
reproducible resamples.
Returns
-------
boot_dist: array
array of bootstrapped statistic values
"""
# Ensure list of arrays are same length
if len(np.unique(list(map(len, args)))) > 1:
raise ValueError("All input arrays must have the same length")
n = len(args[0])
# Default keyword arguments
n_boot = kwargs.get("n_boot", 10000)
func = kwargs.get("func", "mean")
axis = kwargs.get("axis", None)
units = kwargs.get("units", None)
random_seed = kwargs.get("random_seed", None)
if random_seed is not None:
msg = "`random_seed` has been renamed to `seed` and will be removed"
warnings.warn(msg)
seed = kwargs.get("seed", random_seed)
if axis is None:
func_kwargs = dict()
else:
func_kwargs = dict(axis=axis)
# Initialize the resampler
if isinstance(seed, np.random.RandomState):
rng = seed
else:
rng = np.random.default_rng(seed)
# Coerce to arrays
args = list(map(np.asarray, args))
if units is not None:
units = np.asarray(units)
if isinstance(func, str):
# Allow named numpy functions
f = getattr(np, func)
# Try to use nan-aware version of function if necessary
missing_data = np.isnan(np.sum(np.column_stack(args)))
if missing_data and not func.startswith("nan"):
nanf = getattr(np, f"nan{func}", None)
if nanf is None:
msg = f"Data contain nans but no nan-aware version of `{func}` found"
warnings.warn(msg, UserWarning)
else:
f = nanf
else:
f = func
# Handle numpy changes
try:
integers = rng.integers
except AttributeError:
integers = rng.randint
# Do the bootstrap
if units is not None:
return _structured_bootstrap(args, n_boot, units, f,
func_kwargs, integers)
boot_dist = []
for i in range(int(n_boot)):
resampler = integers(0, n, n, dtype=np.intp) # intp is indexing dtype
sample = [a.take(resampler, axis=0) for a in args]
boot_dist.append(f(*sample, **func_kwargs))
return np.array(boot_dist)
def _structured_bootstrap(args, n_boot, units, func, func_kwargs, integers):
"""Resample units instead of datapoints."""
unique_units = np.unique(units)
n_units = len(unique_units)
args = [[a[units == unit] for unit in unique_units] for a in args]
boot_dist = []
for i in range(int(n_boot)):
resampler = integers(0, n_units, n_units, dtype=np.intp)
sample = [[a[i] for i in resampler] for a in args]
lengths = map(len, sample[0])
resampler = [integers(0, n, n, dtype=np.intp) for n in lengths]
sample = [[c.take(r, axis=0) for c, r in zip(a, resampler)] for a in sample]
sample = list(map(np.concatenate, sample))
boot_dist.append(func(*sample, **func_kwargs))
return np.array(boot_dist)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
from .xkcd_rgb import xkcd_rgb # noqa: F401
from .crayons import crayons # noqa: F401

View File

@@ -0,0 +1,120 @@
crayons = {'Almond': '#EFDECD',
'Antique Brass': '#CD9575',
'Apricot': '#FDD9B5',
'Aquamarine': '#78DBE2',
'Asparagus': '#87A96B',
'Atomic Tangerine': '#FFA474',
'Banana Mania': '#FAE7B5',
'Beaver': '#9F8170',
'Bittersweet': '#FD7C6E',
'Black': '#000000',
'Blue': '#1F75FE',
'Blue Bell': '#A2A2D0',
'Blue Green': '#0D98BA',
'Blue Violet': '#7366BD',
'Blush': '#DE5D83',
'Brick Red': '#CB4154',
'Brown': '#B4674D',
'Burnt Orange': '#FF7F49',
'Burnt Sienna': '#EA7E5D',
'Cadet Blue': '#B0B7C6',
'Canary': '#FFFF99',
'Caribbean Green': '#00CC99',
'Carnation Pink': '#FFAACC',
'Cerise': '#DD4492',
'Cerulean': '#1DACD6',
'Chestnut': '#BC5D58',
'Copper': '#DD9475',
'Cornflower': '#9ACEEB',
'Cotton Candy': '#FFBCD9',
'Dandelion': '#FDDB6D',
'Denim': '#2B6CC4',
'Desert Sand': '#EFCDB8',
'Eggplant': '#6E5160',
'Electric Lime': '#CEFF1D',
'Fern': '#71BC78',
'Forest Green': '#6DAE81',
'Fuchsia': '#C364C5',
'Fuzzy Wuzzy': '#CC6666',
'Gold': '#E7C697',
'Goldenrod': '#FCD975',
'Granny Smith Apple': '#A8E4A0',
'Gray': '#95918C',
'Green': '#1CAC78',
'Green Yellow': '#F0E891',
'Hot Magenta': '#FF1DCE',
'Inchworm': '#B2EC5D',
'Indigo': '#5D76CB',
'Jazzberry Jam': '#CA3767',
'Jungle Green': '#3BB08F',
'Laser Lemon': '#FEFE22',
'Lavender': '#FCB4D5',
'Macaroni and Cheese': '#FFBD88',
'Magenta': '#F664AF',
'Mahogany': '#CD4A4C',
'Manatee': '#979AAA',
'Mango Tango': '#FF8243',
'Maroon': '#C8385A',
'Mauvelous': '#EF98AA',
'Melon': '#FDBCB4',
'Midnight Blue': '#1A4876',
'Mountain Meadow': '#30BA8F',
'Navy Blue': '#1974D2',
'Neon Carrot': '#FFA343',
'Olive Green': '#BAB86C',
'Orange': '#FF7538',
'Orchid': '#E6A8D7',
'Outer Space': '#414A4C',
'Outrageous Orange': '#FF6E4A',
'Pacific Blue': '#1CA9C9',
'Peach': '#FFCFAB',
'Periwinkle': '#C5D0E6',
'Piggy Pink': '#FDDDE6',
'Pine Green': '#158078',
'Pink Flamingo': '#FC74FD',
'Pink Sherbert': '#F78FA7',
'Plum': '#8E4585',
'Purple Heart': '#7442C8',
"Purple Mountains' Majesty": '#9D81BA',
'Purple Pizzazz': '#FE4EDA',
'Radical Red': '#FF496C',
'Raw Sienna': '#D68A59',
'Razzle Dazzle Rose': '#FF48D0',
'Razzmatazz': '#E3256B',
'Red': '#EE204D',
'Red Orange': '#FF5349',
'Red Violet': '#C0448F',
"Robin's Egg Blue": '#1FCECB',
'Royal Purple': '#7851A9',
'Salmon': '#FF9BAA',
'Scarlet': '#FC2847',
"Screamin' Green": '#76FF7A',
'Sea Green': '#93DFB8',
'Sepia': '#A5694F',
'Shadow': '#8A795D',
'Shamrock': '#45CEA2',
'Shocking Pink': '#FB7EFD',
'Silver': '#CDC5C2',
'Sky Blue': '#80DAEB',
'Spring Green': '#ECEABE',
'Sunglow': '#FFCF48',
'Sunset Orange': '#FD5E53',
'Tan': '#FAA76C',
'Tickle Me Pink': '#FC89AC',
'Timberwolf': '#DBD7D2',
'Tropical Rain Forest': '#17806D',
'Tumbleweed': '#DEAA88',
'Turquoise Blue': '#77DDE7',
'Unmellow Yellow': '#FFFF66',
'Violet (Purple)': '#926EAE',
'Violet Red': '#F75394',
'Vivid Tangerine': '#FFA089',
'Vivid Violet': '#8F509D',
'White': '#FFFFFF',
'Wild Blue Yonder': '#A2ADD0',
'Wild Strawberry': '#FF43A4',
'Wild Watermelon': '#FC6C85',
'Wisteria': '#CDA4DE',
'Yellow': '#FCE883',
'Yellow Green': '#C5E384',
'Yellow Orange': '#FFAE42'}

View File

@@ -0,0 +1,949 @@
xkcd_rgb = {'acid green': '#8ffe09',
'adobe': '#bd6c48',
'algae': '#54ac68',
'algae green': '#21c36f',
'almost black': '#070d0d',
'amber': '#feb308',
'amethyst': '#9b5fc0',
'apple': '#6ecb3c',
'apple green': '#76cd26',
'apricot': '#ffb16d',
'aqua': '#13eac9',
'aqua blue': '#02d8e9',
'aqua green': '#12e193',
'aqua marine': '#2ee8bb',
'aquamarine': '#04d8b2',
'army green': '#4b5d16',
'asparagus': '#77ab56',
'aubergine': '#3d0734',
'auburn': '#9a3001',
'avocado': '#90b134',
'avocado green': '#87a922',
'azul': '#1d5dec',
'azure': '#069af3',
'baby blue': '#a2cffe',
'baby green': '#8cff9e',
'baby pink': '#ffb7ce',
'baby poo': '#ab9004',
'baby poop': '#937c00',
'baby poop green': '#8f9805',
'baby puke green': '#b6c406',
'baby purple': '#ca9bf7',
'baby shit brown': '#ad900d',
'baby shit green': '#889717',
'banana': '#ffff7e',
'banana yellow': '#fafe4b',
'barbie pink': '#fe46a5',
'barf green': '#94ac02',
'barney': '#ac1db8',
'barney purple': '#a00498',
'battleship grey': '#6b7c85',
'beige': '#e6daa6',
'berry': '#990f4b',
'bile': '#b5c306',
'black': '#000000',
'bland': '#afa88b',
'blood': '#770001',
'blood orange': '#fe4b03',
'blood red': '#980002',
'blue': '#0343df',
'blue blue': '#2242c7',
'blue green': '#137e6d',
'blue grey': '#607c8e',
'blue purple': '#5729ce',
'blue violet': '#5d06e9',
'blue with a hint of purple': '#533cc6',
'blue/green': '#0f9b8e',
'blue/grey': '#758da3',
'blue/purple': '#5a06ef',
'blueberry': '#464196',
'bluegreen': '#017a79',
'bluegrey': '#85a3b2',
'bluey green': '#2bb179',
'bluey grey': '#89a0b0',
'bluey purple': '#6241c7',
'bluish': '#2976bb',
'bluish green': '#10a674',
'bluish grey': '#748b97',
'bluish purple': '#703be7',
'blurple': '#5539cc',
'blush': '#f29e8e',
'blush pink': '#fe828c',
'booger': '#9bb53c',
'booger green': '#96b403',
'bordeaux': '#7b002c',
'boring green': '#63b365',
'bottle green': '#044a05',
'brick': '#a03623',
'brick orange': '#c14a09',
'brick red': '#8f1402',
'bright aqua': '#0bf9ea',
'bright blue': '#0165fc',
'bright cyan': '#41fdfe',
'bright green': '#01ff07',
'bright lavender': '#c760ff',
'bright light blue': '#26f7fd',
'bright light green': '#2dfe54',
'bright lilac': '#c95efb',
'bright lime': '#87fd05',
'bright lime green': '#65fe08',
'bright magenta': '#ff08e8',
'bright olive': '#9cbb04',
'bright orange': '#ff5b00',
'bright pink': '#fe01b1',
'bright purple': '#be03fd',
'bright red': '#ff000d',
'bright sea green': '#05ffa6',
'bright sky blue': '#02ccfe',
'bright teal': '#01f9c6',
'bright turquoise': '#0ffef9',
'bright violet': '#ad0afd',
'bright yellow': '#fffd01',
'bright yellow green': '#9dff00',
'british racing green': '#05480d',
'bronze': '#a87900',
'brown': '#653700',
'brown green': '#706c11',
'brown grey': '#8d8468',
'brown orange': '#b96902',
'brown red': '#922b05',
'brown yellow': '#b29705',
'brownish': '#9c6d57',
'brownish green': '#6a6e09',
'brownish grey': '#86775f',
'brownish orange': '#cb7723',
'brownish pink': '#c27e79',
'brownish purple': '#76424e',
'brownish red': '#9e3623',
'brownish yellow': '#c9b003',
'browny green': '#6f6c0a',
'browny orange': '#ca6b02',
'bruise': '#7e4071',
'bubble gum pink': '#ff69af',
'bubblegum': '#ff6cb5',
'bubblegum pink': '#fe83cc',
'buff': '#fef69e',
'burgundy': '#610023',
'burnt orange': '#c04e01',
'burnt red': '#9f2305',
'burnt siena': '#b75203',
'burnt sienna': '#b04e0f',
'burnt umber': '#a0450e',
'burnt yellow': '#d5ab09',
'burple': '#6832e3',
'butter': '#ffff81',
'butter yellow': '#fffd74',
'butterscotch': '#fdb147',
'cadet blue': '#4e7496',
'camel': '#c69f59',
'camo': '#7f8f4e',
'camo green': '#526525',
'camouflage green': '#4b6113',
'canary': '#fdff63',
'canary yellow': '#fffe40',
'candy pink': '#ff63e9',
'caramel': '#af6f09',
'carmine': '#9d0216',
'carnation': '#fd798f',
'carnation pink': '#ff7fa7',
'carolina blue': '#8ab8fe',
'celadon': '#befdb7',
'celery': '#c1fd95',
'cement': '#a5a391',
'cerise': '#de0c62',
'cerulean': '#0485d1',
'cerulean blue': '#056eee',
'charcoal': '#343837',
'charcoal grey': '#3c4142',
'chartreuse': '#c1f80a',
'cherry': '#cf0234',
'cherry red': '#f7022a',
'chestnut': '#742802',
'chocolate': '#3d1c02',
'chocolate brown': '#411900',
'cinnamon': '#ac4f06',
'claret': '#680018',
'clay': '#b66a50',
'clay brown': '#b2713d',
'clear blue': '#247afd',
'cloudy blue': '#acc2d9',
'cobalt': '#1e488f',
'cobalt blue': '#030aa7',
'cocoa': '#875f42',
'coffee': '#a6814c',
'cool blue': '#4984b8',
'cool green': '#33b864',
'cool grey': '#95a3a6',
'copper': '#b66325',
'coral': '#fc5a50',
'coral pink': '#ff6163',
'cornflower': '#6a79f7',
'cornflower blue': '#5170d7',
'cranberry': '#9e003a',
'cream': '#ffffc2',
'creme': '#ffffb6',
'crimson': '#8c000f',
'custard': '#fffd78',
'cyan': '#00ffff',
'dandelion': '#fedf08',
'dark': '#1b2431',
'dark aqua': '#05696b',
'dark aquamarine': '#017371',
'dark beige': '#ac9362',
'dark blue': '#00035b',
'dark blue green': '#005249',
'dark blue grey': '#1f3b4d',
'dark brown': '#341c02',
'dark coral': '#cf524e',
'dark cream': '#fff39a',
'dark cyan': '#0a888a',
'dark forest green': '#002d04',
'dark fuchsia': '#9d0759',
'dark gold': '#b59410',
'dark grass green': '#388004',
'dark green': '#033500',
'dark green blue': '#1f6357',
'dark grey': '#363737',
'dark grey blue': '#29465b',
'dark hot pink': '#d90166',
'dark indigo': '#1f0954',
'dark khaki': '#9b8f55',
'dark lavender': '#856798',
'dark lilac': '#9c6da5',
'dark lime': '#84b701',
'dark lime green': '#7ebd01',
'dark magenta': '#960056',
'dark maroon': '#3c0008',
'dark mauve': '#874c62',
'dark mint': '#48c072',
'dark mint green': '#20c073',
'dark mustard': '#a88905',
'dark navy': '#000435',
'dark navy blue': '#00022e',
'dark olive': '#373e02',
'dark olive green': '#3c4d03',
'dark orange': '#c65102',
'dark pastel green': '#56ae57',
'dark peach': '#de7e5d',
'dark periwinkle': '#665fd1',
'dark pink': '#cb416b',
'dark plum': '#3f012c',
'dark purple': '#35063e',
'dark red': '#840000',
'dark rose': '#b5485d',
'dark royal blue': '#02066f',
'dark sage': '#598556',
'dark salmon': '#c85a53',
'dark sand': '#a88f59',
'dark sea green': '#11875d',
'dark seafoam': '#1fb57a',
'dark seafoam green': '#3eaf76',
'dark sky blue': '#448ee4',
'dark slate blue': '#214761',
'dark tan': '#af884a',
'dark taupe': '#7f684e',
'dark teal': '#014d4e',
'dark turquoise': '#045c5a',
'dark violet': '#34013f',
'dark yellow': '#d5b60a',
'dark yellow green': '#728f02',
'darkblue': '#030764',
'darkgreen': '#054907',
'darkish blue': '#014182',
'darkish green': '#287c37',
'darkish pink': '#da467d',
'darkish purple': '#751973',
'darkish red': '#a90308',
'deep aqua': '#08787f',
'deep blue': '#040273',
'deep brown': '#410200',
'deep green': '#02590f',
'deep lavender': '#8d5eb7',
'deep lilac': '#966ebd',
'deep magenta': '#a0025c',
'deep orange': '#dc4d01',
'deep pink': '#cb0162',
'deep purple': '#36013f',
'deep red': '#9a0200',
'deep rose': '#c74767',
'deep sea blue': '#015482',
'deep sky blue': '#0d75f8',
'deep teal': '#00555a',
'deep turquoise': '#017374',
'deep violet': '#490648',
'denim': '#3b638c',
'denim blue': '#3b5b92',
'desert': '#ccad60',
'diarrhea': '#9f8303',
'dirt': '#8a6e45',
'dirt brown': '#836539',
'dirty blue': '#3f829d',
'dirty green': '#667e2c',
'dirty orange': '#c87606',
'dirty pink': '#ca7b80',
'dirty purple': '#734a65',
'dirty yellow': '#cdc50a',
'dodger blue': '#3e82fc',
'drab': '#828344',
'drab green': '#749551',
'dried blood': '#4b0101',
'duck egg blue': '#c3fbf4',
'dull blue': '#49759c',
'dull brown': '#876e4b',
'dull green': '#74a662',
'dull orange': '#d8863b',
'dull pink': '#d5869d',
'dull purple': '#84597e',
'dull red': '#bb3f3f',
'dull teal': '#5f9e8f',
'dull yellow': '#eedc5b',
'dusk': '#4e5481',
'dusk blue': '#26538d',
'dusky blue': '#475f94',
'dusky pink': '#cc7a8b',
'dusky purple': '#895b7b',
'dusky rose': '#ba6873',
'dust': '#b2996e',
'dusty blue': '#5a86ad',
'dusty green': '#76a973',
'dusty lavender': '#ac86a8',
'dusty orange': '#f0833a',
'dusty pink': '#d58a94',
'dusty purple': '#825f87',
'dusty red': '#b9484e',
'dusty rose': '#c0737a',
'dusty teal': '#4c9085',
'earth': '#a2653e',
'easter green': '#8cfd7e',
'easter purple': '#c071fe',
'ecru': '#feffca',
'egg shell': '#fffcc4',
'eggplant': '#380835',
'eggplant purple': '#430541',
'eggshell': '#ffffd4',
'eggshell blue': '#c4fff7',
'electric blue': '#0652ff',
'electric green': '#21fc0d',
'electric lime': '#a8ff04',
'electric pink': '#ff0490',
'electric purple': '#aa23ff',
'emerald': '#01a049',
'emerald green': '#028f1e',
'evergreen': '#05472a',
'faded blue': '#658cbb',
'faded green': '#7bb274',
'faded orange': '#f0944d',
'faded pink': '#de9dac',
'faded purple': '#916e99',
'faded red': '#d3494e',
'faded yellow': '#feff7f',
'fawn': '#cfaf7b',
'fern': '#63a950',
'fern green': '#548d44',
'fire engine red': '#fe0002',
'flat blue': '#3c73a8',
'flat green': '#699d4c',
'fluorescent green': '#08ff08',
'fluro green': '#0aff02',
'foam green': '#90fda9',
'forest': '#0b5509',
'forest green': '#06470c',
'forrest green': '#154406',
'french blue': '#436bad',
'fresh green': '#69d84f',
'frog green': '#58bc08',
'fuchsia': '#ed0dd9',
'gold': '#dbb40c',
'golden': '#f5bf03',
'golden brown': '#b27a01',
'golden rod': '#f9bc08',
'golden yellow': '#fec615',
'goldenrod': '#fac205',
'grape': '#6c3461',
'grape purple': '#5d1451',
'grapefruit': '#fd5956',
'grass': '#5cac2d',
'grass green': '#3f9b0b',
'grassy green': '#419c03',
'green': '#15b01a',
'green apple': '#5edc1f',
'green blue': '#06b48b',
'green brown': '#544e03',
'green grey': '#77926f',
'green teal': '#0cb577',
'green yellow': '#c9ff27',
'green/blue': '#01c08d',
'green/yellow': '#b5ce08',
'greenblue': '#23c48b',
'greenish': '#40a368',
'greenish beige': '#c9d179',
'greenish blue': '#0b8b87',
'greenish brown': '#696112',
'greenish cyan': '#2afeb7',
'greenish grey': '#96ae8d',
'greenish tan': '#bccb7a',
'greenish teal': '#32bf84',
'greenish turquoise': '#00fbb0',
'greenish yellow': '#cdfd02',
'greeny blue': '#42b395',
'greeny brown': '#696006',
'greeny grey': '#7ea07a',
'greeny yellow': '#c6f808',
'grey': '#929591',
'grey blue': '#6b8ba4',
'grey brown': '#7f7053',
'grey green': '#789b73',
'grey pink': '#c3909b',
'grey purple': '#826d8c',
'grey teal': '#5e9b8a',
'grey/blue': '#647d8e',
'grey/green': '#86a17d',
'greyblue': '#77a1b5',
'greyish': '#a8a495',
'greyish blue': '#5e819d',
'greyish brown': '#7a6a4f',
'greyish green': '#82a67d',
'greyish pink': '#c88d94',
'greyish purple': '#887191',
'greyish teal': '#719f91',
'gross green': '#a0bf16',
'gunmetal': '#536267',
'hazel': '#8e7618',
'heather': '#a484ac',
'heliotrope': '#d94ff5',
'highlighter green': '#1bfc06',
'hospital green': '#9be5aa',
'hot green': '#25ff29',
'hot magenta': '#f504c9',
'hot pink': '#ff028d',
'hot purple': '#cb00f5',
'hunter green': '#0b4008',
'ice': '#d6fffa',
'ice blue': '#d7fffe',
'icky green': '#8fae22',
'indian red': '#850e04',
'indigo': '#380282',
'indigo blue': '#3a18b1',
'iris': '#6258c4',
'irish green': '#019529',
'ivory': '#ffffcb',
'jade': '#1fa774',
'jade green': '#2baf6a',
'jungle green': '#048243',
'kelley green': '#009337',
'kelly green': '#02ab2e',
'kermit green': '#5cb200',
'key lime': '#aeff6e',
'khaki': '#aaa662',
'khaki green': '#728639',
'kiwi': '#9cef43',
'kiwi green': '#8ee53f',
'lavender': '#c79fef',
'lavender blue': '#8b88f8',
'lavender pink': '#dd85d7',
'lawn green': '#4da409',
'leaf': '#71aa34',
'leaf green': '#5ca904',
'leafy green': '#51b73b',
'leather': '#ac7434',
'lemon': '#fdff52',
'lemon green': '#adf802',
'lemon lime': '#bffe28',
'lemon yellow': '#fdff38',
'lichen': '#8fb67b',
'light aqua': '#8cffdb',
'light aquamarine': '#7bfdc7',
'light beige': '#fffeb6',
'light blue': '#95d0fc',
'light blue green': '#7efbb3',
'light blue grey': '#b7c9e2',
'light bluish green': '#76fda8',
'light bright green': '#53fe5c',
'light brown': '#ad8150',
'light burgundy': '#a8415b',
'light cyan': '#acfffc',
'light eggplant': '#894585',
'light forest green': '#4f9153',
'light gold': '#fddc5c',
'light grass green': '#9af764',
'light green': '#96f97b',
'light green blue': '#56fca2',
'light greenish blue': '#63f7b4',
'light grey': '#d8dcd6',
'light grey blue': '#9dbcd4',
'light grey green': '#b7e1a1',
'light indigo': '#6d5acf',
'light khaki': '#e6f2a2',
'light lavendar': '#efc0fe',
'light lavender': '#dfc5fe',
'light light blue': '#cafffb',
'light light green': '#c8ffb0',
'light lilac': '#edc8ff',
'light lime': '#aefd6c',
'light lime green': '#b9ff66',
'light magenta': '#fa5ff7',
'light maroon': '#a24857',
'light mauve': '#c292a1',
'light mint': '#b6ffbb',
'light mint green': '#a6fbb2',
'light moss green': '#a6c875',
'light mustard': '#f7d560',
'light navy': '#155084',
'light navy blue': '#2e5a88',
'light neon green': '#4efd54',
'light olive': '#acbf69',
'light olive green': '#a4be5c',
'light orange': '#fdaa48',
'light pastel green': '#b2fba5',
'light pea green': '#c4fe82',
'light peach': '#ffd8b1',
'light periwinkle': '#c1c6fc',
'light pink': '#ffd1df',
'light plum': '#9d5783',
'light purple': '#bf77f6',
'light red': '#ff474c',
'light rose': '#ffc5cb',
'light royal blue': '#3a2efe',
'light sage': '#bcecac',
'light salmon': '#fea993',
'light sea green': '#98f6b0',
'light seafoam': '#a0febf',
'light seafoam green': '#a7ffb5',
'light sky blue': '#c6fcff',
'light tan': '#fbeeac',
'light teal': '#90e4c1',
'light turquoise': '#7ef4cc',
'light urple': '#b36ff6',
'light violet': '#d6b4fc',
'light yellow': '#fffe7a',
'light yellow green': '#ccfd7f',
'light yellowish green': '#c2ff89',
'lightblue': '#7bc8f6',
'lighter green': '#75fd63',
'lighter purple': '#a55af4',
'lightgreen': '#76ff7b',
'lightish blue': '#3d7afd',
'lightish green': '#61e160',
'lightish purple': '#a552e6',
'lightish red': '#fe2f4a',
'lilac': '#cea2fd',
'liliac': '#c48efd',
'lime': '#aaff32',
'lime green': '#89fe05',
'lime yellow': '#d0fe1d',
'lipstick': '#d5174e',
'lipstick red': '#c0022f',
'macaroni and cheese': '#efb435',
'magenta': '#c20078',
'mahogany': '#4a0100',
'maize': '#f4d054',
'mango': '#ffa62b',
'manilla': '#fffa86',
'marigold': '#fcc006',
'marine': '#042e60',
'marine blue': '#01386a',
'maroon': '#650021',
'mauve': '#ae7181',
'medium blue': '#2c6fbb',
'medium brown': '#7f5112',
'medium green': '#39ad48',
'medium grey': '#7d7f7c',
'medium pink': '#f36196',
'medium purple': '#9e43a2',
'melon': '#ff7855',
'merlot': '#730039',
'metallic blue': '#4f738e',
'mid blue': '#276ab3',
'mid green': '#50a747',
'midnight': '#03012d',
'midnight blue': '#020035',
'midnight purple': '#280137',
'military green': '#667c3e',
'milk chocolate': '#7f4e1e',
'mint': '#9ffeb0',
'mint green': '#8fff9f',
'minty green': '#0bf77d',
'mocha': '#9d7651',
'moss': '#769958',
'moss green': '#658b38',
'mossy green': '#638b27',
'mud': '#735c12',
'mud brown': '#60460f',
'mud green': '#606602',
'muddy brown': '#886806',
'muddy green': '#657432',
'muddy yellow': '#bfac05',
'mulberry': '#920a4e',
'murky green': '#6c7a0e',
'mushroom': '#ba9e88',
'mustard': '#ceb301',
'mustard brown': '#ac7e04',
'mustard green': '#a8b504',
'mustard yellow': '#d2bd0a',
'muted blue': '#3b719f',
'muted green': '#5fa052',
'muted pink': '#d1768f',
'muted purple': '#805b87',
'nasty green': '#70b23f',
'navy': '#01153e',
'navy blue': '#001146',
'navy green': '#35530a',
'neon blue': '#04d9ff',
'neon green': '#0cff0c',
'neon pink': '#fe019a',
'neon purple': '#bc13fe',
'neon red': '#ff073a',
'neon yellow': '#cfff04',
'nice blue': '#107ab0',
'night blue': '#040348',
'ocean': '#017b92',
'ocean blue': '#03719c',
'ocean green': '#3d9973',
'ocher': '#bf9b0c',
'ochre': '#bf9005',
'ocre': '#c69c04',
'off blue': '#5684ae',
'off green': '#6ba353',
'off white': '#ffffe4',
'off yellow': '#f1f33f',
'old pink': '#c77986',
'old rose': '#c87f89',
'olive': '#6e750e',
'olive brown': '#645403',
'olive drab': '#6f7632',
'olive green': '#677a04',
'olive yellow': '#c2b709',
'orange': '#f97306',
'orange brown': '#be6400',
'orange pink': '#ff6f52',
'orange red': '#fd411e',
'orange yellow': '#ffad01',
'orangeish': '#fd8d49',
'orangered': '#fe420f',
'orangey brown': '#b16002',
'orangey red': '#fa4224',
'orangey yellow': '#fdb915',
'orangish': '#fc824a',
'orangish brown': '#b25f03',
'orangish red': '#f43605',
'orchid': '#c875c4',
'pale': '#fff9d0',
'pale aqua': '#b8ffeb',
'pale blue': '#d0fefe',
'pale brown': '#b1916e',
'pale cyan': '#b7fffa',
'pale gold': '#fdde6c',
'pale green': '#c7fdb5',
'pale grey': '#fdfdfe',
'pale lavender': '#eecffe',
'pale light green': '#b1fc99',
'pale lilac': '#e4cbff',
'pale lime': '#befd73',
'pale lime green': '#b1ff65',
'pale magenta': '#d767ad',
'pale mauve': '#fed0fc',
'pale olive': '#b9cc81',
'pale olive green': '#b1d27b',
'pale orange': '#ffa756',
'pale peach': '#ffe5ad',
'pale pink': '#ffcfdc',
'pale purple': '#b790d4',
'pale red': '#d9544d',
'pale rose': '#fdc1c5',
'pale salmon': '#ffb19a',
'pale sky blue': '#bdf6fe',
'pale teal': '#82cbb2',
'pale turquoise': '#a5fbd5',
'pale violet': '#ceaefa',
'pale yellow': '#ffff84',
'parchment': '#fefcaf',
'pastel blue': '#a2bffe',
'pastel green': '#b0ff9d',
'pastel orange': '#ff964f',
'pastel pink': '#ffbacd',
'pastel purple': '#caa0ff',
'pastel red': '#db5856',
'pastel yellow': '#fffe71',
'pea': '#a4bf20',
'pea green': '#8eab12',
'pea soup': '#929901',
'pea soup green': '#94a617',
'peach': '#ffb07c',
'peachy pink': '#ff9a8a',
'peacock blue': '#016795',
'pear': '#cbf85f',
'periwinkle': '#8e82fe',
'periwinkle blue': '#8f99fb',
'perrywinkle': '#8f8ce7',
'petrol': '#005f6a',
'pig pink': '#e78ea5',
'pine': '#2b5d34',
'pine green': '#0a481e',
'pink': '#ff81c0',
'pink purple': '#db4bda',
'pink red': '#f5054f',
'pink/purple': '#ef1de7',
'pinkish': '#d46a7e',
'pinkish brown': '#b17261',
'pinkish grey': '#c8aca9',
'pinkish orange': '#ff724c',
'pinkish purple': '#d648d7',
'pinkish red': '#f10c45',
'pinkish tan': '#d99b82',
'pinky': '#fc86aa',
'pinky purple': '#c94cbe',
'pinky red': '#fc2647',
'piss yellow': '#ddd618',
'pistachio': '#c0fa8b',
'plum': '#580f41',
'plum purple': '#4e0550',
'poison green': '#40fd14',
'poo': '#8f7303',
'poo brown': '#885f01',
'poop': '#7f5e00',
'poop brown': '#7a5901',
'poop green': '#6f7c00',
'powder blue': '#b1d1fc',
'powder pink': '#ffb2d0',
'primary blue': '#0804f9',
'prussian blue': '#004577',
'puce': '#a57e52',
'puke': '#a5a502',
'puke brown': '#947706',
'puke green': '#9aae07',
'puke yellow': '#c2be0e',
'pumpkin': '#e17701',
'pumpkin orange': '#fb7d07',
'pure blue': '#0203e2',
'purple': '#7e1e9c',
'purple blue': '#632de9',
'purple brown': '#673a3f',
'purple grey': '#866f85',
'purple pink': '#e03fd8',
'purple red': '#990147',
'purple/blue': '#5d21d0',
'purple/pink': '#d725de',
'purpleish': '#98568d',
'purpleish blue': '#6140ef',
'purpleish pink': '#df4ec8',
'purpley': '#8756e4',
'purpley blue': '#5f34e7',
'purpley grey': '#947e94',
'purpley pink': '#c83cb9',
'purplish': '#94568c',
'purplish blue': '#601ef9',
'purplish brown': '#6b4247',
'purplish grey': '#7a687f',
'purplish pink': '#ce5dae',
'purplish red': '#b0054b',
'purply': '#983fb2',
'purply blue': '#661aee',
'purply pink': '#f075e6',
'putty': '#beae8a',
'racing green': '#014600',
'radioactive green': '#2cfa1f',
'raspberry': '#b00149',
'raw sienna': '#9a6200',
'raw umber': '#a75e09',
'really light blue': '#d4ffff',
'red': '#e50000',
'red brown': '#8b2e16',
'red orange': '#fd3c06',
'red pink': '#fa2a55',
'red purple': '#820747',
'red violet': '#9e0168',
'red wine': '#8c0034',
'reddish': '#c44240',
'reddish brown': '#7f2b0a',
'reddish grey': '#997570',
'reddish orange': '#f8481c',
'reddish pink': '#fe2c54',
'reddish purple': '#910951',
'reddy brown': '#6e1005',
'rich blue': '#021bf9',
'rich purple': '#720058',
'robin egg blue': '#8af1fe',
"robin's egg": '#6dedfd',
"robin's egg blue": '#98eff9',
'rosa': '#fe86a4',
'rose': '#cf6275',
'rose pink': '#f7879a',
'rose red': '#be013c',
'rosy pink': '#f6688e',
'rouge': '#ab1239',
'royal': '#0c1793',
'royal blue': '#0504aa',
'royal purple': '#4b006e',
'ruby': '#ca0147',
'russet': '#a13905',
'rust': '#a83c09',
'rust brown': '#8b3103',
'rust orange': '#c45508',
'rust red': '#aa2704',
'rusty orange': '#cd5909',
'rusty red': '#af2f0d',
'saffron': '#feb209',
'sage': '#87ae73',
'sage green': '#88b378',
'salmon': '#ff796c',
'salmon pink': '#fe7b7c',
'sand': '#e2ca76',
'sand brown': '#cba560',
'sand yellow': '#fce166',
'sandstone': '#c9ae74',
'sandy': '#f1da7a',
'sandy brown': '#c4a661',
'sandy yellow': '#fdee73',
'sap green': '#5c8b15',
'sapphire': '#2138ab',
'scarlet': '#be0119',
'sea': '#3c9992',
'sea blue': '#047495',
'sea green': '#53fca1',
'seafoam': '#80f9ad',
'seafoam blue': '#78d1b6',
'seafoam green': '#7af9ab',
'seaweed': '#18d17b',
'seaweed green': '#35ad6b',
'sepia': '#985e2b',
'shamrock': '#01b44c',
'shamrock green': '#02c14d',
'shit': '#7f5f00',
'shit brown': '#7b5804',
'shit green': '#758000',
'shocking pink': '#fe02a2',
'sick green': '#9db92c',
'sickly green': '#94b21c',
'sickly yellow': '#d0e429',
'sienna': '#a9561e',
'silver': '#c5c9c7',
'sky': '#82cafc',
'sky blue': '#75bbfd',
'slate': '#516572',
'slate blue': '#5b7c99',
'slate green': '#658d6d',
'slate grey': '#59656d',
'slime green': '#99cc04',
'snot': '#acbb0d',
'snot green': '#9dc100',
'soft blue': '#6488ea',
'soft green': '#6fc276',
'soft pink': '#fdb0c0',
'soft purple': '#a66fb5',
'spearmint': '#1ef876',
'spring green': '#a9f971',
'spruce': '#0a5f38',
'squash': '#f2ab15',
'steel': '#738595',
'steel blue': '#5a7d9a',
'steel grey': '#6f828a',
'stone': '#ada587',
'stormy blue': '#507b9c',
'straw': '#fcf679',
'strawberry': '#fb2943',
'strong blue': '#0c06f7',
'strong pink': '#ff0789',
'sun yellow': '#ffdf22',
'sunflower': '#ffc512',
'sunflower yellow': '#ffda03',
'sunny yellow': '#fff917',
'sunshine yellow': '#fffd37',
'swamp': '#698339',
'swamp green': '#748500',
'tan': '#d1b26f',
'tan brown': '#ab7e4c',
'tan green': '#a9be70',
'tangerine': '#ff9408',
'taupe': '#b9a281',
'tea': '#65ab7c',
'tea green': '#bdf8a3',
'teal': '#029386',
'teal blue': '#01889f',
'teal green': '#25a36f',
'tealish': '#24bca8',
'tealish green': '#0cdc73',
'terra cotta': '#c9643b',
'terracota': '#cb6843',
'terracotta': '#ca6641',
'tiffany blue': '#7bf2da',
'tomato': '#ef4026',
'tomato red': '#ec2d01',
'topaz': '#13bbaf',
'toupe': '#c7ac7d',
'toxic green': '#61de2a',
'tree green': '#2a7e19',
'true blue': '#010fcc',
'true green': '#089404',
'turquoise': '#06c2ac',
'turquoise blue': '#06b1c4',
'turquoise green': '#04f489',
'turtle green': '#75b84f',
'twilight': '#4e518b',
'twilight blue': '#0a437a',
'ugly blue': '#31668a',
'ugly brown': '#7d7103',
'ugly green': '#7a9703',
'ugly pink': '#cd7584',
'ugly purple': '#a442a0',
'ugly yellow': '#d0c101',
'ultramarine': '#2000b1',
'ultramarine blue': '#1805db',
'umber': '#b26400',
'velvet': '#750851',
'vermillion': '#f4320c',
'very dark blue': '#000133',
'very dark brown': '#1d0200',
'very dark green': '#062e03',
'very dark purple': '#2a0134',
'very light blue': '#d5ffff',
'very light brown': '#d3b683',
'very light green': '#d1ffbd',
'very light pink': '#fff4f2',
'very light purple': '#f6cefc',
'very pale blue': '#d6fffe',
'very pale green': '#cffdbc',
'vibrant blue': '#0339f8',
'vibrant green': '#0add08',
'vibrant purple': '#ad03de',
'violet': '#9a0eea',
'violet blue': '#510ac9',
'violet pink': '#fb5ffc',
'violet red': '#a50055',
'viridian': '#1e9167',
'vivid blue': '#152eff',
'vivid green': '#2fef10',
'vivid purple': '#9900fa',
'vomit': '#a2a415',
'vomit green': '#89a203',
'vomit yellow': '#c7c10c',
'warm blue': '#4b57db',
'warm brown': '#964e02',
'warm grey': '#978a84',
'warm pink': '#fb5581',
'warm purple': '#952e8f',
'washed out green': '#bcf5a6',
'water blue': '#0e87cc',
'watermelon': '#fd4659',
'weird green': '#3ae57f',
'wheat': '#fbdd7e',
'white': '#ffffff',
'windows blue': '#3778bf',
'wine': '#80013f',
'wine red': '#7b0323',
'wintergreen': '#20f986',
'wisteria': '#a87dc2',
'yellow': '#ffff14',
'yellow brown': '#b79400',
'yellow green': '#c0fb2d',
'yellow ochre': '#cb9d06',
'yellow orange': '#fcb001',
'yellow tan': '#ffe36e',
'yellow/green': '#c8fd3d',
'yellowgreen': '#bbf90f',
'yellowish': '#faee66',
'yellowish brown': '#9b7a01',
'yellowish green': '#b0dd16',
'yellowish orange': '#ffab0f',
'yellowish tan': '#fcfc81',
'yellowy brown': '#ae8b0c',
'yellowy green': '#bff128'}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,245 @@
#!/usr/bin/env python3
# Copyright (c) 2005-2010 ActiveState Software Inc.
# Copyright (c) 2013 Eddy Petrișor
# flake8: noqa
"""
This file is directly from
https://github.com/ActiveState/appdirs/blob/3fe6a83776843a46f20c2e5587afcffe05e03b39/appdirs.py
The license of https://github.com/ActiveState/appdirs copied below:
# This is the MIT license
Copyright (c) 2010 ActiveState Software Inc.
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
"""
"""Utilities for determining application-specific dirs.
See <https://github.com/ActiveState/appdirs> for details and usage.
"""
# Dev Notes:
# - MSDN on where to store app data files:
# http://support.microsoft.com/default.aspx?scid=kb;en-us;310294#XSLTH3194121123120121120120
# - Mac OS X: http://developer.apple.com/documentation/MacOSX/Conceptual/BPFileSystem/index.html
# - XDG spec for Un*x: https://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html
__version__ = "1.4.4"
__version_info__ = tuple(int(segment) for segment in __version__.split("."))
import sys
import os
unicode = str
if sys.platform.startswith('java'):
import platform
os_name = platform.java_ver()[3][0]
if os_name.startswith('Windows'): # "Windows XP", "Windows 7", etc.
system = 'win32'
elif os_name.startswith('Mac'): # "Mac OS X", etc.
system = 'darwin'
else: # "Linux", "SunOS", "FreeBSD", etc.
# Setting this to "linux2" is not ideal, but only Windows or Mac
# are actually checked for and the rest of the module expects
# *sys.platform* style strings.
system = 'linux2'
else:
system = sys.platform
def user_cache_dir(appname=None, appauthor=None, version=None, opinion=True):
r"""Return full path to the user-specific cache dir for this application.
"appname" is the name of application.
If None, just the system directory is returned.
"appauthor" (only used on Windows) is the name of the
appauthor or distributing body for this application. Typically
it is the owning company name. This falls back to appname. You may
pass False to disable it.
"version" is an optional version path element to append to the
path. You might want to use this if you want multiple versions
of your app to be able to run independently. If used, this
would typically be "<major>.<minor>".
Only applied when appname is present.
"opinion" (boolean) can be False to disable the appending of
"Cache" to the base app data dir for Windows. See
discussion below.
Typical user cache directories are:
Mac OS X: ~/Library/Caches/<AppName>
Unix: ~/.cache/<AppName> (XDG default)
Win XP: C:\Documents and Settings\<username>\Local Settings\Application Data\<AppAuthor>\<AppName>\Cache
Vista: C:\Users\<username>\AppData\Local\<AppAuthor>\<AppName>\Cache
On Windows the only suggestion in the MSDN docs is that local settings go in
the `CSIDL_LOCAL_APPDATA` directory. This is identical to the non-roaming
app data dir (the default returned by `user_data_dir` above). Apps typically
put cache data somewhere *under* the given dir here. Some examples:
...\Mozilla\Firefox\Profiles\<ProfileName>\Cache
...\Acme\SuperApp\Cache\1.0
OPINION: This function appends "Cache" to the `CSIDL_LOCAL_APPDATA` value.
This can be disabled with the `opinion=False` option.
"""
if system == "win32":
if appauthor is None:
appauthor = appname
path = os.path.normpath(_get_win_folder("CSIDL_LOCAL_APPDATA"))
if appname:
if appauthor is not False:
path = os.path.join(path, appauthor, appname)
else:
path = os.path.join(path, appname)
if opinion:
path = os.path.join(path, "Cache")
elif system == 'darwin':
path = os.path.expanduser('~/Library/Caches')
if appname:
path = os.path.join(path, appname)
else:
path = os.getenv('XDG_CACHE_HOME', os.path.expanduser('~/.cache'))
if appname:
path = os.path.join(path, appname)
if appname and version:
path = os.path.join(path, version)
return path
#---- internal support stuff
def _get_win_folder_from_registry(csidl_name):
"""This is a fallback technique at best. I'm not sure if using the
registry for this guarantees us the correct answer for all CSIDL_*
names.
"""
import winreg as _winreg
shell_folder_name = {
"CSIDL_APPDATA": "AppData",
"CSIDL_COMMON_APPDATA": "Common AppData",
"CSIDL_LOCAL_APPDATA": "Local AppData",
}[csidl_name]
key = _winreg.OpenKey(
_winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders"
)
dir, type = _winreg.QueryValueEx(key, shell_folder_name)
return dir
def _get_win_folder_with_pywin32(csidl_name):
from win32com.shell import shellcon, shell
dir = shell.SHGetFolderPath(0, getattr(shellcon, csidl_name), 0, 0)
# Try to make this a unicode path because SHGetFolderPath does
# not return unicode strings when there is unicode data in the
# path.
try:
dir = unicode(dir)
# Downgrade to short path name if have highbit chars. See
# <http://bugs.activestate.com/show_bug.cgi?id=85099>.
has_high_char = False
for c in dir:
if ord(c) > 255:
has_high_char = True
break
if has_high_char:
try:
import win32api
dir = win32api.GetShortPathName(dir)
except ImportError:
pass
except UnicodeError:
pass
return dir
def _get_win_folder_with_ctypes(csidl_name):
import ctypes
csidl_const = {
"CSIDL_APPDATA": 26,
"CSIDL_COMMON_APPDATA": 35,
"CSIDL_LOCAL_APPDATA": 28,
}[csidl_name]
buf = ctypes.create_unicode_buffer(1024)
ctypes.windll.shell32.SHGetFolderPathW(None, csidl_const, None, 0, buf)
# Downgrade to short path name if have highbit chars. See
# <http://bugs.activestate.com/show_bug.cgi?id=85099>.
has_high_char = False
for c in buf:
if ord(c) > 255:
has_high_char = True
break
if has_high_char:
buf2 = ctypes.create_unicode_buffer(1024)
if ctypes.windll.kernel32.GetShortPathNameW(buf.value, buf2, 1024):
buf = buf2
return buf.value
def _get_win_folder_with_jna(csidl_name):
import array
from com.sun import jna
from com.sun.jna.platform import win32
buf_size = win32.WinDef.MAX_PATH * 2
buf = array.zeros('c', buf_size)
shell = win32.Shell32.INSTANCE
shell.SHGetFolderPath(None, getattr(win32.ShlObj, csidl_name), None, win32.ShlObj.SHGFP_TYPE_CURRENT, buf)
dir = jna.Native.toString(buf.tostring()).rstrip("\0")
# Downgrade to short path name if have highbit chars. See
# <http://bugs.activestate.com/show_bug.cgi?id=85099>.
has_high_char = False
for c in dir:
if ord(c) > 255:
has_high_char = True
break
if has_high_char:
buf = array.zeros('c', buf_size)
kernel = win32.Kernel32.INSTANCE
if kernel.GetShortPathName(dir, buf, buf_size):
dir = jna.Native.toString(buf.tostring()).rstrip("\0")
return dir
if system == "win32":
try:
import win32com.shell
_get_win_folder = _get_win_folder_with_pywin32
except ImportError:
try:
from ctypes import windll
_get_win_folder = _get_win_folder_with_ctypes
except ImportError:
try:
import com.sun.jna
_get_win_folder = _get_win_folder_with_jna
except ImportError:
_get_win_folder = _get_win_folder_from_registry

View File

@@ -0,0 +1,715 @@
"""Extract reference documentation from the NumPy source tree.
Copyright (C) 2008 Stefan van der Walt <stefan@mentat.za.net>, Pauli Virtanen <pav@iki.fi>
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT,
INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING
IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
"""
import inspect
import textwrap
import re
import pydoc
from warnings import warn
from collections import namedtuple
from collections.abc import Callable, Mapping
import copy
import sys
def strip_blank_lines(l):
"Remove leading and trailing blank lines from a list of lines"
while l and not l[0].strip():
del l[0]
while l and not l[-1].strip():
del l[-1]
return l
class Reader:
"""A line-based string reader.
"""
def __init__(self, data):
"""
Parameters
----------
data : str
String with lines separated by '\n'.
"""
if isinstance(data, list):
self._str = data
else:
self._str = data.split('\n') # store string as list of lines
self.reset()
def __getitem__(self, n):
return self._str[n]
def reset(self):
self._l = 0 # current line nr
def read(self):
if not self.eof():
out = self[self._l]
self._l += 1
return out
else:
return ''
def seek_next_non_empty_line(self):
for l in self[self._l:]:
if l.strip():
break
else:
self._l += 1
def eof(self):
return self._l >= len(self._str)
def read_to_condition(self, condition_func):
start = self._l
for line in self[start:]:
if condition_func(line):
return self[start:self._l]
self._l += 1
if self.eof():
return self[start:self._l+1]
return []
def read_to_next_empty_line(self):
self.seek_next_non_empty_line()
def is_empty(line):
return not line.strip()
return self.read_to_condition(is_empty)
def read_to_next_unindented_line(self):
def is_unindented(line):
return (line.strip() and (len(line.lstrip()) == len(line)))
return self.read_to_condition(is_unindented)
def peek(self, n=0):
if self._l + n < len(self._str):
return self[self._l + n]
else:
return ''
def is_empty(self):
return not ''.join(self._str).strip()
class ParseError(Exception):
def __str__(self):
message = self.args[0]
if hasattr(self, 'docstring'):
message = f"{message} in {self.docstring!r}"
return message
Parameter = namedtuple('Parameter', ['name', 'type', 'desc'])
class NumpyDocString(Mapping):
"""Parses a numpydoc string to an abstract representation
Instances define a mapping from section title to structured data.
"""
sections = {
'Signature': '',
'Summary': [''],
'Extended Summary': [],
'Parameters': [],
'Returns': [],
'Yields': [],
'Receives': [],
'Raises': [],
'Warns': [],
'Other Parameters': [],
'Attributes': [],
'Methods': [],
'See Also': [],
'Notes': [],
'Warnings': [],
'References': '',
'Examples': '',
'index': {}
}
def __init__(self, docstring, config={}):
orig_docstring = docstring
docstring = textwrap.dedent(docstring).split('\n')
self._doc = Reader(docstring)
self._parsed_data = copy.deepcopy(self.sections)
try:
self._parse()
except ParseError as e:
e.docstring = orig_docstring
raise
def __getitem__(self, key):
return self._parsed_data[key]
def __setitem__(self, key, val):
if key not in self._parsed_data:
self._error_location(f"Unknown section {key}", error=False)
else:
self._parsed_data[key] = val
def __iter__(self):
return iter(self._parsed_data)
def __len__(self):
return len(self._parsed_data)
def _is_at_section(self):
self._doc.seek_next_non_empty_line()
if self._doc.eof():
return False
l1 = self._doc.peek().strip() # e.g. Parameters
if l1.startswith('.. index::'):
return True
l2 = self._doc.peek(1).strip() # ---------- or ==========
return l2.startswith('-'*len(l1)) or l2.startswith('='*len(l1))
def _strip(self, doc):
i = 0
j = 0
for i, line in enumerate(doc):
if line.strip():
break
for j, line in enumerate(doc[::-1]):
if line.strip():
break
return doc[i:len(doc)-j]
def _read_to_next_section(self):
section = self._doc.read_to_next_empty_line()
while not self._is_at_section() and not self._doc.eof():
if not self._doc.peek(-1).strip(): # previous line was empty
section += ['']
section += self._doc.read_to_next_empty_line()
return section
def _read_sections(self):
while not self._doc.eof():
data = self._read_to_next_section()
name = data[0].strip()
if name.startswith('..'): # index section
yield name, data[1:]
elif len(data) < 2:
yield StopIteration
else:
yield name, self._strip(data[2:])
def _parse_param_list(self, content, single_element_is_type=False):
r = Reader(content)
params = []
while not r.eof():
header = r.read().strip()
if ' : ' in header:
arg_name, arg_type = header.split(' : ')[:2]
else:
if single_element_is_type:
arg_name, arg_type = '', header
else:
arg_name, arg_type = header, ''
desc = r.read_to_next_unindented_line()
desc = dedent_lines(desc)
desc = strip_blank_lines(desc)
params.append(Parameter(arg_name, arg_type, desc))
return params
# See also supports the following formats.
#
# <FUNCNAME>
# <FUNCNAME> SPACE* COLON SPACE+ <DESC> SPACE*
# <FUNCNAME> ( COMMA SPACE+ <FUNCNAME>)+ (COMMA | PERIOD)? SPACE*
# <FUNCNAME> ( COMMA SPACE+ <FUNCNAME>)* SPACE* COLON SPACE+ <DESC> SPACE*
# <FUNCNAME> is one of
# <PLAIN_FUNCNAME>
# COLON <ROLE> COLON BACKTICK <PLAIN_FUNCNAME> BACKTICK
# where
# <PLAIN_FUNCNAME> is a legal function name, and
# <ROLE> is any nonempty sequence of word characters.
# Examples: func_f1 :meth:`func_h1` :obj:`~baz.obj_r` :class:`class_j`
# <DESC> is a string describing the function.
_role = r":(?P<role>\w+):"
_funcbacktick = r"`(?P<name>(?:~\w+\.)?[a-zA-Z0-9_\.-]+)`"
_funcplain = r"(?P<name2>[a-zA-Z0-9_\.-]+)"
_funcname = r"(" + _role + _funcbacktick + r"|" + _funcplain + r")"
_funcnamenext = _funcname.replace('role', 'rolenext')
_funcnamenext = _funcnamenext.replace('name', 'namenext')
_description = r"(?P<description>\s*:(\s+(?P<desc>\S+.*))?)?\s*$"
_func_rgx = re.compile(r"^\s*" + _funcname + r"\s*")
_line_rgx = re.compile(
r"^\s*" +
r"(?P<allfuncs>" + # group for all function names
_funcname +
r"(?P<morefuncs>([,]\s+" + _funcnamenext + r")*)" +
r")" + # end of "allfuncs"
r"(?P<trailing>[,\.])?" + # Some function lists have a trailing comma (or period) '\s*'
_description)
# Empty <DESC> elements are replaced with '..'
empty_description = '..'
def _parse_see_also(self, content):
"""
func_name : Descriptive text
continued text
another_func_name : Descriptive text
func_name1, func_name2, :meth:`func_name`, func_name3
"""
items = []
def parse_item_name(text):
"""Match ':role:`name`' or 'name'."""
m = self._func_rgx.match(text)
if not m:
raise ParseError(f"{text} is not a item name")
role = m.group('role')
name = m.group('name') if role else m.group('name2')
return name, role, m.end()
rest = []
for line in content:
if not line.strip():
continue
line_match = self._line_rgx.match(line)
description = None
if line_match:
description = line_match.group('desc')
if line_match.group('trailing') and description:
self._error_location(
'Unexpected comma or period after function list at index %d of '
'line "%s"' % (line_match.end('trailing'), line),
error=False)
if not description and line.startswith(' '):
rest.append(line.strip())
elif line_match:
funcs = []
text = line_match.group('allfuncs')
while True:
if not text.strip():
break
name, role, match_end = parse_item_name(text)
funcs.append((name, role))
text = text[match_end:].strip()
if text and text[0] == ',':
text = text[1:].strip()
rest = list(filter(None, [description]))
items.append((funcs, rest))
else:
raise ParseError(f"{line} is not a item name")
return items
def _parse_index(self, section, content):
"""
.. index: default
:refguide: something, else, and more
"""
def strip_each_in(lst):
return [s.strip() for s in lst]
out = {}
section = section.split('::')
if len(section) > 1:
out['default'] = strip_each_in(section[1].split(','))[0]
for line in content:
line = line.split(':')
if len(line) > 2:
out[line[1]] = strip_each_in(line[2].split(','))
return out
def _parse_summary(self):
"""Grab signature (if given) and summary"""
if self._is_at_section():
return
# If several signatures present, take the last one
while True:
summary = self._doc.read_to_next_empty_line()
summary_str = " ".join([s.strip() for s in summary]).strip()
compiled = re.compile(r'^([\w., ]+=)?\s*[\w\.]+\(.*\)$')
if compiled.match(summary_str):
self['Signature'] = summary_str
if not self._is_at_section():
continue
break
if summary is not None:
self['Summary'] = summary
if not self._is_at_section():
self['Extended Summary'] = self._read_to_next_section()
def _parse(self):
self._doc.reset()
self._parse_summary()
sections = list(self._read_sections())
section_names = {section for section, content in sections}
has_returns = 'Returns' in section_names
has_yields = 'Yields' in section_names
# We could do more tests, but we are not. Arbitrarily.
if has_returns and has_yields:
msg = 'Docstring contains both a Returns and Yields section.'
raise ValueError(msg)
if not has_yields and 'Receives' in section_names:
msg = 'Docstring contains a Receives section but not Yields.'
raise ValueError(msg)
for (section, content) in sections:
if not section.startswith('..'):
section = (s.capitalize() for s in section.split(' '))
section = ' '.join(section)
if self.get(section):
self._error_location(f"The section {section} appears twice")
if section in ('Parameters', 'Other Parameters', 'Attributes',
'Methods'):
self[section] = self._parse_param_list(content)
elif section in ('Returns', 'Yields', 'Raises', 'Warns', 'Receives'):
self[section] = self._parse_param_list(
content, single_element_is_type=True)
elif section.startswith('.. index::'):
self['index'] = self._parse_index(section, content)
elif section == 'See Also':
self['See Also'] = self._parse_see_also(content)
else:
self[section] = content
def _error_location(self, msg, error=True):
if hasattr(self, '_obj'):
# we know where the docs came from:
try:
filename = inspect.getsourcefile(self._obj)
except TypeError:
filename = None
msg = msg + f" in the docstring of {self._obj} in {filename}."
if error:
raise ValueError(msg)
else:
warn(msg)
# string conversion routines
def _str_header(self, name, symbol='-'):
return [name, len(name)*symbol]
def _str_indent(self, doc, indent=4):
out = []
for line in doc:
out += [' '*indent + line]
return out
def _str_signature(self):
if self['Signature']:
return [self['Signature'].replace('*', r'\*')] + ['']
else:
return ['']
def _str_summary(self):
if self['Summary']:
return self['Summary'] + ['']
else:
return []
def _str_extended_summary(self):
if self['Extended Summary']:
return self['Extended Summary'] + ['']
else:
return []
def _str_param_list(self, name):
out = []
if self[name]:
out += self._str_header(name)
for param in self[name]:
parts = []
if param.name:
parts.append(param.name)
if param.type:
parts.append(param.type)
out += [' : '.join(parts)]
if param.desc and ''.join(param.desc).strip():
out += self._str_indent(param.desc)
out += ['']
return out
def _str_section(self, name):
out = []
if self[name]:
out += self._str_header(name)
out += self[name]
out += ['']
return out
def _str_see_also(self, func_role):
if not self['See Also']:
return []
out = []
out += self._str_header("See Also")
out += ['']
last_had_desc = True
for funcs, desc in self['See Also']:
assert isinstance(funcs, list)
links = []
for func, role in funcs:
if role:
link = f':{role}:`{func}`'
elif func_role:
link = f':{func_role}:`{func}`'
else:
link = f"`{func}`_"
links.append(link)
link = ', '.join(links)
out += [link]
if desc:
out += self._str_indent([' '.join(desc)])
last_had_desc = True
else:
last_had_desc = False
out += self._str_indent([self.empty_description])
if last_had_desc:
out += ['']
out += ['']
return out
def _str_index(self):
idx = self['index']
out = []
output_index = False
default_index = idx.get('default', '')
if default_index:
output_index = True
out += [f'.. index:: {default_index}']
for section, references in idx.items():
if section == 'default':
continue
output_index = True
out += [f" :{section}: {', '.join(references)}"]
if output_index:
return out
else:
return ''
def __str__(self, func_role=''):
out = []
out += self._str_signature()
out += self._str_summary()
out += self._str_extended_summary()
for param_list in ('Parameters', 'Returns', 'Yields', 'Receives',
'Other Parameters', 'Raises', 'Warns'):
out += self._str_param_list(param_list)
out += self._str_section('Warnings')
out += self._str_see_also(func_role)
for s in ('Notes', 'References', 'Examples'):
out += self._str_section(s)
for param_list in ('Attributes', 'Methods'):
out += self._str_param_list(param_list)
out += self._str_index()
return '\n'.join(out)
def indent(str, indent=4):
indent_str = ' '*indent
if str is None:
return indent_str
lines = str.split('\n')
return '\n'.join(indent_str + l for l in lines)
def dedent_lines(lines):
"""Deindent a list of lines maximally"""
return textwrap.dedent("\n".join(lines)).split("\n")
def header(text, style='-'):
return text + '\n' + style*len(text) + '\n'
class FunctionDoc(NumpyDocString):
def __init__(self, func, role='func', doc=None, config={}):
self._f = func
self._role = role # e.g. "func" or "meth"
if doc is None:
if func is None:
raise ValueError("No function or docstring given")
doc = inspect.getdoc(func) or ''
NumpyDocString.__init__(self, doc, config)
if not self['Signature'] and func is not None:
func, func_name = self.get_func()
try:
try:
signature = str(inspect.signature(func))
except (AttributeError, ValueError):
# try to read signature, backward compat for older Python
if sys.version_info[0] >= 3:
argspec = inspect.getfullargspec(func)
else:
argspec = inspect.getargspec(func)
signature = inspect.formatargspec(*argspec)
signature = f'{func_name}{signature}'
except TypeError:
signature = f'{func_name}()'
self['Signature'] = signature
def get_func(self):
func_name = getattr(self._f, '__name__', self.__class__.__name__)
if inspect.isclass(self._f):
func = getattr(self._f, '__call__', self._f.__init__)
else:
func = self._f
return func, func_name
def __str__(self):
out = ''
func, func_name = self.get_func()
roles = {'func': 'function',
'meth': 'method'}
if self._role:
if self._role not in roles:
print(f"Warning: invalid role {self._role}")
out += f".. {roles.get(self._role, '')}:: {func_name}\n \n\n"
out += super().__str__(func_role=self._role)
return out
class ClassDoc(NumpyDocString):
extra_public_methods = ['__call__']
def __init__(self, cls, doc=None, modulename='', func_doc=FunctionDoc,
config={}):
if not inspect.isclass(cls) and cls is not None:
raise ValueError(f"Expected a class or None, but got {cls!r}")
self._cls = cls
if 'sphinx' in sys.modules:
from sphinx.ext.autodoc import ALL
else:
ALL = object()
self.show_inherited_members = config.get(
'show_inherited_class_members', True)
if modulename and not modulename.endswith('.'):
modulename += '.'
self._mod = modulename
if doc is None:
if cls is None:
raise ValueError("No class or documentation string given")
doc = pydoc.getdoc(cls)
NumpyDocString.__init__(self, doc)
_members = config.get('members', [])
if _members is ALL:
_members = None
_exclude = config.get('exclude-members', [])
if config.get('show_class_members', True) and _exclude is not ALL:
def splitlines_x(s):
if not s:
return []
else:
return s.splitlines()
for field, items in [('Methods', self.methods),
('Attributes', self.properties)]:
if not self[field]:
doc_list = []
for name in sorted(items):
if (name in _exclude or
(_members and name not in _members)):
continue
try:
doc_item = pydoc.getdoc(getattr(self._cls, name))
doc_list.append(
Parameter(name, '', splitlines_x(doc_item)))
except AttributeError:
pass # method doesn't exist
self[field] = doc_list
@property
def methods(self):
if self._cls is None:
return []
return [name for name, func in inspect.getmembers(self._cls)
if ((not name.startswith('_')
or name in self.extra_public_methods)
and isinstance(func, Callable)
and self._is_show_member(name))]
@property
def properties(self):
if self._cls is None:
return []
return [name for name, func in inspect.getmembers(self._cls)
if (not name.startswith('_') and
(func is None or isinstance(func, property) or
inspect.isdatadescriptor(func))
and self._is_show_member(name))]
def _is_show_member(self, name):
if self.show_inherited_members:
return True # show all class members
if name not in self._cls.__dict__:
return False # class member is inherited, we do not show it
return True

View File

@@ -0,0 +1,313 @@
import operator
import math
__version__ = "2.1.0"
m = [
[3.2406, -1.5372, -0.4986],
[-0.9689, 1.8758, 0.0415],
[0.0557, -0.2040, 1.0570]
]
m_inv = [
[0.4124, 0.3576, 0.1805],
[0.2126, 0.7152, 0.0722],
[0.0193, 0.1192, 0.9505]
]
# Hard-coded D65 illuminant
refX = 0.95047
refY = 1.00000
refZ = 1.08883
refU = 0.19784
refV = 0.46834
lab_e = 0.008856
lab_k = 903.3
# Public API
def husl_to_rgb(h, s, l):
return lch_to_rgb(*husl_to_lch([h, s, l]))
def husl_to_hex(h, s, l):
return rgb_to_hex(husl_to_rgb(h, s, l))
def rgb_to_husl(r, g, b):
return lch_to_husl(rgb_to_lch(r, g, b))
def hex_to_husl(hex):
return rgb_to_husl(*hex_to_rgb(hex))
def huslp_to_rgb(h, s, l):
return lch_to_rgb(*huslp_to_lch([h, s, l]))
def huslp_to_hex(h, s, l):
return rgb_to_hex(huslp_to_rgb(h, s, l))
def rgb_to_huslp(r, g, b):
return lch_to_huslp(rgb_to_lch(r, g, b))
def hex_to_huslp(hex):
return rgb_to_huslp(*hex_to_rgb(hex))
def lch_to_rgb(l, c, h):
return xyz_to_rgb(luv_to_xyz(lch_to_luv([l, c, h])))
def rgb_to_lch(r, g, b):
return luv_to_lch(xyz_to_luv(rgb_to_xyz([r, g, b])))
def max_chroma(L, H):
hrad = math.radians(H)
sinH = (math.sin(hrad))
cosH = (math.cos(hrad))
sub1 = (math.pow(L + 16, 3.0) / 1560896.0)
sub2 = sub1 if sub1 > 0.008856 else (L / 903.3)
result = float("inf")
for row in m:
m1 = row[0]
m2 = row[1]
m3 = row[2]
top = ((0.99915 * m1 + 1.05122 * m2 + 1.14460 * m3) * sub2)
rbottom = (0.86330 * m3 - 0.17266 * m2)
lbottom = (0.12949 * m3 - 0.38848 * m1)
bottom = (rbottom * sinH + lbottom * cosH) * sub2
for t in (0.0, 1.0):
C = (L * (top - 1.05122 * t) / (bottom + 0.17266 * sinH * t))
if C > 0.0 and C < result:
result = C
return result
def _hrad_extremum(L):
lhs = (math.pow(L, 3.0) + 48.0 * math.pow(L, 2.0) + 768.0 * L + 4096.0) / 1560896.0
rhs = 1107.0 / 125000.0
sub = lhs if lhs > rhs else 10.0 * L / 9033.0
chroma = float("inf")
result = None
for row in m:
for limit in (0.0, 1.0):
[m1, m2, m3] = row
top = -3015466475.0 * m3 * sub + 603093295.0 * m2 * sub - 603093295.0 * limit
bottom = 1356959916.0 * m1 * sub - 452319972.0 * m3 * sub
hrad = math.atan2(top, bottom)
# This is a math hack to deal with tan quadrants, I'm too lazy to figure
# out how to do this properly
if limit == 0.0:
hrad += math.pi
test = max_chroma(L, math.degrees(hrad))
if test < chroma:
chroma = test
result = hrad
return result
def max_chroma_pastel(L):
H = math.degrees(_hrad_extremum(L))
return max_chroma(L, H)
def dot_product(a, b):
return sum(map(operator.mul, a, b))
def f(t):
if t > lab_e:
return (math.pow(t, 1.0 / 3.0))
else:
return (7.787 * t + 16.0 / 116.0)
def f_inv(t):
if math.pow(t, 3.0) > lab_e:
return (math.pow(t, 3.0))
else:
return (116.0 * t - 16.0) / lab_k
def from_linear(c):
if c <= 0.0031308:
return 12.92 * c
else:
return (1.055 * math.pow(c, 1.0 / 2.4) - 0.055)
def to_linear(c):
a = 0.055
if c > 0.04045:
return (math.pow((c + a) / (1.0 + a), 2.4))
else:
return (c / 12.92)
def rgb_prepare(triple):
ret = []
for ch in triple:
ch = round(ch, 3)
if ch < -0.0001 or ch > 1.0001:
raise Exception(f"Illegal RGB value {ch:f}")
if ch < 0:
ch = 0
if ch > 1:
ch = 1
# Fix for Python 3 which by default rounds 4.5 down to 4.0
# instead of Python 2 which is rounded to 5.0 which caused
# a couple off by one errors in the tests. Tests now all pass
# in Python 2 and Python 3
ret.append(int(round(ch * 255 + 0.001, 0)))
return ret
def hex_to_rgb(hex):
if hex.startswith('#'):
hex = hex[1:]
r = int(hex[0:2], 16) / 255.0
g = int(hex[2:4], 16) / 255.0
b = int(hex[4:6], 16) / 255.0
return [r, g, b]
def rgb_to_hex(triple):
[r, g, b] = triple
return '#%02x%02x%02x' % tuple(rgb_prepare([r, g, b]))
def xyz_to_rgb(triple):
xyz = map(lambda row: dot_product(row, triple), m)
return list(map(from_linear, xyz))
def rgb_to_xyz(triple):
rgbl = list(map(to_linear, triple))
return list(map(lambda row: dot_product(row, rgbl), m_inv))
def xyz_to_luv(triple):
X, Y, Z = triple
if X == Y == Z == 0.0:
return [0.0, 0.0, 0.0]
varU = (4.0 * X) / (X + (15.0 * Y) + (3.0 * Z))
varV = (9.0 * Y) / (X + (15.0 * Y) + (3.0 * Z))
L = 116.0 * f(Y / refY) - 16.0
# Black will create a divide-by-zero error
if L == 0.0:
return [0.0, 0.0, 0.0]
U = 13.0 * L * (varU - refU)
V = 13.0 * L * (varV - refV)
return [L, U, V]
def luv_to_xyz(triple):
L, U, V = triple
if L == 0:
return [0.0, 0.0, 0.0]
varY = f_inv((L + 16.0) / 116.0)
varU = U / (13.0 * L) + refU
varV = V / (13.0 * L) + refV
Y = varY * refY
X = 0.0 - (9.0 * Y * varU) / ((varU - 4.0) * varV - varU * varV)
Z = (9.0 * Y - (15.0 * varV * Y) - (varV * X)) / (3.0 * varV)
return [X, Y, Z]
def luv_to_lch(triple):
L, U, V = triple
C = (math.pow(math.pow(U, 2) + math.pow(V, 2), (1.0 / 2.0)))
hrad = (math.atan2(V, U))
H = math.degrees(hrad)
if H < 0.0:
H = 360.0 + H
return [L, C, H]
def lch_to_luv(triple):
L, C, H = triple
Hrad = math.radians(H)
U = (math.cos(Hrad) * C)
V = (math.sin(Hrad) * C)
return [L, U, V]
def husl_to_lch(triple):
H, S, L = triple
if L > 99.9999999:
return [100, 0.0, H]
if L < 0.00000001:
return [0.0, 0.0, H]
mx = max_chroma(L, H)
C = mx / 100.0 * S
return [L, C, H]
def lch_to_husl(triple):
L, C, H = triple
if L > 99.9999999:
return [H, 0.0, 100.0]
if L < 0.00000001:
return [H, 0.0, 0.0]
mx = max_chroma(L, H)
S = C / mx * 100.0
return [H, S, L]
def huslp_to_lch(triple):
H, S, L = triple
if L > 99.9999999:
return [100, 0.0, H]
if L < 0.00000001:
return [0.0, 0.0, H]
mx = max_chroma_pastel(L)
C = mx / 100.0 * S
return [L, C, H]
def lch_to_huslp(triple):
L, C, H = triple
if L > 99.9999999:
return [H, 0.0, 100.0]
if L < 0.00000001:
return [H, 0.0, 0.0]
mx = max_chroma_pastel(L)
S = C / mx * 100.0
return [H, S, L]

View File

@@ -0,0 +1,380 @@
"""
This module was copied from the scipy project.
In the process of copying, some methods were removed because they depended on
other parts of scipy (especially on compiled components), allowing seaborn to
have a simple and pure Python implementation. These include:
- integrate_gaussian
- integrate_box
- integrate_box_1d
- integrate_kde
- logpdf
- resample
Additionally, the numpy.linalg module was substituted for scipy.linalg,
and the examples section (with doctests) was removed from the docstring
The original scipy license is copied below:
Copyright (c) 2001-2002 Enthought, Inc. 2003-2019, SciPy Developers.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following
disclaimer in the documentation and/or other materials provided
with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived
from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
# -------------------------------------------------------------------------------
#
# Define classes for (uni/multi)-variate kernel density estimation.
#
# Currently, only Gaussian kernels are implemented.
#
# Written by: Robert Kern
#
# Date: 2004-08-09
#
# Modified: 2005-02-10 by Robert Kern.
# Contributed to SciPy
# 2005-10-07 by Robert Kern.
# Some fixes to match the new scipy_core
#
# Copyright 2004-2005 by Enthought, Inc.
#
# -------------------------------------------------------------------------------
import numpy as np
from numpy import (asarray, atleast_2d, reshape, zeros, newaxis, dot, exp, pi,
sqrt, power, atleast_1d, sum, ones, cov)
from numpy import linalg
__all__ = ['gaussian_kde']
class gaussian_kde:
"""Representation of a kernel-density estimate using Gaussian kernels.
Kernel density estimation is a way to estimate the probability density
function (PDF) of a random variable in a non-parametric way.
`gaussian_kde` works for both uni-variate and multi-variate data. It
includes automatic bandwidth determination. The estimation works best for
a unimodal distribution; bimodal or multi-modal distributions tend to be
oversmoothed.
Parameters
----------
dataset : array_like
Datapoints to estimate from. In case of univariate data this is a 1-D
array, otherwise a 2-D array with shape (# of dims, # of data).
bw_method : str, scalar or callable, optional
The method used to calculate the estimator bandwidth. This can be
'scott', 'silverman', a scalar constant or a callable. If a scalar,
this will be used directly as `kde.factor`. If a callable, it should
take a `gaussian_kde` instance as only parameter and return a scalar.
If None (default), 'scott' is used. See Notes for more details.
weights : array_like, optional
weights of datapoints. This must be the same shape as dataset.
If None (default), the samples are assumed to be equally weighted
Attributes
----------
dataset : ndarray
The dataset with which `gaussian_kde` was initialized.
d : int
Number of dimensions.
n : int
Number of datapoints.
neff : int
Effective number of datapoints.
.. versionadded:: 1.2.0
factor : float
The bandwidth factor, obtained from `kde.covariance_factor`, with which
the covariance matrix is multiplied.
covariance : ndarray
The covariance matrix of `dataset`, scaled by the calculated bandwidth
(`kde.factor`).
inv_cov : ndarray
The inverse of `covariance`.
Methods
-------
evaluate
__call__
integrate_gaussian
integrate_box_1d
integrate_box
integrate_kde
pdf
logpdf
resample
set_bandwidth
covariance_factor
Notes
-----
Bandwidth selection strongly influences the estimate obtained from the KDE
(much more so than the actual shape of the kernel). Bandwidth selection
can be done by a "rule of thumb", by cross-validation, by "plug-in
methods" or by other means; see [3]_, [4]_ for reviews. `gaussian_kde`
uses a rule of thumb, the default is Scott's Rule.
Scott's Rule [1]_, implemented as `scotts_factor`, is::
n**(-1./(d+4)),
with ``n`` the number of data points and ``d`` the number of dimensions.
In the case of unequally weighted points, `scotts_factor` becomes::
neff**(-1./(d+4)),
with ``neff`` the effective number of datapoints.
Silverman's Rule [2]_, implemented as `silverman_factor`, is::
(n * (d + 2) / 4.)**(-1. / (d + 4)).
or in the case of unequally weighted points::
(neff * (d + 2) / 4.)**(-1. / (d + 4)).
Good general descriptions of kernel density estimation can be found in [1]_
and [2]_, the mathematics for this multi-dimensional implementation can be
found in [1]_.
With a set of weighted samples, the effective number of datapoints ``neff``
is defined by::
neff = sum(weights)^2 / sum(weights^2)
as detailed in [5]_.
References
----------
.. [1] D.W. Scott, "Multivariate Density Estimation: Theory, Practice, and
Visualization", John Wiley & Sons, New York, Chicester, 1992.
.. [2] B.W. Silverman, "Density Estimation for Statistics and Data
Analysis", Vol. 26, Monographs on Statistics and Applied Probability,
Chapman and Hall, London, 1986.
.. [3] B.A. Turlach, "Bandwidth Selection in Kernel Density Estimation: A
Review", CORE and Institut de Statistique, Vol. 19, pp. 1-33, 1993.
.. [4] D.M. Bashtannyk and R.J. Hyndman, "Bandwidth selection for kernel
conditional density estimation", Computational Statistics & Data
Analysis, Vol. 36, pp. 279-298, 2001.
.. [5] Gray P. G., 1969, Journal of the Royal Statistical Society.
Series A (General), 132, 272
"""
def __init__(self, dataset, bw_method=None, weights=None):
self.dataset = atleast_2d(asarray(dataset))
if not self.dataset.size > 1:
raise ValueError("`dataset` input should have multiple elements.")
self.d, self.n = self.dataset.shape
if weights is not None:
self._weights = atleast_1d(weights).astype(float)
self._weights /= sum(self._weights)
if self.weights.ndim != 1:
raise ValueError("`weights` input should be one-dimensional.")
if len(self._weights) != self.n:
raise ValueError("`weights` input should be of length n")
self._neff = 1/sum(self._weights**2)
self.set_bandwidth(bw_method=bw_method)
def evaluate(self, points):
"""Evaluate the estimated pdf on a set of points.
Parameters
----------
points : (# of dimensions, # of points)-array
Alternatively, a (# of dimensions,) vector can be passed in and
treated as a single point.
Returns
-------
values : (# of points,)-array
The values at each point.
Raises
------
ValueError : if the dimensionality of the input points is different than
the dimensionality of the KDE.
"""
points = atleast_2d(asarray(points))
d, m = points.shape
if d != self.d:
if d == 1 and m == self.d:
# points was passed in as a row vector
points = reshape(points, (self.d, 1))
m = 1
else:
msg = f"points have dimension {d}, dataset has dimension {self.d}"
raise ValueError(msg)
output_dtype = np.common_type(self.covariance, points)
result = zeros((m,), dtype=output_dtype)
whitening = linalg.cholesky(self.inv_cov)
scaled_dataset = dot(whitening, self.dataset)
scaled_points = dot(whitening, points)
if m >= self.n:
# there are more points than data, so loop over data
for i in range(self.n):
diff = scaled_dataset[:, i, newaxis] - scaled_points
energy = sum(diff * diff, axis=0) / 2.0
result += self.weights[i]*exp(-energy)
else:
# loop over points
for i in range(m):
diff = scaled_dataset - scaled_points[:, i, newaxis]
energy = sum(diff * diff, axis=0) / 2.0
result[i] = sum(exp(-energy)*self.weights, axis=0)
result = result / self._norm_factor
return result
__call__ = evaluate
def scotts_factor(self):
"""Compute Scott's factor.
Returns
-------
s : float
Scott's factor.
"""
return power(self.neff, -1./(self.d+4))
def silverman_factor(self):
"""Compute the Silverman factor.
Returns
-------
s : float
The silverman factor.
"""
return power(self.neff*(self.d+2.0)/4.0, -1./(self.d+4))
# Default method to calculate bandwidth, can be overwritten by subclass
covariance_factor = scotts_factor
covariance_factor.__doc__ = """Computes the coefficient (`kde.factor`) that
multiplies the data covariance matrix to obtain the kernel covariance
matrix. The default is `scotts_factor`. A subclass can overwrite this
method to provide a different method, or set it through a call to
`kde.set_bandwidth`."""
def set_bandwidth(self, bw_method=None):
"""Compute the estimator bandwidth with given method.
The new bandwidth calculated after a call to `set_bandwidth` is used
for subsequent evaluations of the estimated density.
Parameters
----------
bw_method : str, scalar or callable, optional
The method used to calculate the estimator bandwidth. This can be
'scott', 'silverman', a scalar constant or a callable. If a
scalar, this will be used directly as `kde.factor`. If a callable,
it should take a `gaussian_kde` instance as only parameter and
return a scalar. If None (default), nothing happens; the current
`kde.covariance_factor` method is kept.
Notes
-----
.. versionadded:: 0.11
"""
if bw_method is None:
pass
elif bw_method == 'scott':
self.covariance_factor = self.scotts_factor
elif bw_method == 'silverman':
self.covariance_factor = self.silverman_factor
elif np.isscalar(bw_method) and not isinstance(bw_method, str):
self._bw_method = 'use constant'
self.covariance_factor = lambda: bw_method
elif callable(bw_method):
self._bw_method = bw_method
self.covariance_factor = lambda: self._bw_method(self)
else:
msg = "`bw_method` should be 'scott', 'silverman', a scalar " \
"or a callable."
raise ValueError(msg)
self._compute_covariance()
def _compute_covariance(self):
"""Computes the covariance matrix for each Gaussian kernel using
covariance_factor().
"""
self.factor = self.covariance_factor()
# Cache covariance and inverse covariance of the data
if not hasattr(self, '_data_inv_cov'):
self._data_covariance = atleast_2d(cov(self.dataset, rowvar=1,
bias=False,
aweights=self.weights))
self._data_inv_cov = linalg.inv(self._data_covariance)
self.covariance = self._data_covariance * self.factor**2
self.inv_cov = self._data_inv_cov / self.factor**2
self._norm_factor = sqrt(linalg.det(2*pi*self.covariance))
def pdf(self, x):
"""
Evaluate the estimated pdf on a provided set of points.
Notes
-----
This is an alias for `gaussian_kde.evaluate`. See the ``evaluate``
docstring for more details.
"""
return self.evaluate(x)
@property
def weights(self):
try:
return self._weights
except AttributeError:
self._weights = ones(self.n)/self.n
return self._weights
@property
def neff(self):
try:
return self._neff
except AttributeError:
self._neff = 1/sum(self.weights**2)
return self._neff

View File

@@ -0,0 +1,461 @@
"""Extract reference documentation from the pypa/packaging source tree.
In the process of copying, some unused methods / classes were removed.
These include:
- parse()
- anything involving LegacyVersion
This software is made available under the terms of *either* of the licenses
found in LICENSE.APACHE or LICENSE.BSD. Contributions to this software is made
under the terms of *both* these licenses.
Vendored from:
- https://github.com/pypa/packaging/
- commit ba07d8287b4554754ac7178d177033ea3f75d489 (09/09/2021)
"""
# This file is dual licensed under the terms of the Apache License, Version
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
# for complete details.
import collections
import itertools
import re
from typing import Callable, Optional, SupportsInt, Tuple, Union
__all__ = ["Version", "InvalidVersion", "VERSION_PATTERN"]
# Vendored from https://github.com/pypa/packaging/blob/main/packaging/_structures.py
class InfinityType:
def __repr__(self) -> str:
return "Infinity"
def __hash__(self) -> int:
return hash(repr(self))
def __lt__(self, other: object) -> bool:
return False
def __le__(self, other: object) -> bool:
return False
def __eq__(self, other: object) -> bool:
return isinstance(other, self.__class__)
def __ne__(self, other: object) -> bool:
return not isinstance(other, self.__class__)
def __gt__(self, other: object) -> bool:
return True
def __ge__(self, other: object) -> bool:
return True
def __neg__(self: object) -> "NegativeInfinityType":
return NegativeInfinity
Infinity = InfinityType()
class NegativeInfinityType:
def __repr__(self) -> str:
return "-Infinity"
def __hash__(self) -> int:
return hash(repr(self))
def __lt__(self, other: object) -> bool:
return True
def __le__(self, other: object) -> bool:
return True
def __eq__(self, other: object) -> bool:
return isinstance(other, self.__class__)
def __ne__(self, other: object) -> bool:
return not isinstance(other, self.__class__)
def __gt__(self, other: object) -> bool:
return False
def __ge__(self, other: object) -> bool:
return False
def __neg__(self: object) -> InfinityType:
return Infinity
NegativeInfinity = NegativeInfinityType()
# Vendored from https://github.com/pypa/packaging/blob/main/packaging/version.py
InfiniteTypes = Union[InfinityType, NegativeInfinityType]
PrePostDevType = Union[InfiniteTypes, Tuple[str, int]]
SubLocalType = Union[InfiniteTypes, int, str]
LocalType = Union[
NegativeInfinityType,
Tuple[
Union[
SubLocalType,
Tuple[SubLocalType, str],
Tuple[NegativeInfinityType, SubLocalType],
],
...,
],
]
CmpKey = Tuple[
int, Tuple[int, ...], PrePostDevType, PrePostDevType, PrePostDevType, LocalType
]
LegacyCmpKey = Tuple[int, Tuple[str, ...]]
VersionComparisonMethod = Callable[
[Union[CmpKey, LegacyCmpKey], Union[CmpKey, LegacyCmpKey]], bool
]
_Version = collections.namedtuple(
"_Version", ["epoch", "release", "dev", "pre", "post", "local"]
)
class InvalidVersion(ValueError):
"""
An invalid version was found, users should refer to PEP 440.
"""
class _BaseVersion:
_key: Union[CmpKey, LegacyCmpKey]
def __hash__(self) -> int:
return hash(self._key)
# Please keep the duplicated `isinstance` check
# in the six comparisons hereunder
# unless you find a way to avoid adding overhead function calls.
def __lt__(self, other: "_BaseVersion") -> bool:
if not isinstance(other, _BaseVersion):
return NotImplemented
return self._key < other._key
def __le__(self, other: "_BaseVersion") -> bool:
if not isinstance(other, _BaseVersion):
return NotImplemented
return self._key <= other._key
def __eq__(self, other: object) -> bool:
if not isinstance(other, _BaseVersion):
return NotImplemented
return self._key == other._key
def __ge__(self, other: "_BaseVersion") -> bool:
if not isinstance(other, _BaseVersion):
return NotImplemented
return self._key >= other._key
def __gt__(self, other: "_BaseVersion") -> bool:
if not isinstance(other, _BaseVersion):
return NotImplemented
return self._key > other._key
def __ne__(self, other: object) -> bool:
if not isinstance(other, _BaseVersion):
return NotImplemented
return self._key != other._key
# Deliberately not anchored to the start and end of the string, to make it
# easier for 3rd party code to reuse
VERSION_PATTERN = r"""
v?
(?:
(?:(?P<epoch>[0-9]+)!)? # epoch
(?P<release>[0-9]+(?:\.[0-9]+)*) # release segment
(?P<pre> # pre-release
[-_\.]?
(?P<pre_l>(a|b|c|rc|alpha|beta|pre|preview))
[-_\.]?
(?P<pre_n>[0-9]+)?
)?
(?P<post> # post release
(?:-(?P<post_n1>[0-9]+))
|
(?:
[-_\.]?
(?P<post_l>post|rev|r)
[-_\.]?
(?P<post_n2>[0-9]+)?
)
)?
(?P<dev> # dev release
[-_\.]?
(?P<dev_l>dev)
[-_\.]?
(?P<dev_n>[0-9]+)?
)?
)
(?:\+(?P<local>[a-z0-9]+(?:[-_\.][a-z0-9]+)*))? # local version
"""
class Version(_BaseVersion):
_regex = re.compile(r"^\s*" + VERSION_PATTERN + r"\s*$", re.VERBOSE | re.IGNORECASE)
def __init__(self, version: str) -> None:
# Validate the version and parse it into pieces
match = self._regex.search(version)
if not match:
raise InvalidVersion(f"Invalid version: '{version}'")
# Store the parsed out pieces of the version
self._version = _Version(
epoch=int(match.group("epoch")) if match.group("epoch") else 0,
release=tuple(int(i) for i in match.group("release").split(".")),
pre=_parse_letter_version(match.group("pre_l"), match.group("pre_n")),
post=_parse_letter_version(
match.group("post_l"), match.group("post_n1") or match.group("post_n2")
),
dev=_parse_letter_version(match.group("dev_l"), match.group("dev_n")),
local=_parse_local_version(match.group("local")),
)
# Generate a key which will be used for sorting
self._key = _cmpkey(
self._version.epoch,
self._version.release,
self._version.pre,
self._version.post,
self._version.dev,
self._version.local,
)
def __repr__(self) -> str:
return f"<Version('{self}')>"
def __str__(self) -> str:
parts = []
# Epoch
if self.epoch != 0:
parts.append(f"{self.epoch}!")
# Release segment
parts.append(".".join(str(x) for x in self.release))
# Pre-release
if self.pre is not None:
parts.append("".join(str(x) for x in self.pre))
# Post-release
if self.post is not None:
parts.append(f".post{self.post}")
# Development release
if self.dev is not None:
parts.append(f".dev{self.dev}")
# Local version segment
if self.local is not None:
parts.append(f"+{self.local}")
return "".join(parts)
@property
def epoch(self) -> int:
_epoch: int = self._version.epoch
return _epoch
@property
def release(self) -> Tuple[int, ...]:
_release: Tuple[int, ...] = self._version.release
return _release
@property
def pre(self) -> Optional[Tuple[str, int]]:
_pre: Optional[Tuple[str, int]] = self._version.pre
return _pre
@property
def post(self) -> Optional[int]:
return self._version.post[1] if self._version.post else None
@property
def dev(self) -> Optional[int]:
return self._version.dev[1] if self._version.dev else None
@property
def local(self) -> Optional[str]:
if self._version.local:
return ".".join(str(x) for x in self._version.local)
else:
return None
@property
def public(self) -> str:
return str(self).split("+", 1)[0]
@property
def base_version(self) -> str:
parts = []
# Epoch
if self.epoch != 0:
parts.append(f"{self.epoch}!")
# Release segment
parts.append(".".join(str(x) for x in self.release))
return "".join(parts)
@property
def is_prerelease(self) -> bool:
return self.dev is not None or self.pre is not None
@property
def is_postrelease(self) -> bool:
return self.post is not None
@property
def is_devrelease(self) -> bool:
return self.dev is not None
@property
def major(self) -> int:
return self.release[0] if len(self.release) >= 1 else 0
@property
def minor(self) -> int:
return self.release[1] if len(self.release) >= 2 else 0
@property
def micro(self) -> int:
return self.release[2] if len(self.release) >= 3 else 0
def _parse_letter_version(
letter: str, number: Union[str, bytes, SupportsInt]
) -> Optional[Tuple[str, int]]:
if letter:
# We consider there to be an implicit 0 in a pre-release if there is
# not a numeral associated with it.
if number is None:
number = 0
# We normalize any letters to their lower case form
letter = letter.lower()
# We consider some words to be alternate spellings of other words and
# in those cases we want to normalize the spellings to our preferred
# spelling.
if letter == "alpha":
letter = "a"
elif letter == "beta":
letter = "b"
elif letter in ["c", "pre", "preview"]:
letter = "rc"
elif letter in ["rev", "r"]:
letter = "post"
return letter, int(number)
if not letter and number:
# We assume if we are given a number, but we are not given a letter
# then this is using the implicit post release syntax (e.g. 1.0-1)
letter = "post"
return letter, int(number)
return None
_local_version_separators = re.compile(r"[\._-]")
def _parse_local_version(local: str) -> Optional[LocalType]:
"""
Takes a string like abc.1.twelve and turns it into ("abc", 1, "twelve").
"""
if local is not None:
return tuple(
part.lower() if not part.isdigit() else int(part)
for part in _local_version_separators.split(local)
)
return None
def _cmpkey(
epoch: int,
release: Tuple[int, ...],
pre: Optional[Tuple[str, int]],
post: Optional[Tuple[str, int]],
dev: Optional[Tuple[str, int]],
local: Optional[Tuple[SubLocalType]],
) -> CmpKey:
# When we compare a release version, we want to compare it with all of the
# trailing zeros removed. So we'll use a reverse the list, drop all the now
# leading zeros until we come to something non zero, then take the rest
# re-reverse it back into the correct order and make it a tuple and use
# that for our sorting key.
_release = tuple(
reversed(list(itertools.dropwhile(lambda x: x == 0, reversed(release))))
)
# We need to "trick" the sorting algorithm to put 1.0.dev0 before 1.0a0.
# We'll do this by abusing the pre segment, but we _only_ want to do this
# if there is not a pre or a post segment. If we have one of those then
# the normal sorting rules will handle this case correctly.
if pre is None and post is None and dev is not None:
_pre: PrePostDevType = NegativeInfinity
# Versions without a pre-release (except as noted above) should sort after
# those with one.
elif pre is None:
_pre = Infinity
else:
_pre = pre
# Versions without a post segment should sort before those with one.
if post is None:
_post: PrePostDevType = NegativeInfinity
else:
_post = post
# Versions without a development segment should sort after those with one.
if dev is None:
_dev: PrePostDevType = Infinity
else:
_dev = dev
if local is None:
# Versions without a local segment should sort before those with one.
_local: LocalType = NegativeInfinity
else:
# Versions with a local segment need that segment parsed to implement
# the sorting rules in PEP440.
# - Alpha numeric segments sort before numeric segments
# - Alpha numeric segments sort lexicographically
# - Numeric segments sort numerically
# - Shorter versions sort before longer versions when the prefixes
# match exactly
_local = tuple(
(i, "") if isinstance(i, int) else (NegativeInfinity, i) for i in local
)
return epoch, _release, _pre, _post, _dev, _local

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt
import matplotlib.ticker as ticker
__all__ = ["palplot", "dogplot"]
def palplot(pal, size=1):
"""Plot the values in a color palette as a horizontal array.
Parameters
----------
pal : sequence of matplotlib colors
colors, i.e. as returned by seaborn.color_palette()
size :
scaling factor for size of plot
"""
n = len(pal)
_, ax = plt.subplots(1, 1, figsize=(n * size, size))
ax.imshow(np.arange(n).reshape(1, n),
cmap=mpl.colors.ListedColormap(list(pal)),
interpolation="nearest", aspect="auto")
ax.set_xticks(np.arange(n) - .5)
ax.set_yticks([-.5, .5])
# Ensure nice border between colors
ax.set_xticklabels(["" for _ in range(n)])
# The proper way to set no ticks
ax.yaxis.set_major_locator(ticker.NullLocator())
def dogplot(*_, **__):
"""Who's a good boy?"""
from urllib.request import urlopen
from io import BytesIO
url = "https://github.com/mwaskom/seaborn-data/raw/master/png/img{}.png"
pic = np.random.randint(2, 7)
data = BytesIO(urlopen(url.format(pic)).read())
img = plt.imread(data)
f, ax = plt.subplots(figsize=(5, 5), dpi=100)
f.subplots_adjust(0, 0, 1, 1)
ax.imshow(img)
ax.set_axis_off()

View File

@@ -0,0 +1,49 @@
"""
A declarative, object-oriented interface for creating statistical graphics.
The seaborn.objects namespace contains a number of classes that can be composed
together to build a customized visualization.
The main object is :class:`Plot`, which is the starting point for all figures.
Pass :class:`Plot` a dataset and specify assignments from its variables to
roles in the plot. Build up the visualization by calling its methods.
There are four other general types of objects in this interface:
- :class:`Mark` subclasses, which create matplotlib artists for visualization
- :class:`Stat` subclasses, which apply statistical transforms before plotting
- :class:`Move` subclasses, which make further adjustments to reduce overplotting
These classes are passed to :meth:`Plot.add` to define a layer in the plot.
Each layer has a :class:`Mark` and optional :class:`Stat` and/or :class:`Move`.
Plots can have multiple layers.
The other general type of object is a :class:`Scale` subclass, which provide an
interface for controlling the mappings between data values and visual properties.
Pass :class:`Scale` objects to :meth:`Plot.scale`.
See the documentation for other :class:`Plot` methods to learn about the many
ways that a plot can be enhanced and customized.
"""
from seaborn._core.plot import Plot # noqa: F401
from seaborn._marks.base import Mark # noqa: F401
from seaborn._marks.area import Area, Band # noqa: F401
from seaborn._marks.bar import Bar, Bars # noqa: F401
from seaborn._marks.dot import Dot, Dots # noqa: F401
from seaborn._marks.line import Dash, Line, Lines, Path, Paths, Range # noqa: F401
from seaborn._marks.text import Text # noqa: F401
from seaborn._stats.base import Stat # noqa: F401
from seaborn._stats.aggregation import Agg, Est # noqa: F401
from seaborn._stats.counting import Count, Hist # noqa: F401
from seaborn._stats.density import KDE # noqa: F401
from seaborn._stats.order import Perc # noqa: F401
from seaborn._stats.regression import PolyFit # noqa: F401
from seaborn._core.moves import Dodge, Jitter, Norm, Shift, Stack, Move # noqa: F401
from seaborn._core.scales import ( # noqa: F401
Boolean, Continuous, Nominal, Temporal, Scale
)

View File

@@ -0,0 +1,841 @@
import colorsys
from itertools import cycle
import numpy as np
import matplotlib as mpl
from .external import husl
from .utils import desaturate, get_color_cycle
from .colors import xkcd_rgb, crayons
from ._compat import get_colormap
__all__ = ["color_palette", "hls_palette", "husl_palette", "mpl_palette",
"dark_palette", "light_palette", "diverging_palette",
"blend_palette", "xkcd_palette", "crayon_palette",
"cubehelix_palette", "set_color_codes"]
SEABORN_PALETTES = dict(
deep=["#4C72B0", "#DD8452", "#55A868", "#C44E52", "#8172B3",
"#937860", "#DA8BC3", "#8C8C8C", "#CCB974", "#64B5CD"],
deep6=["#4C72B0", "#55A868", "#C44E52",
"#8172B3", "#CCB974", "#64B5CD"],
muted=["#4878D0", "#EE854A", "#6ACC64", "#D65F5F", "#956CB4",
"#8C613C", "#DC7EC0", "#797979", "#D5BB67", "#82C6E2"],
muted6=["#4878D0", "#6ACC64", "#D65F5F",
"#956CB4", "#D5BB67", "#82C6E2"],
pastel=["#A1C9F4", "#FFB482", "#8DE5A1", "#FF9F9B", "#D0BBFF",
"#DEBB9B", "#FAB0E4", "#CFCFCF", "#FFFEA3", "#B9F2F0"],
pastel6=["#A1C9F4", "#8DE5A1", "#FF9F9B",
"#D0BBFF", "#FFFEA3", "#B9F2F0"],
bright=["#023EFF", "#FF7C00", "#1AC938", "#E8000B", "#8B2BE2",
"#9F4800", "#F14CC1", "#A3A3A3", "#FFC400", "#00D7FF"],
bright6=["#023EFF", "#1AC938", "#E8000B",
"#8B2BE2", "#FFC400", "#00D7FF"],
dark=["#001C7F", "#B1400D", "#12711C", "#8C0800", "#591E71",
"#592F0D", "#A23582", "#3C3C3C", "#B8850A", "#006374"],
dark6=["#001C7F", "#12711C", "#8C0800",
"#591E71", "#B8850A", "#006374"],
colorblind=["#0173B2", "#DE8F05", "#029E73", "#D55E00", "#CC78BC",
"#CA9161", "#FBAFE4", "#949494", "#ECE133", "#56B4E9"],
colorblind6=["#0173B2", "#029E73", "#D55E00",
"#CC78BC", "#ECE133", "#56B4E9"]
)
MPL_QUAL_PALS = {
"tab10": 10, "tab20": 20, "tab20b": 20, "tab20c": 20,
"Set1": 9, "Set2": 8, "Set3": 12,
"Accent": 8, "Paired": 12,
"Pastel1": 9, "Pastel2": 8, "Dark2": 8,
}
QUAL_PALETTE_SIZES = MPL_QUAL_PALS.copy()
QUAL_PALETTE_SIZES.update({k: len(v) for k, v in SEABORN_PALETTES.items()})
QUAL_PALETTES = list(QUAL_PALETTE_SIZES.keys())
class _ColorPalette(list):
"""Set the color palette in a with statement, otherwise be a list."""
def __enter__(self):
"""Open the context."""
from .rcmod import set_palette
self._orig_palette = color_palette()
set_palette(self)
return self
def __exit__(self, *args):
"""Close the context."""
from .rcmod import set_palette
set_palette(self._orig_palette)
def as_hex(self):
"""Return a color palette with hex codes instead of RGB values."""
hex = [mpl.colors.rgb2hex(rgb) for rgb in self]
return _ColorPalette(hex)
def _repr_html_(self):
"""Rich display of the color palette in an HTML frontend."""
s = 55
n = len(self)
html = f'<svg width="{n * s}" height="{s}">'
for i, c in enumerate(self.as_hex()):
html += (
f'<rect x="{i * s}" y="0" width="{s}" height="{s}" style="fill:{c};'
'stroke-width:2;stroke:rgb(255,255,255)"/>'
)
html += '</svg>'
return html
def _patch_colormap_display():
"""Simplify the rich display of matplotlib color maps in a notebook."""
def _repr_png_(self):
"""Generate a PNG representation of the Colormap."""
import io
from PIL import Image
import numpy as np
IMAGE_SIZE = (400, 50)
X = np.tile(np.linspace(0, 1, IMAGE_SIZE[0]), (IMAGE_SIZE[1], 1))
pixels = self(X, bytes=True)
png_bytes = io.BytesIO()
Image.fromarray(pixels).save(png_bytes, format='png')
return png_bytes.getvalue()
def _repr_html_(self):
"""Generate an HTML representation of the Colormap."""
import base64
png_bytes = self._repr_png_()
png_base64 = base64.b64encode(png_bytes).decode('ascii')
return ('<img '
+ 'alt="' + self.name + ' color map" '
+ 'title="' + self.name + '"'
+ 'src="data:image/png;base64,' + png_base64 + '">')
mpl.colors.Colormap._repr_png_ = _repr_png_
mpl.colors.Colormap._repr_html_ = _repr_html_
def color_palette(palette=None, n_colors=None, desat=None, as_cmap=False):
"""Return a list of colors or continuous colormap defining a palette.
Possible ``palette`` values include:
- Name of a seaborn palette (deep, muted, bright, pastel, dark, colorblind)
- Name of matplotlib colormap
- 'husl' or 'hls'
- 'ch:<cubehelix arguments>'
- 'light:<color>', 'dark:<color>', 'blend:<color>,<color>',
- A sequence of colors in any format matplotlib accepts
Calling this function with ``palette=None`` will return the current
matplotlib color cycle.
This function can also be used in a ``with`` statement to temporarily
set the color cycle for a plot or set of plots.
See the :ref:`tutorial <palette_tutorial>` for more information.
Parameters
----------
palette : None, string, or sequence, optional
Name of palette or None to return current palette. If a sequence, input
colors are used but possibly cycled and desaturated.
n_colors : int, optional
Number of colors in the palette. If ``None``, the default will depend
on how ``palette`` is specified. Named palettes default to 6 colors,
but grabbing the current palette or passing in a list of colors will
not change the number of colors unless this is specified. Asking for
more colors than exist in the palette will cause it to cycle. Ignored
when ``as_cmap`` is True.
desat : float, optional
Proportion to desaturate each color by.
as_cmap : bool
If True, return a :class:`matplotlib.colors.ListedColormap`.
Returns
-------
list of RGB tuples or :class:`matplotlib.colors.ListedColormap`
See Also
--------
set_palette : Set the default color cycle for all plots.
set_color_codes : Reassign color codes like ``"b"``, ``"g"``, etc. to
colors from one of the seaborn palettes.
Examples
--------
.. include:: ../docstrings/color_palette.rst
"""
if palette is None:
palette = get_color_cycle()
if n_colors is None:
n_colors = len(palette)
elif not isinstance(palette, str):
palette = palette
if n_colors is None:
n_colors = len(palette)
else:
if n_colors is None:
# Use all colors in a qualitative palette or 6 of another kind
n_colors = QUAL_PALETTE_SIZES.get(palette, 6)
if palette in SEABORN_PALETTES:
# Named "seaborn variant" of matplotlib default color cycle
palette = SEABORN_PALETTES[palette]
elif palette == "hls":
# Evenly spaced colors in cylindrical RGB space
palette = hls_palette(n_colors, as_cmap=as_cmap)
elif palette == "husl":
# Evenly spaced colors in cylindrical Lab space
palette = husl_palette(n_colors, as_cmap=as_cmap)
elif palette.lower() == "jet":
# Paternalism
raise ValueError("No.")
elif palette.startswith("ch:"):
# Cubehelix palette with params specified in string
args, kwargs = _parse_cubehelix_args(palette)
palette = cubehelix_palette(n_colors, *args, **kwargs, as_cmap=as_cmap)
elif palette.startswith("light:"):
# light palette to color specified in string
_, color = palette.split(":")
reverse = color.endswith("_r")
if reverse:
color = color[:-2]
palette = light_palette(color, n_colors, reverse=reverse, as_cmap=as_cmap)
elif palette.startswith("dark:"):
# light palette to color specified in string
_, color = palette.split(":")
reverse = color.endswith("_r")
if reverse:
color = color[:-2]
palette = dark_palette(color, n_colors, reverse=reverse, as_cmap=as_cmap)
elif palette.startswith("blend:"):
# blend palette between colors specified in string
_, colors = palette.split(":")
colors = colors.split(",")
palette = blend_palette(colors, n_colors, as_cmap=as_cmap)
else:
try:
# Perhaps a named matplotlib colormap?
palette = mpl_palette(palette, n_colors, as_cmap=as_cmap)
except (ValueError, KeyError): # Error class changed in mpl36
raise ValueError(f"{palette!r} is not a valid palette name")
if desat is not None:
palette = [desaturate(c, desat) for c in palette]
if not as_cmap:
# Always return as many colors as we asked for
pal_cycle = cycle(palette)
palette = [next(pal_cycle) for _ in range(n_colors)]
# Always return in r, g, b tuple format
try:
palette = map(mpl.colors.colorConverter.to_rgb, palette)
palette = _ColorPalette(palette)
except ValueError:
raise ValueError(f"Could not generate a palette for {palette}")
return palette
def hls_palette(n_colors=6, h=.01, l=.6, s=.65, as_cmap=False): # noqa
"""
Return hues with constant lightness and saturation in the HLS system.
The hues are evenly sampled along a circular path. The resulting palette will be
appropriate for categorical or cyclical data.
The `h`, `l`, and `s` values should be between 0 and 1.
.. note::
While the separation of the resulting colors will be mathematically
constant, the HLS system does not construct a perceptually-uniform space,
so their apparent intensity will vary.
Parameters
----------
n_colors : int
Number of colors in the palette.
h : float
The value of the first hue.
l : float
The lightness value.
s : float
The saturation intensity.
as_cmap : bool
If True, return a matplotlib colormap object.
Returns
-------
palette
list of RGB tuples or :class:`matplotlib.colors.ListedColormap`
See Also
--------
husl_palette : Make a palette using evenly spaced hues in the HUSL system.
Examples
--------
.. include:: ../docstrings/hls_palette.rst
"""
if as_cmap:
n_colors = 256
hues = np.linspace(0, 1, int(n_colors) + 1)[:-1]
hues += h
hues %= 1
hues -= hues.astype(int)
palette = [colorsys.hls_to_rgb(h_i, l, s) for h_i in hues]
if as_cmap:
return mpl.colors.ListedColormap(palette, "hls")
else:
return _ColorPalette(palette)
def husl_palette(n_colors=6, h=.01, s=.9, l=.65, as_cmap=False): # noqa
"""
Return hues with constant lightness and saturation in the HUSL system.
The hues are evenly sampled along a circular path. The resulting palette will be
appropriate for categorical or cyclical data.
The `h`, `l`, and `s` values should be between 0 and 1.
This function is similar to :func:`hls_palette`, but it uses a nonlinear color
space that is more perceptually uniform.
Parameters
----------
n_colors : int
Number of colors in the palette.
h : float
The value of the first hue.
l : float
The lightness value.
s : float
The saturation intensity.
as_cmap : bool
If True, return a matplotlib colormap object.
Returns
-------
palette
list of RGB tuples or :class:`matplotlib.colors.ListedColormap`
See Also
--------
hls_palette : Make a palette using evenly spaced hues in the HSL system.
Examples
--------
.. include:: ../docstrings/husl_palette.rst
"""
if as_cmap:
n_colors = 256
hues = np.linspace(0, 1, int(n_colors) + 1)[:-1]
hues += h
hues %= 1
hues *= 359
s *= 99
l *= 99 # noqa
palette = [_color_to_rgb((h_i, s, l), input="husl") for h_i in hues]
if as_cmap:
return mpl.colors.ListedColormap(palette, "hsl")
else:
return _ColorPalette(palette)
def mpl_palette(name, n_colors=6, as_cmap=False):
"""
Return a palette or colormap from the matplotlib registry.
For continuous palettes, evenly-spaced discrete samples are chosen while
excluding the minimum and maximum value in the colormap to provide better
contrast at the extremes.
For qualitative palettes (e.g. those from colorbrewer), exact values are
indexed (rather than interpolated), but fewer than `n_colors` can be returned
if the palette does not define that many.
Parameters
----------
name : string
Name of the palette. This should be a named matplotlib colormap.
n_colors : int
Number of discrete colors in the palette.
Returns
-------
list of RGB tuples or :class:`matplotlib.colors.ListedColormap`
Examples
--------
.. include:: ../docstrings/mpl_palette.rst
"""
if name.endswith("_d"):
sub_name = name[:-2]
if sub_name.endswith("_r"):
reverse = True
sub_name = sub_name[:-2]
else:
reverse = False
pal = color_palette(sub_name, 2) + ["#333333"]
if reverse:
pal = pal[::-1]
cmap = blend_palette(pal, n_colors, as_cmap=True)
else:
cmap = get_colormap(name)
if name in MPL_QUAL_PALS:
bins = np.linspace(0, 1, MPL_QUAL_PALS[name])[:n_colors]
else:
bins = np.linspace(0, 1, int(n_colors) + 2)[1:-1]
palette = list(map(tuple, cmap(bins)[:, :3]))
if as_cmap:
return cmap
else:
return _ColorPalette(palette)
def _color_to_rgb(color, input):
"""Add some more flexibility to color choices."""
if input == "hls":
color = colorsys.hls_to_rgb(*color)
elif input == "husl":
color = husl.husl_to_rgb(*color)
color = tuple(np.clip(color, 0, 1))
elif input == "xkcd":
color = xkcd_rgb[color]
return mpl.colors.to_rgb(color)
def dark_palette(color, n_colors=6, reverse=False, as_cmap=False, input="rgb"):
"""Make a sequential palette that blends from dark to ``color``.
This kind of palette is good for data that range between relatively
uninteresting low values and interesting high values.
The ``color`` parameter can be specified in a number of ways, including
all options for defining a color in matplotlib and several additional
color spaces that are handled by seaborn. You can also use the database
of named colors from the XKCD color survey.
If you are using the IPython notebook, you can also choose this palette
interactively with the :func:`choose_dark_palette` function.
Parameters
----------
color : base color for high values
hex, rgb-tuple, or html color name
n_colors : int, optional
number of colors in the palette
reverse : bool, optional
if True, reverse the direction of the blend
as_cmap : bool, optional
If True, return a :class:`matplotlib.colors.ListedColormap`.
input : {'rgb', 'hls', 'husl', xkcd'}
Color space to interpret the input color. The first three options
apply to tuple inputs and the latter applies to string inputs.
Returns
-------
palette
list of RGB tuples or :class:`matplotlib.colors.ListedColormap`
See Also
--------
light_palette : Create a sequential palette with bright low values.
diverging_palette : Create a diverging palette with two colors.
Examples
--------
.. include:: ../docstrings/dark_palette.rst
"""
rgb = _color_to_rgb(color, input)
hue, sat, _ = husl.rgb_to_husl(*rgb)
gray_s, gray_l = .15 * sat, 15
gray = _color_to_rgb((hue, gray_s, gray_l), input="husl")
colors = [rgb, gray] if reverse else [gray, rgb]
return blend_palette(colors, n_colors, as_cmap)
def light_palette(color, n_colors=6, reverse=False, as_cmap=False, input="rgb"):
"""Make a sequential palette that blends from light to ``color``.
The ``color`` parameter can be specified in a number of ways, including
all options for defining a color in matplotlib and several additional
color spaces that are handled by seaborn. You can also use the database
of named colors from the XKCD color survey.
If you are using a Jupyter notebook, you can also choose this palette
interactively with the :func:`choose_light_palette` function.
Parameters
----------
color : base color for high values
hex code, html color name, or tuple in `input` space.
n_colors : int, optional
number of colors in the palette
reverse : bool, optional
if True, reverse the direction of the blend
as_cmap : bool, optional
If True, return a :class:`matplotlib.colors.ListedColormap`.
input : {'rgb', 'hls', 'husl', xkcd'}
Color space to interpret the input color. The first three options
apply to tuple inputs and the latter applies to string inputs.
Returns
-------
palette
list of RGB tuples or :class:`matplotlib.colors.ListedColormap`
See Also
--------
dark_palette : Create a sequential palette with dark low values.
diverging_palette : Create a diverging palette with two colors.
Examples
--------
.. include:: ../docstrings/light_palette.rst
"""
rgb = _color_to_rgb(color, input)
hue, sat, _ = husl.rgb_to_husl(*rgb)
gray_s, gray_l = .15 * sat, 95
gray = _color_to_rgb((hue, gray_s, gray_l), input="husl")
colors = [rgb, gray] if reverse else [gray, rgb]
return blend_palette(colors, n_colors, as_cmap)
def diverging_palette(h_neg, h_pos, s=75, l=50, sep=1, n=6, # noqa
center="light", as_cmap=False):
"""Make a diverging palette between two HUSL colors.
If you are using the IPython notebook, you can also choose this palette
interactively with the :func:`choose_diverging_palette` function.
Parameters
----------
h_neg, h_pos : float in [0, 359]
Anchor hues for negative and positive extents of the map.
s : float in [0, 100], optional
Anchor saturation for both extents of the map.
l : float in [0, 100], optional
Anchor lightness for both extents of the map.
sep : int, optional
Size of the intermediate region.
n : int, optional
Number of colors in the palette (if not returning a cmap)
center : {"light", "dark"}, optional
Whether the center of the palette is light or dark
as_cmap : bool, optional
If True, return a :class:`matplotlib.colors.ListedColormap`.
Returns
-------
palette
list of RGB tuples or :class:`matplotlib.colors.ListedColormap`
See Also
--------
dark_palette : Create a sequential palette with dark values.
light_palette : Create a sequential palette with light values.
Examples
--------
.. include: ../docstrings/diverging_palette.rst
"""
palfunc = dict(dark=dark_palette, light=light_palette)[center]
n_half = int(128 - (sep // 2))
neg = palfunc((h_neg, s, l), n_half, reverse=True, input="husl")
pos = palfunc((h_pos, s, l), n_half, input="husl")
midpoint = dict(light=[(.95, .95, .95)], dark=[(.133, .133, .133)])[center]
mid = midpoint * sep
pal = blend_palette(np.concatenate([neg, mid, pos]), n, as_cmap=as_cmap)
return pal
def blend_palette(colors, n_colors=6, as_cmap=False, input="rgb"):
"""Make a palette that blends between a list of colors.
Parameters
----------
colors : sequence of colors in various formats interpreted by `input`
hex code, html color name, or tuple in `input` space.
n_colors : int, optional
Number of colors in the palette.
as_cmap : bool, optional
If True, return a :class:`matplotlib.colors.ListedColormap`.
Returns
-------
palette
list of RGB tuples or :class:`matplotlib.colors.ListedColormap`
Examples
--------
.. include: ../docstrings/blend_palette.rst
"""
colors = [_color_to_rgb(color, input) for color in colors]
name = "blend"
pal = mpl.colors.LinearSegmentedColormap.from_list(name, colors)
if not as_cmap:
rgb_array = pal(np.linspace(0, 1, int(n_colors)))[:, :3] # no alpha
pal = _ColorPalette(map(tuple, rgb_array))
return pal
def xkcd_palette(colors):
"""Make a palette with color names from the xkcd color survey.
See xkcd for the full list of colors: https://xkcd.com/color/rgb/
This is just a simple wrapper around the `seaborn.xkcd_rgb` dictionary.
Parameters
----------
colors : list of strings
List of keys in the `seaborn.xkcd_rgb` dictionary.
Returns
-------
palette
A list of colors as RGB tuples.
See Also
--------
crayon_palette : Make a palette with Crayola crayon colors.
"""
palette = [xkcd_rgb[name] for name in colors]
return color_palette(palette, len(palette))
def crayon_palette(colors):
"""Make a palette with color names from Crayola crayons.
Colors are taken from here:
https://en.wikipedia.org/wiki/List_of_Crayola_crayon_colors
This is just a simple wrapper around the `seaborn.crayons` dictionary.
Parameters
----------
colors : list of strings
List of keys in the `seaborn.crayons` dictionary.
Returns
-------
palette
A list of colors as RGB tuples.
See Also
--------
xkcd_palette : Make a palette with named colors from the XKCD color survey.
"""
palette = [crayons[name] for name in colors]
return color_palette(palette, len(palette))
def cubehelix_palette(n_colors=6, start=0, rot=.4, gamma=1.0, hue=0.8,
light=.85, dark=.15, reverse=False, as_cmap=False):
"""Make a sequential palette from the cubehelix system.
This produces a colormap with linearly-decreasing (or increasing)
brightness. That means that information will be preserved if printed to
black and white or viewed by someone who is colorblind. "cubehelix" is
also available as a matplotlib-based palette, but this function gives the
user more control over the look of the palette and has a different set of
defaults.
In addition to using this function, it is also possible to generate a
cubehelix palette generally in seaborn using a string starting with
`ch:` and containing other parameters (e.g. `"ch:s=.25,r=-.5"`).
Parameters
----------
n_colors : int
Number of colors in the palette.
start : float, 0 <= start <= 3
The hue value at the start of the helix.
rot : float
Rotations around the hue wheel over the range of the palette.
gamma : float 0 <= gamma
Nonlinearity to emphasize dark (gamma < 1) or light (gamma > 1) colors.
hue : float, 0 <= hue <= 1
Saturation of the colors.
dark : float 0 <= dark <= 1
Intensity of the darkest color in the palette.
light : float 0 <= light <= 1
Intensity of the lightest color in the palette.
reverse : bool
If True, the palette will go from dark to light.
as_cmap : bool
If True, return a :class:`matplotlib.colors.ListedColormap`.
Returns
-------
palette
list of RGB tuples or :class:`matplotlib.colors.ListedColormap`
See Also
--------
choose_cubehelix_palette : Launch an interactive widget to select cubehelix
palette parameters.
dark_palette : Create a sequential palette with dark low values.
light_palette : Create a sequential palette with bright low values.
References
----------
Green, D. A. (2011). "A colour scheme for the display of astronomical
intensity images". Bulletin of the Astromical Society of India, Vol. 39,
p. 289-295.
Examples
--------
.. include:: ../docstrings/cubehelix_palette.rst
"""
def get_color_function(p0, p1):
# Copied from matplotlib because it lives in private module
def color(x):
# Apply gamma factor to emphasise low or high intensity values
xg = x ** gamma
# Calculate amplitude and angle of deviation from the black
# to white diagonal in the plane of constant
# perceived intensity.
a = hue * xg * (1 - xg) / 2
phi = 2 * np.pi * (start / 3 + rot * x)
return xg + a * (p0 * np.cos(phi) + p1 * np.sin(phi))
return color
cdict = {
"red": get_color_function(-0.14861, 1.78277),
"green": get_color_function(-0.29227, -0.90649),
"blue": get_color_function(1.97294, 0.0),
}
cmap = mpl.colors.LinearSegmentedColormap("cubehelix", cdict)
x = np.linspace(light, dark, int(n_colors))
pal = cmap(x)[:, :3].tolist()
if reverse:
pal = pal[::-1]
if as_cmap:
x_256 = np.linspace(light, dark, 256)
if reverse:
x_256 = x_256[::-1]
pal_256 = cmap(x_256)
cmap = mpl.colors.ListedColormap(pal_256, "seaborn_cubehelix")
return cmap
else:
return _ColorPalette(pal)
def _parse_cubehelix_args(argstr):
"""Turn stringified cubehelix params into args/kwargs."""
if argstr.startswith("ch:"):
argstr = argstr[3:]
if argstr.endswith("_r"):
reverse = True
argstr = argstr[:-2]
else:
reverse = False
if not argstr:
return [], {"reverse": reverse}
all_args = argstr.split(",")
args = [float(a.strip(" ")) for a in all_args if "=" not in a]
kwargs = [a.split("=") for a in all_args if "=" in a]
kwargs = {k.strip(" "): float(v.strip(" ")) for k, v in kwargs}
kwarg_map = dict(
s="start", r="rot", g="gamma",
h="hue", l="light", d="dark", # noqa: E741
)
kwargs = {kwarg_map.get(k, k): v for k, v in kwargs.items()}
if reverse:
kwargs["reverse"] = True
return args, kwargs
def set_color_codes(palette="deep"):
"""Change how matplotlib color shorthands are interpreted.
Calling this will change how shorthand codes like "b" or "g"
are interpreted by matplotlib in subsequent plots.
Parameters
----------
palette : {deep, muted, pastel, dark, bright, colorblind}
Named seaborn palette to use as the source of colors.
See Also
--------
set : Color codes can be set through the high-level seaborn style
manager.
set_palette : Color codes can also be set through the function that
sets the matplotlib color cycle.
"""
if palette == "reset":
colors = [
(0., 0., 1.),
(0., .5, 0.),
(1., 0., 0.),
(.75, 0., .75),
(.75, .75, 0.),
(0., .75, .75),
(0., 0., 0.)
]
elif not isinstance(palette, str):
err = "set_color_codes requires a named seaborn palette"
raise TypeError(err)
elif palette in SEABORN_PALETTES:
if not palette.endswith("6"):
palette = palette + "6"
colors = SEABORN_PALETTES[palette] + [(.1, .1, .1)]
else:
err = f"Cannot set colors with palette '{palette}'"
raise ValueError(err)
for code, color in zip("bgrmyck", colors):
rgb = mpl.colors.colorConverter.to_rgb(color)
mpl.colors.colorConverter.colors[code] = rgb

View File

@@ -0,0 +1,533 @@
"""Control plot style and scaling using the matplotlib rcParams interface."""
import functools
import matplotlib as mpl
from cycler import cycler
from . import palettes
__all__ = ["set_theme", "set", "reset_defaults", "reset_orig",
"axes_style", "set_style", "plotting_context", "set_context",
"set_palette"]
_style_keys = [
"axes.facecolor",
"axes.edgecolor",
"axes.grid",
"axes.axisbelow",
"axes.labelcolor",
"figure.facecolor",
"grid.color",
"grid.linestyle",
"text.color",
"xtick.color",
"ytick.color",
"xtick.direction",
"ytick.direction",
"lines.solid_capstyle",
"patch.edgecolor",
"patch.force_edgecolor",
"image.cmap",
"font.family",
"font.sans-serif",
"xtick.bottom",
"xtick.top",
"ytick.left",
"ytick.right",
"axes.spines.left",
"axes.spines.bottom",
"axes.spines.right",
"axes.spines.top",
]
_context_keys = [
"font.size",
"axes.labelsize",
"axes.titlesize",
"xtick.labelsize",
"ytick.labelsize",
"legend.fontsize",
"legend.title_fontsize",
"axes.linewidth",
"grid.linewidth",
"lines.linewidth",
"lines.markersize",
"patch.linewidth",
"xtick.major.width",
"ytick.major.width",
"xtick.minor.width",
"ytick.minor.width",
"xtick.major.size",
"ytick.major.size",
"xtick.minor.size",
"ytick.minor.size",
]
def set_theme(context="notebook", style="darkgrid", palette="deep",
font="sans-serif", font_scale=1, color_codes=True, rc=None):
"""
Set aspects of the visual theme for all matplotlib and seaborn plots.
This function changes the global defaults for all plots using the
matplotlib rcParams system. The themeing is decomposed into several distinct
sets of parameter values.
The options are illustrated in the :doc:`aesthetics <../tutorial/aesthetics>`
and :doc:`color palette <../tutorial/color_palettes>` tutorials.
Parameters
----------
context : string or dict
Scaling parameters, see :func:`plotting_context`.
style : string or dict
Axes style parameters, see :func:`axes_style`.
palette : string or sequence
Color palette, see :func:`color_palette`.
font : string
Font family, see matplotlib font manager.
font_scale : float, optional
Separate scaling factor to independently scale the size of the
font elements.
color_codes : bool
If ``True`` and ``palette`` is a seaborn palette, remap the shorthand
color codes (e.g. "b", "g", "r", etc.) to the colors from this palette.
rc : dict or None
Dictionary of rc parameter mappings to override the above.
Examples
--------
.. include:: ../docstrings/set_theme.rst
"""
set_context(context, font_scale)
set_style(style, rc={"font.family": font})
set_palette(palette, color_codes=color_codes)
if rc is not None:
mpl.rcParams.update(rc)
def set(*args, **kwargs):
"""
Alias for :func:`set_theme`, which is the preferred interface.
This function may be removed in the future.
"""
set_theme(*args, **kwargs)
def reset_defaults():
"""Restore all RC params to default settings."""
mpl.rcParams.update(mpl.rcParamsDefault)
def reset_orig():
"""Restore all RC params to original settings (respects custom rc)."""
from . import _orig_rc_params
mpl.rcParams.update(_orig_rc_params)
def axes_style(style=None, rc=None):
"""
Get the parameters that control the general style of the plots.
The style parameters control properties like the color of the background and
whether a grid is enabled by default. This is accomplished using the
matplotlib rcParams system.
The options are illustrated in the
:doc:`aesthetics tutorial <../tutorial/aesthetics>`.
This function can also be used as a context manager to temporarily
alter the global defaults. See :func:`set_theme` or :func:`set_style`
to modify the global defaults for all plots.
Parameters
----------
style : None, dict, or one of {darkgrid, whitegrid, dark, white, ticks}
A dictionary of parameters or the name of a preconfigured style.
rc : dict, optional
Parameter mappings to override the values in the preset seaborn
style dictionaries. This only updates parameters that are
considered part of the style definition.
Examples
--------
.. include:: ../docstrings/axes_style.rst
"""
if style is None:
style_dict = {k: mpl.rcParams[k] for k in _style_keys}
elif isinstance(style, dict):
style_dict = style
else:
styles = ["white", "dark", "whitegrid", "darkgrid", "ticks"]
if style not in styles:
raise ValueError(f"style must be one of {', '.join(styles)}")
# Define colors here
dark_gray = ".15"
light_gray = ".8"
# Common parameters
style_dict = {
"figure.facecolor": "white",
"axes.labelcolor": dark_gray,
"xtick.direction": "out",
"ytick.direction": "out",
"xtick.color": dark_gray,
"ytick.color": dark_gray,
"axes.axisbelow": True,
"grid.linestyle": "-",
"text.color": dark_gray,
"font.family": ["sans-serif"],
"font.sans-serif": ["Arial", "DejaVu Sans", "Liberation Sans",
"Bitstream Vera Sans", "sans-serif"],
"lines.solid_capstyle": "round",
"patch.edgecolor": "w",
"patch.force_edgecolor": True,
"image.cmap": "rocket",
"xtick.top": False,
"ytick.right": False,
}
# Set grid on or off
if "grid" in style:
style_dict.update({
"axes.grid": True,
})
else:
style_dict.update({
"axes.grid": False,
})
# Set the color of the background, spines, and grids
if style.startswith("dark"):
style_dict.update({
"axes.facecolor": "#EAEAF2",
"axes.edgecolor": "white",
"grid.color": "white",
"axes.spines.left": True,
"axes.spines.bottom": True,
"axes.spines.right": True,
"axes.spines.top": True,
})
elif style == "whitegrid":
style_dict.update({
"axes.facecolor": "white",
"axes.edgecolor": light_gray,
"grid.color": light_gray,
"axes.spines.left": True,
"axes.spines.bottom": True,
"axes.spines.right": True,
"axes.spines.top": True,
})
elif style in ["white", "ticks"]:
style_dict.update({
"axes.facecolor": "white",
"axes.edgecolor": dark_gray,
"grid.color": light_gray,
"axes.spines.left": True,
"axes.spines.bottom": True,
"axes.spines.right": True,
"axes.spines.top": True,
})
# Show or hide the axes ticks
if style == "ticks":
style_dict.update({
"xtick.bottom": True,
"ytick.left": True,
})
else:
style_dict.update({
"xtick.bottom": False,
"ytick.left": False,
})
# Remove entries that are not defined in the base list of valid keys
# This lets us handle matplotlib <=/> 2.0
style_dict = {k: v for k, v in style_dict.items() if k in _style_keys}
# Override these settings with the provided rc dictionary
if rc is not None:
rc = {k: v for k, v in rc.items() if k in _style_keys}
style_dict.update(rc)
# Wrap in an _AxesStyle object so this can be used in a with statement
style_object = _AxesStyle(style_dict)
return style_object
def set_style(style=None, rc=None):
"""
Set the parameters that control the general style of the plots.
The style parameters control properties like the color of the background and
whether a grid is enabled by default. This is accomplished using the
matplotlib rcParams system.
The options are illustrated in the
:doc:`aesthetics tutorial <../tutorial/aesthetics>`.
See :func:`axes_style` to get the parameter values.
Parameters
----------
style : dict, or one of {darkgrid, whitegrid, dark, white, ticks}
A dictionary of parameters or the name of a preconfigured style.
rc : dict, optional
Parameter mappings to override the values in the preset seaborn
style dictionaries. This only updates parameters that are
considered part of the style definition.
Examples
--------
.. include:: ../docstrings/set_style.rst
"""
style_object = axes_style(style, rc)
mpl.rcParams.update(style_object)
def plotting_context(context=None, font_scale=1, rc=None):
"""
Get the parameters that control the scaling of plot elements.
These parameters correspond to label size, line thickness, etc. For more
information, see the :doc:`aesthetics tutorial <../tutorial/aesthetics>`.
The base context is "notebook", and the other contexts are "paper", "talk",
and "poster", which are version of the notebook parameters scaled by different
values. Font elements can also be scaled independently of (but relative to)
the other values.
This function can also be used as a context manager to temporarily
alter the global defaults. See :func:`set_theme` or :func:`set_context`
to modify the global defaults for all plots.
Parameters
----------
context : None, dict, or one of {paper, notebook, talk, poster}
A dictionary of parameters or the name of a preconfigured set.
font_scale : float, optional
Separate scaling factor to independently scale the size of the
font elements.
rc : dict, optional
Parameter mappings to override the values in the preset seaborn
context dictionaries. This only updates parameters that are
considered part of the context definition.
Examples
--------
.. include:: ../docstrings/plotting_context.rst
"""
if context is None:
context_dict = {k: mpl.rcParams[k] for k in _context_keys}
elif isinstance(context, dict):
context_dict = context
else:
contexts = ["paper", "notebook", "talk", "poster"]
if context not in contexts:
raise ValueError(f"context must be in {', '.join(contexts)}")
# Set up dictionary of default parameters
texts_base_context = {
"font.size": 12,
"axes.labelsize": 12,
"axes.titlesize": 12,
"xtick.labelsize": 11,
"ytick.labelsize": 11,
"legend.fontsize": 11,
"legend.title_fontsize": 12,
}
base_context = {
"axes.linewidth": 1.25,
"grid.linewidth": 1,
"lines.linewidth": 1.5,
"lines.markersize": 6,
"patch.linewidth": 1,
"xtick.major.width": 1.25,
"ytick.major.width": 1.25,
"xtick.minor.width": 1,
"ytick.minor.width": 1,
"xtick.major.size": 6,
"ytick.major.size": 6,
"xtick.minor.size": 4,
"ytick.minor.size": 4,
}
base_context.update(texts_base_context)
# Scale all the parameters by the same factor depending on the context
scaling = dict(paper=.8, notebook=1, talk=1.5, poster=2)[context]
context_dict = {k: v * scaling for k, v in base_context.items()}
# Now independently scale the fonts
font_keys = texts_base_context.keys()
font_dict = {k: context_dict[k] * font_scale for k in font_keys}
context_dict.update(font_dict)
# Override these settings with the provided rc dictionary
if rc is not None:
rc = {k: v for k, v in rc.items() if k in _context_keys}
context_dict.update(rc)
# Wrap in a _PlottingContext object so this can be used in a with statement
context_object = _PlottingContext(context_dict)
return context_object
def set_context(context=None, font_scale=1, rc=None):
"""
Set the parameters that control the scaling of plot elements.
These parameters correspond to label size, line thickness, etc.
Calling this function modifies the global matplotlib `rcParams`. For more
information, see the :doc:`aesthetics tutorial <../tutorial/aesthetics>`.
The base context is "notebook", and the other contexts are "paper", "talk",
and "poster", which are version of the notebook parameters scaled by different
values. Font elements can also be scaled independently of (but relative to)
the other values.
See :func:`plotting_context` to get the parameter values.
Parameters
----------
context : dict, or one of {paper, notebook, talk, poster}
A dictionary of parameters or the name of a preconfigured set.
font_scale : float, optional
Separate scaling factor to independently scale the size of the
font elements.
rc : dict, optional
Parameter mappings to override the values in the preset seaborn
context dictionaries. This only updates parameters that are
considered part of the context definition.
Examples
--------
.. include:: ../docstrings/set_context.rst
"""
context_object = plotting_context(context, font_scale, rc)
mpl.rcParams.update(context_object)
class _RCAesthetics(dict):
def __enter__(self):
rc = mpl.rcParams
self._orig = {k: rc[k] for k in self._keys}
self._set(self)
def __exit__(self, exc_type, exc_value, exc_tb):
self._set(self._orig)
def __call__(self, func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
with self:
return func(*args, **kwargs)
return wrapper
class _AxesStyle(_RCAesthetics):
"""Light wrapper on a dict to set style temporarily."""
_keys = _style_keys
_set = staticmethod(set_style)
class _PlottingContext(_RCAesthetics):
"""Light wrapper on a dict to set context temporarily."""
_keys = _context_keys
_set = staticmethod(set_context)
def set_palette(palette, n_colors=None, desat=None, color_codes=False):
"""Set the matplotlib color cycle using a seaborn palette.
Parameters
----------
palette : seaborn color palette | matplotlib colormap | hls | husl
Palette definition. Should be something :func:`color_palette` can process.
n_colors : int
Number of colors in the cycle. The default number of colors will depend
on the format of ``palette``, see the :func:`color_palette`
documentation for more information.
desat : float
Proportion to desaturate each color by.
color_codes : bool
If ``True`` and ``palette`` is a seaborn palette, remap the shorthand
color codes (e.g. "b", "g", "r", etc.) to the colors from this palette.
See Also
--------
color_palette : build a color palette or set the color cycle temporarily
in a ``with`` statement.
set_context : set parameters to scale plot elements
set_style : set the default parameters for figure style
"""
colors = palettes.color_palette(palette, n_colors, desat)
cyl = cycler('color', colors)
mpl.rcParams['axes.prop_cycle'] = cyl
if color_codes:
try:
palettes.set_color_codes(palette)
except (ValueError, TypeError):
pass

View File

@@ -0,0 +1,940 @@
"""Plotting functions for linear models (broadly construed)."""
import copy
from textwrap import dedent
import warnings
import numpy as np
import pandas as pd
import matplotlib as mpl
import matplotlib.pyplot as plt
try:
import statsmodels
assert statsmodels
_has_statsmodels = True
except ImportError:
_has_statsmodels = False
from . import utils
from . import algorithms as algo
from .axisgrid import FacetGrid, _facet_docs
__all__ = ["lmplot", "regplot", "residplot"]
class _LinearPlotter:
"""Base class for plotting relational data in tidy format.
To get anything useful done you'll have to inherit from this, but setup
code that can be abstracted out should be put here.
"""
def establish_variables(self, data, **kws):
"""Extract variables from data or use directly."""
self.data = data
# Validate the inputs
any_strings = any([isinstance(v, str) for v in kws.values()])
if any_strings and data is None:
raise ValueError("Must pass `data` if using named variables.")
# Set the variables
for var, val in kws.items():
if isinstance(val, str):
vector = data[val]
elif isinstance(val, list):
vector = np.asarray(val)
else:
vector = val
if vector is not None and vector.shape != (1,):
vector = np.squeeze(vector)
if np.ndim(vector) > 1:
err = "regplot inputs must be 1d"
raise ValueError(err)
setattr(self, var, vector)
def dropna(self, *vars):
"""Remove observations with missing data."""
vals = [getattr(self, var) for var in vars]
vals = [v for v in vals if v is not None]
not_na = np.all(np.column_stack([pd.notnull(v) for v in vals]), axis=1)
for var in vars:
val = getattr(self, var)
if val is not None:
setattr(self, var, val[not_na])
def plot(self, ax):
raise NotImplementedError
class _RegressionPlotter(_LinearPlotter):
"""Plotter for numeric independent variables with regression model.
This does the computations and drawing for the `regplot` function, and
is thus also used indirectly by `lmplot`.
"""
def __init__(self, x, y, data=None, x_estimator=None, x_bins=None,
x_ci="ci", scatter=True, fit_reg=True, ci=95, n_boot=1000,
units=None, seed=None, order=1, logistic=False, lowess=False,
robust=False, logx=False, x_partial=None, y_partial=None,
truncate=False, dropna=True, x_jitter=None, y_jitter=None,
color=None, label=None):
# Set member attributes
self.x_estimator = x_estimator
self.ci = ci
self.x_ci = ci if x_ci == "ci" else x_ci
self.n_boot = n_boot
self.seed = seed
self.scatter = scatter
self.fit_reg = fit_reg
self.order = order
self.logistic = logistic
self.lowess = lowess
self.robust = robust
self.logx = logx
self.truncate = truncate
self.x_jitter = x_jitter
self.y_jitter = y_jitter
self.color = color
self.label = label
# Validate the regression options:
if sum((order > 1, logistic, robust, lowess, logx)) > 1:
raise ValueError("Mutually exclusive regression options.")
# Extract the data vals from the arguments or passed dataframe
self.establish_variables(data, x=x, y=y, units=units,
x_partial=x_partial, y_partial=y_partial)
# Drop null observations
if dropna:
self.dropna("x", "y", "units", "x_partial", "y_partial")
# Regress nuisance variables out of the data
if self.x_partial is not None:
self.x = self.regress_out(self.x, self.x_partial)
if self.y_partial is not None:
self.y = self.regress_out(self.y, self.y_partial)
# Possibly bin the predictor variable, which implies a point estimate
if x_bins is not None:
self.x_estimator = np.mean if x_estimator is None else x_estimator
x_discrete, x_bins = self.bin_predictor(x_bins)
self.x_discrete = x_discrete
else:
self.x_discrete = self.x
# Disable regression in case of singleton inputs
if len(self.x) <= 1:
self.fit_reg = False
# Save the range of the x variable for the grid later
if self.fit_reg:
self.x_range = self.x.min(), self.x.max()
@property
def scatter_data(self):
"""Data where each observation is a point."""
x_j = self.x_jitter
if x_j is None:
x = self.x
else:
x = self.x + np.random.uniform(-x_j, x_j, len(self.x))
y_j = self.y_jitter
if y_j is None:
y = self.y
else:
y = self.y + np.random.uniform(-y_j, y_j, len(self.y))
return x, y
@property
def estimate_data(self):
"""Data with a point estimate and CI for each discrete x value."""
x, y = self.x_discrete, self.y
vals = sorted(np.unique(x))
points, cis = [], []
for val in vals:
# Get the point estimate of the y variable
_y = y[x == val]
est = self.x_estimator(_y)
points.append(est)
# Compute the confidence interval for this estimate
if self.x_ci is None:
cis.append(None)
else:
units = None
if self.x_ci == "sd":
sd = np.std(_y)
_ci = est - sd, est + sd
else:
if self.units is not None:
units = self.units[x == val]
boots = algo.bootstrap(_y,
func=self.x_estimator,
n_boot=self.n_boot,
units=units,
seed=self.seed)
_ci = utils.ci(boots, self.x_ci)
cis.append(_ci)
return vals, points, cis
def _check_statsmodels(self):
"""Check whether statsmodels is installed if any boolean options require it."""
options = "logistic", "robust", "lowess"
err = "`{}=True` requires statsmodels, an optional dependency, to be installed."
for option in options:
if getattr(self, option) and not _has_statsmodels:
raise RuntimeError(err.format(option))
def fit_regression(self, ax=None, x_range=None, grid=None):
"""Fit the regression model."""
self._check_statsmodels()
# Create the grid for the regression
if grid is None:
if self.truncate:
x_min, x_max = self.x_range
else:
if ax is None:
x_min, x_max = x_range
else:
x_min, x_max = ax.get_xlim()
grid = np.linspace(x_min, x_max, 100)
ci = self.ci
# Fit the regression
if self.order > 1:
yhat, yhat_boots = self.fit_poly(grid, self.order)
elif self.logistic:
from statsmodels.genmod.generalized_linear_model import GLM
from statsmodels.genmod.families import Binomial
yhat, yhat_boots = self.fit_statsmodels(grid, GLM,
family=Binomial())
elif self.lowess:
ci = None
grid, yhat = self.fit_lowess()
elif self.robust:
from statsmodels.robust.robust_linear_model import RLM
yhat, yhat_boots = self.fit_statsmodels(grid, RLM)
elif self.logx:
yhat, yhat_boots = self.fit_logx(grid)
else:
yhat, yhat_boots = self.fit_fast(grid)
# Compute the confidence interval at each grid point
if ci is None:
err_bands = None
else:
err_bands = utils.ci(yhat_boots, ci, axis=0)
return grid, yhat, err_bands
def fit_fast(self, grid):
"""Low-level regression and prediction using linear algebra."""
def reg_func(_x, _y):
return np.linalg.pinv(_x).dot(_y)
X, y = np.c_[np.ones(len(self.x)), self.x], self.y
grid = np.c_[np.ones(len(grid)), grid]
yhat = grid.dot(reg_func(X, y))
if self.ci is None:
return yhat, None
beta_boots = algo.bootstrap(X, y,
func=reg_func,
n_boot=self.n_boot,
units=self.units,
seed=self.seed).T
yhat_boots = grid.dot(beta_boots).T
return yhat, yhat_boots
def fit_poly(self, grid, order):
"""Regression using numpy polyfit for higher-order trends."""
def reg_func(_x, _y):
return np.polyval(np.polyfit(_x, _y, order), grid)
x, y = self.x, self.y
yhat = reg_func(x, y)
if self.ci is None:
return yhat, None
yhat_boots = algo.bootstrap(x, y,
func=reg_func,
n_boot=self.n_boot,
units=self.units,
seed=self.seed)
return yhat, yhat_boots
def fit_statsmodels(self, grid, model, **kwargs):
"""More general regression function using statsmodels objects."""
import statsmodels.tools.sm_exceptions as sme
X, y = np.c_[np.ones(len(self.x)), self.x], self.y
grid = np.c_[np.ones(len(grid)), grid]
def reg_func(_x, _y):
err_classes = (sme.PerfectSeparationError,)
try:
with warnings.catch_warnings():
if hasattr(sme, "PerfectSeparationWarning"):
# statsmodels>=0.14.0
warnings.simplefilter("error", sme.PerfectSeparationWarning)
err_classes = (*err_classes, sme.PerfectSeparationWarning)
yhat = model(_y, _x, **kwargs).fit().predict(grid)
except err_classes:
yhat = np.empty(len(grid))
yhat.fill(np.nan)
return yhat
yhat = reg_func(X, y)
if self.ci is None:
return yhat, None
yhat_boots = algo.bootstrap(X, y,
func=reg_func,
n_boot=self.n_boot,
units=self.units,
seed=self.seed)
return yhat, yhat_boots
def fit_lowess(self):
"""Fit a locally-weighted regression, which returns its own grid."""
from statsmodels.nonparametric.smoothers_lowess import lowess
grid, yhat = lowess(self.y, self.x).T
return grid, yhat
def fit_logx(self, grid):
"""Fit the model in log-space."""
X, y = np.c_[np.ones(len(self.x)), self.x], self.y
grid = np.c_[np.ones(len(grid)), np.log(grid)]
def reg_func(_x, _y):
_x = np.c_[_x[:, 0], np.log(_x[:, 1])]
return np.linalg.pinv(_x).dot(_y)
yhat = grid.dot(reg_func(X, y))
if self.ci is None:
return yhat, None
beta_boots = algo.bootstrap(X, y,
func=reg_func,
n_boot=self.n_boot,
units=self.units,
seed=self.seed).T
yhat_boots = grid.dot(beta_boots).T
return yhat, yhat_boots
def bin_predictor(self, bins):
"""Discretize a predictor by assigning value to closest bin."""
x = np.asarray(self.x)
if np.isscalar(bins):
percentiles = np.linspace(0, 100, bins + 2)[1:-1]
bins = np.percentile(x, percentiles)
else:
bins = np.ravel(bins)
dist = np.abs(np.subtract.outer(x, bins))
x_binned = bins[np.argmin(dist, axis=1)].ravel()
return x_binned, bins
def regress_out(self, a, b):
"""Regress b from a keeping a's original mean."""
a_mean = a.mean()
a = a - a_mean
b = b - b.mean()
b = np.c_[b]
a_prime = a - b.dot(np.linalg.pinv(b).dot(a))
return np.asarray(a_prime + a_mean).reshape(a.shape)
def plot(self, ax, scatter_kws, line_kws):
"""Draw the full plot."""
# Insert the plot label into the correct set of keyword arguments
if self.scatter:
scatter_kws["label"] = self.label
else:
line_kws["label"] = self.label
# Use the current color cycle state as a default
if self.color is None:
lines, = ax.plot([], [])
color = lines.get_color()
lines.remove()
else:
color = self.color
# Ensure that color is hex to avoid matplotlib weirdness
color = mpl.colors.rgb2hex(mpl.colors.colorConverter.to_rgb(color))
# Let color in keyword arguments override overall plot color
scatter_kws.setdefault("color", color)
line_kws.setdefault("color", color)
# Draw the constituent plots
if self.scatter:
self.scatterplot(ax, scatter_kws)
if self.fit_reg:
self.lineplot(ax, line_kws)
# Label the axes
if hasattr(self.x, "name"):
ax.set_xlabel(self.x.name)
if hasattr(self.y, "name"):
ax.set_ylabel(self.y.name)
def scatterplot(self, ax, kws):
"""Draw the data."""
# Treat the line-based markers specially, explicitly setting larger
# linewidth than is provided by the seaborn style defaults.
# This would ideally be handled better in matplotlib (i.e., distinguish
# between edgewidth for solid glyphs and linewidth for line glyphs
# but this should do for now.
line_markers = ["1", "2", "3", "4", "+", "x", "|", "_"]
if self.x_estimator is None:
if "marker" in kws and kws["marker"] in line_markers:
lw = mpl.rcParams["lines.linewidth"]
else:
lw = mpl.rcParams["lines.markeredgewidth"]
kws.setdefault("linewidths", lw)
if not hasattr(kws['color'], 'shape') or kws['color'].shape[1] < 4:
kws.setdefault("alpha", .8)
x, y = self.scatter_data
ax.scatter(x, y, **kws)
else:
# TODO abstraction
ci_kws = {"color": kws["color"]}
if "alpha" in kws:
ci_kws["alpha"] = kws["alpha"]
ci_kws["linewidth"] = mpl.rcParams["lines.linewidth"] * 1.75
kws.setdefault("s", 50)
xs, ys, cis = self.estimate_data
if [ci for ci in cis if ci is not None]:
for x, ci in zip(xs, cis):
ax.plot([x, x], ci, **ci_kws)
ax.scatter(xs, ys, **kws)
def lineplot(self, ax, kws):
"""Draw the model."""
# Fit the regression model
grid, yhat, err_bands = self.fit_regression(ax)
edges = grid[0], grid[-1]
# Get set default aesthetics
fill_color = kws["color"]
lw = kws.pop("lw", mpl.rcParams["lines.linewidth"] * 1.5)
kws.setdefault("linewidth", lw)
# Draw the regression line and confidence interval
line, = ax.plot(grid, yhat, **kws)
if not self.truncate:
line.sticky_edges.x[:] = edges # Prevent mpl from adding margin
if err_bands is not None:
ax.fill_between(grid, *err_bands, facecolor=fill_color, alpha=.15)
_regression_docs = dict(
model_api=dedent("""\
There are a number of mutually exclusive options for estimating the
regression model. See the :ref:`tutorial <regression_tutorial>` for more
information.\
"""),
regplot_vs_lmplot=dedent("""\
The :func:`regplot` and :func:`lmplot` functions are closely related, but
the former is an axes-level function while the latter is a figure-level
function that combines :func:`regplot` and :class:`FacetGrid`.\
"""),
x_estimator=dedent("""\
x_estimator : callable that maps vector -> scalar, optional
Apply this function to each unique value of ``x`` and plot the
resulting estimate. This is useful when ``x`` is a discrete variable.
If ``x_ci`` is given, this estimate will be bootstrapped and a
confidence interval will be drawn.\
"""),
x_bins=dedent("""\
x_bins : int or vector, optional
Bin the ``x`` variable into discrete bins and then estimate the central
tendency and a confidence interval. This binning only influences how
the scatterplot is drawn; the regression is still fit to the original
data. This parameter is interpreted either as the number of
evenly-sized (not necessary spaced) bins or the positions of the bin
centers. When this parameter is used, it implies that the default of
``x_estimator`` is ``numpy.mean``.\
"""),
x_ci=dedent("""\
x_ci : "ci", "sd", int in [0, 100] or None, optional
Size of the confidence interval used when plotting a central tendency
for discrete values of ``x``. If ``"ci"``, defer to the value of the
``ci`` parameter. If ``"sd"``, skip bootstrapping and show the
standard deviation of the observations in each bin.\
"""),
scatter=dedent("""\
scatter : bool, optional
If ``True``, draw a scatterplot with the underlying observations (or
the ``x_estimator`` values).\
"""),
fit_reg=dedent("""\
fit_reg : bool, optional
If ``True``, estimate and plot a regression model relating the ``x``
and ``y`` variables.\
"""),
ci=dedent("""\
ci : int in [0, 100] or None, optional
Size of the confidence interval for the regression estimate. This will
be drawn using translucent bands around the regression line. The
confidence interval is estimated using a bootstrap; for large
datasets, it may be advisable to avoid that computation by setting
this parameter to None.\
"""),
n_boot=dedent("""\
n_boot : int, optional
Number of bootstrap resamples used to estimate the ``ci``. The default
value attempts to balance time and stability; you may want to increase
this value for "final" versions of plots.\
"""),
units=dedent("""\
units : variable name in ``data``, optional
If the ``x`` and ``y`` observations are nested within sampling units,
those can be specified here. This will be taken into account when
computing the confidence intervals by performing a multilevel bootstrap
that resamples both units and observations (within unit). This does not
otherwise influence how the regression is estimated or drawn.\
"""),
seed=dedent("""\
seed : int, numpy.random.Generator, or numpy.random.RandomState, optional
Seed or random number generator for reproducible bootstrapping.\
"""),
order=dedent("""\
order : int, optional
If ``order`` is greater than 1, use ``numpy.polyfit`` to estimate a
polynomial regression.\
"""),
logistic=dedent("""\
logistic : bool, optional
If ``True``, assume that ``y`` is a binary variable and use
``statsmodels`` to estimate a logistic regression model. Note that this
is substantially more computationally intensive than linear regression,
so you may wish to decrease the number of bootstrap resamples
(``n_boot``) or set ``ci`` to None.\
"""),
lowess=dedent("""\
lowess : bool, optional
If ``True``, use ``statsmodels`` to estimate a nonparametric lowess
model (locally weighted linear regression). Note that confidence
intervals cannot currently be drawn for this kind of model.\
"""),
robust=dedent("""\
robust : bool, optional
If ``True``, use ``statsmodels`` to estimate a robust regression. This
will de-weight outliers. Note that this is substantially more
computationally intensive than standard linear regression, so you may
wish to decrease the number of bootstrap resamples (``n_boot``) or set
``ci`` to None.\
"""),
logx=dedent("""\
logx : bool, optional
If ``True``, estimate a linear regression of the form y ~ log(x), but
plot the scatterplot and regression model in the input space. Note that
``x`` must be positive for this to work.\
"""),
xy_partial=dedent("""\
{x,y}_partial : strings in ``data`` or matrices
Confounding variables to regress out of the ``x`` or ``y`` variables
before plotting.\
"""),
truncate=dedent("""\
truncate : bool, optional
If ``True``, the regression line is bounded by the data limits. If
``False``, it extends to the ``x`` axis limits.
"""),
xy_jitter=dedent("""\
{x,y}_jitter : floats, optional
Add uniform random noise of this size to either the ``x`` or ``y``
variables. The noise is added to a copy of the data after fitting the
regression, and only influences the look of the scatterplot. This can
be helpful when plotting variables that take discrete values.\
"""),
scatter_line_kws=dedent("""\
{scatter,line}_kws : dictionaries
Additional keyword arguments to pass to ``plt.scatter`` and
``plt.plot``.\
"""),
)
_regression_docs.update(_facet_docs)
def lmplot(
data, *,
x=None, y=None, hue=None, col=None, row=None,
palette=None, col_wrap=None, height=5, aspect=1, markers="o",
sharex=None, sharey=None, hue_order=None, col_order=None, row_order=None,
legend=True, legend_out=None, x_estimator=None, x_bins=None,
x_ci="ci", scatter=True, fit_reg=True, ci=95, n_boot=1000,
units=None, seed=None, order=1, logistic=False, lowess=False,
robust=False, logx=False, x_partial=None, y_partial=None,
truncate=True, x_jitter=None, y_jitter=None, scatter_kws=None,
line_kws=None, facet_kws=None,
):
if facet_kws is None:
facet_kws = {}
def facet_kw_deprecation(key, val):
msg = (
f"{key} is deprecated from the `lmplot` function signature. "
"Please update your code to pass it using `facet_kws`."
)
if val is not None:
warnings.warn(msg, UserWarning)
facet_kws[key] = val
facet_kw_deprecation("sharex", sharex)
facet_kw_deprecation("sharey", sharey)
facet_kw_deprecation("legend_out", legend_out)
if data is None:
raise TypeError("Missing required keyword argument `data`.")
# Reduce the dataframe to only needed columns
need_cols = [x, y, hue, col, row, units, x_partial, y_partial]
cols = np.unique([a for a in need_cols if a is not None]).tolist()
data = data[cols]
# Initialize the grid
facets = FacetGrid(
data, row=row, col=col, hue=hue,
palette=palette,
row_order=row_order, col_order=col_order, hue_order=hue_order,
height=height, aspect=aspect, col_wrap=col_wrap,
**facet_kws,
)
# Add the markers here as FacetGrid has figured out how many levels of the
# hue variable are needed and we don't want to duplicate that process
if facets.hue_names is None:
n_markers = 1
else:
n_markers = len(facets.hue_names)
if not isinstance(markers, list):
markers = [markers] * n_markers
if len(markers) != n_markers:
raise ValueError("markers must be a singleton or a list of markers "
"for each level of the hue variable")
facets.hue_kws = {"marker": markers}
def update_datalim(data, x, y, ax, **kws):
xys = data[[x, y]].to_numpy().astype(float)
ax.update_datalim(xys, updatey=False)
ax.autoscale_view(scaley=False)
facets.map_dataframe(update_datalim, x=x, y=y)
# Draw the regression plot on each facet
regplot_kws = dict(
x_estimator=x_estimator, x_bins=x_bins, x_ci=x_ci,
scatter=scatter, fit_reg=fit_reg, ci=ci, n_boot=n_boot, units=units,
seed=seed, order=order, logistic=logistic, lowess=lowess,
robust=robust, logx=logx, x_partial=x_partial, y_partial=y_partial,
truncate=truncate, x_jitter=x_jitter, y_jitter=y_jitter,
scatter_kws=scatter_kws, line_kws=line_kws,
)
facets.map_dataframe(regplot, x=x, y=y, **regplot_kws)
facets.set_axis_labels(x, y)
# Add a legend
if legend and (hue is not None) and (hue not in [col, row]):
facets.add_legend()
return facets
lmplot.__doc__ = dedent("""\
Plot data and regression model fits across a FacetGrid.
This function combines :func:`regplot` and :class:`FacetGrid`. It is
intended as a convenient interface to fit regression models across
conditional subsets of a dataset.
When thinking about how to assign variables to different facets, a general
rule is that it makes sense to use ``hue`` for the most important
comparison, followed by ``col`` and ``row``. However, always think about
your particular dataset and the goals of the visualization you are
creating.
{model_api}
The parameters to this function span most of the options in
:class:`FacetGrid`, although there may be occasional cases where you will
want to use that class and :func:`regplot` directly.
Parameters
----------
{data}
x, y : strings, optional
Input variables; these should be column names in ``data``.
hue, col, row : strings
Variables that define subsets of the data, which will be drawn on
separate facets in the grid. See the ``*_order`` parameters to control
the order of levels of this variable.
{palette}
{col_wrap}
{height}
{aspect}
markers : matplotlib marker code or list of marker codes, optional
Markers for the scatterplot. If a list, each marker in the list will be
used for each level of the ``hue`` variable.
{share_xy}
.. deprecated:: 0.12.0
Pass using the `facet_kws` dictionary.
{{hue,col,row}}_order : lists, optional
Order for the levels of the faceting variables. By default, this will
be the order that the levels appear in ``data`` or, if the variables
are pandas categoricals, the category order.
legend : bool, optional
If ``True`` and there is a ``hue`` variable, add a legend.
{legend_out}
.. deprecated:: 0.12.0
Pass using the `facet_kws` dictionary.
{x_estimator}
{x_bins}
{x_ci}
{scatter}
{fit_reg}
{ci}
{n_boot}
{units}
{seed}
{order}
{logistic}
{lowess}
{robust}
{logx}
{xy_partial}
{truncate}
{xy_jitter}
{scatter_line_kws}
facet_kws : dict
Dictionary of keyword arguments for :class:`FacetGrid`.
See Also
--------
regplot : Plot data and a conditional model fit.
FacetGrid : Subplot grid for plotting conditional relationships.
pairplot : Combine :func:`regplot` and :class:`PairGrid` (when used with
``kind="reg"``).
Notes
-----
{regplot_vs_lmplot}
Examples
--------
.. include:: ../docstrings/lmplot.rst
""").format(**_regression_docs)
def regplot(
data=None, *, x=None, y=None,
x_estimator=None, x_bins=None, x_ci="ci",
scatter=True, fit_reg=True, ci=95, n_boot=1000, units=None,
seed=None, order=1, logistic=False, lowess=False, robust=False,
logx=False, x_partial=None, y_partial=None,
truncate=True, dropna=True, x_jitter=None, y_jitter=None,
label=None, color=None, marker="o",
scatter_kws=None, line_kws=None, ax=None
):
plotter = _RegressionPlotter(x, y, data, x_estimator, x_bins, x_ci,
scatter, fit_reg, ci, n_boot, units, seed,
order, logistic, lowess, robust, logx,
x_partial, y_partial, truncate, dropna,
x_jitter, y_jitter, color, label)
if ax is None:
ax = plt.gca()
scatter_kws = {} if scatter_kws is None else copy.copy(scatter_kws)
scatter_kws["marker"] = marker
line_kws = {} if line_kws is None else copy.copy(line_kws)
plotter.plot(ax, scatter_kws, line_kws)
return ax
regplot.__doc__ = dedent("""\
Plot data and a linear regression model fit.
{model_api}
Parameters
----------
x, y: string, series, or vector array
Input variables. If strings, these should correspond with column names
in ``data``. When pandas objects are used, axes will be labeled with
the series name.
{data}
{x_estimator}
{x_bins}
{x_ci}
{scatter}
{fit_reg}
{ci}
{n_boot}
{units}
{seed}
{order}
{logistic}
{lowess}
{robust}
{logx}
{xy_partial}
{truncate}
{xy_jitter}
label : string
Label to apply to either the scatterplot or regression line (if
``scatter`` is ``False``) for use in a legend.
color : matplotlib color
Color to apply to all plot elements; will be superseded by colors
passed in ``scatter_kws`` or ``line_kws``.
marker : matplotlib marker code
Marker to use for the scatterplot glyphs.
{scatter_line_kws}
ax : matplotlib Axes, optional
Axes object to draw the plot onto, otherwise uses the current Axes.
Returns
-------
ax : matplotlib Axes
The Axes object containing the plot.
See Also
--------
lmplot : Combine :func:`regplot` and :class:`FacetGrid` to plot multiple
linear relationships in a dataset.
jointplot : Combine :func:`regplot` and :class:`JointGrid` (when used with
``kind="reg"``).
pairplot : Combine :func:`regplot` and :class:`PairGrid` (when used with
``kind="reg"``).
residplot : Plot the residuals of a linear regression model.
Notes
-----
{regplot_vs_lmplot}
It's also easy to combine :func:`regplot` and :class:`JointGrid` or
:class:`PairGrid` through the :func:`jointplot` and :func:`pairplot`
functions, although these do not directly accept all of :func:`regplot`'s
parameters.
Examples
--------
.. include:: ../docstrings/regplot.rst
""").format(**_regression_docs)
def residplot(
data=None, *, x=None, y=None,
x_partial=None, y_partial=None, lowess=False,
order=1, robust=False, dropna=True, label=None, color=None,
scatter_kws=None, line_kws=None, ax=None
):
"""Plot the residuals of a linear regression.
This function will regress y on x (possibly as a robust or polynomial
regression) and then draw a scatterplot of the residuals. You can
optionally fit a lowess smoother to the residual plot, which can
help in determining if there is structure to the residuals.
Parameters
----------
data : DataFrame, optional
DataFrame to use if `x` and `y` are column names.
x : vector or string
Data or column name in `data` for the predictor variable.
y : vector or string
Data or column name in `data` for the response variable.
{x, y}_partial : vectors or string(s) , optional
These variables are treated as confounding and are removed from
the `x` or `y` variables before plotting.
lowess : boolean, optional
Fit a lowess smoother to the residual scatterplot.
order : int, optional
Order of the polynomial to fit when calculating the residuals.
robust : boolean, optional
Fit a robust linear regression when calculating the residuals.
dropna : boolean, optional
If True, ignore observations with missing data when fitting and
plotting.
label : string, optional
Label that will be used in any plot legends.
color : matplotlib color, optional
Color to use for all elements of the plot.
{scatter, line}_kws : dictionaries, optional
Additional keyword arguments passed to scatter() and plot() for drawing
the components of the plot.
ax : matplotlib axis, optional
Plot into this axis, otherwise grab the current axis or make a new
one if not existing.
Returns
-------
ax: matplotlib axes
Axes with the regression plot.
See Also
--------
regplot : Plot a simple linear regression model.
jointplot : Draw a :func:`residplot` with univariate marginal distributions
(when used with ``kind="resid"``).
Examples
--------
.. include:: ../docstrings/residplot.rst
"""
plotter = _RegressionPlotter(x, y, data, ci=None,
order=order, robust=robust,
x_partial=x_partial, y_partial=y_partial,
dropna=dropna, color=color, label=label)
if ax is None:
ax = plt.gca()
# Calculate the residual from a linear regression
_, yhat, _ = plotter.fit_regression(grid=plotter.x)
plotter.y = plotter.y - yhat
# Set the regression option on the plotter
if lowess:
plotter.lowess = True
else:
plotter.fit_reg = False
# Plot a horizontal line at 0
ax.axhline(0, ls=":", c=".2")
# Draw the scatterplot
scatter_kws = {} if scatter_kws is None else scatter_kws.copy()
line_kws = {} if line_kws is None else line_kws.copy()
plotter.plot(ax, scatter_kws, line_kws)
return ax

View File

@@ -0,0 +1,982 @@
from functools import partial
import warnings
import numpy as np
import pandas as pd
import matplotlib as mpl
import matplotlib.pyplot as plt
from matplotlib.cbook import normalize_kwargs
from ._base import (
VectorPlotter,
)
from .utils import (
adjust_legend_subtitles,
_default_color,
_deprecate_ci,
_get_transform_functions,
_scatter_legend_artist,
)
from ._compat import groupby_apply_include_groups
from ._statistics import EstimateAggregator, WeightedAggregator
from .axisgrid import FacetGrid, _facet_docs
from ._docstrings import DocstringComponents, _core_docs
__all__ = ["relplot", "scatterplot", "lineplot"]
_relational_narrative = DocstringComponents(dict(
# --- Introductory prose
main_api="""
The relationship between `x` and `y` can be shown for different subsets
of the data using the `hue`, `size`, and `style` parameters. These
parameters control what visual semantics are used to identify the different
subsets. It is possible to show up to three dimensions independently by
using all three semantic types, but this style of plot can be hard to
interpret and is often ineffective. Using redundant semantics (i.e. both
`hue` and `style` for the same variable) can be helpful for making
graphics more accessible.
See the :ref:`tutorial <relational_tutorial>` for more information.
""",
relational_semantic="""
The default treatment of the `hue` (and to a lesser extent, `size`)
semantic, if present, depends on whether the variable is inferred to
represent "numeric" or "categorical" data. In particular, numeric variables
are represented with a sequential colormap by default, and the legend
entries show regular "ticks" with values that may or may not exist in the
data. This behavior can be controlled through various parameters, as
described and illustrated below.
""",
))
_relational_docs = dict(
# --- Shared function parameters
data_vars="""
x, y : names of variables in `data` or vector data
Input data variables; must be numeric. Can pass data directly or
reference columns in `data`.
""",
data="""
data : DataFrame, array, or list of arrays
Input data structure. If `x` and `y` are specified as names, this
should be a "long-form" DataFrame containing those columns. Otherwise
it is treated as "wide-form" data and grouping variables are ignored.
See the examples for the various ways this parameter can be specified
and the different effects of each.
""",
palette="""
palette : string, list, dict, or matplotlib colormap
An object that determines how colors are chosen when `hue` is used.
It can be the name of a seaborn palette or matplotlib colormap, a list
of colors (anything matplotlib understands), a dict mapping levels
of the `hue` variable to colors, or a matplotlib colormap object.
""",
hue_order="""
hue_order : list
Specified order for the appearance of the `hue` variable levels,
otherwise they are determined from the data. Not relevant when the
`hue` variable is numeric.
""",
hue_norm="""
hue_norm : tuple or :class:`matplotlib.colors.Normalize` object
Normalization in data units for colormap applied to the `hue`
variable when it is numeric. Not relevant if `hue` is categorical.
""",
sizes="""
sizes : list, dict, or tuple
An object that determines how sizes are chosen when `size` is used.
List or dict arguments should provide a size for each unique data value,
which forces a categorical interpretation. The argument may also be a
min, max tuple.
""",
size_order="""
size_order : list
Specified order for appearance of the `size` variable levels,
otherwise they are determined from the data. Not relevant when the
`size` variable is numeric.
""",
size_norm="""
size_norm : tuple or Normalize object
Normalization in data units for scaling plot objects when the
`size` variable is numeric.
""",
dashes="""
dashes : boolean, list, or dictionary
Object determining how to draw the lines for different levels of the
`style` variable. Setting to `True` will use default dash codes, or
you can pass a list of dash codes or a dictionary mapping levels of the
`style` variable to dash codes. Setting to `False` will use solid
lines for all subsets. Dashes are specified as in matplotlib: a tuple
of `(segment, gap)` lengths, or an empty string to draw a solid line.
""",
markers="""
markers : boolean, list, or dictionary
Object determining how to draw the markers for different levels of the
`style` variable. Setting to `True` will use default markers, or
you can pass a list of markers or a dictionary mapping levels of the
`style` variable to markers. Setting to `False` will draw
marker-less lines. Markers are specified as in matplotlib.
""",
style_order="""
style_order : list
Specified order for appearance of the `style` variable levels
otherwise they are determined from the data. Not relevant when the
`style` variable is numeric.
""",
units="""
units : vector or key in `data`
Grouping variable identifying sampling units. When used, a separate
line will be drawn for each unit with appropriate semantics, but no
legend entry will be added. Useful for showing distribution of
experimental replicates when exact identities are not needed.
""",
estimator="""
estimator : name of pandas method or callable or None
Method for aggregating across multiple observations of the `y`
variable at the same `x` level. If `None`, all observations will
be drawn.
""",
ci="""
ci : int or "sd" or None
Size of the confidence interval to draw when aggregating.
.. deprecated:: 0.12.0
Use the new `errorbar` parameter for more flexibility.
""",
n_boot="""
n_boot : int
Number of bootstraps to use for computing the confidence interval.
""",
seed="""
seed : int, numpy.random.Generator, or numpy.random.RandomState
Seed or random number generator for reproducible bootstrapping.
""",
legend="""
legend : "auto", "brief", "full", or False
How to draw the legend. If "brief", numeric `hue` and `size`
variables will be represented with a sample of evenly spaced values.
If "full", every group will get an entry in the legend. If "auto",
choose between brief or full representation based on number of levels.
If `False`, no legend data is added and no legend is drawn.
""",
ax_in="""
ax : matplotlib Axes
Axes object to draw the plot onto, otherwise uses the current Axes.
""",
ax_out="""
ax : matplotlib Axes
Returns the Axes object with the plot drawn onto it.
""",
)
_param_docs = DocstringComponents.from_nested_components(
core=_core_docs["params"],
facets=DocstringComponents(_facet_docs),
rel=DocstringComponents(_relational_docs),
stat=DocstringComponents.from_function_params(EstimateAggregator.__init__),
)
class _RelationalPlotter(VectorPlotter):
wide_structure = {
"x": "@index", "y": "@values", "hue": "@columns", "style": "@columns",
}
# TODO where best to define default parameters?
sort = True
class _LinePlotter(_RelationalPlotter):
_legend_attributes = ["color", "linewidth", "marker", "dashes"]
def __init__(
self, *,
data=None, variables={},
estimator=None, n_boot=None, seed=None, errorbar=None,
sort=True, orient="x", err_style=None, err_kws=None, legend=None
):
# TODO this is messy, we want the mapping to be agnostic about
# the kind of plot to draw, but for the time being we need to set
# this information so the SizeMapping can use it
self._default_size_range = (
np.r_[.5, 2] * mpl.rcParams["lines.linewidth"]
)
super().__init__(data=data, variables=variables)
self.estimator = estimator
self.errorbar = errorbar
self.n_boot = n_boot
self.seed = seed
self.sort = sort
self.orient = orient
self.err_style = err_style
self.err_kws = {} if err_kws is None else err_kws
self.legend = legend
def plot(self, ax, kws):
"""Draw the plot onto an axes, passing matplotlib kwargs."""
# Draw a test plot, using the passed in kwargs. The goal here is to
# honor both (a) the current state of the plot cycler and (b) the
# specified kwargs on all the lines we will draw, overriding when
# relevant with the data semantics. Note that we won't cycle
# internally; in other words, if `hue` is not used, all elements will
# have the same color, but they will have the color that you would have
# gotten from the corresponding matplotlib function, and calling the
# function will advance the axes property cycle.
kws = normalize_kwargs(kws, mpl.lines.Line2D)
kws.setdefault("markeredgewidth", 0.75)
kws.setdefault("markeredgecolor", "w")
# Set default error kwargs
err_kws = self.err_kws.copy()
if self.err_style == "band":
err_kws.setdefault("alpha", .2)
elif self.err_style == "bars":
pass
elif self.err_style is not None:
err = "`err_style` must be 'band' or 'bars', not {}"
raise ValueError(err.format(self.err_style))
# Initialize the aggregation object
weighted = "weight" in self.plot_data
agg = (WeightedAggregator if weighted else EstimateAggregator)(
self.estimator, self.errorbar, n_boot=self.n_boot, seed=self.seed,
)
# TODO abstract variable to aggregate over here-ish. Better name?
orient = self.orient
if orient not in {"x", "y"}:
err = f"`orient` must be either 'x' or 'y', not {orient!r}."
raise ValueError(err)
other = {"x": "y", "y": "x"}[orient]
# TODO How to handle NA? We don't want NA to propagate through to the
# estimate/CI when some values are present, but we would also like
# matplotlib to show "gaps" in the line when all values are missing.
# This is straightforward absent aggregation, but complicated with it.
# If we want to use nas, we need to conditionalize dropna in iter_data.
# Loop over the semantic subsets and add to the plot
grouping_vars = "hue", "size", "style"
for sub_vars, sub_data in self.iter_data(grouping_vars, from_comp_data=True):
if self.sort:
sort_vars = ["units", orient, other]
sort_cols = [var for var in sort_vars if var in self.variables]
sub_data = sub_data.sort_values(sort_cols)
if (
self.estimator is not None
and sub_data[orient].value_counts().max() > 1
):
if "units" in self.variables:
# TODO eventually relax this constraint
err = "estimator must be None when specifying units"
raise ValueError(err)
grouped = sub_data.groupby(orient, sort=self.sort)
# Could pass as_index=False instead of reset_index,
# but that fails on a corner case with older pandas.
sub_data = (
grouped
.apply(agg, other, **groupby_apply_include_groups(False))
.reset_index()
)
else:
sub_data[f"{other}min"] = np.nan
sub_data[f"{other}max"] = np.nan
# Apply inverse axis scaling
for var in "xy":
_, inv = _get_transform_functions(ax, var)
for col in sub_data.filter(regex=f"^{var}"):
sub_data[col] = inv(sub_data[col])
# --- Draw the main line(s)
if "units" in self.variables: # XXX why not add to grouping variables?
lines = []
for _, unit_data in sub_data.groupby("units"):
lines.extend(ax.plot(unit_data["x"], unit_data["y"], **kws))
else:
lines = ax.plot(sub_data["x"], sub_data["y"], **kws)
for line in lines:
if "hue" in sub_vars:
line.set_color(self._hue_map(sub_vars["hue"]))
if "size" in sub_vars:
line.set_linewidth(self._size_map(sub_vars["size"]))
if "style" in sub_vars:
attributes = self._style_map(sub_vars["style"])
if "dashes" in attributes:
line.set_dashes(attributes["dashes"])
if "marker" in attributes:
line.set_marker(attributes["marker"])
line_color = line.get_color()
line_alpha = line.get_alpha()
line_capstyle = line.get_solid_capstyle()
# --- Draw the confidence intervals
if self.estimator is not None and self.errorbar is not None:
# TODO handling of orientation will need to happen here
if self.err_style == "band":
func = {"x": ax.fill_between, "y": ax.fill_betweenx}[orient]
func(
sub_data[orient],
sub_data[f"{other}min"], sub_data[f"{other}max"],
color=line_color, **err_kws
)
elif self.err_style == "bars":
error_param = {
f"{other}err": (
sub_data[other] - sub_data[f"{other}min"],
sub_data[f"{other}max"] - sub_data[other],
)
}
ebars = ax.errorbar(
sub_data["x"], sub_data["y"], **error_param,
linestyle="", color=line_color, alpha=line_alpha,
**err_kws
)
# Set the capstyle properly on the error bars
for obj in ebars.get_children():
if isinstance(obj, mpl.collections.LineCollection):
obj.set_capstyle(line_capstyle)
# Finalize the axes details
self._add_axis_labels(ax)
if self.legend:
legend_artist = partial(mpl.lines.Line2D, xdata=[], ydata=[])
attrs = {"hue": "color", "size": "linewidth", "style": None}
self.add_legend_data(ax, legend_artist, kws, attrs)
handles, _ = ax.get_legend_handles_labels()
if handles:
legend = ax.legend(title=self.legend_title)
adjust_legend_subtitles(legend)
class _ScatterPlotter(_RelationalPlotter):
_legend_attributes = ["color", "s", "marker"]
def __init__(self, *, data=None, variables={}, legend=None):
# TODO this is messy, we want the mapping to be agnostic about
# the kind of plot to draw, but for the time being we need to set
# this information so the SizeMapping can use it
self._default_size_range = (
np.r_[.5, 2] * np.square(mpl.rcParams["lines.markersize"])
)
super().__init__(data=data, variables=variables)
self.legend = legend
def plot(self, ax, kws):
# --- Determine the visual attributes of the plot
data = self.comp_data.dropna()
if data.empty:
return
kws = normalize_kwargs(kws, mpl.collections.PathCollection)
# Define the vectors of x and y positions
empty = np.full(len(data), np.nan)
x = data.get("x", empty)
y = data.get("y", empty)
# Apply inverse scaling to the coordinate variables
_, inv_x = _get_transform_functions(ax, "x")
_, inv_y = _get_transform_functions(ax, "y")
x, y = inv_x(x), inv_y(y)
if "style" in self.variables:
# Use a representative marker so scatter sets the edgecolor
# properly for line art markers. We currently enforce either
# all or none line art so this works.
example_level = self._style_map.levels[0]
example_marker = self._style_map(example_level, "marker")
kws.setdefault("marker", example_marker)
# Conditionally set the marker edgecolor based on whether the marker is "filled"
# See https://github.com/matplotlib/matplotlib/issues/17849 for context
m = kws.get("marker", mpl.rcParams.get("marker", "o"))
if not isinstance(m, mpl.markers.MarkerStyle):
# TODO in more recent matplotlib (which?) can pass a MarkerStyle here
m = mpl.markers.MarkerStyle(m)
if m.is_filled():
kws.setdefault("edgecolor", "w")
# Draw the scatter plot
points = ax.scatter(x=x, y=y, **kws)
# Apply the mapping from semantic variables to artist attributes
if "hue" in self.variables:
points.set_facecolors(self._hue_map(data["hue"]))
if "size" in self.variables:
points.set_sizes(self._size_map(data["size"]))
if "style" in self.variables:
p = [self._style_map(val, "path") for val in data["style"]]
points.set_paths(p)
# Apply dependent default attributes
if "linewidth" not in kws:
sizes = points.get_sizes()
linewidth = .08 * np.sqrt(np.percentile(sizes, 10))
points.set_linewidths(linewidth)
kws["linewidth"] = linewidth
# Finalize the axes details
self._add_axis_labels(ax)
if self.legend:
attrs = {"hue": "color", "size": "s", "style": None}
self.add_legend_data(ax, _scatter_legend_artist, kws, attrs)
handles, _ = ax.get_legend_handles_labels()
if handles:
legend = ax.legend(title=self.legend_title)
adjust_legend_subtitles(legend)
def lineplot(
data=None, *,
x=None, y=None, hue=None, size=None, style=None, units=None, weights=None,
palette=None, hue_order=None, hue_norm=None,
sizes=None, size_order=None, size_norm=None,
dashes=True, markers=None, style_order=None,
estimator="mean", errorbar=("ci", 95), n_boot=1000, seed=None,
orient="x", sort=True, err_style="band", err_kws=None,
legend="auto", ci="deprecated", ax=None, **kwargs
):
# Handle deprecation of ci parameter
errorbar = _deprecate_ci(errorbar, ci)
p = _LinePlotter(
data=data,
variables=dict(
x=x, y=y, hue=hue, size=size, style=style, units=units, weight=weights
),
estimator=estimator, n_boot=n_boot, seed=seed, errorbar=errorbar,
sort=sort, orient=orient, err_style=err_style, err_kws=err_kws,
legend=legend,
)
p.map_hue(palette=palette, order=hue_order, norm=hue_norm)
p.map_size(sizes=sizes, order=size_order, norm=size_norm)
p.map_style(markers=markers, dashes=dashes, order=style_order)
if ax is None:
ax = plt.gca()
if "style" not in p.variables and not {"ls", "linestyle"} & set(kwargs): # XXX
kwargs["dashes"] = "" if dashes is None or isinstance(dashes, bool) else dashes
if not p.has_xy_data:
return ax
p._attach(ax)
# Other functions have color as an explicit param,
# and we should probably do that here too
color = kwargs.pop("color", kwargs.pop("c", None))
kwargs["color"] = _default_color(ax.plot, hue, color, kwargs)
p.plot(ax, kwargs)
return ax
lineplot.__doc__ = """\
Draw a line plot with possibility of several semantic groupings.
{narrative.main_api}
{narrative.relational_semantic}
By default, the plot aggregates over multiple `y` values at each value of
`x` and shows an estimate of the central tendency and a confidence
interval for that estimate.
Parameters
----------
{params.core.data}
{params.core.xy}
hue : vector or key in `data`
Grouping variable that will produce lines with different colors.
Can be either categorical or numeric, although color mapping will
behave differently in latter case.
size : vector or key in `data`
Grouping variable that will produce lines with different widths.
Can be either categorical or numeric, although size mapping will
behave differently in latter case.
style : vector or key in `data`
Grouping variable that will produce lines with different dashes
and/or markers. Can have a numeric dtype but will always be treated
as categorical.
{params.rel.units}
weights : vector or key in `data`
Data values or column used to compute weighted estimation.
Note that use of weights currently limits the choice of statistics
to a 'mean' estimator and 'ci' errorbar.
{params.core.palette}
{params.core.hue_order}
{params.core.hue_norm}
{params.rel.sizes}
{params.rel.size_order}
{params.rel.size_norm}
{params.rel.dashes}
{params.rel.markers}
{params.rel.style_order}
{params.rel.estimator}
{params.stat.errorbar}
{params.rel.n_boot}
{params.rel.seed}
orient : "x" or "y"
Dimension along which the data are sorted / aggregated. Equivalently,
the "independent variable" of the resulting function.
sort : boolean
If True, the data will be sorted by the x and y variables, otherwise
lines will connect points in the order they appear in the dataset.
err_style : "band" or "bars"
Whether to draw the confidence intervals with translucent error bands
or discrete error bars.
err_kws : dict of keyword arguments
Additional parameters to control the aesthetics of the error bars. The
kwargs are passed either to :meth:`matplotlib.axes.Axes.fill_between`
or :meth:`matplotlib.axes.Axes.errorbar`, depending on `err_style`.
{params.rel.legend}
{params.rel.ci}
{params.core.ax}
kwargs : key, value mappings
Other keyword arguments are passed down to
:meth:`matplotlib.axes.Axes.plot`.
Returns
-------
{returns.ax}
See Also
--------
{seealso.scatterplot}
{seealso.pointplot}
Examples
--------
.. include:: ../docstrings/lineplot.rst
""".format(
narrative=_relational_narrative,
params=_param_docs,
returns=_core_docs["returns"],
seealso=_core_docs["seealso"],
)
def scatterplot(
data=None, *,
x=None, y=None, hue=None, size=None, style=None,
palette=None, hue_order=None, hue_norm=None,
sizes=None, size_order=None, size_norm=None,
markers=True, style_order=None, legend="auto", ax=None,
**kwargs
):
p = _ScatterPlotter(
data=data,
variables=dict(x=x, y=y, hue=hue, size=size, style=style),
legend=legend
)
p.map_hue(palette=palette, order=hue_order, norm=hue_norm)
p.map_size(sizes=sizes, order=size_order, norm=size_norm)
p.map_style(markers=markers, order=style_order)
if ax is None:
ax = plt.gca()
if not p.has_xy_data:
return ax
p._attach(ax)
color = kwargs.pop("color", None)
kwargs["color"] = _default_color(ax.scatter, hue, color, kwargs)
p.plot(ax, kwargs)
return ax
scatterplot.__doc__ = """\
Draw a scatter plot with possibility of several semantic groupings.
{narrative.main_api}
{narrative.relational_semantic}
Parameters
----------
{params.core.data}
{params.core.xy}
hue : vector or key in `data`
Grouping variable that will produce points with different colors.
Can be either categorical or numeric, although color mapping will
behave differently in latter case.
size : vector or key in `data`
Grouping variable that will produce points with different sizes.
Can be either categorical or numeric, although size mapping will
behave differently in latter case.
style : vector or key in `data`
Grouping variable that will produce points with different markers.
Can have a numeric dtype but will always be treated as categorical.
{params.core.palette}
{params.core.hue_order}
{params.core.hue_norm}
{params.rel.sizes}
{params.rel.size_order}
{params.rel.size_norm}
{params.rel.markers}
{params.rel.style_order}
{params.rel.legend}
{params.core.ax}
kwargs : key, value mappings
Other keyword arguments are passed down to
:meth:`matplotlib.axes.Axes.scatter`.
Returns
-------
{returns.ax}
See Also
--------
{seealso.lineplot}
{seealso.stripplot}
{seealso.swarmplot}
Examples
--------
.. include:: ../docstrings/scatterplot.rst
""".format(
narrative=_relational_narrative,
params=_param_docs,
returns=_core_docs["returns"],
seealso=_core_docs["seealso"],
)
def relplot(
data=None, *,
x=None, y=None, hue=None, size=None, style=None, units=None, weights=None,
row=None, col=None, col_wrap=None, row_order=None, col_order=None,
palette=None, hue_order=None, hue_norm=None,
sizes=None, size_order=None, size_norm=None,
markers=None, dashes=None, style_order=None,
legend="auto", kind="scatter", height=5, aspect=1, facet_kws=None,
**kwargs
):
if kind == "scatter":
Plotter = _ScatterPlotter
func = scatterplot
markers = True if markers is None else markers
elif kind == "line":
Plotter = _LinePlotter
func = lineplot
dashes = True if dashes is None else dashes
else:
err = f"Plot kind {kind} not recognized"
raise ValueError(err)
# Check for attempt to plot onto specific axes and warn
if "ax" in kwargs:
msg = (
"relplot is a figure-level function and does not accept "
"the `ax` parameter. You may wish to try {}".format(kind + "plot")
)
warnings.warn(msg, UserWarning)
kwargs.pop("ax")
# Use the full dataset to map the semantics
variables = dict(x=x, y=y, hue=hue, size=size, style=style)
if kind == "line":
variables["units"] = units
variables["weight"] = weights
else:
if units is not None:
msg = "The `units` parameter has no effect with kind='scatter'."
warnings.warn(msg, stacklevel=2)
if weights is not None:
msg = "The `weights` parameter has no effect with kind='scatter'."
warnings.warn(msg, stacklevel=2)
p = Plotter(
data=data,
variables=variables,
legend=legend,
)
p.map_hue(palette=palette, order=hue_order, norm=hue_norm)
p.map_size(sizes=sizes, order=size_order, norm=size_norm)
p.map_style(markers=markers, dashes=dashes, order=style_order)
# Extract the semantic mappings
if "hue" in p.variables:
palette = p._hue_map.lookup_table
hue_order = p._hue_map.levels
hue_norm = p._hue_map.norm
else:
palette = hue_order = hue_norm = None
if "size" in p.variables:
sizes = p._size_map.lookup_table
size_order = p._size_map.levels
size_norm = p._size_map.norm
if "style" in p.variables:
style_order = p._style_map.levels
if markers:
markers = {k: p._style_map(k, "marker") for k in style_order}
else:
markers = None
if dashes:
dashes = {k: p._style_map(k, "dashes") for k in style_order}
else:
dashes = None
else:
markers = dashes = style_order = None
# Now extract the data that would be used to draw a single plot
variables = p.variables
plot_data = p.plot_data
# Define the common plotting parameters
plot_kws = dict(
palette=palette, hue_order=hue_order, hue_norm=hue_norm,
sizes=sizes, size_order=size_order, size_norm=size_norm,
markers=markers, dashes=dashes, style_order=style_order,
legend=False,
)
plot_kws.update(kwargs)
if kind == "scatter":
plot_kws.pop("dashes")
# Add the grid semantics onto the plotter
grid_variables = dict(
x=x, y=y, row=row, col=col, hue=hue, size=size, style=style,
)
if kind == "line":
grid_variables.update(units=units, weights=weights)
p.assign_variables(data, grid_variables)
# Define the named variables for plotting on each facet
# Rename the variables with a leading underscore to avoid
# collisions with faceting variable names
plot_variables = {v: f"_{v}" for v in variables}
if "weight" in plot_variables:
plot_variables["weights"] = plot_variables.pop("weight")
plot_kws.update(plot_variables)
# Pass the row/col variables to FacetGrid with their original
# names so that the axes titles render correctly
for var in ["row", "col"]:
# Handle faceting variables that lack name information
if var in p.variables and p.variables[var] is None:
p.variables[var] = f"_{var}_"
grid_kws = {v: p.variables.get(v) for v in ["row", "col"]}
# Rename the columns of the plot_data structure appropriately
new_cols = plot_variables.copy()
new_cols.update(grid_kws)
full_data = p.plot_data.rename(columns=new_cols)
# Set up the FacetGrid object
facet_kws = {} if facet_kws is None else facet_kws.copy()
g = FacetGrid(
data=full_data.dropna(axis=1, how="all"),
**grid_kws,
col_wrap=col_wrap, row_order=row_order, col_order=col_order,
height=height, aspect=aspect, dropna=False,
**facet_kws
)
# Draw the plot
g.map_dataframe(func, **plot_kws)
# Label the axes, using the original variables
# Pass "" when the variable name is None to overwrite internal variables
g.set_axis_labels(variables.get("x") or "", variables.get("y") or "")
if legend:
# Replace the original plot data so the legend uses numeric data with
# the correct type, since we force a categorical mapping above.
p.plot_data = plot_data
# Handle the additional non-semantic keyword arguments out here.
# We're selective because some kwargs may be seaborn function specific
# and not relevant to the matplotlib artists going into the legend.
# Ideally, we will have a better solution where we don't need to re-make
# the legend out here and will have parity with the axes-level functions.
keys = ["c", "color", "alpha", "m", "marker"]
if kind == "scatter":
legend_artist = _scatter_legend_artist
keys += ["s", "facecolor", "fc", "edgecolor", "ec", "linewidth", "lw"]
else:
legend_artist = partial(mpl.lines.Line2D, xdata=[], ydata=[])
keys += [
"markersize", "ms",
"markeredgewidth", "mew",
"markeredgecolor", "mec",
"linestyle", "ls",
"linewidth", "lw",
]
common_kws = {k: v for k, v in kwargs.items() if k in keys}
attrs = {"hue": "color", "style": None}
if kind == "scatter":
attrs["size"] = "s"
elif kind == "line":
attrs["size"] = "linewidth"
p.add_legend_data(g.axes.flat[0], legend_artist, common_kws, attrs)
if p.legend_data:
g.add_legend(legend_data=p.legend_data,
label_order=p.legend_order,
title=p.legend_title,
adjust_subtitles=True)
# Rename the columns of the FacetGrid's `data` attribute
# to match the original column names
orig_cols = {
f"_{k}": f"_{k}_" if v is None else v for k, v in variables.items()
}
grid_data = g.data.rename(columns=orig_cols)
if data is not None and (x is not None or y is not None):
if not isinstance(data, pd.DataFrame):
data = pd.DataFrame(data)
g.data = pd.merge(
data,
grid_data[grid_data.columns.difference(data.columns)],
left_index=True,
right_index=True,
)
else:
g.data = grid_data
return g
relplot.__doc__ = """\
Figure-level interface for drawing relational plots onto a FacetGrid.
This function provides access to several different axes-level functions
that show the relationship between two variables with semantic mappings
of subsets. The `kind` parameter selects the underlying axes-level
function to use:
- :func:`scatterplot` (with `kind="scatter"`; the default)
- :func:`lineplot` (with `kind="line"`)
Extra keyword arguments are passed to the underlying function, so you
should refer to the documentation for each to see kind-specific options.
{narrative.main_api}
{narrative.relational_semantic}
After plotting, the :class:`FacetGrid` with the plot is returned and can
be used directly to tweak supporting plot details or add other layers.
Parameters
----------
{params.core.data}
{params.core.xy}
hue : vector or key in `data`
Grouping variable that will produce elements with different colors.
Can be either categorical or numeric, although color mapping will
behave differently in latter case.
size : vector or key in `data`
Grouping variable that will produce elements with different sizes.
Can be either categorical or numeric, although size mapping will
behave differently in latter case.
style : vector or key in `data`
Grouping variable that will produce elements with different styles.
Can have a numeric dtype but will always be treated as categorical.
{params.rel.units}
weights : vector or key in `data`
Data values or column used to compute weighted estimation.
Note that use of weights currently limits the choice of statistics
to a 'mean' estimator and 'ci' errorbar.
{params.facets.rowcol}
{params.facets.col_wrap}
row_order, col_order : lists of strings
Order to organize the rows and/or columns of the grid in, otherwise the
orders are inferred from the data objects.
{params.core.palette}
{params.core.hue_order}
{params.core.hue_norm}
{params.rel.sizes}
{params.rel.size_order}
{params.rel.size_norm}
{params.rel.style_order}
{params.rel.dashes}
{params.rel.markers}
{params.rel.legend}
kind : string
Kind of plot to draw, corresponding to a seaborn relational plot.
Options are `"scatter"` or `"line"`.
{params.facets.height}
{params.facets.aspect}
facet_kws : dict
Dictionary of other keyword arguments to pass to :class:`FacetGrid`.
kwargs : key, value pairings
Other keyword arguments are passed through to the underlying plotting
function.
Returns
-------
{returns.facetgrid}
Examples
--------
.. include:: ../docstrings/relplot.rst
""".format(
narrative=_relational_narrative,
params=_param_docs,
returns=_core_docs["returns"],
)

View File

@@ -0,0 +1,897 @@
"""Utility functions, mostly for internal use."""
import os
import inspect
import warnings
import colorsys
from contextlib import contextmanager
from urllib.request import urlopen, urlretrieve
from types import ModuleType
import numpy as np
import pandas as pd
import matplotlib as mpl
from matplotlib.colors import to_rgb
import matplotlib.pyplot as plt
from matplotlib.cbook import normalize_kwargs
from seaborn._core.typing import deprecated
from seaborn.external.version import Version
from seaborn.external.appdirs import user_cache_dir
__all__ = ["desaturate", "saturate", "set_hls_values", "move_legend",
"despine", "get_dataset_names", "get_data_home", "load_dataset"]
DATASET_SOURCE = "https://raw.githubusercontent.com/mwaskom/seaborn-data/master"
DATASET_NAMES_URL = f"{DATASET_SOURCE}/dataset_names.txt"
def ci_to_errsize(cis, heights):
"""Convert intervals to error arguments relative to plot heights.
Parameters
----------
cis : 2 x n sequence
sequence of confidence interval limits
heights : n sequence
sequence of plot heights
Returns
-------
errsize : 2 x n array
sequence of error size relative to height values in correct
format as argument for plt.bar
"""
cis = np.atleast_2d(cis).reshape(2, -1)
heights = np.atleast_1d(heights)
errsize = []
for i, (low, high) in enumerate(np.transpose(cis)):
h = heights[i]
elow = h - low
ehigh = high - h
errsize.append([elow, ehigh])
errsize = np.asarray(errsize).T
return errsize
def _draw_figure(fig):
"""Force draw of a matplotlib figure, accounting for back-compat."""
# See https://github.com/matplotlib/matplotlib/issues/19197 for context
fig.canvas.draw()
if fig.stale:
try:
fig.draw(fig.canvas.get_renderer())
except AttributeError:
pass
def _default_color(method, hue, color, kws, saturation=1):
"""If needed, get a default color by using the matplotlib property cycle."""
if hue is not None:
# This warning is probably user-friendly, but it's currently triggered
# in a FacetGrid context and I don't want to mess with that logic right now
# if color is not None:
# msg = "`color` is ignored when `hue` is assigned."
# warnings.warn(msg)
return None
kws = kws.copy()
kws.pop("label", None)
if color is not None:
if saturation < 1:
color = desaturate(color, saturation)
return color
elif method.__name__ == "plot":
color = normalize_kwargs(kws, mpl.lines.Line2D).get("color")
scout, = method([], [], scalex=False, scaley=False, color=color)
color = scout.get_color()
scout.remove()
elif method.__name__ == "scatter":
# Matplotlib will raise if the size of x/y don't match s/c,
# and the latter might be in the kws dict
scout_size = max(
np.atleast_1d(kws.get(key, [])).shape[0]
for key in ["s", "c", "fc", "facecolor", "facecolors"]
)
scout_x = scout_y = np.full(scout_size, np.nan)
scout = method(scout_x, scout_y, **kws)
facecolors = scout.get_facecolors()
if not len(facecolors):
# Handle bug in matplotlib <= 3.2 (I think)
# This will limit the ability to use non color= kwargs to specify
# a color in versions of matplotlib with the bug, but trying to
# work out what the user wanted by re-implementing the broken logic
# of inspecting the kwargs is probably too brittle.
single_color = False
else:
single_color = np.unique(facecolors, axis=0).shape[0] == 1
# Allow the user to specify an array of colors through various kwargs
if "c" not in kws and single_color:
color = to_rgb(facecolors[0])
scout.remove()
elif method.__name__ == "bar":
# bar() needs masked, not empty data, to generate a patch
scout, = method([np.nan], [np.nan], **kws)
color = to_rgb(scout.get_facecolor())
scout.remove()
# Axes.bar adds both a patch and a container
method.__self__.containers.pop(-1)
elif method.__name__ == "fill_between":
kws = normalize_kwargs(kws, mpl.collections.PolyCollection)
scout = method([], [], **kws)
facecolor = scout.get_facecolor()
color = to_rgb(facecolor[0])
scout.remove()
if saturation < 1:
color = desaturate(color, saturation)
return color
def desaturate(color, prop):
"""Decrease the saturation channel of a color by some percent.
Parameters
----------
color : matplotlib color
hex, rgb-tuple, or html color name
prop : float
saturation channel of color will be multiplied by this value
Returns
-------
new_color : rgb tuple
desaturated color code in RGB tuple representation
"""
# Check inputs
if not 0 <= prop <= 1:
raise ValueError("prop must be between 0 and 1")
# Get rgb tuple rep
rgb = to_rgb(color)
# Short circuit to avoid floating point issues
if prop == 1:
return rgb
# Convert to hls
h, l, s = colorsys.rgb_to_hls(*rgb)
# Desaturate the saturation channel
s *= prop
# Convert back to rgb
new_color = colorsys.hls_to_rgb(h, l, s)
return new_color
def saturate(color):
"""Return a fully saturated color with the same hue.
Parameters
----------
color : matplotlib color
hex, rgb-tuple, or html color name
Returns
-------
new_color : rgb tuple
saturated color code in RGB tuple representation
"""
return set_hls_values(color, s=1)
def set_hls_values(color, h=None, l=None, s=None): # noqa
"""Independently manipulate the h, l, or s channels of a color.
Parameters
----------
color : matplotlib color
hex, rgb-tuple, or html color name
h, l, s : floats between 0 and 1, or None
new values for each channel in hls space
Returns
-------
new_color : rgb tuple
new color code in RGB tuple representation
"""
# Get an RGB tuple representation
rgb = to_rgb(color)
vals = list(colorsys.rgb_to_hls(*rgb))
for i, val in enumerate([h, l, s]):
if val is not None:
vals[i] = val
rgb = colorsys.hls_to_rgb(*vals)
return rgb
def axlabel(xlabel, ylabel, **kwargs):
"""Grab current axis and label it.
DEPRECATED: will be removed in a future version.
"""
msg = "This function is deprecated and will be removed in a future version"
warnings.warn(msg, FutureWarning)
ax = plt.gca()
ax.set_xlabel(xlabel, **kwargs)
ax.set_ylabel(ylabel, **kwargs)
def remove_na(vector):
"""Helper method for removing null values from data vectors.
Parameters
----------
vector : vector object
Must implement boolean masking with [] subscript syntax.
Returns
-------
clean_clean : same type as ``vector``
Vector of data with null values removed. May be a copy or a view.
"""
return vector[pd.notnull(vector)]
def get_color_cycle():
"""Return the list of colors in the current matplotlib color cycle
Parameters
----------
None
Returns
-------
colors : list
List of matplotlib colors in the current cycle, or dark gray if
the current color cycle is empty.
"""
cycler = mpl.rcParams['axes.prop_cycle']
return cycler.by_key()['color'] if 'color' in cycler.keys else [".15"]
def despine(fig=None, ax=None, top=True, right=True, left=False,
bottom=False, offset=None, trim=False):
"""Remove the top and right spines from plot(s).
fig : matplotlib figure, optional
Figure to despine all axes of, defaults to the current figure.
ax : matplotlib axes, optional
Specific axes object to despine. Ignored if fig is provided.
top, right, left, bottom : boolean, optional
If True, remove that spine.
offset : int or dict, optional
Absolute distance, in points, spines should be moved away
from the axes (negative values move spines inward). A single value
applies to all spines; a dict can be used to set offset values per
side.
trim : bool, optional
If True, limit spines to the smallest and largest major tick
on each non-despined axis.
Returns
-------
None
"""
# Get references to the axes we want
if fig is None and ax is None:
axes = plt.gcf().axes
elif fig is not None:
axes = fig.axes
elif ax is not None:
axes = [ax]
for ax_i in axes:
for side in ["top", "right", "left", "bottom"]:
# Toggle the spine objects
is_visible = not locals()[side]
ax_i.spines[side].set_visible(is_visible)
if offset is not None and is_visible:
try:
val = offset.get(side, 0)
except AttributeError:
val = offset
ax_i.spines[side].set_position(('outward', val))
# Potentially move the ticks
if left and not right:
maj_on = any(
t.tick1line.get_visible()
for t in ax_i.yaxis.majorTicks
)
min_on = any(
t.tick1line.get_visible()
for t in ax_i.yaxis.minorTicks
)
ax_i.yaxis.set_ticks_position("right")
for t in ax_i.yaxis.majorTicks:
t.tick2line.set_visible(maj_on)
for t in ax_i.yaxis.minorTicks:
t.tick2line.set_visible(min_on)
if bottom and not top:
maj_on = any(
t.tick1line.get_visible()
for t in ax_i.xaxis.majorTicks
)
min_on = any(
t.tick1line.get_visible()
for t in ax_i.xaxis.minorTicks
)
ax_i.xaxis.set_ticks_position("top")
for t in ax_i.xaxis.majorTicks:
t.tick2line.set_visible(maj_on)
for t in ax_i.xaxis.minorTicks:
t.tick2line.set_visible(min_on)
if trim:
# clip off the parts of the spines that extend past major ticks
xticks = np.asarray(ax_i.get_xticks())
if xticks.size:
firsttick = np.compress(xticks >= min(ax_i.get_xlim()),
xticks)[0]
lasttick = np.compress(xticks <= max(ax_i.get_xlim()),
xticks)[-1]
ax_i.spines['bottom'].set_bounds(firsttick, lasttick)
ax_i.spines['top'].set_bounds(firsttick, lasttick)
newticks = xticks.compress(xticks <= lasttick)
newticks = newticks.compress(newticks >= firsttick)
ax_i.set_xticks(newticks)
yticks = np.asarray(ax_i.get_yticks())
if yticks.size:
firsttick = np.compress(yticks >= min(ax_i.get_ylim()),
yticks)[0]
lasttick = np.compress(yticks <= max(ax_i.get_ylim()),
yticks)[-1]
ax_i.spines['left'].set_bounds(firsttick, lasttick)
ax_i.spines['right'].set_bounds(firsttick, lasttick)
newticks = yticks.compress(yticks <= lasttick)
newticks = newticks.compress(newticks >= firsttick)
ax_i.set_yticks(newticks)
def move_legend(obj, loc, **kwargs):
"""
Recreate a plot's legend at a new location.
The name is a slight misnomer. Matplotlib legends do not expose public
control over their position parameters. So this function creates a new legend,
copying over the data from the original object, which is then removed.
Parameters
----------
obj : the object with the plot
This argument can be either a seaborn or matplotlib object:
- :class:`seaborn.FacetGrid` or :class:`seaborn.PairGrid`
- :class:`matplotlib.axes.Axes` or :class:`matplotlib.figure.Figure`
loc : str or int
Location argument, as in :meth:`matplotlib.axes.Axes.legend`.
kwargs
Other keyword arguments are passed to :meth:`matplotlib.axes.Axes.legend`.
Examples
--------
.. include:: ../docstrings/move_legend.rst
"""
# This is a somewhat hackish solution that will hopefully be obviated by
# upstream improvements to matplotlib legends that make them easier to
# modify after creation.
from seaborn.axisgrid import Grid # Avoid circular import
# Locate the legend object and a method to recreate the legend
if isinstance(obj, Grid):
old_legend = obj.legend
legend_func = obj.figure.legend
elif isinstance(obj, mpl.axes.Axes):
old_legend = obj.legend_
legend_func = obj.legend
elif isinstance(obj, mpl.figure.Figure):
if obj.legends:
old_legend = obj.legends[-1]
else:
old_legend = None
legend_func = obj.legend
else:
err = "`obj` must be a seaborn Grid or matplotlib Axes or Figure instance."
raise TypeError(err)
if old_legend is None:
err = f"{obj} has no legend attached."
raise ValueError(err)
# Extract the components of the legend we need to reuse
# Import here to avoid a circular import
from seaborn._compat import get_legend_handles
handles = get_legend_handles(old_legend)
labels = [t.get_text() for t in old_legend.get_texts()]
# Handle the case where the user is trying to override the labels
if (new_labels := kwargs.pop("labels", None)) is not None:
if len(new_labels) != len(labels):
err = "Length of new labels does not match existing legend."
raise ValueError(err)
labels = new_labels
# Extract legend properties that can be passed to the recreation method
# (Vexingly, these don't all round-trip)
legend_kws = inspect.signature(mpl.legend.Legend).parameters
props = {k: v for k, v in old_legend.properties().items() if k in legend_kws}
# Delegate default bbox_to_anchor rules to matplotlib
props.pop("bbox_to_anchor")
# Try to propagate the existing title and font properties; respect new ones too
title = props.pop("title")
if "title" in kwargs:
title.set_text(kwargs.pop("title"))
title_kwargs = {k: v for k, v in kwargs.items() if k.startswith("title_")}
for key, val in title_kwargs.items():
title.set(**{key[6:]: val})
kwargs.pop(key)
# Try to respect the frame visibility
kwargs.setdefault("frameon", old_legend.legendPatch.get_visible())
# Remove the old legend and create the new one
props.update(kwargs)
old_legend.remove()
new_legend = legend_func(handles, labels, loc=loc, **props)
new_legend.set_title(title.get_text(), title.get_fontproperties())
# Let the Grid object continue to track the correct legend object
if isinstance(obj, Grid):
obj._legend = new_legend
def _kde_support(data, bw, gridsize, cut, clip):
"""Establish support for a kernel density estimate."""
support_min = max(data.min() - bw * cut, clip[0])
support_max = min(data.max() + bw * cut, clip[1])
support = np.linspace(support_min, support_max, gridsize)
return support
def ci(a, which=95, axis=None):
"""Return a percentile range from an array of values."""
p = 50 - which / 2, 50 + which / 2
return np.nanpercentile(a, p, axis)
def get_dataset_names():
"""Report available example datasets, useful for reporting issues.
Requires an internet connection.
"""
with urlopen(DATASET_NAMES_URL) as resp:
txt = resp.read()
dataset_names = [name.strip() for name in txt.decode().split("\n")]
return list(filter(None, dataset_names))
def get_data_home(data_home=None):
"""Return a path to the cache directory for example datasets.
This directory is used by :func:`load_dataset`.
If the ``data_home`` argument is not provided, it will use a directory
specified by the `SEABORN_DATA` environment variable (if it exists)
or otherwise default to an OS-appropriate user cache location.
"""
if data_home is None:
data_home = os.environ.get("SEABORN_DATA", user_cache_dir("seaborn"))
data_home = os.path.expanduser(data_home)
if not os.path.exists(data_home):
os.makedirs(data_home)
return data_home
def load_dataset(name, cache=True, data_home=None, **kws):
"""Load an example dataset from the online repository (requires internet).
This function provides quick access to a small number of example datasets
that are useful for documenting seaborn or generating reproducible examples
for bug reports. It is not necessary for normal usage.
Note that some of the datasets have a small amount of preprocessing applied
to define a proper ordering for categorical variables.
Use :func:`get_dataset_names` to see a list of available datasets.
Parameters
----------
name : str
Name of the dataset (``{name}.csv`` on
https://github.com/mwaskom/seaborn-data).
cache : boolean, optional
If True, try to load from the local cache first, and save to the cache
if a download is required.
data_home : string, optional
The directory in which to cache data; see :func:`get_data_home`.
kws : keys and values, optional
Additional keyword arguments are passed to passed through to
:func:`pandas.read_csv`.
Returns
-------
df : :class:`pandas.DataFrame`
Tabular data, possibly with some preprocessing applied.
"""
# A common beginner mistake is to assume that one's personal data needs
# to be passed through this function to be usable with seaborn.
# Let's provide a more helpful error than you would otherwise get.
if isinstance(name, pd.DataFrame):
err = (
"This function accepts only strings (the name of an example dataset). "
"You passed a pandas DataFrame. If you have your own dataset, "
"it is not necessary to use this function before plotting."
)
raise TypeError(err)
url = f"{DATASET_SOURCE}/{name}.csv"
if cache:
cache_path = os.path.join(get_data_home(data_home), os.path.basename(url))
if not os.path.exists(cache_path):
if name not in get_dataset_names():
raise ValueError(f"'{name}' is not one of the example datasets.")
urlretrieve(url, cache_path)
full_path = cache_path
else:
full_path = url
df = pd.read_csv(full_path, **kws)
if df.iloc[-1].isnull().all():
df = df.iloc[:-1]
# Set some columns as a categorical type with ordered levels
if name == "tips":
df["day"] = pd.Categorical(df["day"], ["Thur", "Fri", "Sat", "Sun"])
df["sex"] = pd.Categorical(df["sex"], ["Male", "Female"])
df["time"] = pd.Categorical(df["time"], ["Lunch", "Dinner"])
df["smoker"] = pd.Categorical(df["smoker"], ["Yes", "No"])
elif name == "flights":
months = df["month"].str[:3]
df["month"] = pd.Categorical(months, months.unique())
elif name == "exercise":
df["time"] = pd.Categorical(df["time"], ["1 min", "15 min", "30 min"])
df["kind"] = pd.Categorical(df["kind"], ["rest", "walking", "running"])
df["diet"] = pd.Categorical(df["diet"], ["no fat", "low fat"])
elif name == "titanic":
df["class"] = pd.Categorical(df["class"], ["First", "Second", "Third"])
df["deck"] = pd.Categorical(df["deck"], list("ABCDEFG"))
elif name == "penguins":
df["sex"] = df["sex"].str.title()
elif name == "diamonds":
df["color"] = pd.Categorical(
df["color"], ["D", "E", "F", "G", "H", "I", "J"],
)
df["clarity"] = pd.Categorical(
df["clarity"], ["IF", "VVS1", "VVS2", "VS1", "VS2", "SI1", "SI2", "I1"],
)
df["cut"] = pd.Categorical(
df["cut"], ["Ideal", "Premium", "Very Good", "Good", "Fair"],
)
elif name == "taxis":
df["pickup"] = pd.to_datetime(df["pickup"])
df["dropoff"] = pd.to_datetime(df["dropoff"])
elif name == "seaice":
df["Date"] = pd.to_datetime(df["Date"])
elif name == "dowjones":
df["Date"] = pd.to_datetime(df["Date"])
return df
def axis_ticklabels_overlap(labels):
"""Return a boolean for whether the list of ticklabels have overlaps.
Parameters
----------
labels : list of matplotlib ticklabels
Returns
-------
overlap : boolean
True if any of the labels overlap.
"""
if not labels:
return False
try:
bboxes = [l.get_window_extent() for l in labels]
overlaps = [b.count_overlaps(bboxes) for b in bboxes]
return max(overlaps) > 1
except RuntimeError:
# Issue on macos backend raises an error in the above code
return False
def axes_ticklabels_overlap(ax):
"""Return booleans for whether the x and y ticklabels on an Axes overlap.
Parameters
----------
ax : matplotlib Axes
Returns
-------
x_overlap, y_overlap : booleans
True when the labels on that axis overlap.
"""
return (axis_ticklabels_overlap(ax.get_xticklabels()),
axis_ticklabels_overlap(ax.get_yticklabels()))
def locator_to_legend_entries(locator, limits, dtype):
"""Return levels and formatted levels for brief numeric legends."""
raw_levels = locator.tick_values(*limits).astype(dtype)
# The locator can return ticks outside the limits, clip them here
raw_levels = [l for l in raw_levels if l >= limits[0] and l <= limits[1]]
class dummy_axis:
def get_view_interval(self):
return limits
if isinstance(locator, mpl.ticker.LogLocator):
formatter = mpl.ticker.LogFormatter()
else:
formatter = mpl.ticker.ScalarFormatter()
# Avoid having an offset/scientific notation which we don't currently
# have any way of representing in the legend
formatter.set_useOffset(False)
formatter.set_scientific(False)
formatter.axis = dummy_axis()
formatted_levels = formatter.format_ticks(raw_levels)
return raw_levels, formatted_levels
def relative_luminance(color):
"""Calculate the relative luminance of a color according to W3C standards
Parameters
----------
color : matplotlib color or sequence of matplotlib colors
Hex code, rgb-tuple, or html color name.
Returns
-------
luminance : float(s) between 0 and 1
"""
rgb = mpl.colors.colorConverter.to_rgba_array(color)[:, :3]
rgb = np.where(rgb <= .03928, rgb / 12.92, ((rgb + .055) / 1.055) ** 2.4)
lum = rgb.dot([.2126, .7152, .0722])
try:
return lum.item()
except ValueError:
return lum
def to_utf8(obj):
"""Return a string representing a Python object.
Strings (i.e. type ``str``) are returned unchanged.
Byte strings (i.e. type ``bytes``) are returned as UTF-8-decoded strings.
For other objects, the method ``__str__()`` is called, and the result is
returned as a string.
Parameters
----------
obj : object
Any Python object
Returns
-------
s : str
UTF-8-decoded string representation of ``obj``
"""
if isinstance(obj, str):
return obj
try:
return obj.decode(encoding="utf-8")
except AttributeError: # obj is not bytes-like
return str(obj)
def _check_argument(param, options, value, prefix=False):
"""Raise if value for param is not in options."""
if prefix and value is not None:
failure = not any(value.startswith(p) for p in options if isinstance(p, str))
else:
failure = value not in options
if failure:
raise ValueError(
f"The value for `{param}` must be one of {options}, "
f"but {repr(value)} was passed."
)
return value
def _assign_default_kwargs(kws, call_func, source_func):
"""Assign default kwargs for call_func using values from source_func."""
# This exists so that axes-level functions and figure-level functions can
# both call a Plotter method while having the default kwargs be defined in
# the signature of the axes-level function.
# An alternative would be to have a decorator on the method that sets its
# defaults based on those defined in the axes-level function.
# Then the figure-level function would not need to worry about defaults.
# I am not sure which is better.
needed = inspect.signature(call_func).parameters
defaults = inspect.signature(source_func).parameters
for param in needed:
if param in defaults and param not in kws:
kws[param] = defaults[param].default
return kws
def adjust_legend_subtitles(legend):
"""
Make invisible-handle "subtitles" entries look more like titles.
Note: This function is not part of the public API and may be changed or removed.
"""
# Legend title not in rcParams until 3.0
font_size = plt.rcParams.get("legend.title_fontsize", None)
hpackers = legend.findobj(mpl.offsetbox.VPacker)[0].get_children()
for hpack in hpackers:
draw_area, text_area = hpack.get_children()
handles = draw_area.get_children()
if not all(artist.get_visible() for artist in handles):
draw_area.set_width(0)
for text in text_area.get_children():
if font_size is not None:
text.set_size(font_size)
def _deprecate_ci(errorbar, ci):
"""
Warn on usage of ci= and convert to appropriate errorbar= arg.
ci was deprecated when errorbar was added in 0.12. It should not be removed
completely for some time, but it can be moved out of function definitions
(and extracted from kwargs) after one cycle.
"""
if ci is not deprecated and ci != "deprecated":
if ci is None:
errorbar = None
elif ci == "sd":
errorbar = "sd"
else:
errorbar = ("ci", ci)
msg = (
"\n\nThe `ci` parameter is deprecated. "
f"Use `errorbar={repr(errorbar)}` for the same effect.\n"
)
warnings.warn(msg, FutureWarning, stacklevel=3)
return errorbar
def _get_transform_functions(ax, axis):
"""Return the forward and inverse transforms for a given axis."""
axis_obj = getattr(ax, f"{axis}axis")
transform = axis_obj.get_transform()
return transform.transform, transform.inverted().transform
@contextmanager
def _disable_autolayout():
"""Context manager for preventing rc-controlled auto-layout behavior."""
# This is a workaround for an issue in matplotlib, for details see
# https://github.com/mwaskom/seaborn/issues/2914
# The only affect of this rcParam is to set the default value for
# layout= in plt.figure, so we could just do that instead.
# But then we would need to own the complexity of the transition
# from tight_layout=True -> layout="tight". This seems easier,
# but can be removed when (if) that is simpler on the matplotlib side,
# or if the layout algorithms are improved to handle figure legends.
orig_val = mpl.rcParams["figure.autolayout"]
try:
mpl.rcParams["figure.autolayout"] = False
yield
finally:
mpl.rcParams["figure.autolayout"] = orig_val
def _version_predates(lib: ModuleType, version: str) -> bool:
"""Helper function for checking version compatibility."""
return Version(lib.__version__) < Version(version)
def _scatter_legend_artist(**kws):
kws = normalize_kwargs(kws, mpl.collections.PathCollection)
edgecolor = kws.pop("edgecolor", None)
rc = mpl.rcParams
line_kws = {
"linestyle": "",
"marker": kws.pop("marker", "o"),
"markersize": np.sqrt(kws.pop("s", rc["lines.markersize"] ** 2)),
"markerfacecolor": kws.pop("facecolor", kws.get("color")),
"markeredgewidth": kws.pop("linewidth", 0),
**kws,
}
if edgecolor is not None:
if edgecolor == "face":
line_kws["markeredgecolor"] = line_kws["markerfacecolor"]
else:
line_kws["markeredgecolor"] = edgecolor
return mpl.lines.Line2D([], [], **line_kws)
def _get_patch_legend_artist(fill):
def legend_artist(**kws):
color = kws.pop("color", None)
if color is not None:
if fill:
kws["facecolor"] = color
else:
kws["edgecolor"] = color
kws["facecolor"] = "none"
return mpl.patches.Rectangle((0, 0), 0, 0, **kws)
return legend_artist

View File

@@ -0,0 +1,426 @@
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import LinearSegmentedColormap
try:
from ipywidgets import interact, FloatSlider, IntSlider
except ImportError:
def interact(f):
msg = "Interactive palettes require `ipywidgets`, which is not installed."
raise ImportError(msg)
from .miscplot import palplot
from .palettes import (color_palette, dark_palette, light_palette,
diverging_palette, cubehelix_palette)
__all__ = ["choose_colorbrewer_palette", "choose_cubehelix_palette",
"choose_dark_palette", "choose_light_palette",
"choose_diverging_palette"]
def _init_mutable_colormap():
"""Create a matplotlib colormap that will be updated by the widgets."""
greys = color_palette("Greys", 256)
cmap = LinearSegmentedColormap.from_list("interactive", greys)
cmap._init()
cmap._set_extremes()
return cmap
def _update_lut(cmap, colors):
"""Change the LUT values in a matplotlib colormap in-place."""
cmap._lut[:256] = colors
cmap._set_extremes()
def _show_cmap(cmap):
"""Show a continuous matplotlib colormap."""
from .rcmod import axes_style # Avoid circular import
with axes_style("white"):
f, ax = plt.subplots(figsize=(8.25, .75))
ax.set(xticks=[], yticks=[])
x = np.linspace(0, 1, 256)[np.newaxis, :]
ax.pcolormesh(x, cmap=cmap)
def choose_colorbrewer_palette(data_type, as_cmap=False):
"""Select a palette from the ColorBrewer set.
These palettes are built into matplotlib and can be used by name in
many seaborn functions, or by passing the object returned by this function.
Parameters
----------
data_type : {'sequential', 'diverging', 'qualitative'}
This describes the kind of data you want to visualize. See the seaborn
color palette docs for more information about how to choose this value.
Note that you can pass substrings (e.g. 'q' for 'qualitative.
as_cmap : bool
If True, the return value is a matplotlib colormap rather than a
list of discrete colors.
Returns
-------
pal or cmap : list of colors or matplotlib colormap
Object that can be passed to plotting functions.
See Also
--------
dark_palette : Create a sequential palette with dark low values.
light_palette : Create a sequential palette with bright low values.
diverging_palette : Create a diverging palette from selected colors.
cubehelix_palette : Create a sequential palette or colormap using the
cubehelix system.
"""
if data_type.startswith("q") and as_cmap:
raise ValueError("Qualitative palettes cannot be colormaps.")
pal = []
if as_cmap:
cmap = _init_mutable_colormap()
if data_type.startswith("s"):
opts = ["Greys", "Reds", "Greens", "Blues", "Oranges", "Purples",
"BuGn", "BuPu", "GnBu", "OrRd", "PuBu", "PuRd", "RdPu", "YlGn",
"PuBuGn", "YlGnBu", "YlOrBr", "YlOrRd"]
variants = ["regular", "reverse", "dark"]
@interact
def choose_sequential(name=opts, n=(2, 18),
desat=FloatSlider(min=0, max=1, value=1),
variant=variants):
if variant == "reverse":
name += "_r"
elif variant == "dark":
name += "_d"
if as_cmap:
colors = color_palette(name, 256, desat)
_update_lut(cmap, np.c_[colors, np.ones(256)])
_show_cmap(cmap)
else:
pal[:] = color_palette(name, n, desat)
palplot(pal)
elif data_type.startswith("d"):
opts = ["RdBu", "RdGy", "PRGn", "PiYG", "BrBG",
"RdYlBu", "RdYlGn", "Spectral"]
variants = ["regular", "reverse"]
@interact
def choose_diverging(name=opts, n=(2, 16),
desat=FloatSlider(min=0, max=1, value=1),
variant=variants):
if variant == "reverse":
name += "_r"
if as_cmap:
colors = color_palette(name, 256, desat)
_update_lut(cmap, np.c_[colors, np.ones(256)])
_show_cmap(cmap)
else:
pal[:] = color_palette(name, n, desat)
palplot(pal)
elif data_type.startswith("q"):
opts = ["Set1", "Set2", "Set3", "Paired", "Accent",
"Pastel1", "Pastel2", "Dark2"]
@interact
def choose_qualitative(name=opts, n=(2, 16),
desat=FloatSlider(min=0, max=1, value=1)):
pal[:] = color_palette(name, n, desat)
palplot(pal)
if as_cmap:
return cmap
return pal
def choose_dark_palette(input="husl", as_cmap=False):
"""Launch an interactive widget to create a dark sequential palette.
This corresponds with the :func:`dark_palette` function. This kind
of palette is good for data that range between relatively uninteresting
low values and interesting high values.
Requires IPython 2+ and must be used in the notebook.
Parameters
----------
input : {'husl', 'hls', 'rgb'}
Color space for defining the seed value. Note that the default is
different than the default input for :func:`dark_palette`.
as_cmap : bool
If True, the return value is a matplotlib colormap rather than a
list of discrete colors.
Returns
-------
pal or cmap : list of colors or matplotlib colormap
Object that can be passed to plotting functions.
See Also
--------
dark_palette : Create a sequential palette with dark low values.
light_palette : Create a sequential palette with bright low values.
cubehelix_palette : Create a sequential palette or colormap using the
cubehelix system.
"""
pal = []
if as_cmap:
cmap = _init_mutable_colormap()
if input == "rgb":
@interact
def choose_dark_palette_rgb(r=(0., 1.),
g=(0., 1.),
b=(0., 1.),
n=(3, 17)):
color = r, g, b
if as_cmap:
colors = dark_palette(color, 256, input="rgb")
_update_lut(cmap, colors)
_show_cmap(cmap)
else:
pal[:] = dark_palette(color, n, input="rgb")
palplot(pal)
elif input == "hls":
@interact
def choose_dark_palette_hls(h=(0., 1.),
l=(0., 1.), # noqa: E741
s=(0., 1.),
n=(3, 17)):
color = h, l, s
if as_cmap:
colors = dark_palette(color, 256, input="hls")
_update_lut(cmap, colors)
_show_cmap(cmap)
else:
pal[:] = dark_palette(color, n, input="hls")
palplot(pal)
elif input == "husl":
@interact
def choose_dark_palette_husl(h=(0, 359),
s=(0, 99),
l=(0, 99), # noqa: E741
n=(3, 17)):
color = h, s, l
if as_cmap:
colors = dark_palette(color, 256, input="husl")
_update_lut(cmap, colors)
_show_cmap(cmap)
else:
pal[:] = dark_palette(color, n, input="husl")
palplot(pal)
if as_cmap:
return cmap
return pal
def choose_light_palette(input="husl", as_cmap=False):
"""Launch an interactive widget to create a light sequential palette.
This corresponds with the :func:`light_palette` function. This kind
of palette is good for data that range between relatively uninteresting
low values and interesting high values.
Requires IPython 2+ and must be used in the notebook.
Parameters
----------
input : {'husl', 'hls', 'rgb'}
Color space for defining the seed value. Note that the default is
different than the default input for :func:`light_palette`.
as_cmap : bool
If True, the return value is a matplotlib colormap rather than a
list of discrete colors.
Returns
-------
pal or cmap : list of colors or matplotlib colormap
Object that can be passed to plotting functions.
See Also
--------
light_palette : Create a sequential palette with bright low values.
dark_palette : Create a sequential palette with dark low values.
cubehelix_palette : Create a sequential palette or colormap using the
cubehelix system.
"""
pal = []
if as_cmap:
cmap = _init_mutable_colormap()
if input == "rgb":
@interact
def choose_light_palette_rgb(r=(0., 1.),
g=(0., 1.),
b=(0., 1.),
n=(3, 17)):
color = r, g, b
if as_cmap:
colors = light_palette(color, 256, input="rgb")
_update_lut(cmap, colors)
_show_cmap(cmap)
else:
pal[:] = light_palette(color, n, input="rgb")
palplot(pal)
elif input == "hls":
@interact
def choose_light_palette_hls(h=(0., 1.),
l=(0., 1.), # noqa: E741
s=(0., 1.),
n=(3, 17)):
color = h, l, s
if as_cmap:
colors = light_palette(color, 256, input="hls")
_update_lut(cmap, colors)
_show_cmap(cmap)
else:
pal[:] = light_palette(color, n, input="hls")
palplot(pal)
elif input == "husl":
@interact
def choose_light_palette_husl(h=(0, 359),
s=(0, 99),
l=(0, 99), # noqa: E741
n=(3, 17)):
color = h, s, l
if as_cmap:
colors = light_palette(color, 256, input="husl")
_update_lut(cmap, colors)
_show_cmap(cmap)
else:
pal[:] = light_palette(color, n, input="husl")
palplot(pal)
if as_cmap:
return cmap
return pal
def choose_diverging_palette(as_cmap=False):
"""Launch an interactive widget to choose a diverging color palette.
This corresponds with the :func:`diverging_palette` function. This kind
of palette is good for data that range between interesting low values
and interesting high values with a meaningful midpoint. (For example,
change scores relative to some baseline value).
Requires IPython 2+ and must be used in the notebook.
Parameters
----------
as_cmap : bool
If True, the return value is a matplotlib colormap rather than a
list of discrete colors.
Returns
-------
pal or cmap : list of colors or matplotlib colormap
Object that can be passed to plotting functions.
See Also
--------
diverging_palette : Create a diverging color palette or colormap.
choose_colorbrewer_palette : Interactively choose palettes from the
colorbrewer set, including diverging palettes.
"""
pal = []
if as_cmap:
cmap = _init_mutable_colormap()
@interact
def choose_diverging_palette(
h_neg=IntSlider(min=0,
max=359,
value=220),
h_pos=IntSlider(min=0,
max=359,
value=10),
s=IntSlider(min=0, max=99, value=74),
l=IntSlider(min=0, max=99, value=50), # noqa: E741
sep=IntSlider(min=1, max=50, value=10),
n=(2, 16),
center=["light", "dark"]
):
if as_cmap:
colors = diverging_palette(h_neg, h_pos, s, l, sep, 256, center)
_update_lut(cmap, colors)
_show_cmap(cmap)
else:
pal[:] = diverging_palette(h_neg, h_pos, s, l, sep, n, center)
palplot(pal)
if as_cmap:
return cmap
return pal
def choose_cubehelix_palette(as_cmap=False):
"""Launch an interactive widget to create a sequential cubehelix palette.
This corresponds with the :func:`cubehelix_palette` function. This kind
of palette is good for data that range between relatively uninteresting
low values and interesting high values. The cubehelix system allows the
palette to have more hue variance across the range, which can be helpful
for distinguishing a wider range of values.
Requires IPython 2+ and must be used in the notebook.
Parameters
----------
as_cmap : bool
If True, the return value is a matplotlib colormap rather than a
list of discrete colors.
Returns
-------
pal or cmap : list of colors or matplotlib colormap
Object that can be passed to plotting functions.
See Also
--------
cubehelix_palette : Create a sequential palette or colormap using the
cubehelix system.
"""
pal = []
if as_cmap:
cmap = _init_mutable_colormap()
@interact
def choose_cubehelix(n_colors=IntSlider(min=2, max=16, value=9),
start=FloatSlider(min=0, max=3, value=0),
rot=FloatSlider(min=-1, max=1, value=.4),
gamma=FloatSlider(min=0, max=5, value=1),
hue=FloatSlider(min=0, max=1, value=.8),
light=FloatSlider(min=0, max=1, value=.85),
dark=FloatSlider(min=0, max=1, value=.15),
reverse=False):
if as_cmap:
colors = cubehelix_palette(256, start, rot, gamma,
hue, light, dark, reverse)
_update_lut(cmap, np.c_[colors, np.ones(256)])
_show_cmap(cmap)
else:
pal[:] = cubehelix_palette(n_colors, start, rot, gamma,
hue, light, dark, reverse)
palplot(pal)
if as_cmap:
return cmap
return pal