2장에서는 캘리포니아 주택 가격 데이터셋을 활용해 머신러닝 프로젝트를 처음부터 끝까지 직접 진행해보면서 배우는 것을 목표로 한다.
큰 그림 보기
문제 정의
풀고자 하는 문제가 무엇인지 먼저 정의하는 것이 필요함. 그렇게 하여 문제 상황을 정확히 파악하고, 해당 문제에 적합한 시스템을 설계하게 됨. 지금 문제는 레이블된 훈련 샘플이 있고, 여러 특성을 통해 한 개의 값을 예측하므로 지도 학습, 단변량 회귀 문제가 됨.
성능 측정 지표 선택
회귀 문제에서는 일반적으로 평균 제곱근 오차, RMSE를 사용한다.
$RMSE(X, h) = \sqrt{\frac{1}{m} \sum_{i=1}^{n} (h(x^{(i)}) - y^{(i)})^2}$
하지만, 이상치로 보이는 구역이 많은 경우, 평균 절대 오차, MAE를 사용하기도 한다.
$MAE(X, h) = \frac{1}{m}\sum_{i=1}^{m}|h(x^{(i)})-y^{(i)}|$
둘 모두 예측값의 벡터와 타깃값의 벡터 사이의 거리를 재는 방식이다. 거리 측정에는 여러 방식을 사용할 수 있다.
- 유클리드 노름 :
RMSE계산과 같다. $l_2$ 노름이라고도 부른다. - 맨해튼 노름 : 절댓값의 합을 계산한다. $l_1$ 노름이라고도 부른다.
- $l_k$ 노름 = $(|v_0|^k + |v_1|^k + … + |v_n|^k)^{\frac{1}{k}}$
가정 검사
시스템에서 요구로 출력을 하고 있는 출력이 현재 개발하고자 하는 모델의 출력과 일치한지 (ex: 가정에서는 수치형을 출력하고자 하는데 범주형을 요구한다던가) 확인해야한다.
데이터 가져오기
데이터 다운로드
아래 코드를 실행하면 데이터를 다운로드 할 수 있다.
from pathlib import Path
import pandas as pd
import tarfile
import urllib.request
def load_housing_data():
tarball_path = Path("datasets/housing.tgz")
if not tarball_path.is_file():
Path("datasets").mkdir(parents=True, exist_ok=True)
url = "https://github.com/ageron/data/raw/main/housing.tgz"
urllib.request.urlretrieve(url, tarball_path)
with tarfile.open(tarball_path) as housing_tarball:
housing_tarball.extractall(path="datasets")
return pd.read_csv("datasets/housing/housing.csv")
housing = load_housing_data()
tarball_path에 있는 파일이 있는지 확인한다. 1-1. 만약 파일이 없다면,datasets폴더를 만들고 그 폴더에tgz파일을 다운로드받은 뒤, 압축 해제를 한다.- 파일을 pandas DataFrame으로 읽어온 결과를 리턴한다.
데이터 구조 훑어보기
head() 메서드를 사용하면 데이터의 첫 다섯 행을 읽어올 수 있다.

info() 메서드는 데이터에 관한 간략한 설명을 보여준다.
특히, 전체 행 수, 각 특성의 데이터 타입, 널이 아닌 값의 개수를 확인하는 데 유용하다.

다음과 같은 정보를 알 수 있다.
- 전체 샘플 개수는 20640개이다.
total_bedrooms는 20433개만 널값이 아니다. 즉, 나머지 207개는 이 특성을 가지고 있지 않다.ocean_proximity필드를 제외한 모든 특성이 숫자형이다.
value_counts() 메서드는 어떤 카테고리가 있고, 각 카테고리마다 얼마나 많은 구역이 있는지 확인할 수 있다.

describe 메서드는 숫자형 특성의 요약 정보를 보여준다.

hist() 메서드는 모든 숫자형 형태에 대한 히스토그램을 출력할 수 있다.

이를 통해 알 수 있는 사실은 다음과 같다.
- 중간 소득이 US 달러로 표시되어 있지 않다. 따라서, 그 단위를 통일하는 것이 필요하다.
- 중간 주택 연도와 중간 주택 가격 그래프의 오른쪽 값이 심하게 높아지면서 그래프가 끝나는 것으로 보아 최댓값과 최솟값이 한정되어 있음을 알 수 있다.
- 특성들의 스케일이 많이 다르다.
- 많은 히스토그램에서 오른쪽 꼬리가 더 길다. 이러한 형태는 일부 머신러닝 알고리즘에서 패턴을 찾기 어렵게 만든다.
테스트 세트 만들기
- 데이터 스누핑 편향 : 테스트 세트로 일반화 오차를 추정하여 매우 낙관적인 추정이 나오게 되는 것
다음과 같은 코드로 랜덤으로 테스트 세트를 만들 수 있다.
import numpy as np
def shuffle_and_split_data(data, test_ratio):
shuffled_indices = np.random.permutation(len(data)) # 괄호 안 숫자 범위로 생성된 배열을 무작위로 섞음.
test_set_size = int(len(data) * test_ratio)
test_indices = shuffled_indices[:test_set_size]
train_indices = shuffled_indices[test_set_size:]
return data.iloc[train_indices], data.iloc[test_indices]
하지만, 프로그램을 실행할 때마다 다른 테스트 세트가 생성된다는 문제점이 존재한다. 이를 해결하기 위해서는,
- 처음 실행에서 테스트 세트를 저장하고 다음번 실행에서 불러들이기
- 항상 같은 난수 인덱스가 생성되도록
np.random.permutation()을 호출하기 전에 난수 발생기의 초깃값을 지정하기
하지만 위 방법 또한 데이터셋이 업데이트된 경우 문제가 생긴다. 따라서, 샘플의 식별자를 사용하여 테스트 세트로 보낼지 말지를 결정할 수 있다.
from zlib import crc32
def is_id_in_test_set(identifier, test_ratio):
return crc32(np.int64(identifier).tobytes()) < test_ratio * 2 ** 32
def split_data_with_id_hash(data, test_ratio, id_column):
ids = data[id_column]
in_test_set = ids.apply(lambda id_: is_id_in_test_set(id_, test_ratio))
return data.loc[~in_test_set], data.loc[in_test_set]
위 방법을 통해 나눈 훈련 / 테스트 세트를 보면 다음과 같다.

사이킷런을 사용하면 위 과정을 간단히 구현할 수 있다. 가장 간단한 함수는 train_test_split으로, 난수 초깃값을 설정하는 기능과 행의 개수가 같은 여러 개의 데이터셋을 넘겨 동일한 인덱스를 기반으로 나누는 기능이 존재한다.
from sklearn.model_selection import train_test_split
train_set, test_set = train_test_split(housing, test_size=0.2, random_state=42)
앞서 본 샘플링은 순수한 랜덤 샘플링이다. 데이터셋이 충분히 크다면 괜찮지만, 그렇지 않다면 샘플링 편향이 생길 가능성이 크다. 이를 위해 계층적 샘플링이 존재한다.
- 계층적 샘플링 : 전체를 계층이라는 동질의 그룹으로 나누고, 테스트 세트가 전체를 대표할 수 있도록 각 계층에서 올바른 수의 샘플을 추출하는 것.
이를 위해 전체를 여러 개의 계층으로 나눈 뒤, 계층의 비율대로 전체에서 샘플링하는 것이 가능하다.
housing["income_cat"] = pd.cut(housing["median_income"],
bins=[0., 1.5, 3.0, 4.5, 6., np.inf],
labels=[1, 2, 3, 4, 5])
pandas.cut reference
pandas.cut 함수는 특정 1차원 배열을 bins에 따라 해당 개수, 또는 해당 구간에 따라 분할한 뒤, 각 값들에 대해 해당하는 구간의 labels의 값을 부여한다.
사이킷런의 sklearn.model_selection 패키지 안에는 여러 가지 분할기 클래스를 제공한다. 모든 분할기는 또한 훈련과 테스트 분할에 대한 반복자를 반환하는 split() 메소드를 가지고 있다.
이 코드에서는 StratifiedShuffleSplit을 사용해 10개의 다른 계층 분할을 생성한다.
from sklearn.model_selection import StratifiedShuffleSplit
splitter = StratifiedShuffleSplit(n_splits=10, test_size=0.2, random_state=42)
strat_splits = []
for train_index, test_index in splitter.split(housing, housing["income_cat"]):
strat_train_set_n = housing.iloc[train_index]
strat_test_set_n = housing.iloc[test_index]
strat_splits.append([strat_train_set_n, strat_test_set_n])
첫 번째 분할을 다음과 같이 사용할 수 있다.
strat_train_set, strat_test_set = strat_splits[0]
다음과 같은 방식으로 계층적 샘플링을 코드 한 줄로 불러오는 것도 가능하다.
strat_train_set, strat_test_set = train_test_split(housing, test_size=0.2, stratify=housing["income_cat"], random_state=42)
데이터 이해를 위한 탐색과 시각화
먼저, 다음과 같은 코드를 통해 지리 정보를 산점도로 만들어 시각화해보자.
housing.plot(kind="scatter", x="longitude", y="latitude", grid=True)
plt.xlabel("경도")
plt.ylabel("위도")
plt.show()

이 그래프에서, 데이터 포인트가 밀접된 영역을 확인해보자.
housing.plot(kind="scatter", x="longitude", y="latitude", grid=True, alpha=0.2)
plt.xlabel("logitude")
plt.ylabel("latitude")
plt.show()

다음으로는 구역의 인구와 주택가격까지 그림에 나타내보자.
housing.plot(kind="scatter", x="longitude", y="latitude", grid=True,
s=housing["population"] / 100, label="population",
c="median_house_value", cmap="jet", colorbar=True,
legend=True, figsize=(10, 7))
plt.show()

상관관계 조사하기
모든 특성 간의 표준 상관계수를 corr() 메서드를 사용해 쉽게 계산할 수 있다.
corr_matrix["median_house_value"].sort_values(ascending=False)
median_house_value 1.000000
median_income 0.688380
total_rooms 0.137455
housing_median_age 0.102175
households 0.071426
total_bedrooms 0.054635
population -0.020153
longitude -0.050859
latitude -0.139584
Name: median_house_value, dtype: float64
상관관계의 범위는 -1부터 1까지로, 1에 가까우면 강한 양의 상관관계, -1에 가까우면 강한 음의 상관관계를 가진다는 뜻이다.
판다스의 scatter_matrix 함수를 통해 숫자형 특성 간 산점도를 그려보는 것 또한 가능하다.
from pandas.plotting import scatter_matrix
attributes = ["median_house_value", "median_income", "total_rooms",
"housing_median_age"]
scatter_matrix(housing[attributes], figsize=(12, 8))
plt.show()

이 그림을 보았을 때, 중간 주택 가격을 예측하는 데 중간 소득이 가장 유용해보인다는 것을 발견할 수 있다. 이 산점도를 확대해보자.

이 그래프를 통해 알 수 있는 사실은 다음과 같다.
- 상관관계가 매우 강하다.
- 위쪽으로 향하는 경향이 보인다.
- 포인트들이 너무 많이 퍼져 있지는 않다.
- 가격의 한계값이 수평선으로 잘 보이는데, 이러한 형태가 다른 곳에서도 더 나타나므로 이상한 형태를 알고리즘이 학습하지 않도록 구역을 제거할 수 있다.
특성 조합으로 실험하기
특정 구역의 가구 당 방 개수가 몇 개인지, 방 당 침실 개수가 몇 개인지 등 여러 특성을 조합하여 새로운 특성을 만들 수 있다.
housing["rooms_per_house"] = housing["total_rooms"] / housing["population"]
housing["bedrooms_ratio"] = housing["total_bedrooms"] / housing["total_rooms"]
housing["population_per_house"] = housing["population"] / housing["households"]
median_house_value 1.000000
median_income 0.688380
rooms_per_house 0.202050
total_rooms 0.137455
housing_median_age 0.102175
households 0.071426
total_bedrooms 0.054635
population -0.020153
population_per_house -0.038224
longitude -0.050859
latitude -0.139584
bedrooms_ratio -0.256397
Name: median_house_value, dtype: float64
기존의 다른 특성들에 비해 bedrooms_ratio, rooms_per_house 특성이 중간 주택 가격과의 상관관계가 높게 나타나는 것을 확인할 수 있다.
머신러닝 알고리즘을 위한 데이터 준비
머신러닝 알고리즘을 위해 데이터를 준비하는 과정은 함수를 만들어 자동화해야 하는데, 그 이유는 다음과 같다.
- 어떤 데이터셋에 대해서도 데이터 변환을 손쉽게 반복할 수 있음.
- 향후 프로젝트에 재사용 가능한 변환 라이브러리를 점진적으로 구축할 수 있음.
- 실제 시스템에서 알고리즘에 새 데이터를 주입하기 전에 이 함수를 사용해 변환할 수 있음.
- 여러 가지 데이터 변환을 쉽게 시도해볼 수 있고 어떤 조합이 가장 좋은지 확인하는 데 편리함.
데이터 정제
먼저, total_bedrooms 특성에 값이 없는 경우가 있는데, 이를 수정해보자. 방법에는 다음과 같이 세 가지가 있다.
- 해당 구역을 제거하기
- 전체 특성을 삭제하기
- 대체 : 누락된 갓을 어떤 값으로 채우기
판다스의 dropana(), drop(), fillna() 메서드로 이런 작업을 간단하게 처리할 수 있다.
# housing.dropna(subset=["total_bedrooms"], inplace=True)
# housing.drop("total_bedrooms", axis=1, inplace=True)
median = housing["total_bedrooms"].median()
housing["total_bedrooms"].fillna(median, inplace=True)
데이터 정제
먼저, total_bedrooms 특성에 값이 없는 경우가 있는데, 이를 수정해보자. 방법에는 다음과 같이 세 가지가 있다.
- 해당 구역을 제거하기
- 전체 특성을 삭제하기
- 대체 : 누락된 값을 어떤 값으로 채우기
판다스의 dropana(), drop(), fillna() 메서드로 이런 작업을 간단하게 처리할 수 있다.
# housing.dropna(subset=["total_bedrooms"], inplace=True)
# housing.drop("total_bedrooms", axis=1, inplace=True)
median = housing["total_bedrooms"].median()
housing["total_bedrooms"].fillna(median, inplace=True)
여기까지는 저번 파트에서 다루었던 내용이다.
세 번째 옵션이 누락된 값을 중간값으로 채우는 코드이다.
위와 같은 동작을 사이킷런의 SimpleImputer 클래스를 통해 할 수 있다. 이 클래스는 각 특성의 중간값을 저장하고 있어 유용하다. 또한, 훈련 세트뿐만 아니라 검증 세트와 테스트 세트 그리고 모델에 주입될 새로운 데이터에 있는 누락된 값을 대체할 수 있다.
다음과 같이 누락된 값을 특성의 중간값으로 대체하도록 지정하여 SimpleImputer의 객체를 생성한다.
from sklearn.impute import SimpleImputer
imputer = SimpleImputer(strategy="median")
중간값은 수치형 특성에서만 계산될 수 있으므로 수치 특성만 가진 데이터 복사본을 생성한다.
housing_num = housing.select_dtypes(include=[np.number])
이후, imputer 객체의 fit() 메서드를 사용해 훈련 데이터에 적용할 수 있다.
imputer.fit(housing_num)
imputer은 각 특성의 중간값을 계산하여 그 결과를 객체의 statistics_ 속성에 저장한다.
이후, 학습된 impuer 객체를 사용해 훈련 세트에서 누락된 값을 학습된 중간값으로 바꿀 수 있다.
X = imputer.transform(housing_num)
누락된 값을 평균이나 가장 자주 등장하는 값, 상수로 바꾸는 것도 가능하다. 뒤 두 가지 방법은 수치가 아닌 데이터도 지원한다.
사이킷런 변환기는 판다스 데이터프레임이 입력되더라도 넘파이 배열이나 사이파이 희소 행렬을 출력한다. 따라서 imputer.transform(housing_num) 의 출력 또한 넘파이 배열이다.
type(X)
>> numpy.ndarray
현재 상태에서는 열 이름도 인덱스도 없기 때문에, 이를 데이터프레임으로 감싸서 열 이름과 인덱스를 복원할 수 있다.
housing_tr = pd.DataFrame(X, columns=housing_num.columns,
index=housing_num.index)
텍스트와 범주형 특성 다루기
위에서는 수치형 특성만을 다뤘고, 이제는 텍스트 특성을 살펴보자.
housing_cat = housing[["ocean_proximity"]]
housing_cat.head(8)
ocean_proximity
13096 NEAR BAY
14973 <1H OCEAN
3785 INLAND
14689 INLAND
20507 NEAR OCEAN
1286 INLAND
18078 <1H OCEAN
4396 NEAR BAY
housing_cat.value_counts()
ocean_proximity
<1H OCEAN 7274
INLAND 5301
NEAR OCEAN 2089
NEAR BAY 1846
ISLAND 2
Name: count, dtype: int64
이를 통해, 이 특성은 범주형 특성임을 알 수 있다.
이 카테고리를 텍스트에서 숫자로 변환하기 위해 사이킷런의 OrdinalEncoder을 사용한다.
from sklearn.preprocessing import OrdinalEncoder
ordinal_encoder = OrdinalEncoder()
housing_cat_encoded = ordinal_encoder.fit_transform(housing_cat)
인코딩된 몇 개의 값을 확인하면 다음과 같다.
housing_cat_encoded[:8]
array([[3.],
[0.],
[1.],
[1.],
[4.],
[1.],
[0.],
[3.]])
categories_ 인스턴스 변수를 사용해 카테고리 리스트를 얻을 수 있다. 범주형 특성마다 1D 카테고리 배열을 담은 리스트가 반환된다.
ordinal_encoder.categories_
[array(['<1H OCEAN', 'INLAND', 'ISLAND', 'NEAR BAY', 'NEAR OCEAN'],
dtype=object)]
이런 표현 방식의 문제는 머신러닝 알고리즘이 가까이 있는 두 값을 떨어져 있는 두 값보다 더 비슷하다고 생각한다는 점이다. (숫자 상으로 비슷하면, 실제로는 전혀 다른 특징을 가진 값이어도 비슷하게 인식하게 된다.)
이러한 문제는 카테고리별 이진 특성을 만들어 해결한다. 카테고리가 <1H OCEAN일 때 한 특성이 1이고, 카테고리가 INLAND일 때 다른 한 특성이 1이 되는 식이다.
한 특성만 1이고 나머지는 0이므로 이를 원-핫 인코딩이라고 부른다. 새로운 특성을 더미 특성이라고도 부른다. 사이킷런은 범주 값을 원-핫 벡터로 바꾸기 위한 OneHotEncoder 클래스를 제공한다.
from sklearn.preprocessing import OneHotEncoder
cat_encoder = OneHotEncoder()
housing_cat_1hot = cat_encoder.fit_transform(housing_cat)
OneHotEncoder의 출력은 넘파이 배열이 아니라 사이파이 희소 행렬이다.
housing_cat_1hot
<16512x5 sparse matrix of type '<class 'numpy.float64'>'
with 16512 stored elements in Compressed Sparse Row format>
희소 행렬은 0이 대부분인 행렬을 매우 효율적으로 표현한다. 내부적으로 0이 아닌 값과 그 위치만 저장한다. 이는 많은 메모리를 절약하고 계산 속도를 높여주는 효과를 가진다. 공식 documentation
대부분 희소 행렬을 보통의 2D 배열처럼 사용할 수 있지만, 넘파이 배열로 바꾸려면 toarray() 메서드를 호출해야 한다.
housing_cat_1hot.toarray()
array([[0., 0., 0., 1., 0.],
[1., 0., 0., 0., 0.],
[0., 1., 0., 0., 0.],
...,
[0., 0., 0., 0., 1.],
[1., 0., 0., 0., 0.],
[0., 0., 0., 0., 1.]])