[Pandas] np.where과 np.logical_and를 이용하여 특정 조건 만족 여부를 확인하기

송희
17 min readJun 27, 2023

--

Photo by Brooke Lark on Unsplash

회사 입사 후 당시 처음으로 맡았던 큰 프로젝트가 있었습니다. 제대로 사용해본 적이 없던 pandas를 토대로 복잡한 로직을 담느라 생각했던 것보다 오랜시간 끙끙 앓았지만, 결과적으로 유저들의 반응이 예상보다 좋아서 뿌듯함이 더 큰 프로젝트이기도 했습니다.

이 프로젝트를 준비하면서 고민했던 과정을 담아보고 싶어서 글을 작성하게 되었습니다. 그동안은 이커머스 위주의 회사였는데, 다음과 같은 사용자들의 니즈를 확인하면서 건강 관리 솔루션으로의 확장을 준비하게 되어 의미가 깊은 프로젝트입니다.

<사용자들의 니즈>

  • 섭취 후 섭취, 건강, 신체 변화에 대해 회고하고 싶어하는 사용자의 니즈
  • 식단기록을 바탕으로 어떻게 유저의 회고를 돕고, 프로덕트 내에서 건강하게 먹는 습관을 형성할 수 있을까에 대한 고민

간략한 기능명세서

그래서 만든 식단 리포트 기능은

  • 얼마나 먹었는지
  • 섭취 결정을 위해 ( 적정 ,초과 , 미달)
  • 근성장과 다이어트 등의 목표 달성

을 토대로 개발되었습니다.

랜딩 페이지에 있는 기능소개

실제 예시들은 아래에서 확인할 수 있습니다.

일간, 주간, 월간 리포트 예시

그래서 가장 어려웠던 부분은?

다음 두 가지 사항이 까다롭게 느껴졌습니다.

  • 적정 ,초과 , 미달에 대한 판단
  • 예외 처리들

이 사항들이 왜 까다로웠는지, 어떻게 문제를 풀어나갔는지 과정을 풀어나가보겠습니다.

적정 ,초과 , 미달에 대한 판단

  • 저희 서비스에서는 사용자의 신체 정보활동 정보를 토대로 먹어야하는 칼탄단지(칼로리, 탄수화물, 단백질, 지방) 범위를 계산하고 있습니다.

기록된 내역이 있다는 전제하에서,
하루의 영양상태는 다음과 같은 4가지 종류로 나눌 수 있습니다.

<일간 영양상태>

  • 적정 : 최소~최대 범위 사이의 값
  • 초과 : 최대 범위 초과
  • 미달 : 최소 범위 미만
  • 불균형 : 초과 & 미달이 같이 있을 때

<일간 영양소>

상단의 일간 영양상태를 구하기 위해서는 먼저, 일간 영양소의 상태(적정 ,초과 , 미달)를 판단해야 했습니다.

하지만 모든 영양소가 적정 ,초과 , 미달를 따르는 것은 아니었습니다.

일간 리포트에서 보여지는 영양소는 총 하단의 6가지였고,

  • 칼로리
  • 탄수화물
  • 단백질
  • 지방
  • 나트륨

이 중 칼로리, 탄수화물, 단백질, 지방, 나트륨은 상단의 기준대로 적정 ,초과 , 미달로 분류할 수 있었지만,

의 경우에는 적정 ,초과 로만 분류했어야 했습니다.

(당은 최소 섭취량이 없다는 영향학적 지식을 배울 수 있었습니다🌝 )

<로직 — 일간 영양소>

pandas의 apply 함수를 이용해 해당 row 값을 기준으로 영양소의 적정 ,초과 , 미달 상태를 판단하였습니다.

이때 당시에는 이중 if문을 쓰는 것이 꺼려져

(..지금은 라이브러리 소스 코드에서도 이중 if문이 많은 것들을 보고 깨달음을 얻어 필요하다면 잘 씁니다..)

이 방법이 맞나 한참 고민했으나, 이 방법외에 해결사항이 없어서 다음과 같이 이중 if문을 통해 당의 경우 초과만 판단하도록 해당 로직을 구현하였습니다.

# 각 영양소의 영양 상태(적정/초과/미달) 판단
NUTRIENT_6_KEYS = ['calorie', 'carbohydrate', 'protein', 'fat', 'sugar', 'sodium']df_diet_diary_by_day[NUTRIENT_6_KEYS] = df_diet_diary_by_day[NUTRIENT_6_KEYS].fillna(0)for nutrient in NUTRIENT_6_KEYS:
df_diet_diary_by_day[f'{nutrient}_status'] = df_diet_diary_by_day.apply(
lambda x: self.set_meal_nutrient_proper_status(row=x, nutrient=nutrient), axis=1
)
@staticmethod
def set_meal_nutrient_proper_status(row, nutrient):
if pd.isnull(row[nutrient]):
# None 인경우 None으로 처리
return MEAL_NUTRIENT_NONE_STATUS
    try:
if nutrient == 'sugar':
if row[nutrient] > row[f'{nutrient}_excess']:
return MEAL_NUTRIENT_EXCESS_STATUS
else:
return MEAL_NUTRIENT_PROPER_STATUS
else:
if row[f"{nutrient}_lack"] <= row[nutrient] <= row[f"{nutrient}_excess"]:
return MEAL_NUTRIENT_PROPER_STATUS
elif row[nutrient] < row[f"{nutrient}_lack"]:
return MEAL_NUTRIENT_LACK_STATUS
else:
return MEAL_NUTRIENT_EXCESS_STATUS
except TypeError:
return None

위의 함수를 실행하였을 때, 각 영양소의 상태가 나옴을 확인할 수 있습니다

(1: 미달, 2: 적정, 3:초과)

<로직 — 일간 영양상태>

이제 상단에서 구한 각 영양소의 상태를 바탕으로 일간 영양상태를 판단해보겠습니다.

적정 ,초과 , 미달 ,불균형

더 풀어쓰면 아래와 같습니다.

적정 : 모든 영양소가 초과, 미달 없이 다 적정일때

초과 : 모든 영양소가 적정, 미달 없이 다 초과일 때

= 초과가 한 개 이상이면서 & 미달이 없을 때

미달 : 모든 영양소가 적정, 초과 없이 다 미달일 때

= 미달이 한 개 이상이면서 & 초과가 없을 때

불균형: 모든 영양소에 적정이 없으며 & 초과 + 미달이 모두 있을 때

= 적정이 한 개 이상이면서 & 초과가 한 개 이상 있을 때

이를 로직으로 구현하기 위해서 stackoverflow도 열심히 뒤지고..

계속 시도해본 끝에 np.wherelogical_and(+eq , all ,any )를 이용하여 하루만에 구현을 할 수 있었습니다.

Pandas에서 조건문을 만드는 여러가지 방법이 있으나,

이 중에서 np.where를 이용한 이유는 속도면에서 가장 큰 장점이 있기 때문입니다.

(cf. axis=1일 경우 pandas apply가 느려지는 이유)

https://www.terality.com/post/why-pandas-apply-method-is-slow

적정의 경우에는 가장 독립적이기 때문에, 먼저 판단하였습니다.

각 영양소들이 모두(all )적정상태일 때 약속된 적정 코드를 가집니다.

df_diet_diary_by_day['day_status'] = np.where(
df_diet_diary_by_day[['calorie_status', 'carbohydrate_status', 'protein_status', 'fat_status']]
.eq(MEAL_NUTRIENT_PROPER_STATUS).all(axis=1, skipna=True), MEAL_NUTRIENT_PROPER_STATUS, None
)

불균형 의 경우에는 미달과 초과가 각각 한 개 이상 존재해야 하기 때문에,

미달과 초과가 하나라도 있을 때 any를 이용하여 약속된 불균형 코드를 가집니다.

df_diet_diary_by_day['day_status'] = np.where(
np.logical_and(
df_diet_diary_by_day[['calorie_status', 'carbohydrate_status', 'protein_status', 'fat_status']].isin(
[MEAL_NUTRIENT_LACK_STATUS]).any(axis=1, skipna=True),
df_diet_diary_by_day[['calorie_status', 'carbohydrate_status', 'protein_status', 'fat_status']].isin(
[MEAL_NUTRIENT_EXCESS_STATUS]).any(axis=1, skipna=True)
), MEAL_NUTRIENT_LACK_AND_EXCESS_STATUS, df_diet_diary_by_day['day_status']
)

마찬가지로 초과 의 경우에는 초과가 한개라도 있으면서, 미달이 없어야 하므로

  • any를 이용하여 초과가 한개라도 있는지 여부를 확인
  • ~ any를 이용하여 하나라도 있는 것들을 제외하였고,
df_diet_diary_by_day['day_status'] = np.where(
np.logical_and(
# 초과가 한 개 이상이면서
df_diet_diary_by_day[['calorie_status', 'carbohydrate_status', 'protein_status', 'fat_status']].isin(
[MEAL_NUTRIENT_EXCESS_STATUS]).any(axis=1, skipna=True),
# 미달이 없을 때
~df_diet_diary_by_day[['calorie_status', 'carbohydrate_status', 'protein_status', 'fat_status']].isin(
[MEAL_NUTRIENT_LACK_STATUS]).any(axis=1, skipna=True)
    ), MEAL_NUTRIENT_EXCESS_STATUS, df_diet_diary_by_day['day_status']
)

마지막으로 미달 의 경우에는 미달이 한개라도 있으면서, 적정이 없어야하므로

  • any를 이용하여 미달이 한개라도 있는지 여부를 확인
  • ~ any를 이용하여 초과가 하나라도 있는 것들을 제외

하였습니다.

각각을 합친 전체 코드는 다음과 같습니다.

# 일자별 상태 (초과/미달/적정/불균형) 판단
df_diet_diary_by_day['day_status'] = np.where(
df_diet_diary_by_day[['calorie_status', 'carbohydrate_status', 'protein_status', 'fat_status']]
.eq(MEAL_NUTRIENT_PROPER_STATUS).all(axis=1, skipna=True), MEAL_NUTRIENT_PROPER_STATUS, None
)
df_diet_diary_by_day['day_status'] = np.where(
np.logical_and(
df_diet_diary_by_day[['calorie_status', 'carbohydrate_status', 'protein_status', 'fat_status']].isin(
[MEAL_NUTRIENT_LACK_STATUS]).any(axis=1, skipna=True),
df_diet_diary_by_day[['calorie_status', 'carbohydrate_status', 'protein_status', 'fat_status']].isin(
[MEAL_NUTRIENT_EXCESS_STATUS]).any(axis=1, skipna=True)
), MEAL_NUTRIENT_LACK_AND_EXCESS_STATUS, df_diet_diary_by_day['day_status']
)
df_diet_diary_by_day['day_status'] = np.where(
np.logical_and(
# 초과가 한 개 이상이면서
df_diet_diary_by_day[['calorie_status', 'carbohydrate_status', 'protein_status', 'fat_status']].isin(
[MEAL_NUTRIENT_EXCESS_STATUS]).any(axis=1, skipna=True),
# 미달이 없을 때
~df_diet_diary_by_day[['calorie_status', 'carbohydrate_status', 'protein_status', 'fat_status']].isin(
[MEAL_NUTRIENT_LACK_STATUS]).any(axis=1, skipna=True)
), MEAL_NUTRIENT_EXCESS_STATUS, df_diet_diary_by_day['day_status']
)
df_diet_diary_by_day['day_status'] = np.where(
np.logical_and(
# 미달이 한 개 이상이면서
df_diet_diary_by_day[['calorie_status', 'carbohydrate_status', 'protein_status', 'fat_status']].isin(
[MEAL_NUTRIENT_LACK_STATUS]).any(axis=1, skipna=True),
# 초과가 없을 때
~df_diet_diary_by_day[['calorie_status', 'carbohydrate_status', 'protein_status', 'fat_status']].isin(
[MEAL_NUTRIENT_EXCESS_STATUS]).any(axis=1, skipna=True)
), MEAL_NUTRIENT_LACK_STATUS, df_diet_diary_by_day['day_status']
)

예외 처리들

  1. 해당 기능 배포 후 Pandas와 Numpy의 숫자형때문에 버그가 발생한 적이 있었습니다.
    각 일간 영양소의 상태를 int로 정의하여 적용하도록 하였는데, 최종 return값은 float로 return되었습니다.

▼ 이에 대한 자세한 내용은 하단에 있습니다. 원인은 NaN값 때문이었습니다

2. prefetch 에 queryset filter를 넣으면 filter가 안되는 버그가 있었습니다.

  • soft delete를 기반으로 삭제가 구현
  • 리포트에 계산되는 내역들은 사용자가 삭제되지 않은 식품이어야 함

따라서 위의 조건은 filter 조건문으로 이용하였으나, filter 조건이 작동하지 않아 삭제된 내역도 리포트에 포함되는 문제가 있었습니다.

▼ 버그 이후 원인파악과 ORM에 대해서 공부하고, 스터디한 내용입니다. 원인은 prefetch 후 values를 했기 때문입니다.

3. 값이 없을 때, 해당 부분을 처리하는 로직이 부족했습니다.

  • 저희 서비스에서는 사용자의 신체 정보활동 정보를 토대로 먹어야하는 칼탄단지(칼로리, 탄수화물, 단백질, 지방) 범위를 계산하고 있습니다.

편의상, 상단의 내용을 권장 영양소라고 하겠습니다.

리포트 기능 이전, 기록하기 기능을 개발하면서 기획상 섭취 내역이 없더라도 권장 영양소를 그 때 최근 신체정보와, 활동정보를 토대로 보여줘야 했습니다

따라서 권장 영양소를 get_or_create 메소드를 이용하여 구현했었습니다.

일간 영양소와 권장 영양소pd.merge 를 이용하여 합쳤었는데,

이 경우 섭취 내역이 없더라도 권장 영양소와 merge가 되면서 섭취내역이 있는 것처럼 간주되었습니다.

이 버그를 해결하기 위해서 영양 정보가 모두 0일 때는 기록되지 않은 것으로 생각하고 제거하는 코드를 마지막 검증에 추가하였습니다.

# 영양 정보가 모두 0이면 기록 되지 않은 것으로 제거
remove_index = np.where(df_diet_diary_by_day[NUTRIENT_6_KEYS].eq(0.0).all(axis=1, skipna=False))[0]
df_diet_diary_by_day = df_diet_diary_by_day.drop(remove_index, axis=0)

그 외에 배웠던 것들

  1. 프론트와 소통의 필요성
리드님의 프로젝트 총평

아무래도 첫 프로젝트로 인한 부담감에 기능 만드는데에 급급해서 상태 코드를 어떻게 할 지 사전에 공지하지 않고, 작업 후에 명세서에 기재하여 공유하려고 했었습니다.

하지만 빠른 배포 일정을 위해, 명세서 작성이 생략되었고 이로 인한 부작용들이 많았습니다.

이후로는 프론트와 원팀임을 잊지 않고,

기능 작업 전, 작업 중에 스키마에 대한 회의와 소통을 필수적으로 나누고 있습니다.

2. 프론트와 백엔드를 나누는 기준

개인적으로 그전까지 백엔드에서 모든 연산을 다해야 한다고 굳게 믿고 있었습니다. 백엔드란 비즈니스 로직 그 자체라고 생각했어서.. 그래서 월간 리포트에서 제일 많이 먹은 날들도 구해서 줬는데, 저런 총평을 듣고 고민을 하게 됐습니다. 🤔

서버에서 연산을 하는 것이 맞을까

ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤvs

클라이언트에서 연산을 하는 것이 맞을까

지금도 완벽히 구분하기는 어렵지만, 지금까지의 경험을 바탕으로

  • 유효숫자처럼 데이터의 정밀성이 아주 중요한 경우가 아니라면 간단한 연산(더하기,뺄셈, 최소/최댓값)등의 값
  • 장바구니 금액처럼 실시간으로 계속 바꿔야 하는 경우 > 매번 서버 호출을 하는 리소스가 클 것 같음

의 경우에 클라이언트에서 연산을 하는 것이 맞을 것 같습니다.

입사 후 7개월이나 지난 현재의 시점에서 돌이켜봤을 때도,

첫 프로젝트임에도 기획적으로 굉장히 복잡한 로직이었던 것 같습니다.

또한 기획적인 사항 외에도

  • 프레임워크(Pandas)에 대한 내부 이해가 없었고
  • 엣지 케이스를 고려하지 않아서

등 의 이유로 product에 배포된 후에도 많은 버그가 발생했었습니다.

여러 버그를 고치면서, 놓쳤던 부분들을 다시 파악할 수 있게 되어

앞으로 이를 잘 보완하여 성장해야겠다는 다짐도 했었습니다.

이 글을 쓰는 현재의 시점에서는 엣지 케이스나 예외에 대한 처리가 익숙한데, 큰 확률로 해당 프로젝트가 이런 점들에 도움을 많이 준 것 같습니다.🙂

긴 글 읽어주셔서 감사합니다.

긴 글 읽어주셔서 감사합니다.

--

--

송희

커피와 책, 구름을 좋아하는 개발자입니다. 고민의 과정을 담고자 노력하고 있습니다. Github : https://github.com/song-hee-1