본문 바로가기
혼공학습단9기

혼자 공부하는 데이터 분석 with 파이썬: 3주차(Chapter 03-2)

by 이두스 2023. 1. 26.

Chapter03 "데이터 정제하기" 

 

Chapter 03-2의 주제는 '잘못된 데이터 수정하기' 이다.

 

  • 판다스에서 누락된 값은 NaN이라고 표시한다.
  • NaN을 찾는 방법과 NaN을 수정하는 방법을 배울 예정

데이터 프레임 정보 요약 확인하기

먼저 03-1에서 처리한 남산도서관 데이터를 가져온다.

import gdown 
gdown.download('https://bit.ly/3GisL6J','ns_book4.csv',quiet = False)

import pandas as pd
ns_book4 = pd.read_csv('ns_book4.csv',low_memory = False)

요약 정보는 아래와 같이 확인한다.

# ns_book4의 요약 정보
ns_book4.info()

총 384591개의 행을 가지고 있다. 하지만 non-null값이 다 다르다.

누락된 값 처리하기

누락된 값 개수 확인하기: isna() 메서드

# isna로 NaN 개수확인
# 반대는 notna()
ns_book4.isna().sum()

누락된 값 으로 표시하기 : None과 np.nan

  • 숫자를 담는 열에 None 저장
# 도서권수 누락 하나 만들어주기
ns_book4.loc[0, '도서권수'] = None
ns_book4['도서권수'].isna().sum()

ns_book4.head(2)

원랜 int 타입이었는데 NaN가 특별한 실수 값이라 그런지 float으로 변했다.

# 데이터 타입 지정 : astype()
ns_book4.loc[0,'도서권수'] = 1
ns_book4 = ns_book4.astype({'도서권수':'int32','대출건수':'int32'})
ns_book4.head(2)

고쳐졌다

  • 문자열을 담는 열에 None 저장
# 문자열을 담는 열에 None 저장
ns_book4.loc[0,'부가기호'] = None
ns_book4.head(2)

None과 NaN이 같이 있다. NaN은 따로 값이 없다. None에서 NaN으로 가려면 np.nan을 사용한다

# None -> NaN : np.nan
import numpy as np
ns_book4.loc[0,'부가기호'] = np.nan
ns_book4.head(2)

누락된 값 바꾸기(1): loc, fillna()메서드

# fillna() 없이 바꾸기
set_isbn_na_rows = ns_book4['세트 ISBN'].isna() # 누락된 값을 찾아 불리언 배열로 반환
ns_book4.loc[set_isbn_na_rows,'세트 ISBN'] = '' # 누락된 값을 빈 문자열로 바꿈
ns_book4['세트 ISBN'].isna().sum()

# fillna() 사용
ns_book4.fillna('없음').isna().sum()

# 특정 열만 선택
ns_book4['부가기호'].fillna('없음').isna().sum()

# 전체 데이터프레임 반환을 하려면 딕셔너리를 사용
ns_book4.fillna({'부가기호':'없음'}).isna().sum()

누락된 값 바꾸기(2): replace()메서드

replace메서드는 어떤 값이든 바꿀 수 있는 메서드이다.
  1. 바꾸려는 값이 하나일 때
    • replace(원래 값, 새로운 값)
  2.  바꾸려는 값이 여러 개일 때: 리스트, 딕셔너리
    • replace([ 원래 값1,원래 값2 ], [ 새로운 값1,새로운 값2 ] )
  3. 열 마다 다른 값으로 바꿀 때: 딕셔너리
    • replace({열 이름:원래 값},새로운 값)
    • 중첩된 딕셔너리 : replace({열 이름: {원래 값1,새로운 값1}})
## 1 바꾸려는 값이 하나일 때
# replace(원래 값, 새로운 값)
ns_book4.replace(np.nan, '없음').isna().sum()

## 2 바꾸려는 값이 여러 개일 때: 리스트, 딕셔너리
# replace([ 원래 값1,원래 값2 ], [ 새로운 값1,새로운 값2 ])
# 리스트
ns_book4.replace([np.nan,'2021'], ['없음','21']).head(2)

# 딕셔너리
ns_book4.replace({np.nan:'없음','2021':'21'}).head(2)

## 3 열 마다 다른 값으로 바꿀 때: 딕셔너리
# replace({열 이름:원래 값},새로운 값)
# 중첩된 딕셔너리 : replace({열 이름: {원래 값1,새로운 값1}})
ns_book4.replace({'부가기호':np.nan}, '없음').head(2)

# 중첩된 딕셔너리
ns_book4.replace({'부가기호':{np.nan: '없음'},
                  '발행년도':{'2021':'21'}}).head(2)

## 1 실행결과
## 2, ## 3 실행결과

정규표현식

replace()메서드를 사용해도 2021을 21로 바꾸는게 가능해도 2018은 바꿀 수 없다.

이를 위해 정규표현식 사용

정규표현식문자열 패턴을 찾아서 대체하기 위한 규칙의 모음

 

숫자 찾기: \d

# 숫자는 \d. 네 자리 숫자는 \d\d\d\d가 됨. 그룹으로 묶으면 ()를 사용. 그룹은 \1\2
# 정규표현식을 사용한다는 의미로 regex 매개변수를 True로 저장
ns_book4.replace({'발행년도':{r'\d\d(\d\d)':r'\1'}}, regex = True)[100:102]

# r은 파이썬에서 정규 표현식을 다른 문자열과 구분하기 위해 접두사처럼 붙임
# \d\d\d\d처럼 일일이 쓰지 않고 중괄호로 묶기 가능
ns_book4.replace({'발행년도':{r'\d{2}}(\d{2})':r'\1'}}, regex = True)[100:102]

문자 찾기: 마침표( . )

  • 모든 문자 : .
  • 공백 : \s
  • 만약 "로런스 인그래시아 (지은이), 안기순(옮긴이)"에서 지은이와 옮긴이를 삭제한다면
    • (.*)\s\(지은이\)(.*)\s\(옮긴이\)
    • ()는 정규표현식에서 그룹을 의미하지만 그룹으로 쓰이지 않는 괄호는 \를 이용해 탈출
    • *은 왼쪽의 표현식이 한 번 이상 나타난다는 의미이다.
ns_book4.replace({'저자':{r'(.*)\s\(지은이\)(.*)\s\(옮긴이\)':r'\1\2'},
                  '발행년도':{r'\d{2}}(\d{2})':r'\1'}}, regex = True)[100:102]

잘못된 값 바꾸기

1988년에 출간한 도서를 찾을 수 없음
ns_book4.astype({'발행년도':'int32'})
# "1988." 를 변환하지 못함

# 1988.을 얼마나 가지고 있는지 확인
ns_book4['발행년도'].str.contains('1988').sum()
# 407

# contains 메서드는 기본적으로 정규 표현식을 인식. 
# \d가 숫자이면 \D는 숫자가 아닌 다른 모든 문자를 뜻함
# '발행년도' 열에서 숫자가 아닌 문자를 포함하는 모든 행 찾기

# '발행년도' 열에서 숫자가 아닌 문자를 포함하는 모든 행 찾기
invalid_number = ns_book4['발행년도'].str.contains('\D', na = True)
print(invalid_number.sum())
ns_book4[invalid_number].head()

발행년도가 숫자가 아니다

정규표현식으로 숫자만 추출한다

# 정규표현식으로 연도 앞뒤의 문자 제외
ns_book5 = ns_book4.replace({'발행년도': '.*(\d{4}).*'},r'\1', regex = True)
ns_book5[invalid_number].head()

이렇게 수정해도 숫자가 아닌 행이 있는지 확인해준다

# 숫자 이외의 문자가 들어간 행의 개수와 데이터 확인
unkown_year = ns_book5['발행년도'].str.contains('\D',na = True)
print(unkown_year.sum())
ns_book5[unkown_year].head()

67개나 있다! NaN이거나 네 자리 숫자가 아닌 경우이다

# 임의로 -1로 바꾸고 정수형 변환을 해주겠다
ns_book5.loc[unkown_year, '발행년도'] = -1
ns_book5 = ns_book5.astype({'발행년도':'int32'})

# gt :  전달된 값보다 더 큰 값 찾기
ns_book5['발행년도'].gt(4000).sum()

# 단군기원인 경우에는 2333을 빼줌
dangun_yy_rows = ns_book5['발행년도'].gt(4000) # 4000보다 큰 값
ns_book5.loc[dangun_yy_rows,'발행년도'] = ns_book5.loc[dangun_yy_rows,'발행년도'] - 2333
dangun_year = ns_book5['발행년도'].gt(4000)

# 그래도 연도가 이상하게 높으면 -1
ns_book5.loc[dangun_year, '발행년도'] = -1

old_books = ns_book5['발행년도'].gt(0) & ns_book5['발행년도'].lt(1900) #0보다 크고 1900보다 작은 값

ns_book5.loc[old_books, '발행년도'] = -1
ns_book5['발행년도'].eq(-1).sum()

네 자리 숫자에서도 연도가 너무 작거나, 너무 큰 경우를 처리해주는 코드이다.

누락된 정보 채우기

뷰티플수프를 사용해 값을 채우도록 하겠다

import requests
from bs4 import BeautifulSoup

# isbn으로 책 제목을 반환해주는 함수
def get_book_title(isbn):
  # Yes24 도서 검색 페이지 URL
  url = 'http://www.yes24.com/Product/Search?domain=BOOK&query={}'
  # URL에 ISBN을 넣어 HTML을 가져옴
  r = requests.get(url.format(isbn))
  soup = BeautifulSoup(r.text,'html.parser')
  # 클래스 이름이 'gd_name'인 <a>태그의 텍스트를 가져오기
  title = soup.find('a',attrs = {'class':'gd_name'}).get_text()
  return title
  
# 저자, 출판사, 발행 연도를 추출하여 반환하는 함수 
import re

def get_book_info(row):
  title = row['도서명']
  author = row['저자']
  pub = row['출판사']
  year = row['발행년도']

  # Yes24 도서 검색 페이지 URL
  url = 'http://www.yes24.com/Product/Search?domain=BOOK&query={}'
  # URL에 ISBN을 넣어 HTML을 가져옴
  r = requests.get(url.format(row['ISBN']))
  soup = BeautifulSoup(r.text,'html.parser')
  try:
    if pd.isna(title):
      # 클래스 이름이 'gd_name'인 <a>태그의 텍스트를 가져옴
      title = soup.find('a',attrs = {'class':'gd_name'}).get_text()
  except AttributeError:
      pass

  try:
    if pd.isna(author):
      # 클래스 이름이 'info_auth'인 <span>태그 아래 <a>태그의 텍스트를 가져옴
      authors = soup.find('span', attrs = {'class':'info_auth'} ).find_all('a')
      author_list = [auth.get_text() for auth in authors] # 모든 텍스트를 리스트에 저장
      author = ','.join(author_list) # 하나의 문자열로 합침
  except AttributeError:
    pass
  
  try:
    if pd.isna(pub):
      # 클래스 이름이 'info_pub'인 <span> 태그 아래 <a> 태그의 텍스트를 가져옴
      pub = soup.find('span',attrs = {'class':'info_pub'}).find('a').get_text()
  except AttributeError:
    pass

  try:
    if year == -1:
      # 클래스 이름이 'info_date'인 <span> 태그 아래 텍스트를 가져옴
      year_str = soup.find('span',attrs = {'class':'info_date'}).get_text()
      # 정규 표현식으로 찾은 값 중에 첫 번재 것만 사용
      year = re.findall(r'\d{4}', year_str)[0]
  except AttributeError:
    pass

  return title, author, pub, year
# 함수 적용
updated_sample = ns_book5[na_rows].head(2).apply(get_book_info, axis = 1, result_type = 'expand')
updated_sample

함수 적용 결과

교재에서는 함수를 적용한 데이터 프레임을 미리 만들어 놓았다.

# 함수 적용
ns_book5_update = ns_book5[na_rows].apply(get_book_info, axis = 1, result_type = 'expand')

# ns_book5를 ns_book5_update로 업데이트 한 후 누락된 행 확인
ns_book5.update(ns_book5_update)
na_rows = ns_book5['도서명'].isna() | ns_book5['저자'].isna() 
		  | ns_book5['출판사'].isna() | ns_book5['발행년도'].eq(-1)
print(na_rows.sum())

 

누락된 값이 있는 행은 4615개로 뷰티풀수프로 데이터를 채우기 전보다 653개가 줄었는데 이 값은 삭제한다.
# dropna()로 행 삭제
ns_book5 = ns_book5.astype({'발행년도':'int32'})
ns_book6 = ns_book5.dropna(subset = ['도서명','저자','출판사'])
ns_book6 = ns_book6[ns_book6['발행년도'] != -1]
ns_book6.head()

데이터를 이해하고 올바르게 정제하기

  • 누락된 값을 바꾸고 정규 표현식을 이용해 수정함
  • 누락된 값 채우는 방법을 알아봄

일괄 처리 함수

def data_fixing(ns_book4):
  """
  잘못된 값을 수정하거나 NaN을 채우는 함수
  :param ns_book4: data_cleaning 함수에서 전처리된 데이터프레임
  """
  # 도서권수와 대출건수를 int32로 바꾼다
  ns_book4 = ns_book4.astype({'도서권수':'int32','대출건수':'int32'})

  # NaN인 세트 ISBN을 빈 문자열로 바꾼다
  set_isbn_na_rows = ns_book4['세트 ISBN'].isna()
  ns_book4.loc[set_isbn_na_rows, '세트 ISBN'] = ''

  # 발행년도 열에서 연도 네 자리를 추출하여 대체한다. 나머지 발행년도는 -1로 대체
  ns_book5 = ns_book4.replace({'발행년도':'.*(\d{4}).*'},r'\1',regex = True)
  unknown_year = ns_book5['발행년도'].str.contains('\D', na = True)
  ns_book5.loc[unknown_year, '발행년도'] = '-1'

  # 발행년도를 int32로 바꾼다
  ns_book5 = ns_book5.astype({'발행년도':'int32'})

  # 4000년 이상인 경우 2333년을 빼줌
  dangun_yy_rows = ns_book5['발행년도'].gt(4000)
  ns_book5.loc[dangun_yy_rows, '발행년도'] = ns_book5.loc[dangun_yy_rows, '발행년도'] - 2333

  # 여전히 4000년 이상인 경우 -1로 바꿈
  dangun_year = ns_book5['발행년도'].gt(4000)
  ns_book5.loc[dangun_year, '발행년도'] = -1

  # 0~1900년 사이의 발행년도는 -1
  old_books = ns_book5['발행년도'].gt(0) & ns_book5['발행년도'].lt(1900)
  ns_book5.loc[old_books, '발행년도'] = -1

  # 도서명, 저자, 출판사가 NaN이거나 발행년도가 -1인 행 찾기
  na_rows = ns_book5['도서명'].isna() | ns_book5['저자'].isna() 
  			| ns_book5['출판사'].isna() | ns_book5['발행년도'].eq(-1)

  # Yes24 도서 상세 페이지에서 누락된 정보 채우기
  updated_sample = ns_book5[na_rows].apply(get_book_info, axis = 1, result_type = 'expand')
  
  # 도서명, 저자, 출판사가 NaN이거나 발행년도가 -1인 행 삭제
  ns_book6 = ns_book5.dropna(subset = ['도서명', '저자', '출판사'])
  ns_book6 = ns_book6[ns_book6['발행년도'] != -1]

  return ns_book6


기본미션

df1 데이터 프레임 col1 col2 col3
0 1 a NaN
1 2 NaN NaN
2 3 c 100.0

[False, False, True]에 의하면 col3이 True이기 때문에 col3이 합해진다

선택미션

정규표현식 r'ba.*'는 ba뒤에 어떤 문자가 한 번이상 오는 것을 의미한다. 그 값들을 new로 바꾸는 것이기 때문에 df에 동그라미 친 것들이 new로 바뀌게 된다.


3주차 후기

3주차는 데이터 정제란 주제로 많은 걸 배웠다. 정말 양이 많다! 

사실 결과 캡처하는게 귀찮아서 블로그도 주피터처럼 실행결과가 자동으로 나오게 됐음 좋겟다 ~ 라는 마음에 후기를 남겼는데.. 그냥 실행결과 공유라고 나와버려서 혼공족장님뿐만 아니라 다른 사람들도 그냥 읽는데 이해가 안되는 문장을 작성한 것 같다. 하지만 이번주는 귀찮음을 참고 캡처했다.