# Dicts and DataFrames
- Src:
https://github.com/westurner/pythondictsanddataframes/blob/master/dicts_and_dataframes.ipynb
- Binder:
https://mybinder.org/v2/gh/westurner/pythondictsanddataframes/master?filepath=dicts_and_dataframes.ipynb
  - (interactive Jupyter Notebook hosted by https://mybinder.org/ )

## Question / Problem / Challenge
Question: Now that dicts are insertion-ordered (since Python 3.6), can we
lookup items by integer-location?
- As of CPython 3.10: No; the public Python `dict` API neither:
  - (1) offers any method to access keys, values, or items by
integer-location; nor
  - (2) exposes anything from the underlying C code like `dict._array`
which could be used for such a method. `dict._array` would be considered an
implementation detail that could be different in Python versions and
implementations

How could lookup of `dict` keys, values, and items *by integer-location* be
implemented?
    - This is the subject of this document.

## Background
- The CPython dict is an insertion-ordered Hashmap:
  https://docs.python.org/3/library/stdtypes.html#mapping-types-dict
  - https://github.com/python/cpython/blob/master/Objects/dictobject.c
  - https://github.com/python/cpython/blob/master/Objects/odictobject.c
- The Pandas Series and DataFrames are insertion-ordered columnar data
structures
  - https://pandas.pydata.org/pandas-docs/stable/reference/series.html
    -
https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.Series.html#pandas.Series
    - https://github.com/pandas-dev/pandas/blob/master/pandas/core/series.py
  - https://pandas.pydata.org/pandas-docs/stable/reference/frame.html
    -
https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.html

    - https://github.com/pandas-dev/pandas/blob/master/pandas/core/frame.py

```python
import itertools
import pandas as pd
import pytest
import random
try:
    display  # IPython
except NameError:
    def display(*args): print(args)
pd.set_option('display.notebook_repr_html', False)
```

### CPython dict basics

```python
odict = {'a':'A', 'b':'B', 'c':'C', 'd': 'D'}
odict = dict(a='A', b='B', c='C', d='D')
odict = dict((x, x.upper()) for x in 'abcd')  # list comprehension
odict = {x:x.upper() for x in 'abcd'}         # dict comprehension
odict = dict.fromkeys('abcd')
[odict.__setitem__(x, x.upper()) for x in 'abcd']
display(odict)
assert list(odict.keys()) == list('abcd') == ['a', 'b', 'c', 'd']
assert random.seed(1) or list(odict.keys()) == random.seed(2**10) or
list(odict.keys())
assert list(odict.values()) == list('ABCD')
assert list(odict.items()) == [('a', 'A'), ('b', 'B'), ('c', 'C'), ('d',
'D')]
```

    {'a': 'A', 'b': 'B', 'c': 'C', 'd': 'D'}

`itertools.islice(dict.items())` is suboptimal for a number of cases
because we don't need to iterate through the items at the beginning: we
could directly address the underlying array.

```python

# This would not call next() unnecessarily:
with pytest.raises(AttributeError): # 'dict' object has no attribute
'_array'
    odict._array[3]   # _array[3]
```

## Possible Solutions

### No changes to CPython

#### Make an unnecessary copy of the whole dict in order to only take(3)

```python
assert list(odict.items())[3] == ('d', 'D')
```

#### Call `itertools.islice(dict.items())`
- Does this call `next()`, `next()`, `next()`, `next()` unnecessarily?
- `itertools.islice(dict.items())` is suboptimal for a number of cases
because we don't need to iterate through the items at the beginning.
Directly addressing the underlying array would be much faster but unlikely
to happen because the underlying array is an implementation detail.

```python
list(itertools.islice(odict.items(), 0))
```


    []


```python
list(itertools.islice(odict.items(), 3))
```


    [('a', 'A'), ('b', 'B'), ('c', 'C')]


```python
list(itertools.islice(odict.items(), 3, 3+1))
```


    [('d', 'D')]


### Change CPython

#### Expose something like `dict._array`
- Again, the underlying array is a \[CPython,] implementation detail
- So, there must be methods that provide access to the [(key, item),] data
that hide the implementation details.

#### Overload `dict.__getitem__` (`dict[]`)

`dict[]` (`dict.__getitem__`) is already used for lookup by key value, so
`dict[3]` could either be lookup by value or lookup by integer-location:
which would be dangerously abiguous at runtime and frustratingly vague to
review. (See also the note below regarding the fate of the Pandas
`.ix.__getitem__` method).

#### Make all iterators subscriptable: define `iterator.__getitem__`

`dict.items()[3]` fails with a `TypeError: 'dict_items' object is not
subscriptable`:`dict.items()` returns a view (instead of an unnecessarily
copied list like in Python 2) which is not subscriptable.

Could we define `iterator.__getitem__` such that:

```python
obj = list('abcd')
iter(obj)[3] => islice(obj, 3, 3+1)
iter(obj)[3:4] => islice(obj, 3, 4)
iter(obj)[0:4:2] => islice(obj, 1, 4, 2)
```

- This would require a change to the python grammar.
- This would result in implicit iteration/traversal, which may be
destructive and opaquely resource-prohibitive.
- This would not break existing code.

#### Add `dict.getkey(index)` and `dict.getitem(index)`
```python
def getkey(self: dict, index: int):
    pass

def getitem(self: dict, index: int):
    pass
```

- This does not support slices
- This still has to unnecessarily iterate with `islice` without something
like `dict._array`

#### Make `dict.keys()`, `dict.values()`, and `dict.items()` subscriptable

```python
obj = dict.fromkeys('abcd')
obj.keys()[3] => next(islice(obj, 3))
```

- This would require a change to the python grammar.
- This would be a special case; and then we'd all be asking for
subscriptable iterators, too.
- This would not break existing code.

```python
# iterator[3] does not call islice(iterator, 3):
with pytest.raises(TypeError): # 'dict_keys' object is not subscriptable
    odict.keys()[3]
with pytest.raises(TypeError): # 'dict_values' object is not subscriptable
    odict.values()[3]
with pytest.raises(TypeError): # 'dict_items' object is not subscriptable
    odict.items()[3]
```

#### Define `dict.keys.__getitem__` and handle slices

```python
obj = dict.fromkeys('abcd')
obj.keys[3] => obj._array[3]
```

- This would be backward incompatible because `dict.keys()` is a method and
not a property.
  To not break all existing code, it would have to be:
  ```python
  obj = dict.fromkeys('abcd')
  obj.keys()[3] => obj._array[3]
  ```

  Which brings us back to the previous question.
- Would this be confusing to newcomers? Why don't other iterators work that
way?

#### Copy the Pandas Series / DataFrame `.iloc` API?

##### How is the Pandas DataFrame API at all relevant to dicts?
- Pandas DataFrames (and Series, which have an index and one column; more
like dicts) support key/value lookup just like dicts
- IIUC, OP is asking for lookup by integer-location; which is supported by
`DataFrame.iloc` (and `Series.iloc`)
- Pandas already handles the presented use cases with an `.iloc` method
that handles slices and tuples (and callables).
  - Pandas [used to have `.ix`](
https://pandas.pydata.org/pandas-docs/version/0.23.4/generated/pandas.DataFrame.ix.html),
which was:
    > A primarily label-location based indexer, with integer position
fallback.
    >
    > Warning: Starting in 0.20.0, the .ix indexer is deprecated, in favor
of the more strict .iloc and .loc indexers.
- The Pandas API is now also implemented by Dask, Modin, CuDF.
  It may be the most widely-used DataFrame API.
- Granted, a DataFrame is not the same as an OrderedHashmap; because we
often find that data is multi-columnar and we usually don't want to have to
`lookup`/`seek()` to the n-th field of each hashmap value.
  But we do want indexed data with fast sequential reads and the option to
return one or more items by integer-location.

##### Add an `.iloc` property with a `__getitem__` to `dict` that returns
just the key (slice) or the `(key, item)` (slice)
```python
obj = dict.fromkeys('abcd')
obj.iloc[3] => obj._array[3][0]  # key-only? or
obj.iloc[3] => obj._array[3]     # (key, item)?
```

##### Add an `.iloc` property to the `.keys`, `.values`, and `.items`
methods?

```python
obj.keys.iloc[3] => obj._array[3][0]
obj.values.iloc[3] => obj._array[3][1]
obj.items.iloc[3] => obj._array[3]
```

- Is there any precedent for adding a property to a method in the CPython
standard library?
- It can be done with *slow* init-time binding and/or metaclassery.
  - See: `IlocIndexer` below

#### Add `.keys_iloc()`, `.values_iloc()`, and `.items_iloc()` to `dict`

- Does not require binding `IlocIndexer` to `.keys.iloc`, `.values.iloc`,
and `.items.iloc` at `dict.__init__` time or metaclassery.
- Supports slices

```python
def _dict_view_islice(iterable, start: int=None, stop: int=None, step:
int=None):
    # This implementation calls islice()
    # which, AFAIU, calls next() a bunch of times unnecessarily
    _slice = slice(start, stop, step) if not isinstance(start, slice) else
start
    return itertools.islice(iterable, _slice.start, _slice.stop,
_slice.step)

def keys_iloc(self: dict, start: int=None, stop: int=None, step: int=None):
    return _dict_view_islice(self, start, stop, step)

def values_iloc(self: dict, start: int=None, stop: int=None, step:
int=None):
    return _dict_view_islice(self.values(), start, stop, step)

def items_iloc(self: dict, start: int=None, stop: int=None, step: int=None):
    return _dict_view_islice(self.items(), start, stop, step)

assert next(keys_iloc(odict, 3)) == 'd'
assert next(values_iloc(odict, 3)) == 'D'
assert next(items_iloc(odict, 3)) == ('d', 'D')
assert list(items_iloc(odict, 0, 4, 2)) == [('a', 'A'), ('c', 'C')]
assert list(items_iloc(odict, slice(0, 4, 2))) == [('a', 'A'), ('c', 'C')]

# ...

def _dict_view_ilookup(obj: dict, start: int=None, stop: int=None, step:
int=None):
    # This implementation would access the underlying dict array values
directly
    # (without calling iter() and next() on dict views)
    _slice = slice(start, stop, step) if not isinstance(start, slice) else
start
    return obj._array[_slice]
```

### Support passing slices to methods so that `.keys(1:2)` or
`.keys_iloc(1:2)` works

- AFAIU, this would require a change to the Python grammar.

### Add `start`, `stop`, and `step` arguments to `.keys()`, were `start`
can optionally be a `slice()`

- This would not be break any existing code, but alone would not support
normal slice syntax.

```python
class IlocIndexer:
    def __init__(self, obj):
        self.obj = obj

    def __getitem__(self, obj):
        if isinstance(obj, int):
            _slice = slice(obj, obj+1)
        elif isinstance(obj, slice):
            _slice = obj
        else:
            _slice = slice(obj)
        return itertools.islice(self.obj(), _slice.start, _slice.stop,
_slice.step)
        # return self.obj._array[_slice]

class Dict(dict):
    def keys(self):
        return super().keys()

    def values(self):
        return super().values()

    def items(self):
        return super().items()

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.keys.__func__.iloc = IlocIndexer(self.keys)
        self.values.__func__.iloc = IlocIndexer(self.values)
        self.items.__func__.iloc = IlocIndexer(self.items)

d = Dict.fromkeys('abcd')
d = Dict(odict)
assert list(d.keys()) == list('abcd')
assert list(d.keys.iloc[0]) == list('a')
assert list(d.keys.iloc[2:4]) == list('cd')
assert list(d.values()) == list('ABCD')
assert list(d.values.iloc[0]) == list('A')
assert list(d.values.iloc[2:4]) == list('CD')
assert list(d.items()) == [(x,x.upper()) for x in 'abcd']
assert list(d.items.iloc[0]) == [('a', 'A')]
assert list(d.items.iloc[2:4]) == [('c', 'C'), ('d', 'D')]
```

## If you give a mouse a cookie
**Once we've added the ability to lookup from (now ordered) dicts by
integer-location, what else will people ask for?**

Probably features from `pandas.Series`, `pandas.DataFrame`,
`pandas.MultiIndex`.
- Slices
- Multidimensional lookup
- Lookup of integer-locations

### Slices

```python
df = pd.DataFrame.from_dict(odict, orient='index')
display(df)
df.columns = [0]
assert list(df.iloc[2:4]) == [0]
df.columns = ['letter']
assert list(df.iloc[2:4]) == ['letter']
```

       0
    a  A
    b  B
    c  C
    d  D

### Multidimensional lookup: `.iloc[0, 1, 0]`

```python
df = pd.DataFrame.from_dict(odict, orient='index')
df.columns = ['letter']
display(df)
assert df.iloc[2, 0] == 'C'
assert df.iloc[3, 0] == 'D'
assert list(df.iloc[2:4, 0]) == ['C', 'D']

df = df.T
display(df)
assert list(df.iloc[0, 2:4]) == ['C', 'D']
```

      letter
    a      A
    b      B
    c      C
    d      D


            a  b  c  d
    letter  A  B  C  D

### Lookup of integer-locations

#### Python `list.index(value)`

```python
alist = list('abcd')
assert alist.index('d') == 3
```

#### Python `dict.get(key, default=None)`

```python
assert odict.get('d') == 'D'
```

Something like this would sort next to `.get()` when tab-completing:
```python
assert odict.getkeypos('d') == 3
```

#### Pandas

```python
df = pd.DataFrame.from_dict(odict, orient='index')
display(df)

# Lookup ordinal integer-location by index value:
assert df.index.get_loc('d') == 3
assert df.iloc[df.index.get_loc('d')].tolist() == ['D']

# Lookup index values by column value:
assert df[df[0] == 'D'].index.tolist() == ['d']
df.columns = ['letters']
assert df[df['letters'] == 'D'].index.tolist() == ['d'] == \
    df.query('letters == "D"').index.tolist()

# Lookup ordinal integer-location(s) of value:
assert [df.index.get_loc(idxval) for idxval in df[df['letters'] ==
'D'].index.tolist()] == [3]

import numpy as np
assert list(np.where(df['letters'].values == 'D')[0]) == [3]
```

       0
    a  A
    b  B
    c  C
    d  D

```python
assert \
    df[df['letters'].eq('D')].index == \
    df.index[df['letters'].eq('D')] == \
    df.query('letters == "D"').index == \
    df[df['letters'] == 'D'].index == \
    df.eval('x = letters == "D"').query("x").index
```

### Why would I use Series or DataFrame here instead of dict?
**How do and why would I refactor code to use `Series` or `DataFrame`
instead of `dict`?**
- Why:
  - performance & scalability (Cython, profiling, dask.distributed scales
the DataFrame API to multiple processes and/or multiple machines with
datasets larger than can fit into RAM)
  - Maintainability & security: nobody wants to figure out your poor-man's
in-memory datastore implemented with only the standard library; months
later you realize you need to optimize a query and something like
`df.query` would take your team years to get partially working, so you just
dump everything into a database and add SQL vulnerabilities and a whole
dict/object layer, and all you needed was a join and merge that you *could*
just do with coreutils join and merge (but that would lose type safety and
unescaped newlines would then be as dangerous as unparametrized SQL
queries). And then it's time to write docs for what we did here.
  - De/serialization (`to_numpy`, `to_parquet`, `to_json`, `to_csv`,
`to_html`, `to_markdown`)
- How:
  - Add a dependency on an external library that needs to be compiled or
installed and kept upgraded.
  - Load the data into a DataFrame
  - Read the docs and use the API; which supports lookup by value, by
integer-position (`.iloc`), by complex chained queries, etc.

# Pandas DataFrame reference
- There are many excellent Pandas tutorials.
- There was a docs sprint.
- `Series` have an `.index` and no `.columns` (only one column; more like
`dict`)
- `DataFrames` have an `.index` and a `.columns`
- There are a number of ways to load a dict into a DataFrame and lookup
values:

```python
df = pd.DataFrame.from_records([odict])
display(df)
assert list(df.index) == [0]
assert list(df.columns) == list('abcd')
assert list(df.iloc[0]) == list('ABCD')
assert list(df.iloc[0].index) == list(df.columns) == list('abcd')
assert list(df.iloc[0]) == list(df.loc[0])
assert list(df.loc[0].index) == list(df.columns) == list('abcd')
assert list(df.loc[0]) == list('ABCD')
assert df.iloc[0]['a'] == 'A' == df.loc[0]['a']
```

       a  b  c  d
    0  A  B  C  D

```python
df = pd.DataFrame.from_records(list(odict.items()))
display(df)
assert list(df.index) == [0, 1, 2, 3]
assert list(df.columns) == [0, 1]
assert list(df.iloc[0]) == list(df.loc[0])
assert list(df.iloc[0]) == ['a', 'A']
assert list(df.iloc[0].index) == list(df.columns) == [0, 1]
```

       0  1
    0  a  A
    1  b  B
    2  c  C
    3  d  D

```python
with pytest.raises(ValueError):
    df = pd.DataFrame.from_dict(odict)
```

```python
df = pd.DataFrame.from_dict(odict.items())
display(df)
assert list(df.index) == [0, 1, 2, 3]
assert list(df.columns) == [0, 1]
assert df[0][0] == 'a' == df.iloc[0].iloc[0]
```

       0  1
    0  a  A
    1  b  B
    2  c  C
    3  d  D

```python
df = pd.DataFrame.from_dict(odict, orient='index')
display(df)
assert list(df.index) == list('abcd')
assert list(df.columns) == [0]
assert df.loc['a'][0] == 'A' == df.iloc[0][0]
```

       0
    a  A
    b  B
    c  C
    d  D

```python
df.columns = ['letter']
display(df)
assert df.loc['a']['letter'] == 'A' == df.iloc[0][0] == df.loc['a'].iloc[0]
== df.iloc[0].loc['letter']
```

      letter
    a      A
    b      B
    c      C
    d      D

```python
df.index = ['red', 'green', 'blue', 'orange']
display(df)
```

           letter
    red         A
    green       B
    blue        C
    orange      D

```python
assert type(df.iloc[0][0]) == str
df.iloc[0][0] = dict.fromkeys('abc')
assert type(df.iloc[0][0]) == dict
display(df)
```

                                       letter
    red     {'a': None, 'b': None, 'c': None}
    green                                   B
    blue                                    C
    orange                                  D

On Fri, Jul 31, 2020 at 1:35 AM Marco Sulla <marco.sulla.pyt...@gmail.com>
wrote:

> On Thu, 30 Jul 2020 at 23:42, Steven D'Aprano <st...@pearwood.info> wrote:
>
>> Of course anything is possible in theory, but I think that would require
>> shifting the existing keys and values in the array, and updating the
>> entries in the hash table to point to their key in the new position, and
>> probably other complications I haven't thought of.
>>
>
> Also list must shift the internal array. You don't have to update the
> hashtable. For example, when you pop an item, the key is not removed from
> the hashtable completely, but it's marked as dummy.
>
> I think the main problem here is set, since set is not ordered. In theory,
> dict views could be an "ordered" set, so you can do:
>
> d.keys()[5]
> d.values()[1:]
> d.items()[3] = "omg"
>
> In any case, if people want to propose turning dicts into fully-fledged
>> reorderable, slicable sequences as well as mappings, they will probably
>> need a PEP :-)
>>
>
> I'm just brainstorming. Not sure about the real use-case.
> _______________________________________________
> Python-ideas mailing list -- python-ideas@python.org
> To unsubscribe send an email to python-ideas-le...@python.org
> https://mail.python.org/mailman3/lists/python-ideas.python.org/
> Message archived at
> https://mail.python.org/archives/list/python-ideas@python.org/message/EF3FRDGV5DPTEIFY2DVRTAMEUUSOEFV7/
> Code of Conduct: http://python.org/psf/codeofconduct/
>
_______________________________________________
Python-ideas mailing list -- python-ideas@python.org
To unsubscribe send an email to python-ideas-le...@python.org
https://mail.python.org/mailman3/lists/python-ideas.python.org/
Message archived at 
https://mail.python.org/archives/list/python-ideas@python.org/message/SV6NZ5RVDZICD42VN7RCHXPVDU66GDKO/
Code of Conduct: http://python.org/psf/codeofconduct/

Reply via email to