Repository: arviz-devs/arviz

Issue:
summary throws error on missing variables while plots do not
There is an inconsistency across functions as to how ArviZ handles `var_names` that don't exist in the model. Plots simply ignore them; the following model actually has no `s_γ' variable, but `plot_trace` simply ignores it and plots the rest:

    az.plot_trace(trace, var_names=['m_μ', 'm_β', 's_μ', 's_β', 's_γ', 'σ', 'ν']);

whereas, the same arguments passed to `summary` throws a `KeyError`:

    az.summary(trace, var_names=['m_μ', 'm_β', 's_μ', 's_β', 's_γ', 'σ', 'ν']);

```
~/anaconda3/envs/org_velo/lib/python3.7/site-packages/xarray/core/dataset.py in _get_virtual_variable(variables, key, level_vars, dim_sizes)
     89         ref_var = dim_var.to_index_variable().get_level_variable(ref_name)
     90     else:
---> 91         ref_var = variables[ref_name]
     92 
     93     if var_name is None:

KeyError: 's_γ'
```
I prefer the error, but they should .at least behave similarly across functions.

Discussion:

Patch:
diff --git a/arviz/__init__.py b/arviz/__init__.py
index fd297eb..fdf9884 100644
--- a/arviz/__init__.py
+++ b/arviz/__init__.py
@@ -23,6 +23,7 @@ if not logging.root.handlers:
     _log.setLevel(logging.INFO)
     _log.addHandler(handler)
 
+from .rcparams import rcParams, rc_context
 from .data import *
 from .plots import *
 from .stats import *
diff --git a/arviz/data/inference_data.py b/arviz/data/inference_data.py
index 29c97db..2834392 100644
--- a/arviz/data/inference_data.py
+++ b/arviz/data/inference_data.py
@@ -1,5 +1,4 @@
 """Data structure for using netcdf groups with xarray."""
-import os
 from collections import OrderedDict
 from collections.abc import Sequence
 from copy import copy as ccopy, deepcopy
@@ -8,6 +7,8 @@ import netCDF4 as nc
 import numpy as np
 import xarray as xr
 
+from ..rcparams import rcParams
+
 
 class InferenceData:
     """Container for accessing netCDF files using xarray."""
@@ -52,9 +53,9 @@ class InferenceData:
         """Initialize object from a netcdf file.
 
         Expects that the file will have groups, each of which can be loaded by xarray.
-        By default, the datasets of the InferenceData object will be lazily loaded. To
-        modify this behaviour, the environment variable ``ARVIZ_LOAD`` must be set to
-        ``EAGER`` (case insensitive) in order to load objects in memory instead.
+        By default, the datasets of the InferenceData object will be lazily loaded instead
+        of being loaded into memory. This
+        behaviour is regulated by the value of ``az.rcParams["data.load"]``.
 
         Parameters
         ----------
@@ -69,10 +70,9 @@ class InferenceData:
         with nc.Dataset(filename, mode="r") as data:
             data_groups = list(data.groups)
 
-        arviz_load_mode = os.environ.get("ARVIZ_LOAD", "lazy").lower()
         for group in data_groups:
             with xr.open_dataset(filename, group=group) as data:
-                if arviz_load_mode == "eager":
+                if rcParams["data.load"] == "eager":
                     groups[group] = data.load()
                 else:
                     groups[group] = data
diff --git a/arviz/data/io_netcdf.py b/arviz/data/io_netcdf.py
index af162a6..b1c80f7 100644
--- a/arviz/data/io_netcdf.py
+++ b/arviz/data/io_netcdf.py
@@ -18,9 +18,9 @@ def from_netcdf(filename):
 
     Notes
     -----
-    By default, the datasets of the InferenceData object will be lazily loaded. To
-    modify this behaviour, the environment variable ``ARVIZ_LOAD`` must be set to
-    ``EAGER`` (case insensitive) in order to load objects in memory instead.
+    By default, the datasets of the InferenceData object will be lazily loaded instead
+    of loaded into memory. This behaviour is regulated by the value of
+    ``az.rcParams["data.load"]``.
     """
     return InferenceData.from_netcdf(filename)
 
diff --git a/arviz/plots/elpdplot.py b/arviz/plots/elpdplot.py
index f1710fd..5600634 100644
--- a/arviz/plots/elpdplot.py
+++ b/arviz/plots/elpdplot.py
@@ -13,6 +13,7 @@ from .plot_utils import (
     set_xticklabels,
 )
 from ..stats import waic, loo, ELPDData
+from ..rcparams import rcParams
 
 
 def plot_elpd(
@@ -25,7 +26,7 @@ def plot_elpd(
     legend=False,
     threshold=None,
     ax=None,
-    ic="waic",
+    ic=None,
     scale="deviance",
     plot_kwargs=None,
 ):
@@ -59,7 +60,8 @@ def plot_elpd(
     ax: axes, optional
         Matplotlib axes
     ic : str, optional
-        Information Criterion (WAIC or LOO) used to compare models. Default WAIC. Only taken
+        Information Criterion (WAIC or LOO) used to compare models. Defaults to
+        ``rcParams["stats.information_criterion"]``. Only taken
         into account when input is InferenceData.
     scale : str, optional
         scale argument passed to az.waic or az.loo, see their docs for details. Only taken
@@ -88,7 +90,7 @@ def plot_elpd(
 
     """
     valid_ics = ["waic", "loo"]
-    ic = ic.lower()
+    ic = rcParams["stats.information_criterion"] if ic is None else ic.lower()
     if ic not in valid_ics:
         raise ValueError(
             ("Information Criteria type {} not recognized." "IC must be in {}").format(
diff --git a/arviz/plots/ppcplot.py b/arviz/plots/ppcplot.py
index e0b1abf..9d45ea4 100644
--- a/arviz/plots/ppcplot.py
+++ b/arviz/plots/ppcplot.py
@@ -191,9 +191,10 @@ def plot_ppc(
     posterior_predictive = data.posterior_predictive
 
     if var_names is None:
-        var_names = observed.data_vars
+        var_names = list(observed.data_vars)
     var_names = _var_names(var_names, observed)
     pp_var_names = [data_pairs.get(var, var) for var in var_names]
+    pp_var_names = _var_names(pp_var_names, posterior_predictive)
 
     if flatten_pp is None and flatten is None:
         flatten_pp = list(posterior_predictive.dims.keys())
diff --git a/arviz/rcparams.py b/arviz/rcparams.py
new file mode 100644
index 0000000..2b630db
--- /dev/null
+++ b/arviz/rcparams.py
@@ -0,0 +1,266 @@
+"""ArviZ rcparams."""
+import sys
+import os
+from pathlib import Path
+import re
+import pprint
+import logging
+import locale
+from collections import MutableMapping
+
+_log = logging.getLogger(__name__)
+
+
+def _make_validate_choice(accepted_values):
+    """Validate value is in accepted_values."""
+    # no blank lines allowed after function docstring by pydocstyle,
+    # but black requires white line before function
+
+    def validate_choice(value):
+        if value.lower() in accepted_values:
+            return value.lower()
+        raise ValueError("{} is not one of {}".format(value, accepted_values))
+
+    return validate_choice
+
+
+def _validate_positive_int(value):
+    """Validate value is a natural number."""
+    try:
+        value = int(value)
+    except ValueError:
+        raise ValueError("Could not convert to int")
+    if value > 0:
+        return value
+    else:
+        raise ValueError("Only positive values are valid")
+
+
+def _validate_positive_int_or_none(value):
+    """Validate value is a natural number or None."""
+    if value is None or isinstance(value, str) and value.lower() == "none":
+        return None
+    else:
+        return _validate_positive_int(value)
+
+
+defaultParams = {  # pylint: disable=invalid-name
+    "data.load": ("lazy", _make_validate_choice(("lazy", "eager"))),
+    "plot.max_subplots": (40, _validate_positive_int_or_none),
+    "stats.information_criterion": ("waic", _make_validate_choice(("waic", "loo"))),
+}
+
+
+class RcParams(MutableMapping, dict):  # pylint: disable=too-many-ancestors
+    """Class to contain ArviZ default parameters.
+
+    It is implemented as a dict with validation when setting items.
+    """
+
+    validate = {key: validate_fun for key, (_, validate_fun) in defaultParams.items()}
+
+    # validate values on the way in
+    def __init__(self, *args, **kwargs):  # pylint: disable=super-init-not-called
+        self.update(*args, **kwargs)
+
+    def __setitem__(self, key, val):
+        """Add validation to __setitem__ function."""
+        try:
+            try:
+                cval = self.validate[key](val)
+            except ValueError as verr:
+                raise ValueError("Key %s: %s" % (key, str(verr)))
+            dict.__setitem__(self, key, cval)
+        except KeyError:
+            raise KeyError(
+                "{} is not a valid rc parameter (see rcParams.keys() for "
+                "a list of valid parameters)".format(key)
+            )
+
+    def __getitem__(self, key):
+        """Use dict getitem method."""
+        return dict.__getitem__(self, key)
+
+    def __delitem__(self, key):
+        """Raise TypeError if someone ever tries to delete a key from RcParams."""
+        raise TypeError("RcParams keys cannot be deleted")
+
+    def __repr__(self):
+        """Customize repr of RcParams objects."""
+        class_name = self.__class__.__name__
+        indent = len(class_name) + 1
+        repr_split = pprint.pformat(dict(self), indent=1, width=80 - indent).split("\n")
+        repr_indented = ("\n" + " " * indent).join(repr_split)
+        return "{}({})".format(class_name, repr_indented)
+
+    def __str__(self):
+        """Customize str/print of RcParams objects."""
+        return "\n".join(map("{0[0]:<22}: {0[1]}".format, sorted(self.items())))
+
+    def __iter__(self):
+        """Yield sorted list of keys."""
+        yield from sorted(dict.__iter__(self))
+
+    def __len__(self):
+        """Use dict len method."""
+        return dict.__len__(self)
+
+    def find_all(self, pattern):
+        """
+        Find keys that match a regex pattern.
+
+        Return the subset of this RcParams dictionary whose keys match,
+        using :func:`re.search`, the given ``pattern``.
+
+        Notes
+        -----
+            Changes to the returned dictionary are *not* propagated to
+            the parent RcParams dictionary.
+        """
+        pattern_re = re.compile(pattern)
+        return RcParams((key, value) for key, value in self.items() if pattern_re.search(key))
+
+    def copy(self):
+        """Get a copy of the RcParams object."""
+        return {k: dict.__getitem__(self, k) for k in self}
+
+
+def get_arviz_rcfile():
+    """Get arvizrc file.
+
+    The file location is determined in the following order:
+
+    - ``$PWD/arvizrc``
+    - ``$ARVIZ_DATA/arvizrc``
+    - On Linux,
+        - ``$XDG_CONFIG_HOME/arviz/arvizrc`` (if ``$XDG_CONFIG_HOME``
+          is defined)
+        - or ``$HOME/.config/arviz/arvizrc`` (if ``$XDG_CONFIG_HOME``
+          is not defined)
+    - On other platforms,
+        - ``$HOME/.arviz/arvizrc`` if ``$HOME`` is defined
+
+    Otherwise, the default defined in ``rcparams.py`` file will be used.
+    """
+    # no blank lines allowed after function docstring by pydocstyle,
+    # but black requires white line before function
+
+    def gen_candidates():
+        yield os.path.join(os.getcwd(), "arvizrc")
+        arviz_data_dir = os.environ.get("ARVIZ_DATA")
+        if arviz_data_dir:
+            yield os.path.join(arviz_data_dir, "arvizrc")
+        xdg_base = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
+        if sys.platform.startswith(("linux", "freebsd")):
+            configdir = str(Path(xdg_base, "arviz"))
+        else:
+            configdir = str(Path.home() / ".arviz")
+        yield os.path.join(configdir, "arvizrc")
+
+    for fname in gen_candidates():
+        if os.path.exists(fname) and not os.path.isdir(fname):
+            return fname
+
+    return None
+
+
+def read_rcfile(fname):
+    """Return :class:`arviz.RcParams` from the contents of the given file.
+
+    Unlike `rc_params_from_file`, the configuration class only contains the
+    parameters specified in the file (i.e. default values are not filled in).
+    """
+    _error_details_fmt = 'line #%d\n\t"%s"\n\tin file "%s"'
+
+    config = RcParams()
+    with open(fname, "r") as rcfile:
+        try:
+            for line_no, line in enumerate(rcfile, 1):
+                strippedline = line.split("#", 1)[0].strip()
+                if not strippedline:
+                    continue
+                tup = strippedline.split(":", 1)
+                if len(tup) != 2:
+                    error_details = _error_details_fmt % (line_no, line, fname)
+                    _log.warning("Illegal %s", error_details)
+                    continue
+                key, val = tup
+                key = key.strip()
+                val = val.strip()
+                if key in config:
+                    _log.warning("Duplicate key in file %r line #%d.", fname, line_no)
+                try:
+                    config[key] = val
+                except ValueError as verr:
+                    error_details = _error_details_fmt % (line_no, line, fname)
+                    raise ValueError("Bad val {} on {}\n\t{}".format(val, error_details, str(verr)))
+
+        except UnicodeDecodeError:
+            _log.warning(
+                "Cannot decode configuration file %s with encoding "
+                "%s, check LANG and LC_* variables.",
+                fname,
+                locale.getpreferredencoding(do_setlocale=False) or "utf-8 (default)",
+            )
+            raise
+
+        return config
+
+
+def rc_params():
+    """Read and validate arvizrc file."""
+    fname = get_arviz_rcfile()
+    defaults = RcParams([(key, default) for key, (default, _) in defaultParams.items()])
+    if fname is not None:
+        file_defaults = read_rcfile(fname)
+        defaults.update(file_defaults)
+    return defaults
+
+
+rcParams = rc_params()  # pylint: disable=invalid-name
+
+
+class rc_context:  # pylint: disable=invalid-name
+    """
+    Return a context manager for managing rc settings.
+
+    Parameters
+    ----------
+    rc : dict, optional
+        Mapping containing the rcParams to modify temporally.
+    fname : str, optional
+        Filename of the file containig the rcParams to use inside the rc_context.
+
+    Examples
+    --------
+    This allows one to do::
+        with az.rc_context(fname='pystan.rc'):
+            idata = az.load_arviz_data("radon")
+            az.plot_posterior(idata, var_names=["gamma"])
+    The plot would have settings from 'screen.rc'
+
+    A dictionary can also be passed to the context manager::
+        with az.rc_context(rc={'plot.max_subplots': None}, fname='pystan.rc'):
+            idata = az.load_arviz_data("radon")
+            az.plot_posterior(idata, var_names=["gamma"])
+    The 'rc' dictionary takes precedence over the settings loaded from
+    'fname'. Passing a dictionary only is also valid.
+    """
+
+    # Based on mpl.rc_context
+
+    def __init__(self, rc=None, fname=None):
+        self._orig = rcParams.copy()
+        if fname:
+            file_rcparams = read_rcfile(fname)
+            rcParams.update(file_rcparams)
+        if rc:
+            rcParams.update(rc)
+
+    def __enter__(self):
+        """Define enter method of context manager."""
+        return self
+
+    def __exit__(self, exc_type, exc_value, exc_tb):
+        """Define exit method of context manager."""
+        rcParams.update(self._orig)
diff --git a/arviz/stats/stats.py b/arviz/stats/stats.py
index ef54c9e..a82267b 100644
--- a/arviz/stats/stats.py
+++ b/arviz/stats/stats.py
@@ -21,6 +21,7 @@ from .stats_utils import (
     stats_variance_2d as svar,
 )
 from ..utils import _var_names, Numba, _numba_var
+from ..rcparams import rcParams
 
 _log = logging.getLogger(__name__)
 
@@ -39,7 +40,7 @@ __all__ = [
 
 def compare(
     dataset_dict,
-    ic="waic",
+    ic=None,
     method="BB-pseudo-BMA",
     b_samples=1000,
     alpha=1,
@@ -57,7 +58,8 @@ def compare(
     dataset_dict : dict[str] -> InferenceData
         A dictionary of model names and InferenceData objects
     ic : str
-        Information Criterion (WAIC or LOO) used to compare models. Default WAIC.
+        Information Criterion (WAIC or LOO) used to compare models. Defaults to
+        ``rcParams["stats.information_criterion"]``.
     method : str
         Method used to estimate the weights for each model. Available options are:
 
@@ -142,7 +144,7 @@ def compare(
             scale_value = -2
         ascending = True
 
-    ic = ic.lower()
+    ic = rcParams["stats.information_criterion"] if ic is None else ic.lower()
     if ic == "waic":
         ic_func = waic
         df_comp = pd.DataFrame(
diff --git a/arviz/utils.py b/arviz/utils.py
index f7b60f6..0997790 100644
--- a/arviz/utils.py
+++ b/arviz/utils.py
@@ -49,6 +49,14 @@ def _var_names(var_names, data):
         if excluded_vars:
             var_names = [i for i in all_vars if i not in excluded_vars]
 
+        existent_vars = np.isin(var_names, all_vars)
+        if not np.all(existent_vars):
+            raise KeyError(
+                "{} var names are not present in dataset".format(
+                    np.array(var_names)[~existent_vars]
+                )
+            )
+
     return var_names
 
 
diff --git a/arvizrc.template b/arvizrc.template
new file mode 100644
index 0000000..89b715b
--- /dev/null
+++ b/arvizrc.template
@@ -0,0 +1,14 @@
+#### ArviZ rcParams template ####
+### DATA  ###
+# rcParams related with data loading related functions
+data.load                    : lazy  # Sets the default data loading mode.
+                                     # "lazy" stands for xarray lazy loading,
+                                     # "eager" loads all datasets into memory
+
+### PLOT  ###
+# rcParams related with plotting functions
+plot.max_subplots            : 40    # Maximum number of subplots.
+
+### STATS ###
+# rcParams related with statistical and diagnostic functions
+stats.information_criterion  : waic  # Default information criterion
diff --git a/doc/api.rst b/doc/api.rst
index 92a965c..3f87cff 100644
--- a/doc/api.rst
+++ b/doc/api.rst
@@ -108,3 +108,12 @@ Utils
 
       Numba
       interactive_backend
+
+rcParams
+--------
+
+.. autosummary::
+   :toctree: generated/
+   :template: class.rst
+
+   rc_context
diff --git a/doc/conf.py b/doc/conf.py
index c365330..43e2b13 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -20,11 +20,12 @@
 import os
 import sys
 
-os.environ["ARVIZ_LOAD"] = "EAGER"
 sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
 import sphinx_bootstrap_theme
 import arviz
 
+arviz.rcParams["data.load"] = "eager"
+
 
 # -- General configuration ------------------------------------------------
 


Test patch:
diff --git a/arviz/tests/test_diagnostics.py b/arviz/tests/test_diagnostics.py
index 7851b2e..3f9062e 100644
--- a/arviz/tests/test_diagnostics.py
+++ b/arviz/tests/test_diagnostics.py
@@ -27,12 +27,13 @@ from ..stats.diagnostics import (
     _circular_standard_deviation,
 )
 from ..utils import Numba
+from ..rcparams import rcParams
 
 # For tests only, recommended value should be closer to 1.01-1.05
 # See discussion in https://github.com/stan-dev/rstan/pull/618
 GOOD_RHAT = 1.1
 
-os.environ["ARVIZ_LOAD"] = "EAGER"
+rcParams["data.load"] = "eager"
 
 
 @pytest.fixture(scope="session")
diff --git a/arviz/tests/test_plots.py b/arviz/tests/test_plots.py
index afb921b..f274d24 100644
--- a/arviz/tests/test_plots.py
+++ b/arviz/tests/test_plots.py
@@ -16,6 +16,7 @@ from .helpers import (  # pylint: disable=unused-import
     multidim_models,
     create_multidimensional_model,
 )
+from ..rcparams import rcParams
 from ..plots import (
     plot_density,
     plot_trace,
@@ -43,7 +44,7 @@ from ..plots import (
 from ..plots.kdeplot import _cov
 
 np.random.seed(0)
-os.environ["ARVIZ_LOAD"] = "EAGER"
+rcParams["data.load"] = "eager"
 
 
 @pytest.fixture(scope="function", autouse=True)
diff --git a/arviz/tests/test_rcparams.py b/arviz/tests/test_rcparams.py
new file mode 100644
index 0000000..dcf01c8
--- /dev/null
+++ b/arviz/tests/test_rcparams.py
@@ -0,0 +1,56 @@
+# pylint: disable=redefined-outer-name
+import numpy as np
+import pytest
+from xarray.core.indexing import MemoryCachedArray
+
+
+from ..data import load_arviz_data
+from ..stats import compare
+from ..rcparams import rcParams, rc_context
+from .helpers import models  # pylint: disable=unused-import
+
+
+def test_bad_key():
+    """Test the error when using unexistent keys in rcParams is correct."""
+    with pytest.raises(KeyError, match="bad_key is not a valid rc"):
+        rcParams["bad_key"] = "nothing"
+
+
+@pytest.mark.parametrize("param", ["data.load", "stats.information_criterion"])
+def test_choice_bad_values(param):
+    """Test error messages are correct for rcParams validated with _make_validate_choice."""
+    msg = "{}: bad_value is not one of".format(param.replace(".", r"\."))
+    with pytest.raises(ValueError, match=msg):
+        rcParams[param] = "bad_value"
+
+
+def test_rc_context():
+    rcParams["data.load"] = "lazy"
+    with rc_context(rc={"data.load": "eager"}):
+        assert rcParams["data.load"] == "eager"
+    assert rcParams["data.load"] == "lazy"
+
+
+def test_data_load():
+    rcParams["data.load"] = "lazy"
+    idata_lazy = load_arviz_data("centered_eight")
+    assert isinstance(
+        idata_lazy.posterior.mu.variable._data,  # pylint: disable=protected-access
+        MemoryCachedArray,
+    )
+    assert rcParams["data.load"] == "lazy"
+    rcParams["data.load"] = "eager"
+    idata_eager = load_arviz_data("centered_eight")
+    assert isinstance(
+        idata_eager.posterior.mu.variable._data, np.ndarray  # pylint: disable=protected-access
+    )
+    assert rcParams["data.load"] == "eager"
+
+
+def test_stats_information_criterion(models):
+    rcParams["stats.information_criterion"] = "waic"
+    df_comp = compare({"model1": models.model_1, "model2": models.model_2})
+    assert "waic" in df_comp.columns
+    rcParams["stats.information_criterion"] = "loo"
+    df_comp = compare({"model1": models.model_1, "model2": models.model_2})
+    assert "loo" in df_comp.columns
diff --git a/arviz/tests/test_stats.py b/arviz/tests/test_stats.py
index 1f6c9f2..2996552 100644
--- a/arviz/tests/test_stats.py
+++ b/arviz/tests/test_stats.py
@@ -1,5 +1,4 @@
 # pylint: disable=redefined-outer-name, no-member
-import os
 from copy import deepcopy
 import numpy as np
 from numpy.testing import assert_allclose, assert_array_almost_equal
@@ -24,8 +23,10 @@ from ..stats import (
 from ..stats.stats import _gpinv
 from ..utils import Numba
 from .helpers import check_multiple_attrs, multidim_models  # pylint: disable=unused-import
+from ..rcparams import rcParams
 
-os.environ["ARVIZ_LOAD"] = "EAGER"
+
+rcParams["data.load"] = "eager"
 
 
 @pytest.fixture(scope="session")
diff --git a/arviz/tests/test_utils.py b/arviz/tests/test_utils.py
index 9b94994..76f5f8d 100644
--- a/arviz/tests/test_utils.py
+++ b/arviz/tests/test_utils.py
@@ -48,6 +48,11 @@ def test_var_names_warning():
         assert _var_names(var_names, data) == expected
 
 
+def test_var_names_key_error(data):
+    with pytest.raises(KeyError, match="bad_var_name"):
+        _var_names(("theta", "tau", "bad_var_name"), data)
+
+
 @pytest.fixture(scope="function")
 def utils_with_numba_import_fail(monkeypatch):
     """Patch numba in utils so when its imported it raises ImportError"""