Repository: IAMconsortium/pyam
Pull request #286 (2019-11-09 03:54:42)

Metadata description in the docs
# Please confirm that this PR has done the following:

- [x] Documentation Added
- [ ] Description in RELEASE_NOTES.md Added

## Adding to RELEASE_NOTES.md (remove section after adding to RELEASE_NOTES.md)

Please add a single line in the release notes similar to the following:

```
- (#XX)[http://link-to-pr.com] Added feature which does something
```

# Description of PR

Improves the documentation about the metadata table.


coveralls: 
[![Coverage Status](https://coveralls.io/builds/26872407/badge)](https://coveralls.io/builds/26872407)

Coverage decreased (-0.3%) to 85.067% when pulling **722322947f812cfe44de4645e3b0a21bbffbb44e on znicholls:meta-docs** into **a4e266c93f531f94285311fd354aaa881aa403c4 on IAMconsortium:master**.

danielhuppmann: Thanks @znicholls for starting the discussion.

You are correct, `meta` is (so far) only intended for categorization and quantitative indicators at the model/scenario level - primarily due to consideration that at this level, it's fairly obvious we need something. Otherwise, you end up with a table with as many rows as `data` (after all, why have meta-tags only for variables but not years or region or units) and with potentially a lot of duplicate rows.

Can you describe a use case where you would need tagging at some intermediate level, which would not be covered by a proper workflow or provenance tracking (see #287)?
znicholls: > Otherwise, you end up with a table with as many rows as `data` (after all, why have meta-tags only for variables but not years or region or units) and with potentially a lot of duplicate rows.

That's true, but the duplicats rows are only a problem because of the long form data structure of pyam. Given it's timeseries based, storing things internally in wide format (like what comes out of `.timeseries()`) would save a lot of that duplication would make this meta much more feasible. You can still cast to long when you need to for whatever operation. That's the approach we've taken in [scmdata](https://github.com/lewisjared/scmdata) and it's way quicker for filtering and means you can have a timeseries by timeseries metadata table without it becoming stupidly big.

> Can you describe a use case where you would need tagging at some intermediate level, which would not be covered by a proper workflow or provenance tracking (see #287)?

Adding an extra column, `climate_model` for determining where e.g. temperature output came from. That would solve the problem of the mangled variable names like in the SR1.5 database. 
danielhuppmann: > Given it's timeseries based, storing things internally in wide format would save a lot of that duplication would make this meta much more feasible.

But what would happen if you want to add meta-tags for particular years? For example, keeping track that year `2025` was interpolated during the postprocessing (with a particular method), whereas `2020` and `2030` were reported by the modeling team. I'm hesitant to implement a feature that will support some additional dimensions but not all.

> Adding an extra column, `climate_model` for determining where e.g. temperature output came from. That would solve the problem of the mangled variable names like in the SR1.5 database.

It's already possible to add extra-columns in `data`, e.g., to distinguish between multiple `Temperature|Global Mean` timeseries for each scenario. What additional climate-model-specific information would need to be stored in `meta`?
znicholls: I think I finally get the data model now. I've re-written what do you think?

> I'm hesitant to implement a feature that will support some additional dimensions but not all.

Yep good call (I had misunderstood how pyam works).

> What additional climate-model-specific information would need to be stored in `meta`?

Yep also good call (again I had misunderstood how pyam works).
jkikstra: Hi @znicholls and @danielhuppmann great discussion! As a new user, I was already wondering about the designed purpose/use of the `meta` column.

I took the liberty to restructure the paragraphs a bit, but left all information the same. To me, all this info seems very relevant and should be in the docs. 

I'm only a bit torn as to whether the bit about data point-by-data point metadata should be described under the `meta` column, as it actually is used under `data` .

So what about splitting up this docs section into two? A "`meta` column: scenario-model specific metadata" section and a "Data point specific metadata" section? (feel free to change these headers into something you deem more clear.)
danielhuppmann: thanks @jkikstra, good call to separate some of this into an own `data` section. If you all don't mind, I'd like to do another round of editing/rewriting later today or tomorrow.
znicholls: > If you all don't mind, I'd like to do another round of editing/rewriting later today or tomorrow.

Works for me I won't touch it until that's done then

Patch:
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index bf6064d..faefb12 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,6 +1,8 @@
 
 # Next Release
 
+- [#288](https://github.com/IAMconsortium/pyam/pull/288) Put `pyam` logger in its own namespace (see [here](https://docs.python-guide.org/writing/logging/#logging-in-a-library>))
+
 # Release v0.3.0
 
 ## Highlights
@@ -23,7 +25,7 @@
 - [#243](https://github.com/IAMconsortium/pyam/pull/243) Update `pyam.iiasa.Connection` to support all public and private database connections. DEPRECATED: the argument 'iamc15' has been deprecated in favor of names as queryable directly from the REST API.
 - [#241](https://github.com/IAMconsortium/pyam/pull/241) Add `set_meta_from_data` feature
 - [#236](https://github.com/IAMconsortium/pyam/pull/236) Add `swap_time_for_year` method and confirm datetime column is compatible with pyam features
-- [#273](https://github.com/IAMconsortium/pyam/pull/273) Fix several issues accessing IXMP API (passing correct credentials, improve reliability for optional fields in result payload) 
+- [#273](https://github.com/IAMconsortium/pyam/pull/273) Fix several issues accessing IXMP API (passing correct credentials, improve reliability for optional fields in result payload)
 
 # Release v0.2.0
 
diff --git a/RELEASE_PROCEDURE.md b/RELEASE_PROCEDURE.md
index acb02d1..57b2427 100644
--- a/RELEASE_PROCEDURE.md
+++ b/RELEASE_PROCEDURE.md
@@ -1,38 +1,51 @@
 
-# Steps required for a release
+# Release procedure
 
-1. Make a release candidate branch and pull request it into master with the following updates:
-   1. Deprecate any stated portion of the API (you can find them by searching the code base for "deprecate")
-   1. Update `RELEASE_NOTES.md` (see the examples of other releases)
-	  - create new section under "# Next Release" with "# Release v<release version>"
-	  - For this new release, add a "Highlights section" with some of the most important updates
-      - Make a new heading "## Individual Updates" before the PR listing
+## Required accounts and admin privileges
+
+- pip: https://pypi.org/project/pyam-iamc/ and https://test.pypi.org/project/pyam-iamc/
+- conda: https://github.com/conda-forge/pyam-feedstock/
+- rtd: https://readthedocs.org/projects/pyam-iamc/
+
+## Steps for publishing a new release
+
+1. Make a release candidate branch (e.g., `rc_v<release version>`)
+   and pull request it into `master` with the following updates:
+   1. Deprecate any stated portion of the API
+      (you can find them by searching the code base for "deprecate")
+   1. Update `RELEASE_NOTES.md` (see the examples of previous releases)
+	  - replace "# Next Release" with "# Release v<release version>"
+	  - for this new release, add a "## Highlights" section with the most important updates & changes
+      - add a new heading "## Individual Updates" before the PR listing
   1. Confirm that the PR passes all tests and checks
   1. Tag the release number: `git tag v<release version>`, e.g., `git tag v1.2.0`
      - **THIS IS NOT THE TAGGED COMMIT WE WILL DISTRIBUTE, IT IS ONLY FOR TESTING**
 	 - **DO NOT PUSH THIS TAG TO UPSTREAM**
   1. Run `make publish-on-testpypi`
-     - this should "just work" if it does not, fix any issues, retag (`git tag
-       -d` then `git tag`), and try again
-	 - note, you need an account on https://test.pypi.org
-  1. Once successful, delete the tag, and merge the candidate PR into master on Github
-1. Switch to now updated master branch: `git checkout master` and `git pull upstream master`
+     - this should "just work" - if it does not, fix any issues,
+       retag (`git tag -d` then `git tag`), and try again
+  1. Once successful, delete the tag, and merge the candidate PR into `master` on Github
+1. Switch to the now-updated master branch: `git checkout master` and `git pull upstream master`
 1. Tag the release number: `git tag v<release version>`, e.g., `git tag v1.2.0`
    - `versioneer` automatically updates the version number based on the tag
    - this is now the official tagged commit
 1. Push the tag upstream: `git push upstream --tags`
 1. Run `make publish-on-pypi`
-   - note, you need an account on https://pypi.org
-   - this will make wheels that all us to be installed via `pip install`
+   - this will make wheels that allow `pyam` to be installed via `pip install`
+   - check that the new version is available at https://pypi.org/project/pyam-iamc/
 1. Make a new release on Github
-   - Make sure that you choose the same tag name as was used earlier
-   - Copy the markdown from `RELEASE_NOTES.md` into the release description box
-1. Update on conda-forge:
-   - A PR should automatically be opened by the bot after the Github release
+   - make sure that you choose the tag name defined above
+   - copy the release summary from `RELEASE_NOTES.md` into the description box
+1. Update on `conda-forge`
+   - a PR should automatically be opened by the bot after the Github release
    - confirm that any new depedencies are added there
+   - merge the PR
+   - check that the new version is available at https://anaconda.org/conda-forge/pyam
+1. Confirm that the doc pages are updated on https://pyam-iamc.readthedocs.io/
+   - both the **latest** and the **stable** versions point to the new release
+   - the new release has been added to the list of available versions
+1. Add a new line "# Next Release" at the top of `RELEASE_NOTES.md` and commit to `master`
 1. Announce it on our mailing list: https://groups.io/g/pyam
-   - Again, just copy the now rendered HTML from the Github release directly in
-     the email
-1. Confirm that the doc page is updated to the latest release: https://pyam-iamc.readthedocs.io/
+   - again, copy the rendered HTML from the Github release directly in the email
 
 And that's it! Whew...
diff --git a/doc/source/data.rst b/doc/source/data.rst
index 5912e72..a396faf 100644
--- a/doc/source/data.rst
+++ b/doc/source/data.rst
@@ -1,15 +1,15 @@
 Data Model
 ==========
 
-Scenario data following the IAMC format
----------------------------------------
+The IAMC timeseries format for scenario data
+--------------------------------------------
 
 .. figure:: _static/iamc_logo.jpg
    :width: 120px
    :align: right
-   
+
    `IAMC website`_
-   
+
 .. _`IAMC Website`: http://www.globalchange.umd.edu/iamc/
 
 Over the past decade, the Integrated Assessment Modeling Consortium (IAMC)
@@ -68,10 +68,11 @@ In its original design, the IAMC data format (see above) assumed that the
 temporal dimension of any scenario data was restricted to full years
 represented as integer values.
 
-Two additional use cases are currently supported by :code:`pyam` in development
+Two additional use cases are currently supported by :class:`pyam` in development
 mode (beta):
 
- - using representative sub-annual timesteps
+ - using representative sub-annual timesteps via the :code:`extra_cols` feature
+   (see the section on `custom columns`_ in the :code:`data` table)
 
  - using continuous time via :class:`pandas.datetime`, replacing the name of
    the :code:`year` column by :code:`time`
@@ -79,42 +80,114 @@ mode (beta):
 Please reach out to the developers to get more information on this
 ongoing work.
 
-The :class:`pyam.IamDataFrame` class
-------------------------------------
+The :class:`IamDataFrame` class
+-------------------------------
 
 A :class:`pyam.IamDataFrame` instance is a wrapper for
-two :class:`pandas.DataFrame` instances (read the `docs`_):
-
- - :code:`data`: The data table is a dataframe containing the timeseries data
-   in "long format". It has the columns of the long data format :code:`['model',
-   'scenario', 'region', 'unit', 'year', 'value']`.
-
- - :code:`meta`: The meta table is a dataframe containing categorisation and
-   descriptive indicators. It has the index :code:`pyam.META_IDX = ['model',
-   'scenario']`.
-
-The standard output format is the IAMC-style "wide format", see the example
-above. This format can be accessed using :meth:`pyam.IamDataFrame.timeseries`,
-which returns a :class:`pandas.DataFrame` with the index :code:`pyam.IAMC_IDX =
-['model', 'scenario', 'region', 'variable', 'unit']` and the years as columns.
-
-.. _`docs`: https://pandas.pydata.org/pandas-docs/stable/reference/frame.html
+two :class:`pandas.DataFrame` instances (i.e., tables, see the `pandas docs`_
+for more information).
+
+.. _`pandas docs`: https://pandas.pydata.org/pandas-docs/stable/reference/frame.html
+
+The :code:`data` table
+~~~~~~~~~~~~~~~~~~~~~~
+
+This table contains the timeseries data related to an ensemble of scenarios.
+It is structured in *long format*, where each datapoint is one row. In contrast,
+the standard IAMC-style format is in *wide format* (see the example above),
+where each timeseries is one row and the timesteps are represented as columns.
+
+While long-format tables have advantages for the internal implementation of many
+:class:`pyam` functions, wide-format tables are more intuitive to users.
+The method :meth:`timeseries() <pyam.IamDataFrame.timeseries>` converts between
+the formats and returns a :class:`pandas.DataFrame` in wide format.
+Exporting an :class:`IamDataFrame` to file using
+:meth:`to_excel() <pyam.IamDataFrame.to_excel>` or
+:meth:`to_csv() <pyam.IamDataFrame.to_csv>` also writes the data table
+in wide format.
+
+The standard columns
+^^^^^^^^^^^^^^^^^^^^
+
+The columns of the :code:`data` table are :code:`['model', 'scenario', 'region',
+'unit', <time_format>, 'value']`, where :code:`time_format` is :code:`year`
+when timesteps are given in years (as :class:`int`) or :code:`time` when time
+is represented on a continuous scale (as :class:`pandas.datetime`).
+
+.. _`custom columns`:
+
+Custom columns of the :code:`data` table
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+If an :class:`IamDataFrame` is initialised with columns that are not in the
+list above nor interpreted as belonging to the time dimension (in wide format), these
+columns are included in the :code:`data` table as :code:`extra_cols`.
+This feature can be used, for example, to distinguish between multiple
+climate models providing different values for the variable
+:code:`Temperature|Global Mean`
+.
+
+.. warning::
+
+    Not all :class:`pyam` functions currently support the continuous
+    time or custom columns in a :code:`data` table. Please reach out via the 
+    mailing list or GitHub issues if you are not sure whether your use case
+    is supported.
+
+.. warning::
+
+    A word of warning for adding annotations relating to custom columns:
+    :class:`pyam` drops any data rows which have :code:`NaN` values.
+    Hence, if you are adding meta information to :code:`data`, you need to make
+    sure that you **add a value to every single row**.
+
+    The reason for that implementation is that pandas does not work as expected
+    with :code:`NaN` in many cases
+    (see `here <https://stackoverflow.com/a/18431417>`_ and
+    `here <https://stackoverflow.com/a/13606221>`_).
+    Therefore, it is simpler to remove :code:`NaN`'s to ensure that
+    :class:`pyam` has a clean dataset on which to operate.
+
+The :code:`meta` table
+~~~~~~~~~~~~~~~~~~~~~~
+
+This table is intended for categorisation and quantitative indicators at the
+model-scenario level. Examples in the `SR15`_ context are the warming category 
+('Below 1.5°C', '1.5°C with low overshoot', etc.) and the cumulative
+CO2 emissions until the end of the century.
+
+:class:`pyam` attempts to keep the information in :code:`meta` consistent with
+:code:`data` when performing operations (e.g.,
+:meth:`rename() <pyam.IamDataFrame.rename>`,
+:meth:`append() <pyam.IamDataFrame.append>`).
+See :meth:`utils.merge_meta() <pyam.utils.merge_meta>` for details.
+
+.. note::
+
+    The :code:`meta` table is not intended for annotations of individual
+    data points. If you want to add meta information at this level (e.g., 
+    which stylized climate model provided the variable
+    :code:`Temperature|Global Mean`, or whether a data point is from the 
+    original data source or the result of an operation), this should operate on
+    the :code:`data` table of the :class:`IamDataFrame` using the
+    custom-columns feature (see `custom columns`_ above).
 
 Filtering
 ---------
 
 The |pyam| package provides two methods for filtering scenario data:
 
-An existing `class`:IamDataFrame can be filtered using
-:meth:`pyam.IamDataFrame.filter(col=...) <pyam.IamDataFrame.filter>`,
-where :code:`col` can be any column of the
-:code:`data` table (i.e., `['model', 'scenario', 'region', 'unit', 'year']`)
-or any column of the :code:`meta` table. The returned object is
-a new :class:`pyam.IamDataFrame` instance.
-
-A :class:`pandas.DataFrame` with columns or index :code:`['model', 'scenario']`
-can be filtered by any :code:`meta` columns from a :code:`pyam.IamDataFrame`
-using :func:`pyam.filter_by_meta(data, df, col=..., join_meta=False) <pyam.filter_by_meta>`.
+An existing :class:`IamDataFrame` can be filtered using
+:meth:`filter(col=...) <pyam.IamDataFrame.filter>`,
+where :code:`col` can be any column of the :code:`data` table (i.e.,
+:code:`['model', 'scenario', 'region', 'unit', 'year'/'time']` or any `custom
+columns`_), or a column of the :code:`meta` table. The returned object is
+a new :class:`IamDataFrame` instance.
+
+A :class:`pandas.DataFrame` (:code:`data`) with columns or index
+:code:`['model', 'scenario']` can be filtered by any :code:`meta` columns from
+an :class:`IamDataFrame` (:code:`df`) using 
+:meth:`pyam.filter_by_meta(data, df, col=..., join_meta=False) <pyam.filter_by_meta>`.
 The returned object is a :class:`pandas.DataFrame` down-selected to those
 models-and-scenarios where the :code:`meta` column satisfies the criteria given
 by :code:`col=...` .
diff --git a/pyam/__init__.py b/pyam/__init__.py
index 5300ac7..e412360 100644
--- a/pyam/__init__.py
+++ b/pyam/__init__.py
@@ -1,9 +1,11 @@
+import logging
+
 from pyam.core import *
 from pyam.utils import *
 from pyam.statistics import *
 from pyam.timeseries import *
 from pyam.read_ixmp import *
-from pyam.logger import *
+from pyam.logging import *
 from pyam.run_control import *
 from pyam.iiasa import read_iiasa  # noqa: F401
 
@@ -23,3 +25,5 @@ except Exception:
 from ._version import get_versions
 __version__ = get_versions()['version']
 del get_versions
+
+logging.getLogger(__name__).addHandler(logging.NullHandler())
diff --git a/pyam/core.py b/pyam/core.py
index c912587..d5787f1 100644
--- a/pyam/core.py
+++ b/pyam/core.py
@@ -1,6 +1,7 @@
 import copy
 import importlib
 import itertools
+import logging
 import os
 import sys
 
@@ -15,7 +16,7 @@ except ImportError:
 
 from pyam import plotting
 
-from pyam.logger import logger, adjust_log_level
+from pyam.logging import adjust_log_level
 from pyam.run_control import run_control
 from pyam.utils import (
     write_sheet,
@@ -41,6 +42,8 @@ from pyam.utils import (
 from pyam.read_ixmp import read_ix
 from pyam.timeseries import fill_series
 
+logger = logging.getLogger(__name__)
+
 
 class IamDataFrame(object):
     """This class is a wrapper for dataframes following the IAMC format.
@@ -512,14 +515,14 @@ class IamDataFrame(object):
         idx = _meta_idx(rows)
 
         if len(idx) == 0:
-            logger().info("No scenarios satisfy the criteria")
+            logger.info("No scenarios satisfy the criteria")
             return  # EXIT FUNCTION
 
         # update metadata dataframe
         self._new_meta_column(name)
         self.meta.loc[idx, name] = value
         msg = '{} scenario{} categorized as `{}: {}`'
-        logger().info(msg.format(len(idx), '' if len(idx) == 1 else 's',
+        logger.info(msg.format(len(idx), '' if len(idx) == 1 else 's',
                                  name, value))
 
     def _new_meta_column(self, name):
@@ -555,7 +558,7 @@ class IamDataFrame(object):
 
         n = len(idx)
         if n == 0:
-            logger().info('All scenarios have the required variable `{}`'
+            logger.info('All scenarios have the required variable `{}`'
                           .format(variable))
             return
 
@@ -566,7 +569,7 @@ class IamDataFrame(object):
             self.meta.loc[idx, 'exclude'] = True
             msg += ', marked as `exclude: True` in metadata'
 
-        logger().info(msg.format(n, variable))
+        logger.info(msg.format(n, variable))
         return pd.DataFrame(index=idx).reset_index()
 
     def validate(self, criteria={}, exclude_on_fail=False):
@@ -590,7 +593,7 @@ class IamDataFrame(object):
 
         if not df.empty:
             msg = '{} of {} data points do not satisfy the criteria'
-            logger().info(msg.format(len(df), len(self.data)))
+            logger.info(msg.format(len(df), len(self.data)))
 
             if exclude_on_fail and len(df) > 0:
                 self._exclude_on_fail(df)
@@ -751,7 +754,7 @@ class IamDataFrame(object):
 
         if not len(components):
             msg = 'cannot aggregate variable `{}` because it has no components'
-            logger().info(msg.format(variable))
+            logger.info(msg.format(variable))
 
             return
 
@@ -796,7 +799,7 @@ class IamDataFrame(object):
 
         if len(diff):
             msg = '`{}` - {} of {} rows are not aggregates of components'
-            logger().info(msg.format(variable, len(diff), len(df_variable)))
+            logger.info(msg.format(variable, len(diff), len(df_variable)))
 
             if exclude_on_fail:
                 self._exclude_on_fail(diff.index.droplevel([2, 3, 4]))
@@ -832,7 +835,7 @@ class IamDataFrame(object):
         if not len(subregions):
             msg = 'cannot aggregate variable `{}` to `{}` because it does not'\
                   ' exist in any subregion'
-            logger().info(msg.format(variable, region))
+            logger.info(msg.format(variable, region))
 
             return
 
@@ -843,7 +846,7 @@ class IamDataFrame(object):
 
         # add components at the `region` level, defaults to all variables one
         # level below `variable` that are only present in `region`
-        with adjust_log_level():
+        with adjust_log_level(logger):
             region_df = self.filter(region=region)
 
         rdf_comps = region_df._variable_components(variable, level=None)
@@ -891,7 +894,7 @@ class IamDataFrame(object):
         rows = self._apply_filters(region=region, variable=variable)
         if not rows.any():
             msg = 'variable `{}` does not exist in region `{}`'
-            logger().info(msg.format(variable, region))
+            logger.info(msg.format(variable, region))
             return
 
         df_region, df_subregions = (
@@ -906,7 +909,7 @@ class IamDataFrame(object):
             msg = (
                 '`{}` - {} of {} rows are not aggregates of subregions'
             )
-            logger().info(msg.format(variable, len(diff), len(df_region)))
+            logger.info(msg.format(variable, len(diff), len(df_region)))
 
             if exclude_on_fail:
                 self._exclude_on_fail(diff.index.droplevel([2, 3]))
@@ -958,7 +961,7 @@ class IamDataFrame(object):
         """Assign a selection of scenarios as `exclude: True` in meta"""
         idx = df if isinstance(df, pd.MultiIndex) else _meta_idx(df)
         self.meta.loc[idx, 'exclude'] = True
-        logger().info('{} non-valid scenario{} will be excluded'
+        logger.info('{} non-valid scenario{} will be excluded'
                       .format(len(idx), '' if len(idx) == 1 else 's'))
 
     def filter(self, keep=True, inplace=False, **kwargs):
@@ -995,7 +998,7 @@ class IamDataFrame(object):
 
         idx = _make_index(ret.data)
         if len(idx) == 0:
-            logger().warning('Filtered IamDataFrame is empty!')
+            logger.warning('Filtered IamDataFrame is empty!')
         ret.meta = ret.meta.loc[idx]
         if not inplace:
             return ret
@@ -1177,7 +1180,7 @@ class IamDataFrame(object):
         n_invalid = len(df) - len(idx)
         if n_invalid > 0:
             msg = 'Ignoring {} scenario{} from imported metadata'
-            logger().info(msg.format(n_invalid, 's' if n_invalid > 1 else ''))
+            logger.info(msg.format(n_invalid, 's' if n_invalid > 1 else ''))
 
         if idx.empty:
             raise ValueError('No valid scenarios in imported metadata file!')
@@ -1186,7 +1189,7 @@ class IamDataFrame(object):
 
         # Merge in imported metadata
         msg = 'Importing metadata for {} scenario{} (for total of {})'
-        logger().info(msg.format(len(df), 's' if len(df) > 1 else '',
+        logger.info(msg.format(len(df), 's' if len(df) > 1 else '',
                                  len(self.meta)))
 
         for col in df.columns:
@@ -1342,7 +1345,7 @@ class IamDataFrame(object):
                 # find duplicates
                 where_dup = _map['region'].duplicated(keep=False)
                 dups = _map[where_dup]
-                logger().warning("""
+                logger.warning("""
                 Duplicate entries found for the following regions.
                 Mapping will occur only for the most common instance.
                 {}""".format(dups['region'].unique()))
diff --git a/pyam/iiasa.py b/pyam/iiasa.py
index 2039336..d52015a 100644
--- a/pyam/iiasa.py
+++ b/pyam/iiasa.py
@@ -15,9 +15,9 @@ except ImportError:
     from functools32 import lru_cache
 
 from pyam.core import IamDataFrame
-from pyam.logger import logger
 from pyam.utils import META_IDX, islistable, isstr, pattern_match
 
+logger = logging.getLogger(__name__)
 # quiet this fool
 logging.getLogger('requests').setLevel(logging.WARNING)
 
@@ -139,7 +139,7 @@ class Connection(object):
         # TODO: request the full citation to be added to this metadata intead
         # of linking to the about page
         about = '/'.join([response[idxs['uiUrl']]['value'], '#', 'about'])
-        logger().info(_CITE_MSG.format(name, about))
+        logger.info(_CITE_MSG.format(name, about))
 
         self._connected = name
 
@@ -325,13 +325,13 @@ class Connection(object):
         }
         data = json.dumps(self._query_post_data(**kwargs))
         url = '/'.join([self._base_url, 'runs/bulk/ts'])
-        logger().debug('Querying timeseries data '
+        logger.debug('Querying timeseries data '
                        'from {} with filter {}'.format(url, data))
         r = requests.post(url, headers=headers, data=data)
         _check_response(r)
         # refactor returned json object to be castable to an IamDataFrame
         df = pd.read_json(r.content, orient='records')
-        logger().debug('Response size is {0} bytes, '
+        logger.debug('Response size is {0} bytes, '
                        '{1} records'.format(len(r.content), len(df)))
         columns = ['model', 'scenario', 'variable', 'unit',
                    'region', 'year', 'value', 'time', 'meta',
diff --git a/pyam/logger.py b/pyam/logger.py
deleted file mode 100644
index 4ed5432..0000000
--- a/pyam/logger.py
+++ /dev/null
@@ -1,25 +0,0 @@
-import logging
-from contextlib import contextmanager
-
-# globally accessible logger
-_LOGGER = None
-
-
-def logger():
-    """Access global logger"""
-    global _LOGGER
-    if _LOGGER is None:
-        logging.basicConfig()
-        _LOGGER = logging.getLogger()
-        _LOGGER.setLevel('INFO')
-    return _LOGGER
-
-
-@contextmanager
-def adjust_log_level(level='ERROR'):
-    """Context manager to change log level"""
-    _logger = logger()
-    old_level = _logger.getEffectiveLevel()
-    _logger.setLevel(level)
-    yield
-    _logger.setLevel(old_level)
diff --git a/pyam/logging.py b/pyam/logging.py
new file mode 100644
index 0000000..effcc56
--- /dev/null
+++ b/pyam/logging.py
@@ -0,0 +1,10 @@
+from contextlib import contextmanager
+
+
+@contextmanager
+def adjust_log_level(logger, level='ERROR'):
+    """Context manager to change log level"""
+    old_level = logger.getEffectiveLevel()
+    logger.setLevel(level)
+    yield
+    logger.setLevel(old_level)
diff --git a/pyam/plotting.py b/pyam/plotting.py
index 97d3472..93c4136 100644
--- a/pyam/plotting.py
+++ b/pyam/plotting.py
@@ -1,4 +1,5 @@
 import itertools
+import logging
 import warnings
 
 import matplotlib.pyplot as plt
@@ -16,7 +17,6 @@ try:
 except ImportError:
     from functools32 import lru_cache
 
-from pyam.logger import logger
 from pyam.run_control import run_control
 from pyam.utils import requires_package, IAMC_IDX, SORT_IDX, isstr
 # TODO: this is a hotfix for changes in pandas 0.25.0, per discussions on the
@@ -24,6 +24,8 @@ from pyam.utils import requires_package, IAMC_IDX, SORT_IDX, isstr
 # standard feature in their library
 from pyam._style import _get_standard_colors
 
+logger = logging.getLogger(__name__)
+
 # line colors, markers, and styles that are cycled through when not
 # explicitly declared
 _DEFAULT_PROPS = None
@@ -805,7 +807,7 @@ def line_plot(df, x='year', y='value', ax=None, legend=None, title=True,
 
 def _add_legend(ax, handles, labels, legend):
     if legend is None and len(labels) >= MAX_LEGEND_LABELS:
-        logger().info('>={} labels, not applying legend'.format(
+        logger.info('>={} labels, not applying legend'.format(
             MAX_LEGEND_LABELS))
     else:
         legend = {} if legend in [True, None] else legend
diff --git a/pyam/timeseries.py b/pyam/timeseries.py
index ef2d8ed..83f724f 100644
--- a/pyam/timeseries.py
+++ b/pyam/timeseries.py
@@ -1,9 +1,10 @@
 # -*- coding: utf-8 -*-
-
+import logging
 import numpy as np
-from pyam.logger import logger
 from pyam.utils import isstr, to_int
 
+logger = logging.getLogger(__name__)
+
 # %%
 
 
@@ -51,11 +52,11 @@ def cumulative(x, first_year, last_year):
     # if the timeseries does not cover the range `[first_year, last_year]`,
     # return nan to avoid erroneous aggregation
     if min(x.index) > first_year:
-        logger().warning('the timeseries `{}` does not start by {}'.format(
+        logger.warning('the timeseries `{}` does not start by {}'.format(
             x.name or x, first_year))
         return np.nan
     if max(x.index) < last_year:
-        logger().warning('the timeseries `{}` does not extend until {}'
+        logger.warning('the timeseries `{}` does not extend until {}'
                          .format(x.name or x, last_year))
         return np.nan
 
diff --git a/pyam/utils.py b/pyam/utils.py
index 3d725f3..75b9c18 100644
--- a/pyam/utils.py
+++ b/pyam/utils.py
@@ -1,5 +1,6 @@
 import os
 import itertools
+import logging
 import string
 import six
 import re
@@ -17,7 +18,7 @@ try:
 except ImportError:
     pass
 
-from pyam.logger import logger
+logger = logging.getLogger(__name__)
 
 # common indicies
 META_IDX = ['model', 'scenario']
@@ -117,7 +118,7 @@ def read_file(fname, *args, **kwargs):
     if not isstr(fname):
         raise ValueError('reading multiple files not supported, '
                          'please use `pyam.IamDataFrame.append()`')
-    logger().info('Reading `{}`'.format(fname))
+    logger.info('Reading `{}`'.format(fname))
     format_kwargs = {}
     # extract kwargs that are intended for `format_data`
     for c in [i for i in IAMC_IDX + ['year', 'time', 'value'] if i in kwargs]:
@@ -185,7 +186,7 @@ def format_data(df, **kwargs):
     df.rename(columns={c: str(c).lower() for c in str_cols}, inplace=True)
 
     if 'notes' in df.columns:  # this came from the database
-        logger().info('Ignoring notes column in dataframe')
+        logger.info('Ignoring notes column in dataframe')
         df.drop(columns='notes', inplace=True)
         col = df.columns[0]  # first column has database copyright notice
         df = df[~df[col].str.contains('database', case=False)]


Test patch:
diff --git a/tests/test_feature_aggregate.py b/tests/test_feature_aggregate.py
index 506cd40..31f4cd3 100644
--- a/tests/test_feature_aggregate.py
+++ b/tests/test_feature_aggregate.py
@@ -90,7 +90,7 @@ def test_check_internal_consistency_no_world_for_variable(
     test_df = check_aggregate_df.filter(
         variable='Emissions|CH4', region='World', keep=False
     )
-    caplog.set_level(logging.INFO)
+    caplog.set_level(logging.INFO, logger="pyam.core")
     test_df.check_internal_consistency()
     warn_idx = caplog.messages.index("variable `Emissions|CH4` does not exist "
                                      "in region `World`")
@@ -306,6 +306,7 @@ def test_aggregate_region_components_handling(check_aggregate_regional_df,
 
 def test_check_aggregate_region_no_world(check_aggregate_regional_df, caplog):
     test_df = check_aggregate_regional_df.filter(region='World', keep=False)
+    caplog.set_level(logging.INFO, logger="pyam.core")
     test_df.check_aggregate_region('Emissions|N2O', region='World')
     warn_idx = caplog.messages.index("variable `Emissions|N2O` does not exist "
                                      "in region `World`")
diff --git a/tests/test_logger.py b/tests/test_logger.py
index 3c52cf0..120d393 100644
--- a/tests/test_logger.py
+++ b/tests/test_logger.py
@@ -1,18 +1,38 @@
+import logging
 import sys
 
-from pyam.logger import logger, adjust_log_level
 
+def test_logger_namespacing(test_df, caplog):
+    with caplog.at_level(logging.INFO, logger="pyam.core"):
+        test_df.filter(model="junk")
 
-def test_context_adjust_log_level():
-    assert logger().getEffectiveLevel() == 20
-    with adjust_log_level():
-        assert logger().getEffectiveLevel() == 40
-    assert logger().getEffectiveLevel() == 20
+    assert caplog.record_tuples == [(
+        "pyam.core",  # namespacing
+        logging.WARNING,  # level
+        "Filtered IamDataFrame is empty!",  # message
+    )]
 
 
-def test_adjusting_level_for_not_initialized_logger():
-    # de-initialize logger to simulate adjust_log_level called before logger
-    pyam_logger = sys.modules['pyam.logger']
-    pyam_logger._LOGGER = None
-    with adjust_log_level():
-        pass
+def test_adjusting_logger_level(test_df, caplog):
+    def throw_warning():
+        logging.warning("This is a root warning")
+
+    with caplog.at_level(logging.INFO, logger="pyam.core"):
+        test_df.filter(model="junk")
+        throw_warning()
+
+    assert caplog.record_tuples == [
+        ("pyam.core", logging.WARNING, "Filtered IamDataFrame is empty!"),
+        ("root", logging.WARNING, "This is a root warning"),
+    ]
+
+    caplog.clear()
+    with caplog.at_level(logging.ERROR, logger="pyam.core"):
+        test_df.filter(model="junk")
+        throw_warning()
+
+    # only the root warning should come through now i.e. we can silence pyam
+    # without silencing everything
+    assert caplog.record_tuples == [
+        ("root", logging.WARNING, "This is a root warning"),
+    ]
diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py
index decda35..90262b3 100644
--- a/tests/test_timeseries.py
+++ b/tests/test_timeseries.py
@@ -1,9 +1,9 @@
 #!/usr/bin/env python3
 # -*- coding: utf-8 -*-
+import logging
 
 import numpy as np
 import pandas as pd
-from pyam.logger import logger
 from pyam import fill_series, cumulative, cross_threshold, to_int
 import pytest
 
@@ -32,11 +32,11 @@ def test_cumulative():
 
 def test_cumulative_out_of_range():
     # set logger level to exclude warnings in unit test output
-    logger().setLevel('ERROR')
+    logging.getLogger("pyam").setLevel('ERROR')
     # note that the series is not order and the index is defined as float
     y = pd.Series(data=[np.nan, 1, 3, 1], index=[2002., 2005., 2007., 2013.])
     assert cumulative(y, 2008, 2015) is np.nan
-    logger().setLevel('NOTSET')
+    logging.getLogger("pyam").setLevel('NOTSET')
 
 
 def test_cross_treshold():