앎을 경계하기

[가짜연구소3기] Data Engineer

[가짜연구소 3기] 데이터 엔지니어링 42 - Gaining efficiencies

양갱맨 2021. 9. 13. 09:53

주제


객체를 효율적으로 결합, 계산, 반복하는 방법은 무엇일까

예시를 통해 방법을 알아보자.

포켓몬 데이터셋

포켓몬을 수집하려는 트레이너를 중심으로 게임이 진행된다.

트레이너는 포켓몬을 포획하여 컬렉션에 추가하고자 한다.

포획에 성공하면 포켓덱스에 정보를 추가하게 된다.

 

각 포켓몬들은 고유의 메타데이터를 가지고 있다.

포켓몬 이름과 체력이 저장된 목록 두 개가 있다.

두 목록을 하나로 결합할 수 있는 방법은 앞서 배웠던 enumerate 를 사용하는 것이다.

names = ['이상해씨', '파이리', '꼬부기']
hps = [45, 39, 44]
combined = []

for i, pokemon in enumerate(names):
	combined.append((pokemon, hps[i]))
print(combined)
[('이상해씨', 45), ('파이리', 39), ('꼬부기', 44)]

그러나 내장함수 zip을 사용하면 더 간결하고 효율적으로 사용할 수 있다.

names = ['이상해씨', '파이리', '꼬부기']
hps = [45, 39, 44]
combined_zip = zip(names, hps)
print(combined_zip)
<zip object at 0x104f938c0>

바로 출력하면 zip 객체에 대한 출력이 나오기 때문에 각 요소를 확인하기 위해서는 언패킹을 해야한다.

combined_zip_list = [*combined_zip]
print(combined_zip_list)
[('이상해씨', 45), ('파이리', 39), ('꼬부기', 44)]

collections 모듈

파이썬의 내장 모듈인 collections를 사용하면 편리하게 특수 데이터 타입들을 사용할 수 있다.

자주 사용하는 dict, list, set, tuple의 대안 자료구조를 사용할 수 있다.

  • 대표예시
    • namedtuple : 인덱스로만 접근하던 튜플과 다르게 field_name을 사용해서 접근할 수 있다.
    • deque : 양 끝에서 삽입 삭제를 할 수 있는 양방향 큐, 리스트는 양 끝 요소 삽입/제거가 O(n) 요소, 덱은 O(1)로 가능하다.
    • Counter : dictionary의 확장버전, 동일한 값이 몇개인지 파악할 때 사용한다.
    • OrderedDict : 아이템들의 순서를 기억하는 dictionary, key 인수를 통해 정렬 기준을 설정할 수 있다.
    • defaultdict : dict 클래스의 서브클래스, 요소의 기본 값을 초기값으로 지정할 수 있다. 키나 값이 존재하지 않는 경우를 처리할 때 많이 사용한다.

Counter

포켓몬 데이터에 총 720개의 포켓몬 데이터가 있다고 한다.

타입 별 개수가 몇 개인지 알아보기 위해 dictionary 객체를 만든다.

# 각 포켓몬들의 타입
poke_types = ['Grass', 'Dark', 'Fire', 'Fire', ...]
type_counts = {}

for poke_type in poke_types:
	# key가 없으면
	if poke_type not in type_counts:
		type_counts[poke_type] = 1
	else:
		type_counts[poke_type] += 1
print(type_counts)

Counter를 사용하면 쉽고 간결하게 코드를 작성할 수 있다.

poke_types = ['Grass', 'Dark', 'Fire', 'Fire', ...]
from collections import Counter
type_counts = Counter(poke_types)
print(type_counts)

시간 비교

!pip install line_profiler
%load_ext line_profiler

def dict_for():
	type_counts = {}

	for poke_type in poke_types:
		# key가 없으면
		if poke_type not in type_counts:
			type_counts[poke_type] = 1
		else:
			type_counts[poke_type] += 1
	print(type_counts)

from collections import Counter

def use_counter():
	type_counts = Counter(poke_types)
	print(type_counts)


%lprun -f dict_for dict_for() # Total time: 0.000541 s
%lprun -f use_counter use_counter() # Total time: 0.000105 s

itertools 모듈

collections와 마찬가지로 내장모듈 itertools 를 사용하여 효율적으로 반복 작업을 할 수 있다.

  • 대표 예시
    • 무한 반복 : count, cycle, repeat
    • 유한 반복 : accumulate, chain, zip_longest
    • 조합 생성 : product, permutations, combinations

조합 생성기를 사용하여 가능한 모든 포켓몬 유형 조합 쌍을 만들어보자.

poke_types = ['곤충', '불', '유령', '풀', '물']
combos = []

# 7.22 µs
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))
print(combos)
[('곤충', '불'), ('곤충', '유령'), ('곤충', '풀'), ('곤충', '물'), 
('불', '유령'), ('불', '풀'), ('불', '물'), ('유령', '풀'), ('유령', '물'), ('풀', '물')]

combinations 사용하기

poke_types = ['곤충', '불', '유령', '풀', '물']
from itertools import combinations
combos_obj = combinations(poke_types, 2) # 77.2ns
print([*combos_obj])
[('곤충', '불'), ('곤충', '유령'), ('곤충', '풀'), ('곤충', '물'), ('불', '유령'), ('불', '풀'), ('불', '물'), ('유령', '풀'), ('유령', '물'), ('풀', '물')]

집합 이론

파이썬에서는 쉽게 집합 연산을 할 수 있도록 set 내장 타입을 제공하고 있다.

  • intersection() : 교집합
  • difference() : 차집합
  • symmetric_difference() : 대칭 차집합 ( 한 집합에는 속하지만 모두에는 속하지 않는 원소들의 집합)
  • union() : 합집합

마찬가지로 예제를 통해 이해해보자

list_a = ['이상해씨', '파이리', '꼬부기']
list_b = ['캐터피', '구구', '꼬부기']

#공통 요소 찾기
in_common = []

#브루트 포스 탐색
for pokemon_a in list_a:
	for pokemon_b in list_b:
		if pokemon_a == pokemon_b:
			in_common.append(pokemon_a)

print(in_common)
['꼬부기']

O(n^2)이 걸린다.

비효율적임

집합 연산을 사용해보자

set_a = set(list_a)
set_b = set(list_b)

set_a.intersection(set_b) #O(len(s) + len(t)) 걸림
{'꼬부기'}

차집합을 사용해서 각자 집합에 공통되지 않은 요소를 추출할 수 있다.

print(set_a.difference(set_b),
set_b.difference(set_a),
set_a.symmetric_difference(set_b))
{'파이리', '이상해씨'} {'캐터피', '구구'} {'구구', '캐터피', '파이리', '이상해씨'}

in을 사용한 요소 확인

list, tuple, set을 사용한 목록 객체에서 특정 요소가 존재하는지를 판단할 때 in 을 사용하는데, 이때 set을 사용하는 것이 list나 tuple보다 훨씬 빠르다.

set은 또한 중복이 불가능하기 때문에 각 원소가 고유하다.


루프 제거하기

코드 작성 시 루프를 사용하는 것이 속도 면에서 효율성을 좌지우지한다고 할 수 있다.

(시간복잡도를 계산할 때 생각해보면 반복문이 중요함!)

파이썬에서는 객체 내용을 반복할 수 있는 패턴을 제공한다.

  • for
  • while
  • 중첩 루프

효율적인 코드를 위해 이런 루프를 최소화 해야한다.

루프를 제거하면 좋은 점

  • 코드 수 줄어즘
  • 가독성 개선
  • 효율성 개선

루프를 제거할 수 있는 방법의 예시를 보자.

poke_stats = [
	[90,92,75,60],
	[25,20,15,90],
	[65,130,60,75],
	...]

각 포켓몬의 상태 값에 대한 2차원 리스트가 있다.

각 행의 원소는 체력, 공격력, 방어력, 속도를 나타낸다.

컬럼 별로 합산하기 위해 코드를 작성해보자.

# 1. loop 사용
totals = []
for row in poke_stats:
	totals.append(sum(row))

# 2. list comprehension
totals_comp = [sum(row) for row in poke_stats]

# 3. mapping
totals_map = [*map(sum, poke_stats)]

map() 함수를 사용하는 것이 속도면에서 훨씬 효율적이다.

그리고 앞에서 배운 itertools 모듈을 사용하여 루프로 구현한 코드를 간단하게 바꿔줄 수 있다.

poke_types = ['곤충', '불', '유령', '풀', '물']

# 중첩 루프
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))

# 조합 사용
from itertools import combinations
combos_obj = [*combinations(poke_types, 2)]

또 다른 방법은 NumPy 를 사용하는 것이다.

넘파이 패키지는 행렬, 벡터 연산 기능이 잘 되어있어서 루프를 사용하지 않고 연산이 가능하다.

import numpy as np

poke_stats = np.array([
	[90,92,75,60],
	[25,20,15,90],
	[65,130,60,75],
	...])

#각 행 별 평균 구하기 - loop
avgs = []
for row in poke_stats:
	avg = np.mean(row)
	avgs.append(avg)
print(avgs)

#각 행 별 평균 구하기 - numpy
avgs_np = poke_stats.mean(axis=1)

랜덤 샘플링 값이 들어있는 매트릭스 예제를 통해 시간을 측정해보았다.

rnd_matrix = np.random.random_sample((100,4))
avgs = []

#248 µs ± 737 ns per loop (mean ± std. dev. of 7 runs, 1000 loops each)
%%timeit
for row in rnd_matrix:
    avgs.append(np.mean(row))

#3.39 µs ± 8.2 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
%timeit avgs_np = rnd_matrix.mean(axis=1)

모든 루프를 다 제거할 수는 없다.

루프를 반드시 써야하는 경우도 있기 때문에 최대한 루프 코드를 효율적으로 작성하는 방법에 대해 배워보자.

  • 각 루프마다 수행되는 것에 대해 이해해야한다.
  • 일회성 계산은 루프 밖에서 진행
  • 전체 변환 작업도 루프 밖에서 진행
import numpy as np

names = ['앱솔', '가보리', '루주라', '네이티', '롱스톤']
attacks = np.array([13,70,50,50,45])

for pokemon, attack in zip(names, attacks):
    total_attack_avg = attacks.mean()
    if attack > total_attack_avg:
        print("{}'s attack : {} > average : {}!".format(pokemon, attack, total_attack_avg))
가보리's attack : 70 > average : 45.6!
루주라's attack : 50 > average : 45.6!
네이티's attack : 50 > average : 45.6!

total_attack_avg = attacks.mean() 은 매번 계산할 필요가 없기 때문에 루프 밖으로 빼는 것이 효율적임

names = ['피카츄', '꼬부기', '프리져', '이상해씨']
legend_status = [False, False, True, False]
generations = [1,1,1,1]
poke_data = []

for poke_tuple in zip(names, legend_status, generations):
    poke_list = list(poke_tuple)
    poke_data.append(poke_list)

print(poke_data)
[['피카츄', False, 1], ['꼬부기', False, 1], ['프리져', True, 1], ['이상해씨', False, 1]]

tuple → list 변환을 굳이 루프 안에서 할 필요가 없다.

poke_data_tuples = []
for poke_tuple in zip(names, legend_status, generations):
    poke_data_tuples.append(poke_tuple)
poke_data = [*map(list, poke_data_tuples)]
print(poke_data)
[['피카츄', False, 1], ['꼬부기', False, 1], ['프리져', True, 1], ['이상해씨', False, 1]]