From b36df762c9420bd9fb36caef6f1907ac1b469826 Mon Sep 17 00:00:00 2001 From: Tom Augspurger Date: Wed, 6 Jan 2016 19:37:47 -0600 Subject: [PATCH] ENH/API: Series.rename and NDFrame.rename_axis Overloading the APIs of Series.rename and NDFramed.rename_axis for method chaining. --- doc/source/basics.rst | 9 ++ doc/source/dsintro.rst | 11 ++ doc/source/whatsnew/v0.18.0.txt | 21 ++++ pandas/core/common.py | 4 + pandas/core/generic.py | 138 +++++++++++++++++++++++-- pandas/core/series.py | 21 ++++ pandas/tests/series/test_alter_axes.py | 23 +++++ pandas/tests/test_common.py | 11 ++ pandas/tests/test_generic.py | 59 ++++++++++- 9 files changed, 287 insertions(+), 10 deletions(-) diff --git a/doc/source/basics.rst b/doc/source/basics.rst index 68ab07bc3df91..d0469078aa3e9 100644 --- a/doc/source/basics.rst +++ b/doc/source/basics.rst @@ -1170,6 +1170,15 @@ The :meth:`~DataFrame.rename` method also provides an ``inplace`` named parameter that is by default ``False`` and copies the underlying data. Pass ``inplace=True`` to rename the data in place. +.. versionadded:: 0.18.0 + +Finally, :meth:`~Series.rename` also accepts a scalar or list-like +for altering the ``Series.name`` attribute. + +.. ipython:: python + + s.rename("scalar-name") + .. _basics.rename_axis: The Panel class has a related :meth:`~Panel.rename_axis` class which can rename diff --git a/doc/source/dsintro.rst b/doc/source/dsintro.rst index 11c743d6ef047..d67e31b576654 100644 --- a/doc/source/dsintro.rst +++ b/doc/source/dsintro.rst @@ -214,6 +214,17 @@ Series can also have a ``name`` attribute: The Series ``name`` will be assigned automatically in many cases, in particular when taking 1D slices of DataFrame as you will see below. +.. versionadded:: 0.18.0 + +You can rename a Series with the :meth:`pandas.Series.rename` method. + +.. ipython:: python + + s2 = s.rename("different") + s2.name + +Note that ``s`` and ``s2`` refer to different objects. + .. _basics.dataframe: DataFrame diff --git a/doc/source/whatsnew/v0.18.0.txt b/doc/source/whatsnew/v0.18.0.txt index ec002fae3b4b9..6eb478e5634b8 100644 --- a/doc/source/whatsnew/v0.18.0.txt +++ b/doc/source/whatsnew/v0.18.0.txt @@ -104,6 +104,27 @@ And multiple aggregations r.agg({'A' : ['mean','std'], 'B' : ['mean','std']}) +.. _whatsnew_0180.enhancements.rename: + +``Series.rename`` and ``NDFrame.rename_axis`` can now take a scalar or list-like +argument for altering the Series or axis *name*, in addition to their old behaviors of altering labels. (:issue:`9494`, :issue:`11965`) + +.. ipython: python + + s = pd.Series(np.random.randn(10)) + s.rename('newname') + +.. ipython: python + + df = pd.DataFrame(np.random.randn(10, 2)) + (df.rename_axis("indexname") + .rename_axis("columns_name", axis="columns")) + +The new functionality works well in method chains. +Previously these methods only accepted functions or dicts mapping a *label* to a new label. +This continues to work as before for function or dict-like values. + + .. _whatsnew_0180.enhancements.rangeindex: Range Index diff --git a/pandas/core/common.py b/pandas/core/common.py index da20fd75ceb35..70c02c5632d80 100644 --- a/pandas/core/common.py +++ b/pandas/core/common.py @@ -2456,6 +2456,10 @@ def is_list_like(arg): not isinstance(arg, compat.string_and_binary_types)) +def is_dict_like(arg): + return hasattr(arg, '__getitem__') and hasattr(arg, 'keys') + + def is_named_tuple(arg): return isinstance(arg, tuple) and hasattr(arg, '_fields') diff --git a/pandas/core/generic.py b/pandas/core/generic.py index 7ae00fb501614..1d657171acb10 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -546,13 +546,16 @@ def swaplevel(self, i, j, axis=0): _shared_docs['rename'] = """ Alter axes input function or functions. Function / dict values must be unique (1-to-1). Labels not contained in a dict / Series will be left - as-is. + as-is. Alternatively, change ``Series.name`` with a scalar + value (Series only). Parameters ---------- - %(axes)s : dict-like or function, optional - Transformation to apply to that axis values - + %(axes)s : scalar, list-like, dict-like or function, optional + Scalar or list-like will alter the ``Series.name`` attribute, + and raise on DataFrame or Panel. + dict-like or functions are transformations to apply to + that axis' values copy : boolean, default True Also copy underlying data inplace : boolean, default False @@ -562,6 +565,43 @@ def swaplevel(self, i, j, axis=0): Returns ------- renamed : %(klass)s (new object) + + See Also + -------- + pandas.NDFrame.rename_axis + + Examples + -------- + >>> s = pd.Series([1, 2, 3]) + >>> s + 0 1 + 1 2 + 2 3 + dtype: int64 + >>> s.rename("my_name") # scalar, changes Series.name + 0 1 + 1 2 + 2 3 + Name: my_name, dtype: int64 + >>> s.rename(lambda x: x ** 2) # function, changes labels + 0 1 + 1 2 + 4 3 + dtype: int64 + >>> s.rename({1: 3, 2: 5}) # mapping, changes labels + 0 1 + 3 2 + 5 3 + dtype: int64 + >>> df = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]}) + >>> df.rename(2) + ... + TypeError: 'int' object is not callable + >>> df.rename(index=str, columns={"A": "a", "B": "c"}) + a c + 0 1 4 + 1 2 5 + 2 3 6 """ @Appender(_shared_docs['rename'] % dict(axes='axes keywords for this' @@ -617,12 +657,15 @@ def f(x): def rename_axis(self, mapper, axis=0, copy=True, inplace=False): """ Alter index and / or columns using input function or functions. + A scaler or list-like for ``mapper`` will alter the ``Index.name`` + or ``MultiIndex.names`` attribute. + A function or dict for ``mapper`` will alter the labels. Function / dict values must be unique (1-to-1). Labels not contained in a dict / Series will be left as-is. Parameters ---------- - mapper : dict-like or function, optional + mapper : scalar, list-like, dict-like or function, optional axis : int or string, default 0 copy : boolean, default True Also copy underlying data @@ -631,11 +674,88 @@ def rename_axis(self, mapper, axis=0, copy=True, inplace=False): Returns ------- renamed : type of caller + + See Also + -------- + pandas.NDFrame.rename + pandas.Index.rename + + Examples + -------- + >>> df = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]}) + >>> df.rename_axis("foo") # scalar, alters df.index.name + A B + foo + 0 1 4 + 1 2 5 + 2 3 6 + >>> df.rename_axis(lambda x: 2 * x) # function: alters labels + A B + 0 1 4 + 2 2 5 + 4 3 6 + >>> df.rename_axis({"A": "ehh", "C": "see"}, axis="columns") # mapping + ehh B + 0 1 4 + 1 2 5 + 2 3 6 + """ + is_scalar_or_list = ( + (not com.is_sequence(mapper) and not callable(mapper)) or + (com.is_list_like(mapper) and not com.is_dict_like(mapper)) + ) + + if is_scalar_or_list: + return self._set_axis_name(mapper, axis=axis) + else: + axis = self._get_axis_name(axis) + d = {'copy': copy, 'inplace': inplace} + d[axis] = mapper + return self.rename(**d) + + def _set_axis_name(self, name, axis=0): """ - axis = self._get_axis_name(axis) - d = {'copy': copy, 'inplace': inplace} - d[axis] = mapper - return self.rename(**d) + Alter the name or names of the axis, returning self. + + Parameters + ---------- + name : str or list of str + Name for the Index, or list of names for the MultiIndex + axis : int or str + 0 or 'index' for the index; 1 or 'columns' for the columns + + Returns + ------- + renamed : type of caller + + See Also + -------- + pandas.DataFrame.rename + pandas.Series.rename + pandas.Index.rename + + Examples + -------- + >>> df._set_axis_name("foo") + A + foo + 0 1 + 1 2 + 2 3 + >>> df.index = pd.MultiIndex.from_product([['A'], ['a', 'b', 'c']]) + >>> df._set_axis_name(["bar", "baz"]) + A + bar baz + A a 1 + b 2 + c 3 + """ + axis = self._get_axis_number(axis) + idx = self._get_axis(axis).set_names(name) + + renamed = self.copy(deep=True) + renamed.set_axis(axis, idx) + return renamed # ---------------------------------------------------------------------- # Comparisons diff --git a/pandas/core/series.py b/pandas/core/series.py index 68ae58737916b..8c878fe6389cd 100644 --- a/pandas/core/series.py +++ b/pandas/core/series.py @@ -8,6 +8,7 @@ import types import warnings +from collections import MutableMapping from numpy import nan, ndarray import numpy as np @@ -1109,6 +1110,20 @@ def to_sparse(self, kind='block', fill_value=None): return SparseSeries(self, kind=kind, fill_value=fill_value).__finalize__(self) + def _set_name(self, name, inplace=False): + ''' + Set the Series name. + + Parameters + ---------- + name : str + inplace : bool + whether to modify `self` directly or return a copy + ''' + ser = self if inplace else self.copy() + ser.name = name + return ser + # ---------------------------------------------------------------------- # Statistics, overridden ndarray methods @@ -2313,6 +2328,12 @@ def align(self, other, join='outer', axis=None, level=None, copy=True, @Appender(generic._shared_docs['rename'] % _shared_doc_kwargs) def rename(self, index=None, **kwargs): + is_scalar_or_list = ( + (not com.is_sequence(index) and not callable(index)) or + (com.is_list_like(index) and not isinstance(index, MutableMapping)) + ) + if is_scalar_or_list: + return self._set_name(index, inplace=kwargs.get('inplace')) return super(Series, self).rename(index=index, **kwargs) @Appender(generic._shared_docs['reindex'] % _shared_doc_kwargs) diff --git a/pandas/tests/series/test_alter_axes.py b/pandas/tests/series/test_alter_axes.py index 14abad1fac599..0bbb96d3e1d5d 100644 --- a/pandas/tests/series/test_alter_axes.py +++ b/pandas/tests/series/test_alter_axes.py @@ -55,6 +55,29 @@ def test_rename(self): renamed = renamer.rename({}) self.assertEqual(renamed.index.name, renamer.index.name) + def test_rename_set_name(self): + s = Series(range(4), index=list('abcd')) + for name in ['foo', ['foo'], ('foo',)]: + result = s.rename(name) + self.assertEqual(result.name, name) + self.assert_numpy_array_equal(result.index.values, s.index.values) + self.assertTrue(s.name is None) + + def test_rename_set_name_inplace(self): + s = Series(range(3), index=list('abc')) + for name in ['foo', ['foo'], ('foo',)]: + s.rename(name, inplace=True) + self.assertEqual(s.name, name) + self.assert_numpy_array_equal(s.index.values, + np.array(['a', 'b', 'c'])) + + def test_set_name(self): + s = Series([1, 2, 3]) + s2 = s._set_name('foo') + self.assertEqual(s2.name, 'foo') + self.assertTrue(s.name is None) + self.assertTrue(s is not s2) + def test_rename_inplace(self): renamer = lambda x: x.strftime('%Y%m%d') expected = renamer(self.ts.index[0]) diff --git a/pandas/tests/test_common.py b/pandas/tests/test_common.py index 3fd8ee5879ff8..d24e1eab1cea8 100644 --- a/pandas/tests/test_common.py +++ b/pandas/tests/test_common.py @@ -631,6 +631,17 @@ def test_is_list_like(): assert not com.is_list_like(f) +def test_is_dict_like(): + passes = [{}, {'A': 1}, pd.Series([1])] + fails = ['1', 1, [1, 2], (1, 2), range(2), pd.Index([1])] + + for p in passes: + assert com.is_dict_like(p) + + for f in fails: + assert not com.is_dict_like(f) + + def test_is_named_tuple(): passes = (collections.namedtuple('Test', list('abc'))(1, 2, 3), ) fails = ((1, 2, 3), 'a', Series({'pi': 3.14})) diff --git a/pandas/tests/test_generic.py b/pandas/tests/test_generic.py index 7cb0dd249effd..be4bb25aef5fe 100644 --- a/pandas/tests/test_generic.py +++ b/pandas/tests/test_generic.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # pylint: disable-msg=E1101,W0612 +from operator import methodcaller import nose import numpy as np from numpy import nan @@ -630,6 +631,33 @@ def test_rename_mi(self): [("A", x) for x in ["a", "B", "c"]])) s.rename(str.lower) + def test_set_axis_name(self): + s = Series([1, 2, 3], index=['a', 'b', 'c']) + funcs = ['rename_axis', '_set_axis_name'] + name = 'foo' + for func in funcs: + result = methodcaller(func, name)(s) + self.assertTrue(s.index.name is None) + self.assertEqual(result.index.name, name) + + def test_set_axis_name_mi(self): + s = Series([11, 21, 31], index=MultiIndex.from_tuples( + [("A", x) for x in ["a", "B", "c"]], + names=['l1', 'l2']) + ) + funcs = ['rename_axis', '_set_axis_name'] + for func in funcs: + result = methodcaller(func, ['L1', 'L2'])(s) + self.assertTrue(s.index.name is None) + self.assertEqual(s.index.names, ['l1', 'l2']) + self.assertTrue(result.index.name is None) + self.assertTrue(result.index.names, ['L1', 'L2']) + + def test_set_axis_name_raises(self): + s = pd.Series([1]) + with tm.assertRaises(ValueError): + s._set_axis_name(name='a', axis=1) + def test_get_numeric_data_preserve_dtype(self): # get the numeric data @@ -1068,6 +1096,36 @@ def test_rename_mi(self): ], index=MultiIndex.from_tuples([("A", x) for x in ["a", "B", "c"]])) df.rename(str.lower) + def test_set_axis_name(self): + df = pd.DataFrame([[1, 2], [3, 4]]) + funcs = ['_set_axis_name', 'rename_axis'] + for func in funcs: + result = methodcaller(func, 'foo')(df) + self.assertTrue(df.index.name is None) + self.assertEqual(result.index.name, 'foo') + + result = methodcaller(func, 'cols', axis=1)(df) + self.assertTrue(df.columns.name is None) + self.assertEqual(result.columns.name, 'cols') + + def test_set_axis_name_mi(self): + df = DataFrame( + np.empty((3, 3)), + index=MultiIndex.from_tuples([("A", x) for x in list('aBc')]), + columns=MultiIndex.from_tuples([('C', x) for x in list('xyz')]) + ) + + level_names = ['L1', 'L2'] + funcs = ['_set_axis_name', 'rename_axis'] + for func in funcs: + result = methodcaller(func, level_names)(df) + self.assertEqual(result.index.names, level_names) + self.assertEqual(result.columns.names, [None, None]) + + result = methodcaller(func, level_names, axis=1)(df) + self.assertEqual(result.columns.names, ["L1", "L2"]) + self.assertEqual(result.index.names, [None, None]) + def test_nonzero_single_element(self): # allow single item via bool method @@ -1958,7 +2016,6 @@ def test_pipe_panel(self): with tm.assertRaises(ValueError): result = wp.pipe((f, 'y'), x=1, y=1) - if __name__ == '__main__': nose.runmodule(argv=[__file__, '-vvs', '-x', '--pdb', '--pdb-failure'], exit=False)