반복 (Iteration)

서론

앞선 함수 (Functions) 장에서 복사해서 붙여넣기 대신 함수를 만들어 코드의 중복을 줄이는 것이 얼마나 중요한지 이야기했습니다. 코드 중복을 줄이면 세 가지 주요 이점이 있습니다:

  1. 코드의 의도를 파악하기 쉬워집니다. 눈이 변하지 않는 부분이 아니라 변화하는 부분에 집중하게 되기 때문입니다.

  2. 요구사항 변경에 대응하기 쉬워집니다. 요구가 바뀔 때 코드를 복사해서 붙여넣은 모든 곳을 기억해내며 수정하는 대신, 단 한 곳만 수정하면 됩니다.

  3. 버그가 줄어듭니다. 각 코드 줄이 더 많은 곳에서 사용되기 때문입니다.

중복을 줄이는 한 가지 도구가 함수라면, 반복되는 코드 패턴을 식별하여 쉽게 재사용하고 업데이트할 수 있는 독립적인 조각으로 추출하는 것이 함수입니다. 중복을 줄이는 또 다른 도구는 반복(iteration)입니다. 반복은 여러 입력에 대해 동일한 작업을 수행해야 할 때 도움을 줍니다: 서로 다른 열이나 서로 다른 데이터셋에 대해 동일한 연산을 반복하는 경우입니다.

이 장에서는 세 가지 방식으로 반복을 배웁니다: for 루프와 while 루프를 사용한 명시적 반복, 컴프리헨션(예: 리스트 컴프리헨션)을 통한 반복, 그리고 pandas 데이터 프레임을 위한 반복입니다.

사전 준비

이 장에서는 pandas 데이터 분석 패키지를 사용합니다.

For 루프 (For Loops)

루프는 유사한 코드 조각을 유사한 방식으로 반복해서 실행하는 방법입니다.

for 루프는 조건이 만족되는 동안(for) 작업을 수행합니다. 예를 들어,

코드 보기
name_list = ["Lovelace", "Smith", "Pigou", "Babbage"]

for name in name_list:
    print(name)
Lovelace
Smith
Pigou
Babbage

모든 이름이 출력될 때까지 이름을 출력합니다.

모든 for 루프에는 세 가지 구성 요소가 있습니다:

  1. 출력(output): 여기서는 출력문입니다. 하지만 데이터 프레임이나 리스트의 각 항목을 채우는 for 루프를 상상할 수도 있습니다(단, 루프 내에서 파이썬 객체의 크기를 변경하는 것은 느리므로 항상 전체 객체를 먼저 생성하고 나중에 값을 채워야 합니다).

  2. 시퀀스(sequence): for name in name_list: 부분입니다. 이는 반복할 대상을 결정합니다: for 루프가 실행될 때마다 반복 가능한(iterable) 객체인 name_list로부터 서로 다른 값을 name에 할당합니다. 리스트일 필요는 없으며, 반복 가능한 어떤 객체든 가능합니다. 위의 name을 “그것(it)”과 같은 대명사로 생각하면 유용합니다.

  3. 본문(body): print(name) 부분입니다. 실제로 작업을 수행하는 코드입니다. name에 매번 다른 값을 담아 반복적으로 실행됩니다. 첫 번째 반복에서는 사실상 print(name_list[0])을 실행하고, 두 번째는 print(name_list[1])을 실행하는 식입니다.

여러분의 객체가 반복 가능한 객체라면(즉, 그것을 가로질러 반복할 수 있다면), for 루프에서 이런 방식으로 사용될 수 있습니다. 가장 흔한 예는 리스트와 튜플이지만, 문자열에 대해서도 반복할 수 있습니다(이 경우 각 문자가 차례로 선택됩니다). 주의할 점 하나는 “hello”라는 문자열 자체를 반복하는 것과, ["hello"]와 같이 문자열 리스트(또는 튜플)를 반복하는 것의 차이입니다. 후자의 경우 다음과 같습니다:

코드 보기
for entry in ["hello"]:
    print(entry)
    print("---항목 종료---")
hello
---항목 종료---

반면 전자의 경우 꽤 다르고 대개는 그리 유용하지 않은 결과를 얻게 됩니다:

코드 보기
for entry in "hello":
    print(entry)
    print("---항목 종료---")
h
---항목 종료---
e
---항목 종료---
l
---항목 종료---
l
---항목 종료---
o
---항목 종료---

hmkdxpzkeiue 연습 문제 성공적인 각 반복에서 단어 하나씩 출력되도록 "Python for Data Science"를 출력하는 for 루프를 작성해 보세요.

for 루프에서 유용한 기법은 enumerate() 키워드를 사용하는 것입니다. 이는 리스트에서 항목의 위치를 추적하는 인덱스와 함께 실행됩니다:

코드 보기
name_list = ["Lovelace", "Smith", "Hopper", "Babbage"]

for i, name in enumerate(name_list):
    print(f"{i}번 위치의 이름은 {name}입니다.")
0번 위치의 이름은 Lovelace입니다.
1번 위치의 이름은 Smith입니다.
2번 위치의 이름은 Hopper입니다.
3번 위치의 이름은 Babbage입니다.

기억하세요, 파이썬은 0부터 인덱싱하므로 i의 첫 번째 항목은 0이 됩니다. 하지만 다른 숫자부터 인덱싱하고 싶다면 그렇게 할 수 있습니다:

코드 보기
for i, name in enumerate(name_list, start=1):
    print(f"{i}번 위치의 이름은 {name}입니다.")
1번 위치의 이름은 Lovelace입니다.
2번 위치의 이름은 Smith입니다.
3번 위치의 이름은 Hopper입니다.
4번 위치의 이름은 Babbage입니다.

딕셔너리로 for 루프를 수행할 때 유용한 또 다른 패턴은 키(key), 값(value) 쌍에 대해 반복하는 것입니다. 곧 딕셔너리에 대해 더 자세히 배우겠지만, 지금 중요한 점은 딕셔너리가 키를 값에 매핑한다는 것입니다(예: “apple”은 “fruit”에 매핑될 수 있습니다). 도시를 온도로 매핑했던 이전 예제를 가져와 보겠습니다. 키와 값 모두에 대해 반복하고 싶다면 다음과 같이 for 루프를 작성할 수 있습니다:

코드 보기
cities_to_temps = {"Paris": 28, "London": 22, "Seville": 36, "Wellesley": 29}

for key, value in cities_to_temps.items():
    print(f"{key}의 오늘 기온은 섭씨 {value}도입니다.")
Paris의 오늘 기온은 섭씨 28도입니다.
London의 오늘 기온은 섭씨 22도입니다.
Seville의 오늘 기온은 섭씨 36도입니다.
Wellesley의 오늘 기온은 섭씨 29도입니다.

딕셔너리 끝에 .items()를 추가한 것에 주목하세요. 그리고 키를 반드시 key라고 부르거나 값을 value라고 부를 필요는 없다는 점도 기억하세요. 이름은 위치에 의해 결정됩니다. 하지만 코드를 작성할 때 가장 좋은 원칙 중 하나는 놀라운 일이 없어야 한다(there should be no surprises)는 것이며, key, value라고 쓰는 것은 딕셔너리에서 값을 가져오고 있음을 정말 명확하게 해줍니다.

hmkdxpzkeiue 연습 문제 여러분이 아는 네 개의 도시를 각각의 국가로 매핑하는 딕셔너리를 작성하고, `key, value` 반복 기법을 사용하여 결과를 출력해 보세요.

또 다른 유용한 유형의 for 루프는 zip() 함수에 의해 제공됩니다. zip() 함수는 두 가지 다른 반복자(iterators)의 요소들을 차례대로 함께 묶어주는 지퍼와 같다고 생각할 수 있습니다. 다음은 예제입니다:

코드 보기
first_names = ["Ada", "Adam", "Grace", "Charles"]
last_names = ["Lovelace", "Smith", "Hopper", "Babbage"]

for forename, surname in zip(first_names, last_names):
    print(f"{forename} {surname}")
Ada Lovelace
Adam Smith
Grace Hopper
Charles Babbage

zip 함수는 실무에서 매우 유용합니다.

``{.callout-note} 연습 문제 위의 이름 리스트와 이 뒤섞인 성(surnames) 리스트를 함께 묶어보세요:[‘Babbage’, ‘Hopper’, ‘Smith’, ‘Lovelace’]`.

(힌트: 리스트를 재배치하는 데 도움이 되는 요령을 앞서 장에서 본 적이 있습니다.)


## 리스트(및 기타) 컴프리헨션 (List Comprehensions)

파이썬에서 루프를 수행하는 두 번째 방법이 있는데, 거의 대부분의(하지만 [전부는 아닌](https://towardsdatascience.com/list-comprehensions-vs-for-loops-it-is-not-what-you-think-34071d4d8207)) [경우](https://stackoverflow.com/questions/22108488/are-list-comprehensions-and-functional-functions-faster-than-for-loops)에 더 빠르게 실행됩니다. 더 중요한 점은, 그리고 이것이 가능하면 이것을 사용하는 것이 좋은 실제 이유인데, 가독성이 매우 높다는 것입니다. 이를 *리스트 컴프리헨션*이라고 합니다.

리스트 컴프리헨션은 `for` 루프와 (필요한 경우) `조건문`이 하는 일을 단 한 줄의 코드로 결합할 수 있습니다. 먼저, 각 값에 1을 더하는 `for` 루프를 리스트 컴프리헨션으로 작성해 보겠습니다 (참고: 실무에서는 이런 종류의 연산에는 매우 빠른 **numpy** 배열을 사용합니다):

::: {#7efed381 .cell execution_count=9}
``` {.python .cell-code}
num_list = range(50, 60)
[1 + num for num in num_list]
[51, 52, 53, 54, 55, 56, 57, 58, 59, 60]

:::

일반적인 패턴은 for 루프와 비슷하지만 몇 가지 차이점이 있습니다. 콜론도 없고 들여쓰기도 없습니다. 구문은 “x로 무언가를 해라” 그 다음 for x in 반복_가능한_객체입니다. 마지막으로, 출력을 리스트로 만들기 위해 표현식을 []로 감쌉니다.

리스트가 이런 구조에 제공할 수 있는 유일한 포장이 아니라는 점에 유의하세요. 제너레이터(generator, 이것이 무엇인지는 일단 걱정하지 마세요)를 만들려면 (), 세트(고유한 값만 포함하는 객체)를 만들려면 {}를 사용하며, 컴프리헨션으로부터 딕셔너리를 생성하는 것도 가능합니다! 리스트 컴프리헨션이 가장 일반적이므로, 딱 한 종류만 기억해야 한다면 이것을 기억하세요.

```hmkdxpzkeiue 연습 문제 1부터 10 사이의 숫자에 5를 곱하는 리스트 컴프리헨션을 만드세요.

범위(range)를 올바르게 설정했나요?


이제 리스트 컴프리헨션 내에 조건을 포함하는 방법을 보겠습니다. 숫자 리스트가 있고 나머지 연산자(modulo)를 사용하여 숫자가 3으로 나누어지는지에 따라 필터링하고 싶다고 가정해 보겠습니다:

::: {#722fda21 .cell execution_count=10}
``` {.python .cell-code}
number_list = range(1, 40)
divide_list = [x for x in number_list if x % 3 == 0]
print(divide_list)
[3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 36, 39]

:::

여기서 구문은 x가 어떤 조건을 만족할 때 x에 대해 무언가를 하라는 것입니다.

다음은 이름들 중에서 ’Smith’가 포함된 이름만 골라내는 또 다른 예제입니다:

코드 보기
names_list = ["Joe Bloggs", "Adam Smith", "Sandra Noone", "leonara smith"]
smith_list = [x for x in names_list if "smith" in x.lower()]
print(smith_list)
['Adam Smith', 'leonara smith']

대소문자에 관계없이 이름을 매칭하기 위해 ‘Smith’ 대신 ’smith’를 사용하고 lower()를 사용한 것에 주목하세요.

리스트 컴프리헨션 내부에서 전체 ifelse 구조를 수행할 수도 있습니다:

코드 보기
names_list = ["Joe Bloggs", "Adam Smith", "Sandra Noone", "leonara smith"]
smith_list = [x if "smith" in x.lower() else "Not Smith!" for x in names_list]
print(smith_list)
['Not Smith!', 'Adam Smith', 'Not Smith!', 'leonara smith']

우리가 본 많은 구조들을 결합할 수 있습니다. 예를 들어, zip()을 사용한 중첩되거나 반복되는 리스트 컴프리헨션을 가지지 못할 이유가 없으며, 때로는 이것들이 꽤 유용합니다!

코드 보기
first_names = ["Ada", "Adam", "Grace", "Charles"]
last_names = ["Lovelace", "Smith", "Hopper", "Babbage"]
names_list = [x + " " + y for x, y in zip(first_names, last_names)]
print(names_list)
['Ada Lovelace', 'Adam Smith', 'Grace Hopper', 'Charles Babbage']

리스트 컴프리헨션을 더 극단적으로 사용하면 중첩된 구조를 만들 수 있습니다:

코드 보기
first_names = ["Ada", "Adam"]
last_names = ["Lovelace", "Smith"]
names_list = [[x + " " + y for x in first_names] for y in last_names]
print(names_list)
[['Ada Lovelace', 'Adam Lovelace'], ['Ada Smith', 'Adam Smith']]

이는 (이 경우) first_names를 먼저 반복한 다음 last_names를 반복하는 중첩된 구조를 제공합니다. (이 객체는 문자열 리스트들의 리스트임에 유의하세요!)

이제 딕셔너리 컴프리헨션을 보겠습니다. 양 끝에 {}를 사용한다는 점에서 세트 컴프리헨션과 비슷해 보이지만 키와 값을 구분하는 콜론이 있다는 점이 다릅니다:

코드 보기
{key: value for key, value in zip(first_names, last_names)}
{'Ada': 'Lovelace', 'Adam': 'Smith'}

hmkdxpzkeiue 연습 문제 `[['a0', 'b0', 'c0'], ['a1', 'b1', 'c1'], ['a2', 'b2', 'c2']]`와 같은 문자열 리스트들의 리스트(즉, 처음 세 개의 정수와 알파벳 문자의 조합)를 결과로 내는 중첩 리스트 컴프리헨션을 만드세요. 이를 위해 숫자를 문자열로 변환하는 `str(x)`가 필요할 수 있습니다.

리스트 컴프리헨션에 대해 더 배우고 싶다면, 이 짧은 비디오 튜토리얼을 확인해 보세요.

While 루프 (While Loops)

while 루프는 조건식이 False로 평가될 때까지 코드를 계속 실행합니다. (물론 영원히 True로 평가된다면 코드는 계속해서 실행될 것입니다…)

코드 보기
n = 10
while n > 0:
    print(n)
    n -= 1

print("실행 완료")
10
9
8
7
6
5
4
3
2
1
실행 완료

참고: -=가 무엇을 하는지 궁금하다면, 이는 좌변을 좌변 마이너스 우변으로 설정하는 복합 할당문입니다.

예를 들어 수렴하지 않고 특정 횟수의 반복에 도달했을 때 break 키워드를 사용하여 while 루프를 빠져나올 수 있습니다.

hmkdxpzkeiue 연습 문제 `import string`을 한 뒤 `string.ascii_lowercase`를 사용하여 알파벳 문자를 가져오고, "z"부터 시작하여 알파벳을 거꾸로 반복한 뒤 "실행 완료"를 출력하는 while 루프를 작성해 보세요.

pandas 데이터 프레임을 사용한 반복

For 루프, while 루프, 그리고 컴프리헨션은 모두 pandas 데이터 프레임에서 작동하지만, 속도가 느리고 메모리 효율적이지 않기 때문에 대개는 좋은 방법이 아닙니다. 반복이 필요한 경우를 돕기 위해 pandas에는 수행하려는 작업에 따른 내장 반복 메서드들이 있습니다.

이러한 내장 반복 메서드들은 데이터 변환 (Data Transformation) 에서 본 것과 겹치는 부분이 있지만 여기서는 assign()/할당 연산, apply(), 그리고 eval()에 대해 좀 더 깊이 파고들어 보겠습니다.

할당 연산 (Assignment Operations) 및 assign

할당은 등호를 중간에 두고 오른쪽에 있는 값을 왼쪽의 객체에 할당하는 문장입니다.

다음과 같은 데이터 프레임이 있다고 상상해 봅시다:

코드 보기
import numpy as np
import pandas as pd

df = pd.DataFrame(np.random.normal(size=(6, 4)), columns=["a", "b", "c", "d"])
df
a b c d
0 0.358638 0.102193 -0.146995 0.077085
1 -0.567513 0.192648 0.447023 1.341536
2 -1.959364 0.529949 0.762739 0.908061
3 0.229658 -0.993967 0.437578 1.287358
4 -0.407756 -0.037928 1.090639 -0.326071
5 -0.233777 0.212949 -0.754871 -1.593886

pandas에는 행과 열을 반복하도록 이미 구축된 많은 내장 함수가 있습니다. 예를 들어 행이나 열의 중앙값을 각각 계산하려면 다음과 같이 합니다:

코드 보기
df.median(axis="rows")  # axis=1을 사용할 수도 있음
a   -0.320767
b    0.147420
c    0.442300
d    0.492573
dtype: float64
코드 보기
df.median(axis="columns")  # axis=0을 사용할 수도 있음
0    0.089639
1    0.319836
2    0.646344
3    0.333618
4   -0.182000
5   -0.494324
dtype: float64

이러한 경우와 내장 함수를 사용하는 다른 경우들에서 반복은 숨겨져 있습니다. 그렇다면 내장 함수도 아니고 집계(aggregation)도 아닌 무언가를 하고 싶다면 어떻게 해야 할까요? 모든 항목에 5를 더하는 예제를 들어보겠습니다. 명시적으로 행별로 반복한 다음 각 열에 대해 이를 반복하여 수행할 수도 있습니다. 즉,

코드 보기
# 이렇게 하지 마세요!


def add_five_slow(df):
    for i in range(len(df)):
        for j in range(len(df.columns)):
            df.iloc[i, j] = df.iloc[i, j] + 5


%timeit add_five_slow(df)
1.5 ms ± 20 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

하지만 이렇게 하려면 모든 개별 셀에 접근하여 조작해야 하므로 밀리초 단위가 걸려 매우 느립니다. pandas에는 동일한 연산을 수행하는 훨씬 빠른 방법들이 있습니다. 일관된 타입의 데이터 프레임에 대한 간단한 연산의 경우, 전체 데이터 프레임에 단순히 5를 더하면 됩니다:

코드 보기
%timeit df + 5
33.4 μs ± 390 ns per loop (mean ± std. dev. of 7 runs, 10,000 loops each)

수십 마이크로초가 걸렸으며, 훨씬 빠릅니다.

이는 열 단위로도 작동하므로 df["a"] = df["a"] + 5와 같이 할 수 있습니다.

이러한 연산들은 assign() 연산자를 사용하는 방식과 동일하며, 이를 통해 여러 연산을 한데 묶는 메서드 체이닝(method chaining)이 가능해집니다. df["new_a"] = df["a"] + 5assign() 연산자 버전은 다음과 같습니다.

코드 보기
df = df.assign(new_a=lambda x: x["a"] + 5)

Apply

반복하려는 함수가 더 복잡하다면 어떻게 될까요? 이때 pandasapply()가 등장하며, 할당(assignment)과 함께 사용될 수 있습니다. apply()는 행이나 열을 가로질러 사용될 수 있습니다. assign()과 마찬가지로 람다 함수와 결합하여 전체 데이터 프레임이나 단일 열(이 경우 axis=를 지정할 필요 없음)에 사용할 수 있습니다.

코드 보기
df.apply(lambda x: x["a"] - x["new_a"].mean() * x["c"] / x["b"], axis=1)
0   -4.750780
1   -5.254401
2   -5.232805
3   -6.431764
4   -6.128695
5   -4.032071
dtype: float64

이는 단지 예시일 뿐입니다: 여전히 apply를 사용하지 않고도 이 전체 연산을 수행할 수 있습니다! 하지만 가끔은 이를 사용해야 하는 경우를 발견하게 될 것입니다.

Apply는 사용자 정의 함수를 포함한 함수들과도 작동합니다:

코드 보기
def complicated_function(x):
    return x - x.mean()


df = df.apply(complicated_function, axis=1)
df
a b c d new_a
0 -0.791274 -1.047719 -1.296907 -1.072827 4.208726
1 -1.736750 -0.976588 -0.722213 0.172300 3.263250
2 -2.615769 -0.126455 0.106335 0.251657 2.384231
3 -1.008399 -2.232024 -0.800479 0.049301 3.991601
4 -1.389981 -1.020153 0.108413 -1.308297 3.610019
5 -0.713105 -0.266379 -1.234199 -2.073213 4.286895

Eval(uate)

eval()은 데이터 프레임 열에 대한 연산을 설명하는 문자열을 평가하여 새로운 열을 생성합니다. 행이나 요소가 아닌 열에 대해서만 작동합니다. 예제입니다:

코드 보기
df["ratio"] = df.eval("a / new_a")
df
a b c d new_a ratio
0 -0.791274 -1.047719 -1.296907 -1.072827 4.208726 -0.188008
1 -1.736750 -0.976588 -0.722213 0.172300 3.263250 -0.532215
2 -2.615769 -0.126455 0.106335 0.251657 2.384231 -1.097112
3 -1.008399 -2.232024 -0.800479 0.049301 3.991601 -0.252630
4 -1.389981 -1.020153 0.108413 -1.308297 3.610019 -0.385034
5 -0.713105 -0.266379 -1.234199 -2.073213 4.286895 -0.166345

Evaluate는 위 예제에서 "a > 0.5"와 같은 문자열을 사용하여 새로운 불리언 열을 만드는 데에도 사용될 수 있습니다.