API/BUG: .apply will correctly infer output shape when axis=1
State: closed

closes #16353
closes #17348
closes #17437
closes #18573
closes #17970
closes #17892
closes #17602 
closes #15628 
closes #18775 
closes #18901
closes #18919 

This fixes apply to work correctly when the returned shape mismatches the original. It will try to set the indices if it possible. Setting to a list-like with ``axis=1`` is now disallowed (but still possible if you operate *row-wise*). We were applying this inconsitently. This is of course a discouraged practice anyhow.

Prob should add some examples / update the doc-string a bit.


Labels:
name: Bug
name: Reshaping
description: Concat, Merge/Join, Stack/Unstack, Explode
name: API Design
name: Apply
description: Apply, Aggregate, Transform, Map

Comments:
# [Codecov](https://codecov.io/gh/pandas-dev/pandas/pull/18577?src=pr&el=h1) Report
> Merging [#18577](https://codecov.io/gh/pandas-dev/pandas/pull/18577?src=pr&el=desc) into [master](https://codecov.io/gh/pandas-dev/pandas/commit/c40c8f8b3baccbd658d078816698f85e3268a781?src=pr&el=desc) will **decrease** coverage by `0.02%`.
> The diff coverage is `80%`.

[![Impacted file tree graph](https://codecov.io/gh/pandas-dev/pandas/pull/18577/graphs/tree.svg?token=eZ4WkYLtcO&width=650&height=150&src=pr)](https://codecov.io/gh/pandas-dev/pandas/pull/18577?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master   #18577      +/-   ##
==========================================
- Coverage   91.45%   91.43%   -0.03%     
==========================================
  Files         157      157              
  Lines       51378    51392      +14     
==========================================
+ Hits        46987    46988       +1     
- Misses       4391     4404      +13
```

| Flag | Coverage Δ | |
|---|---|---|
| #multiple | `89.29% <80%> (-0.01%)` | :arrow_down: |
| #single | `40.56% <36%> (-0.12%)` | :arrow_down: |

| [Impacted Files](https://codecov.io/gh/pandas-dev/pandas/pull/18577?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [pandas/core/frame.py](https://codecov.io/gh/pandas-dev/pandas/pull/18577/diff?src=pr&el=tree#diff-cGFuZGFzL2NvcmUvZnJhbWUucHk=) | `97.63% <80%> (-0.28%)` | :arrow_down: |
| [pandas/io/gbq.py](https://codecov.io/gh/pandas-dev/pandas/pull/18577/diff?src=pr&el=tree#diff-cGFuZGFzL2lvL2dicS5weQ==) | `25% <0%> (-58.34%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/pandas-dev/pandas/pull/18577?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/pandas-dev/pandas/pull/18577?src=pr&el=footer). Last update [c40c8f8...dce186a](https://codecov.io/gh/pandas-dev/pandas/pull/18577?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

# [Codecov](https://codecov.io/gh/pandas-dev/pandas/pull/18577?src=pr&el=h1) Report
> Merging [#18577](https://codecov.io/gh/pandas-dev/pandas/pull/18577?src=pr&el=desc) into [master](https://codecov.io/gh/pandas-dev/pandas/commit/983d71fa8477439aaa227367d7f2f14952e4e235?src=pr&el=desc) will **decrease** coverage by `<.01%`.
> The diff coverage is `94.21%`.

[![Impacted file tree graph](https://codecov.io/gh/pandas-dev/pandas/pull/18577/graphs/tree.svg?height=150&width=650&src=pr&token=eZ4WkYLtcO)](https://codecov.io/gh/pandas-dev/pandas/pull/18577?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master   #18577      +/-   ##
==========================================
- Coverage    91.6%    91.6%   -0.01%     
==========================================
  Files         150      150              
  Lines       48750    48793      +43     
==========================================
+ Hits        44656    44695      +39     
- Misses       4094     4098       +4
```

| Flag | Coverage Δ | |
|---|---|---|
| #multiple | `89.97% <94.21%> (ø)` | :arrow_up: |
| #single | `41.74% <47.93%> (-0.02%)` | :arrow_down: |

| [Impacted Files](https://codecov.io/gh/pandas-dev/pandas/pull/18577?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [pandas/io/formats/style.py](https://codecov.io/gh/pandas-dev/pandas/pull/18577/diff?src=pr&el=tree#diff-cGFuZGFzL2lvL2Zvcm1hdHMvc3R5bGUucHk=) | `96.22% <100%> (ø)` | :arrow_up: |
| [pandas/core/frame.py](https://codecov.io/gh/pandas-dev/pandas/pull/18577/diff?src=pr&el=tree#diff-cGFuZGFzL2NvcmUvZnJhbWUucHk=) | `97.43% <100%> (ø)` | :arrow_up: |
| [pandas/core/sparse/frame.py](https://codecov.io/gh/pandas-dev/pandas/pull/18577/diff?src=pr&el=tree#diff-cGFuZGFzL2NvcmUvc3BhcnNlL2ZyYW1lLnB5) | `94.82% <100%> (-0.03%)` | :arrow_down: |
| [pandas/core/apply.py](https://codecov.io/gh/pandas-dev/pandas/pull/18577/diff?src=pr&el=tree#diff-cGFuZGFzL2NvcmUvYXBwbHkucHk=) | `96.77% <93.75%> (-2.66%)` | :arrow_down: |
| [pandas/util/testing.py](https://codecov.io/gh/pandas-dev/pandas/pull/18577/diff?src=pr&el=tree#diff-cGFuZGFzL3V0aWwvdGVzdGluZy5weQ==) | `83.85% <0%> (+0.2%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/pandas-dev/pandas/pull/18577?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/pandas-dev/pandas/pull/18577?src=pr&el=footer). Last update [983d71f...1d93380](https://codecov.io/gh/pandas-dev/pandas/pull/18577?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

cc @TomAugspurger @jorisvandenbossche @chris-b1 
cc @jschendel 
@TomAugspurger @jorisvandenbossche if you have a chance
Unfortunately I'm guessing this will break _someone's_ code in an subtle way, but current behavior is obviously bad, so seems like a net positive.

I've never used it so not sure I fully understand the itention, but this does seem to break the `reduce` keyword, docstring says "If reduce is True a Series will always be returned, and if False a  DataFrame will always be returned."

```python
In [23]: pd.__version__
Out[23]: '0.22.0.dev0+310.gf6f0371'

In [24]: df.apply(lambda x: (1, 2, 3), axis=1, reduce=False)
Out[24]: 
   A  B  C
0  1  2  3
1  1  2  3
2  1  2  3
3  1  2  3
4  1  2  3
5  1  2  3

In [25]: df.apply(lambda x: (1, 2, 3), axis=1, reduce=True)
Out[25]: 
   A  B  C
0  1  2  3
1  1  2  3
2  1  2  3
3  1  2  3
4  1  2  3
5  1  2  3


In [92]: pd.__version__
Out[92]: '0.21.0'

In [93]: df.apply(lambda x: (1, 2, 3), axis=1, reduce=False)
Out[93]: 
   A  B  C
0  1  2  3
1  1  2  3
2  1  2  3
3  1  2  3
4  1  2  3
5  1  2  3

In [94]: df.apply(lambda x: (1, 2, 3), axis=1, reduce=True)
Out[94]: 
0    (1, 2, 3)
1    (1, 2, 3)
2    (1, 2, 3)
3    (1, 2, 3)
4    (1, 2, 3)
5    (1, 2, 3)
dtype: object
```


@jreback I will try to review this tomorrow
i think i can simply deprecate reduce 

let me see
``reduce=`` is deprecated, but I think we should actually figure out the use case and fix it. IIRC its if the passed apply function returns an empty, what do you return (a Series or a DataFrame). but need to sort thru some issues. maybe I will remove this and we can try later. (its the last commit and orthogonal).
I reverted the deprecation off ``reduce``, I can deprecate but its complicated to actually replace it.
thanks for comments @jschendel 
I didn't look at any code yet, but some general comments from experimenting with the branch:

* I am big +1 on making those things more consistent, but I think we should provide an alternative to achieve the original (in the cases where it changes), i.e. have a way to specify you want no inference on the result, just put the result as a scalar in a single cell. 
  We should also add documentation that explains the rules here to make this more predictable.

*   Column names of the result: using the example of the whatsnew, there is still an inconsistency in the resulting column names:

    ```
    In [1]: df = pd.DataFrame(np.random.randn(4, 3), columns=['A', 'B', 'C'])

    In [2]: df.apply(lambda x: [1, 2, 3], axis=1)
    Out[2]: 
       A  B  C
    0  1  2  3
    1  1  2  3
    2  1  2  3
    3  1  2  3

    In [3]: df.apply(lambda x: [1, 2], axis=1)
    Out[3]: 
       0  1
    0  1  2
    1  1  2
    2  1  2
    3  1  2
    ```

    So depending on the length of the list, it reuses the original column names or not. I would personally be in favor of fixing this inconsistency to not reuse the column names (although that would be an additional breaking change for the first case). 
    Related, when returning a Series it no longer uses the explicit given names of the result, if the length of the result matches that of the original frame:

    ```
    In [10]: df.apply(lambda x: pd.Series([1, 2], index=['test', 'other']), 1)
    Out[10]: 
       test  other
    0     1      2
    1     1      2
    2     1      2
    3     1      2

    In [11]: df.apply(lambda x: pd.Series([1, 2, 3], index=['test', 'other', 'cols']), 1)
    Out[11]: 
       A  B  C
    0  1  2  3
    1  1  2  3
    2  1  2  3
    3  1  2  3
    ```

    So this last example ([11]) is a regression, as before it correctly returned the specified new names.

*   `raw=True` seems to change some of the inference, while, based on the explanation in the docstring, it should only change the values passed to the function and not the inference afterwards on the result of the function.

    ```
    In [24]: df.apply(lambda x: (1, 2), axis=1)
    Out[24]: 
       0  1
    0  1  2
    1  1  2
    2  1  2
    3  1  2

    In [25]: df.apply(lambda x: (1, 2), axis=1, raw=True)
    Out[25]: 
    0    (1, 2)
    1    (1, 2)
    2    (1, 2)
    3    (1, 2)
    dtype: object
    ```

* Regarding the example of @chris-b1 above (https://github.com/pandas-dev/pandas/pull/18577#issuecomment-349994027), so for tuples this change is breaking the behaviour always (regardless of the length of the tuple). Before, tuples were always regarded as 'scalars', unless you specified `reduce=False`. 
  I am not fully sure I like this change, and as said above I think we need an alternative if keeping to this change.

*   An illustration of where this "now always try to unpack iterable things like tuples into columns" would cause a problem in a real world use case:

    This branch:

    ```
    In [53]: from shapely.geometry import MultiLineString

    In [54]: def f(row):
        ...:     return MultiLineString([[(1,2), (3,4)], [(3,4), (4,2)]])

    In [55]: df.apply(f, axis=1)
    Out[55]: 
                           0                      1
    0  LINESTRING (1 2, 3 4)  LINESTRING (3 4, 4 2)
    1  LINESTRING (1 2, 3 4)  LINESTRING (3 4, 4 2)
    2  LINESTRING (1 2, 3 4)  LINESTRING (3 4, 4 2)
    3  LINESTRING (1 2, 3 4)  LINESTRING (3 4, 4 2)
    ```

    with 0.21.1 it preserves the returned objects in a single series (the desired result):

    ```
    In [94]: df.apply(f, axis=1)
    Out[94]: 
    0    (LINESTRING (1 2, 3 4), LINESTRING (3 4, 4 2))
    1    (LINESTRING (1 2, 3 4), LINESTRING (3 4, 4 2))
    2    (LINESTRING (1 2, 3 4), LINESTRING (3 4, 4 2))
    3    (LINESTRING (1 2, 3 4), LINESTRING (3 4, 4 2))
    4    (LINESTRING (1 2, 3 4), LINESTRING (3 4, 4 2))
    5    (LINESTRING (1 2, 3 4), LINESTRING (3 4, 4 2))
    dtype: object
    ```

* I also noticed that the behaviour when returning arrays changed (to be consistent with lists, so probably the change is good), but just noticing it to make sure it is tested (previously it raised an error when the shape didn't match (eg `lambda x: np.array([1, 2])` with the example above), now it behaves like a list).



Contemplating my above comments, I think I would be in favor of (1) "no inference" for general iterable things (objects, tuples, ..), and (2) a clear way to enable / disable inference. 

The switch to enable/disable inference could have a default depending on the result type, eg by default infer (so not putting the result as a scalar in a single cell) for Series objects and maybe arrays, and by default no inference for tuples, dicts, generic objects, and probably lists as well. When doing this, I think the main breakage is the case of a "list with length equal to number of columns of original frame", but in that case we could even catch this case and raise a FutureWarning.

Note I didn't look at all examples from the issues yet, so I might be missing some cases.

so we need some sort of a parameter to allow inference on the returned result. This is analgous to specifying an output dtype & (shape) in  [np.vectorize](https://docs.scipy.org/doc/numpy-1.10.0/reference/generated/numpy.vectorize.html) maybe something like:

``output=infer|scalar|reduce`` (and can remove the ``reduce=`` & ``raw=`` kwargs as well
> (and can remove the reduce= & raw= kwargs as well

I think `raw` is still orthogonal (it is to determine which type if passed to the function, not related to what is returned by the function). 
But `broadcast` *could* maybe be integrated.

What would a `output='reduce'` do? (compared to `output='scalar'` ?) 
yeah going to see what I can do with all of these parameters, calling it ``result_type`` for now.
cc @TomAugspurger 
ok this is cleaned up a bit more. I think we do need to try to rationalize all of the arguments, maybe deprecating some. can do that after.
@jorisvandenbossche if any comments, merging soon.
start frame
```
In [1]: df = DataFrame(np.tile(np.arange(3), 6).reshape(6, -1) + 1, columns=['A', 'B', 'C'])

In [2]: df
Out[2]: 
   A  B  C
0  1  2  3
1  1  2  3
2  1  2  3
3  1  2  3
4  1  2  3
5  1  2  3
```

1) above is fixed
```
In [3]: print(df.apply(lambda x: [1, 2, 3], axis=1, result_type='infer'))
   ...: print(df.apply(lambda x: [1, 2], axis=1, result_type='infer'))
   ...: 
   0  1  2
0  1  2  3
1  1  2  3
2  1  2  3
3  1  2  3
4  1  2  3
5  1  2  3
   0  1
0  1  2
1  1  2
2  1  2
3  1  2
4  1  2
5  1  2
```

2) is fixed
```In [4]: print(df.apply(lambda x: pd.Series([1, 2, 3], index=['test', 'other', 'cols']), axis=1))
   ...: print(df.apply(lambda x: pd.Series([1, 2], index=['test', 'other']), axis=1))
   ...: 
   test  other  cols
0     1      2     3
1     1      2     3
2     1      2     3
3     1      2     3
4     1      2     3
5     1      2     3
   test  other
0     1      2
1     1      2
2     1      2
3     1      2
4     1      2
5     1      2
```

3) not handling anything different with reduce ATM. I agree we need to fix this, maybe just make it a kw of result_type. Not really sure of this usecase anyhow.

4) I added a [test](https://github.com/pandas-dev/pandas/pull/18577/commits/c25d453b539cf701e3c24fddf2943a5c33025dea) for this (though it wasn't different for boxing with array/list).

5) added example docs

cc @jorisvandenbossche 
ping @jorisvandenbossche 
Just to note this is on my to do list to look at this again, will try to do it the latest this weekend
> not handling anything different with reduce ATM. I agree we need to fix this, maybe just make it a kw of result_type. Not really sure of this usecase anyhow.

Maybe you didn't change any code related to `reduce`, but as far as I can see, the consequence of the changes is that `reduce` does no longer work in certain cases. See the example of Chris above (https://github.com/pandas-dev/pandas/pull/18577#issuecomment-349994027). 
I think I am OK with this case having breaking behaviour, but then IMO we should at least at the same time deprecate the `reduce` keyword, so the user gets some information on why it breaks and how he can replace the `reduce` keyword.
> Maybe you didn't change any code related to reduce, but as far as I can see, the consequence of the changes is that reduce does no longer work in certain cases. See the example of Chris above (#18577 (comment)).

and we can do this as a followup. I didn't change reduce behavior at all. That is an existing issue. Of course it is true that reduce is not well tested either.

I am more looking to how to consolidate the API though. e.g. say we deprecate ``reduce``, how can we fold this into ``result_type``?

well I can certainly deprecate the ``broadcast`` keyword. I am not sure of the usecase of it. To be honest this function is way too complicated and we should remove as much as possible and just say, tough. This is an endless source of trouble.
A good use case of broadcast=True is to keep the existing behaviour with lists (which I think should be mentioned in the whatsnew). Another one could be to broadcast a scalar result (which is kind of the same as `.transform` then, but transform fails on such case (it cannot broadcast)) 


these are already the same (from your question above), meaning no inference
```
In [1]:     df = pd.DataFrame(np.tile(np.arange(3), 6).reshape(6, -1) + 1, columns=['A', 'B', 'C'])
   ...:     df
   ...: 
Out[1]: 
   A  B  C
0  1  2  3
1  1  2  3
2  1  2  3
3  1  2  3
4  1  2  3
5  1  2  3

In [2]: df.apply(lambda x: [1, 2, 3], axis=1)
   ...: 
Out[2]: 
0    [1, 2, 3]
1    [1, 2, 3]
2    [1, 2, 3]
3    [1, 2, 3]
4    [1, 2, 3]
5    [1, 2, 3]
dtype: object

In [3]: df.apply(lambda x: [1, 2], axis=1)
Out[3]: 
0    [1, 2]
1    [1, 2]
2    [1, 2]
3    [1, 2]
4    [1, 2]
5    [1, 2]
dtype: object
```
if any final comments. We will broad need a followup to deprecate ``reduce``.
> if any final comments. We will broad need a followup to deprecate reduce.

I am fine with keeping that for a follow-up, *if* we plan to do that before 0.23. 

Regarding the `result_type` keyword / the possible output shape and format, I think the possible options are:

- whatever is returned from the function is regarded a 'scalar', and so result will be a Series
  -> this is the new default for eg list)
- whatever is returned from the function is tried to 'fit in' the original frame shape (preserving the column names). 
  -> So this is the new `result_type='broadcast'`
- whatever is returned we try to 'expand' if it is list-like to multiple columns (not preserving the original column names)
  -> this is the introduced `result_type='infer'`

And the default is then for list-likes option 1, and for Series option 3 (`result_type='infer'`). 

I have the feeling that it would be a nicer design to make all three options explicit option of `result_type`, so also add a keyword argument for option 1 (eg `result_type` = 'reduce' or 'scalar', ..). 

If we do that, the `result_type='infer'` also does not fully sound like the correct name for option 3. It is true that there we 'infer' the output shape, but the default is also already a kind of 'infer' (we infer which of the three options it is ..). So maybe we can go for `result_type='expand'` instead? (not fully sure it will be clearer though).