Pandas의 map함수, apply함수, applymap함수 차이점 분석

Pandas를 쓰다보면 초반에 개념 잡기 힘든 부분이 map함수, apply함수, applymap함수이다. 데이터분석을 하다가 헷갈려서 이참에 정리를 좀 해보았다. Pandas에서 배열의 합계나 평균같은 일반적인 통계는 DataFrame내 함수를 사용하면 되지만, Pandas에서 제공하지 않는 기능, 즉 내가 만든 커스텀 함수(custom function)를 DataFrame에 적용하려면 map함수, apply함수, applymap함수를 사용하면 된다. 초보자들은 공식 Documentation을 보고 이 세가지를 구분하기가 쉽지 않으니 파이썬 디버거(pdb)를 이용하여 세가지 함수의 차이가 무엇인지 비교해보자.


파이썬 디버거 튜토리얼

map함수

map함수는 DataFrame 타입이 아니라, 반드시 Series 타입에서만 사용해야 한다.
먼저, Series를 한마디로 정의하면 딱 이거다.

값(value) + 인덱스(index) = 시리즈 클래스(Series)

Series는 NumPy에서 제공하는 1차원 배열과 비슷하지만 각 데이터의 의미를 표시하는 인덱스(index)를 붙일 수 있다. 하지만 데이터 자체는 그냥 값(value)의 1차원 배열이다.
map함수는 Series의 이러한 값 하나하나에 접근하면서 해당 함수를 수행한다.

예를 들어보자.

import pandas as pd

data = {'team ' : ['russia', 'saudiarabia', 'egypt', 'uruguay']
        'against': ['saudiarabia', 'russia', 'uruguay', 'egypt'],
        'fifa_rank': [65, 63, 31, 21]}
columns = ['team', 'against', 'fifa_rank']
df = pd.DataFrame(data, columns = columns)

다음과 같이 축구 대진표(2018 러시아월드컵 A조)가 담겨진 DataFrame이 있다.(index는 0, 1, 2, 3)

  team againt fifa_rank
0 russia saudiarabia 65
1 saudiarabia russia 63
2 egypt uruguay 31
3 uruguay egypt 21

그리고 팀명(team)이 주어졌을때, 그 팀의 통산성적(승, 무, 패, 승률)을 반환하는 커스텀 함수가 있다고 가정하자.

def total_record(team):
    ...
    # calculation from Database
    ...
    return win_count, draw_count, lose_count, winning_rate

team 컬럼에 있는 데이터들에 대해 각각 통산성적에 대한 승률winning_rate을 구한 다음 DataFrame에 추가하고자 한다면 다음과 같이 작성한다. 선택한 컬럼 df[`team`]은 Series 객체이므로, map함수를 사용한다.

df['winning_rate']  = df['team'].map(lambda x : total_record(x)[3])

이 과정을 pdb로 살펴보자. 호출문 바로 위에 pdb를 import하고 set_trace()를 걸기 위해 다음과 같이 작성한다.

import pdb; pdb.set_trace()
df['winning_rate']  = df['team'].map(lambda x : total_record(x)[3])

Jupyter의 셀을 실행하면, set_trace()가 호출되는 라인에서 실행이 중단될 것이다.

--Return--
> <ipython-input-20-0dc1e6a7021b>(1)<module>()->None
-> import pdb; pdb.set_trace()

map함수가 호출되는 2번째 라인에 브레이크 포인트를 설정한다.(Jupyter 셀 안의 라인기준임)

(Pdb) b 2
Breakpoint 2 at <ipython-input-20-0dc1e6a7021b>:2

c(continue) 명령어는 브레이크 포인트를 만날때까지 실행을 계속한다.

(Pdb) c
> <ipython-input-20-0dc1e6a7021b>(2)<module>()->None
-> df['winning_rate']  = df['team'].map(lambda x : total_record(x)[3])

(2)<module>()->None이라는 표시는, 2번째 라인 브레이크 포인트에 진입했다는 것이다. 이 시점에는 아직 어떤 할당도 없으니, 코드를 한 줄씩 실행해본다. n(next) 명령어를 이용해서 lambda함수를 실행시키고, x값을 확인해보자.

(Pdb) n
> <ipython-input-20-0dc1e6a7021b>(2)<lambda>()
-> df['winning_rate']  = df['team'].map(lambda x : total_record(x)[3])

(Pdb) x, type(x)
'russia', <class'str'>

df['team']의 첫 인덱스 값인 russia값과 스트링 타입임을 확인할 수 있다. 즉 map함수는 Series의 값(value)을 하나씩 꺼내서 lambda 함수의 인자로 넘기는 커스텀 함수를 각 value별로 실행시키는 것이다. 추가로 pdb로 n명령어를 실행하면 total_record()의 결과로 0.513 이라는 승률(반환 리스트의 4번째 값[3])이 나왔고 그 값을 df['winning_rate']에 할당한다. (pdb상에서 a명령어를 사용해도 동일하게 현재 함수의 매개변수를 볼 수 있다)

(Pdb) n
--Return--
> <ipython-input-20-0dc1e6a7021b>(2)<lambda>()->0.513
-> df['winning_rate']  = df['team'].map(lambda x : total_record(x)[3])

pdb에서 continue 명령어를 계속 날리면 Seires에서 뽑아내는 x값이 계속 바뀜을 알 수 있다.

(Pdb) c
> <ipython-input-33-d5229ef2c444>(2)<lambda>()
-> df['winning_rate']  = df['team'].map(lambda x : total_record(x)[3])

(Pdb) x
'saudiarabia'

(Pdb) c
> <ipython-input-33-d5229ef2c444>(2)<lambda>()
-> df['winning_rate']  = df['team'].map(lambda x : total_record(x)[3])

(Pdb) x
'egypt'
...
...

비교적 쉬운 예지만 파이썬 디버거를 활용해서 map함수의 호출 스택을 살펴보며 개념을 정리할 수 있다.

사실 map함수의 인자로서는 lambda함수나 커스텀 함수뿐 아니라, 딕셔너리나 Series도 올 수 있지만, function을 인자로 전달할 때는 map함수 대신 apply함수를 사용해도 동일한 결과를 얻을 수 있다. (반대로 얘기하면 apply함수는 Series 객체에서도 사용가능하고, DataFrame 객체에서도 사용가능하다는 의미다. 뒤에서 더 얘기하겠다.)

apply함수

커스텀 함수를 사용하기 위해 DataFrame에서 복수 개의 컬럼이 필요하다면, apply함수를 사용해야 한다. 커스텀 함수를 아래와 같이 수정해보기로 한다.

def relative_record(team, against):
    ...
    # calculation from Database
    ...
    return win_count, draw_count, lose_count, winning_rate

앞서 total_record()가 팀(team)의 통산성적을 반환하는 커스텀 함수였다면, relative_record()는 팀과 상대팀(against)간의 상대전적을 반환하는 커스텀 함수라고 가정하자.

team 컬럼과 against 컬럼의 값들을 각각 인자로 넘겨서 각 팀(team)의 상대팀(against) 대상 상대전적에 대한 승률(winning_rate)을 구한 다음 DataFrame에 추가하고자 한다. lambda함수와 커스텀 함수로 넘길 인자가 2개이므로, DataFrame의 apply함수를 사용하되, 함수를 적용할 대상이 각각의 로우에 해당하므로 axis는 1이나 columns로 지정하여 넘긴다.

df['winning_rate'] = df.apply(lambda x:relative_record(x['team'], x['against'])[3], axis=1)

DataFrame의 정의 자체가 사실은 공통 인덱스를 가지는 열 시리즈(Column Series)를 딕셔너리로 묶어놓은 것일 뿐이다. 따라서, 커스텀 함수로 넘길 인자를 x['team'], x['against']와 같이 열 Series로 넘기면, 각 컬럼의 공통 인덱스별로 x(DataFrame의 each row)를 꺼내서 커스텀 함수를 적용하는 것이다.

백문이 불여일견, 말로 하면 어려우니 pdb로 직접 확인해보자.

import pdb; pdb.set_trace()
df['winning_rate'] = df.apply(lambda x:relative_record(x['team'], x['against'])[3], axis=1)
--Return--
> <ipython-input-20-0dc1e6a7021b>(1)<module>()->None
-> import pdb; pdb.set_trace()

apply함수가 호출되는 2번째 라인에 브레이크 포인트를 설정한다.

(Pdb) b 2
Breakpoint 2 at <ipython-input-20-0dc1e6a7021b>:2

c(continue)는 브레이크 포인트를 만날때까지 실행을 계속한다.

(Pdb) c
> <ipython-input-20-0dc1e6a7021b>(2)<module>()->None
-> df['winning_rate'] = df.apply(lambda x:relative_record(x['team'], x['against'])[3], axis=1)

(Pdb) n
> <ipython-input-83-94757cca7fcd>(2)<lambda>()
-> df['winning_rate'] = df.apply(lambda x:relative_record(x['team'], x['against'])[3], axis=1)

이제 lambda함수에 진입했다. 여기서 x가 어떤 값인지 확인해보자.

(Pdb) x
team                   russia
against           saudiarabia
fifa_rank                  65
Name: 0, dtype: object

(Pdb) type(x)
<class'pandas.core.series.Series'>

(Pdb) x.index
Index(['team', 'against', 'fifa_rank'],dtype='object')

(Pdb) x['team']
'russia'

(Pdb) x['against']
'saudiarabia'

즉, x는 원래의 df의 컬럼명을 새로운 index로 갖는 Series객체임을 확인할 수 있다. DataFrame관점에서 보면 하나의 row 값이다.

위에서는 DataFrame전체에 apply함수를 사용했지만, 커스텀 함수에 필요한 컬럼만 뽑아서 apply함수를 적용해도 동일한 결과를 얻을 수 있다. 아래 코드는 지금까지 했던 코드와 동일한 코드다. df[['team','against']]와 같이 []를 하나 더 감싼 이유는, 이렇게 해야 Dataframe이 반환되기 때문이다.

df['winning_rate'] = df[['team','against']].apply(lambda x:relative_record(x[0], x[1])[3], axis=1)

applymap함수

applymap함수는 DataFrame클래스의 함수이긴 하나, 위의 apply함수처럼 각 row(axis=1)나 각 column(axis=0)별로 작동하는 함수가 아니라, 각 요소(element)별로 작동을 한다. 마치 선형대수에서 벡터에 스칼라를 연산하면, 벡터의 요소 하나하나에 해당 연산을 해주는 것처럼(elementwise) 적용하는 DataFrame의 각 요소마다 커스텀 함수(반드시 Single vaule를 반환하는)를 수행한다고 보면 된다. applymap에 인자로 전달하는 커스텀함수가 Single value로부터 Single value를 반환한다는 점이 중요하다. 크게 어려운 개념은 아니므로 설명은 생략…

index값을 apply함수에 적용하고 싶을 때 방법

apply함수를 사용할 때, 각 row의 index에 접근이 필요할 때가 있다. 하지만 그렇다고 DataFrame.apply 함수에서 lambda x: func(x.index) 라고 인자를 넘기면, x의 index접근할 수 없을 것이다 (오류가 발생한다). 그 이유는, apply함수자체가 각각의 row를 꺼내올 때, row를 Series 객체로써 커스텀 함수를 적용하는 것이 아니라, numpy 객체로써 커스텀 함수를 적용하는 것이기 때문이다. 여기서 헷갈릴 수 있는데, 우리가 찾으려는 row의 indexlambda함수가 꺼내오는 Series객체인 x의 index가 아니라, Series객체인 x의 indexname임을 기억해야 한다. x의 type자체는 Series가 맞긴 하지만, apply함수가 Series의 index를 식별해서 적용하는 것이지, Series의 indexname에 적용하는 것이 아니다.

따라서 index에 접근할때, Series에서 map함수를 쓸 때는 df.index.map(labmda x:funx(x))로 접근하고, DataFrame에서 apply함수를 쓸 때는, df.apply(lambda x :func(x.name), axis=1) 으로 호출해야 한다.

말로 하면 어려우니 코드로 보자. 앞서 예시로 든 DataFrame을 좀 변형해서, team컬럼을 index로 지정해보자.

import pandas as pd

data = {'team ' : ['russia', 'saudiarabia', 'egypt', 'uruguay']
        'against': ['saudiarabia', 'russia', 'uruguay', 'egypt'],
        'fifa_rank': [65, 63, 31, 21]}
columns = ['team', 'against', 'fifa_rank']
df = pd.DataFrame(data, columns = columns)
df.set_index('team')
  againt fifa_rank
team    
russia saudiarabia 65
saudiarabia russia 63
egypt uruguay 31
uruguay egypt 21

위의 apply함수에서 예로 든 relative_record()함수를 사용하기 위해, 이번엔 DataFrame의 컬럼 값이 아닌, index값을 넘겨야 하는 상황이 되었다. (물론 x['against']도 같이 넘겨야 한다) 첫 row의 index인 russia를 접근하기 위해, x.name 변수를 사용해서 인자로 넘겨야 한다. 상세한 내용은 pdb로 확인해보자.

import pdb; pdb.set_trace()
df['winning_rate'] = df.apply(lambda x:relative_record(x.name, x['against'])[3], axis=1)
--Return--
> <ipython-input-18-0e06d1dbe154>(1)<module>()->None
-> import pdb; pdb.set_trace()

(Pdb) b 2
Breakpoint 2 at <ipython-input-18-0e06d1dbe154>:2

(Pdb) c
> <ipython-input-18-0e06d1dbe154>(2)<module>()->None
df['winning_rate'] = df.apply(lambda x:relative_record(x.name, x['against'])[3], axis=1)

(Pdb) n
> <ipython-input-18-0e06d1dbe154>(2)<lambda>()
df['winning_rate'] = df.apply(lambda x:relative_record(x.name, x['against'])[3], axis=1)

(Pdb) type(x)
<class'pandas.core.series.Series'>

(Pdb) x
against         saudiarabia
fifa_rank                65
Name: russia, dtype: object

(Pdb) x.index
Index(['against', 'fifa_rank'], dtype='object')

(Pdb) x.index.name
#값 없음

(Pdb) x.name
'russia'

이렇게 x.name을 통해 DataFrame의 index 값에 접근할 수 있음을 확인했다. 끄읕.