다룰 내용
- 제너레이터에서 메모리를 절약하는 법
- 제너레이터가 유용한 상황
- 복잡한 제너레이터 작업에 itertools를 사용하는 방법
- 지연 연산이 효과적인 경우와 그렇지 않은 경우
제너레이터
제너레이터는 필요할 때마다 값을 생성할 수 있는, 즉 지연 연산을 할 수 있는 함수이다.
간단히 return 대신 yield를 사용해 값을 반환하는 함수가 제너레이터이다.
for i in range(n):
do_work(i)
가장 쉽게 볼 수 있는 제너레이터는 range()로 미리 모든 항목을 만들지 않고 필요할 때마다 지연 연산을 해서 값을 불러온다. 고로 미리 만들 필요가 없어 시간도 오래 걸리지 않고 메모리도 많이 필요하지 않다.
# 피보나치 리스트를 만드는 함수
def fibonacci_list(num_items):
numbers = []
a, b = 0, 1
while len(numbers) < num_items:
numbers.append(a)
a, b = b , a+b
return numbers
# 피보나치 수를 하나씩 반환하는 제너레이터
def fibonacci_gen(num_items):
a, b = 0, 1
while num_items:
yield a
a, b = b , a+b
num_items -= 1
fibonacci_gen() 함수는 return이 아니라 yield를 사용해 필요할 때마다 값을 생산하는 제너레이터이다.
제너레이터에서 더이상 값을 만들지 못하면 StopIteration 예외를 발생시켜 값이 없다는 것을 알린다.
두 함수는 같은 연산을 같은 회수만큼 수행하지만 첫번째 함수가 메모리를 num_items배 더 많이 사용한다.
for 루프의 내부 동작
# 이 파이썬 for 루프는
for i in object:
do_work(i)
# 아래 코드와 같다.
object_iterator = iter(object)
while True:
try:
i = next(object_iterator)
except StopIteration:
break
else:
do_work(i)
for문에서는 iter(object)를 만들어 이터레이터로 만든 뒤 하나씩 순차적으로 값을 내보낸다.
만약 object가 제너레이터라면 제너레이터는 이미 이터레이터이므로 새로운 이터레이터를 만들지 않고 그대로 내보내지만,
object가 리스트라면 새로운 리스트 이터레이터 객체를 만들어야 한다.
즉, 한 번에 하나의 값만 필요하더라도 전체 데이터를 저장하는 공간을 다시 할당해야 한다.
%timeit, %memit을 통한 for문의 내부 동작 비교
# 리스트 생성 함수
"""
>>> %timeit test_fibonacci_list()
332 ms ± 13.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
>>> %memit test_fibonacci_list()
peak memory: 492.82 MiB, increment: 441.75 MiB
"""
# 제너레이터
"""
>>> %timeit test_fibonacci_gen()
126 ms ± 905 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
>>> %memit test_fibonacci_gen()
peak memory: 51.13 MiB, increment: 0.00 MiB
"""
리스트 컴프리헨션과 제너레이터 컴프리헨션
간단하게 리스트 컴프리헨션은 대괄호 안에 for문을 사용하는 것이고,
제너레이터 컴프리헨션은 괄호 안에 for문을 사용하는 것이다.
# 리스트 컴프리헨션
result = len([n for n in fibonacci_gen(100_000) if n % 3 == 0])
# 제너레이터 컴프리헨션
result = sum(1 for n in fibonacci_gen(100_000) if n % 3 == 0)
결과만 얻고 싶을 때, 리스트 컴프리헨션을 쓸 경우 값을 배열에 저장하므로 불필요하게 메모리를 소모한다.
하지만 제너레이터 컴프리헨션을 쓰면 배열이 아닌 값을 생성하는 제너레이터를 사용해 메모리를 절약할 수 있다.
제너레이터의 장점
피보나치 리스트는 주어진 n에 대해 리스트를 만들지만, 제너레이터는 무한히 값을 구할 수 있고 충분히 처리가 끝나면 프로그램을 종료할 수 있다.
밑의 코드는 제너레이터의 장점을 보여준다.
def fibonacci_naive():
i, j = 0, 1
count = 0
while j <= 5000:
if j % 2:
count += 1
i, j = j, i + j
return count
def fibonacci_transform():
count = 0
for f in fibonacci():
if f > 5000:
break
if f % 2:
count += 1
return count
from itertools import takewhile
def fibonacci_succinct():
first_5000 = takewhile(lambda x: x <= 5000,
fibonacci())
return sum(1 for x in first_5000
if x % 2)
- 1번째 함수는 피보나치도 계산하고, 몇 번 계산했는지 등 한번에 여러 작업을 하므로 실제로 어떤 계산을 수행하는지 알아보기 힘들다.
- 3번째 함수는 코드를 아주 간단히 작성했지만 가독성이 떨어진다.
- 2번째 함수는 fibonacci()에서 하나씩 피보나치 수가 나오고 홀수면 카운팅하는 함수임을 쉽게 파악할 수 있다. (가독성이 좋고 파이써닉하다.)
- 그리고 2번째 함수는 fibonacci()가 아니어도 다른 함수를 사용할 수 있기 때문에 다른 코드에 대해 일반화하기 쉽다.
제너레이터의 장점은 제너레이터를 데이터를 생성하는 목적으로만 사용하고 생성된 데이터는 일반 함수가 처리하도록 분명히 구분할 때 얻을 수 있다.
제너레이터의 지연 계산
제너레이터는 리스트가 아니므로 현재값만 사용할 뿐, 수열의 다른 값을 참조할 수 없다.
이런 특징 때문에 제너레이터를 사용하기 어려울 때가 있는데, 이럴 때 도움이 되는 모듈과 함수가 많다.
그 중 가장 대표적인게 itertools이다.
예제
20년간 초단위로 '타임스탬프, 값'의 형태로 기록한 데이터의 특이점을 찾아라. (제너레이터를 사용해서)
from random import normalvariate, randint
from itertools import count, islice, groupby, filterfalse
from datetime import datetime
from scipy.stats import normaltest
def read_data(filename):
'''
파일을 불러와서 한줄씩 반환하는 제너레이터
'''
with open(filename) as fd:
for line in fd:
data = line.strip().split(',')
timestamp, value = map(int, data)
yield datetime.fromtimestamp(timestamp), value
def groupby_day(iterable):
'''
read_data 제너레이터 객체를 인자로 받아서
itertools의 groupby를 이용해 날짜를 기준으로 묶은 하나의 그룹을 반환하는 제너레이터
예를 들어, (11일, 11일, 12일)이면 (11일, 11일)처럼 한 그룹을 만든 후 이를 반환
is_normal()에 있는 normaltest()가 인자로 배열을 요구하므로
groupby로 묶인 그룹을 list로 형변황해서 반환
'''
key = lambda row: row[0].day
for day, data_group in groupby(iterable, key):
yield list(data_group)
def is_normal(data, threshold=1e-3):
'''
groupby_day에서 반환된 한 그룹을 인자로 받아
이 하루 동안의 데이터에서 특이점이 있는지 확인하는 함수
'''
_, values = zip(*data)
k2, p_value = normaltest(values)
if p_value < threshold:
return False
return True
def filter_anomalous_groups(data):
'''
itertools의 filterfalse()는 특정 함수에 특정 제너레이터를 인자로 주고, is_normal에서 false가 반환될 때까지 반복하다 false가 반환되면 해당 데이터를 반환한다.
'''
yield from filterfalse(is_normal, data)
def filter_anomalous_data(data):
'''
데이터를 그룹별로 나눠서 한 그룹을 가져오고
해당 그룹을 필터링을 통해 특이점이 있는지 확인하고 특이점이 있으면 반환한다.
'''
data_group = groupby_day(data)
yield from filter_anomalous_groups(data_group)
data = read_data("./filename")
anomaly_generator = filter_anomalous_data(data)
# itertools의 islice는 주어진 제너레이터 객체에서 5개의 항목을 요청해 이터레이터로 반환하는 함수이다.
first_five_anomalies = islice(anomaly_generator, 5)
for data_anomaly in first_five_anomalies:
start_date = data_anomaly[0][0]
end_date = data_anomaly[-1][0]
print(f"Anomaly from {start_date} - {end_date}")
위 코드의 동작과정은 다음과 같다.
- 한줄씩 데이터를 읽어오는 제너레이터 객체 'data' 생성
- 한줄씩 데이터를 읽어와서 날짜별로 그룹을 묶어 특이점이 있는 날짜가 나올 때까지 반복한 후 찾으면 반환하는 제너레이터 객체 'anomaly_generator' 생성
- islice를 통해 anomaly_generator에게 5번 값을 요청해 이터레이터를 만들어 first_five_anomalies에게 반환
- for문을 돌며 first_five_anomalies안 5개 값을 출력
이중에 1, 2, 3번이 헷갈리는데,
1, 2번은 단순히 제너레이터 객체를 만든 것이고 아직 동작은 안한 상태이다.
3번이 시작된 순간, islice가 anomaly_generator에게 값을 요청하기 시작해 제너레이터가 동작한다.
이때, 코드 상의 4개의 제너레이터가 어떻게 동작하는지 감이 안올 수 있다.
처음에 islice가 anomaly_generator에게 값을 요청하면 먼저 날짜별로 그룹을 묶기 위한 제너레이터 객체 'data_group'을 생성한다. (이를 생성하는 코드는 처음 한번만 동작한다.)
이후 filter_anomalous_groups()에 data_group을 인자로 넘겨주면 false가 나올 때까지 제너레이터 값을 요청하는 filterfalse()가 동작하고 false가 나온 데이터를 찾으면 이를 5번 반복한 후 이터레이터로 만들어 first_five_anomalies에 저장한다.
이후 값이 더 필요하다면 next나 islice를 사용해 값을 더 가져올 수 있다.
마치며
대용량 데이터를 처리할 때, 리스트를 사용하면 실행 시간을 잡아먹는 append 연산 때문에 느리게 동작할 것이다.
하지만 제너레이터를 사용하면 요청된 데이터에 대한 계산만 이뤄지므로, 전체 실행시간과 필요한 메모리가 엄청나게 줄어드므로 대용량 데이터를 처리하기에 용이하다.
'Python' 카테고리의 다른 글
[Python] 삼항 연산자 (0) | 2023.01.26 |
---|---|
[Python] 파이썬 개요 (0) | 2023.01.26 |
[고성능 파이썬] 4. 사전과 셋 (0) | 2023.01.25 |
[고성능 파이썬] 3. 리스트와 튜플 (0) | 2023.01.25 |
[고성능 파이썬] 2. 프로파일링으로 병목 지점 찾기 (0) | 2023.01.24 |