해당 내용은 Datacamp의 Data engineering track을 정리했습니다.
Writing Efficient Python Code의 chapter 3에 대한 내용입니다.
기본적인 내용보다는 강의를 들으면서 처음 알게 된 내용을 위주로 작성했습니다.
해당 포스팅에는 아래의 내용을 포함하고 있습니다.
- 객체 모으기, Counting하기 (collections, itertools)
- 집합연산, list, tuple, set 탐색 속도 비교
- for, while 문 대신 효율적으로 바꾸기
1. Efficiently combining, counting, and iterating
다양한 리스트를 하나의 튜플형태로 만들거나, 합치는 작업은 많이 쓰게 됩니다. 강의에서는 포켓몬스터로 예시를 들어서 설명했습니다.
names = ['Bulbasaur', 'Charmander', 'Squirtle']
hps = [45, 39, 44]
combined = []
for i, pokemon in enumerate(names):
combined.append((pokemon, hps[i]))
일반적으로 두 개의 리스트의 각각 대응되는 값을 튜플로 묶을 때, 반복문을 주로 활용하게 됩니다. 이 방법 말고도 더 빠르게 할 수 있는 방법들이 있습니다. 바로, zip을 통해서 2개를 하나로 묶을 수 있습니다.
#Use zip()
combined_zip = zip(names, hps)
combined_zip_list = [*combined_zip] # list(combined_zip)
다음으로 Built-in module 중에 collections를 소개하고 있습니다. collections에는 다양한 형태의 datatype을 지원하고 있습니다. 종류에는 namedtuple, deque, Counter, OrderedDict, defaultdict 등이 존재합니다. 그 중에 객체의 개수를 파악하는 Counter를 사용해보겠습니다.
# General
poke_types = ['Grass', 'Dark', 'Fire', 'Fire', ... ]
type_counts = {}
for poke_type in poke_types:
if poke_type not in type_counts:
type_counts[poke_type] = 1
else:
type_counts[poke_type] += 1
# Use collections.Counter
from collections import Counter
type_counts = Counter(poke_types)
훨씬 간결하게 작성할 수 있습니다. Counter의 경우에는 개수가 많은 순서대로 내림차순으로 정렬되지만 위의 for 문을 이용한 경우에는 key값이 입력된 순서대로 저장하는 차이가 있습니다. 시간적인 측면에서는 Counter를 사용했을 때 절반의 시간밖에 소요되지 않습니다.
이번에는 itertools라는 모듈을 소개합니다. itertools는 수학적인 툴로 순열, 조합 등 다양한 연산을 할 수 있는 모듈입니다.
#Use For Loop
Poke_types = ['Bug', 'Fire', 'Ghost', 'Grass', 'Water']
combos = []
for x in poke_types:
for y in poke_types:
if x == y:
continue
if ((x,y) not in combos) & ((y,x) not in combos):
combos.append((x,y))
#Use itertools.combinations()
from itertools import combinations
combos_obj = combinations(poke_types, 2)
combos = [*combos_obj] #list(combos_obj)
위의 코드를 통해서 이중 For문을 활용하는 것보다 훨씬 간단하게 조합을 구할 수 있습니다.
2. Set theory
파이썬은 기본적으로 집합연산에 대한 method를 지원하고 있습니다. intersection(교집합), difference(차집합), symmetric_difference(대칭차집합), union(합집합) 등을 지원합니다. 또한 집합에 원소를 포함하고 있는지 확인하려면 exists나 in을 활용할 수 있습니다.
#Use For Loop
%%timeit
in_common = []
for pokemon_a in list_a:
for pokemon_b in list_b:
if pokemon_a == pokemon_b:
in_common.append(pokemon_a)
#Use built-in method
%timeit in_common = set_a.intersection(set_b)
위의 코드에서 확인해보면 For Loop로 구성한 것에 비해 built-in method를 사용한 경우 속도면에서 4배가량 빠른 것을 확인할 수 있습니다.
다음으로 원소가 집합 내에 있는지 확인할 때, 데이터타입마다 어떤 것이 더 빠른지 확인했습니다. 리스트, 튜플, set을 비교했을 때, 리스트와 튜플은 비슷한 속도를 보였으나, set은 확실히 리스트와 튜플에 비해 빠른 속도를 보였습니다. 또한 리스트 내부의 고유값만 남기고 싶을 때에는 굳이 전체의 리스트를 확인할 필요없이 set()을 사용하면, 중복된 값들은 제거할 수 있습니다.
3. Eliminating loops
이전 강의들에서 확인했던 것처럼 loop가 꼭 필요하지 않은 경우에는 비효율적일 수 있습니다. Loop를 제거할 때, Loop를 활용하기 전보다 더 적은 code를 사용해도 되고, 가독성, 효율성 면에서 장점이 존재합니다.
# List of HP, Attack, Defense, Speed
poke_stats = [
[90, 92, 75, 60],
[25, 20, 15, 90],
[65, 130, 60, 75],
]
# For loop approach
totals = []
for row in poke_stats:
totals.append(sum(row))
# list comprehension
totals_comp = [sum(row) for row in poke_stats]
# Built-in map() function
totals_map = [*map(sum, poke_stats)]
위의 3가지 방법으로 각 stat을 합한 결과를 얻을 수 있습니다. 실행 결과를 확인해보면 map - list comprehension - For loop 순으로 빠르게 동작했습니다. 굳이 필요없는 loop의 경우에는 함수를 활용하여 제거하는 것이 좋습니다.
4. Writing better loops
만약, loop를 꼭 설정해야 한다면 loop 내부에서 연산하지 않아도 되는 것은 밖으로 빼주면 더 좋은 성능을 보장할 수 있습니다.
import numpy as np
names = ['Absol', 'Aron', 'Jynx', 'Natu', 'Onix']
attacks = np.array([130, 70, 50, 50, 45])
# Bad
for pokemon, attack in zip(names, attacks):
total_attack_avg = attacks.mean() # 불필요하게 for문 마다 계산
if attack > total_attack_avg:
print(
"{}'s attack: {} > average: {}!".format(pokemon, attack, total_attack_avg)
)
# Good
total_attack_avg = attacks.mean()
for pokemon, attack in zip(names, attacks):
if attack > total_attack_avg:
print(
"{}'s attack: {} > average: {}!".format(pokemon, attack, total_attack_avg)
)
위의 코드를 보면 불필요하게 평균을 loop가 돌 때마다 계산하는 구조로 되어 있습니다. 이처럼 1번만 구해도 되는 경우에는 위로 빼서 계산하도록 해주는 것이 시간 효율을 높일 수 있는 방법입니다. 실제로 해당 코드를 %timeit으로 비교하면 절반가량 줄어드는 것을 확인할 수 있습니다.
names = ['Pikachu', 'Squirtle', 'Articuno', ...]
legend_status = [False, False, True, ...]
generations = [1, 1, 1, ...]
poke_data = []
# Bad
for poke_tuple in zip(names, legend_status, generations):
poke_list = list(poke_tuple)
poke_data.append(poke_list)
# Good
for poke_tuple in zip(names, legend_status, generations):
poke_data.append(poke_list)
poke_data = [*map(list, poke_data)]
zip함수를 사용하면 tuple형태로 반환하게 되는데, 리스트 내부에 리스트 형태로 넣고 싶다면 형태를 변형해줄 필요가 있습니다. 매번 tuple을 바꾸는 것보다 tuple형태로 얻어진 데이터를 쌓은 뒤에 map을 통해서 한번에 변환해주는 것이 더 좋은 방법입니다. 시간복잡도를 보면 매번 리스트로 변경했을 때보다 10%정도 더 빠른 것을 볼 수 있습니다.