🚢 titanic

[python] 항해일지 01. 엔진룸 구경하기: numpy

Floaty 2026. 6. 23. 10:00

데이터 가져오기

kaggle에서 타이타닉과 관련된 데이터를 가져왔습니다. 다운로드한 csv 파일을 `data` 폴더에 넣어주세요. 이름은 원하는 형태로 해도 좋지만 저는 `titanic`으로 하겠습니다. 

 

 

 

라이브러리 불러오기 & 데이터 numpy 배열로 변환

# [1] 라이브러리 불러오기
import pandas as pd
import numpy as np

df = pd.read_csv("data/titanic.csv")


# [2] 데이터를 numpy 배열로 변환
age = df["age"].dropna().to_numpy()
fare = df["fare"].to_numpy()
survived = df["survived"].to_numpy()

print(f"age[{type(age)}]: {age[:5]}")
# age[<class 'numpy.ndarray'>]: [22. 38. 26. 35. 35.]

가장 먼저 사용할 라이브러리를 불러보겠습니다. `pandas`를 부르는 이유는 CSV 파일 읽기를 `pandas`에서 할 수 있기 때문입니다. `read_csv()`를 통해 CSV 파일을 읽으면, `DataFrame` 형태로 데이터를 읽을 수 있습니다. 그 후에 `to_numpy()`라는 함수를 사용해서 `numpy` 배열로 만들어요. 그래야 나중에 기초 통계가 필요할 때 사용할 수 있거든요.

쉽게 설명하자면, CSV 파일을 `DataFrame` 형태로 읽고, 통계 연산을 하기 위해서 `numpy` 배열로 만드는 작업입니다. 

 

그런데 `age`를 보면, `dropna()`라는 함수가 보입니다. 이 함수는 `DataFrame` 전체가 아니라 `age` 컬럼 하나에서만, 비어있는 값(NaN)이 있는 자리를 제거합니다. 하나의 컬럼에만 적용한 거라서 나중에 나오지만, `age`와 `fare`처럼 길이가 다른 배열을 함께 다룰 때 주의가 필요합니다. 

 

 

 

기초 통계로 나이 데이터 살펴보기: 평균, 표준편차, 최댓값, 최솟값, 중간값

# [3] 기초 통계 함수: 데이터의 분포를 숫자로 요약
print(f"평균: {np.mean(age).round(2)}")      # 평균: 29.7
print(f"표준편차: {np.std(age).round(2)}")    # 표준편차: 14.52
print(f"최댓값: {np.max(age)}")              # 최댓값: 80.0
print(f"최솟값: {np.min(age)}")              # 최솟값: 0.42
print(f"중간값: {np.median(age).round(2)}")  # 중간값: 28.0

그럼 이제 기초 통계 함수를 사용해서 데이터의 분포를 숫자로 요약해볼까 합니다. `mean()`은 우리가 아주 잘 알고 있는 평균입니다. `round()`는 소수점 몇 번째 자리에서 반올림을 할지 정할 수 있어요. 이 코드에서는 두 번째 자리에서 반올림을 했기에 소수점 첫 번째 자리까지 나온 것을 알 수 있습니다. `std()`는 표준편차, `max()`는 최댓값, `min()`은 최솟값, `median()`은 중앙값을 구할 수 있습니다. 

 

 

 

불리언 인덱싱으로 조건 필터링하기: 성인, 미성년자

# [4] 불리언 인덱싱: 조건 필터링
is_adult = age >= 18
print(f"성인: {np.sum(is_adult)}명")      # 성인: 601명
print(f"미성년자: {np.sum(~is_adult)}명")  # 미성년자: 113명

 

그럼 이제 "나이"하면 떠오르는 미성년자와 성인에 대한 구분을 해볼까요? `age >= 18` 연산은 `age` 배열의 각 원소마다 18 이상인지 비교해서 `True`, `False`로 이루어진 새로운 배열을 만듭니다. 이게 `is_adult`예요.

 

그렇기에 `np.sum(is_adult)`는 18살 이상인, `age`가 18 이상이라  `True`인 값의 카운트입니다. `sum`은 합을 말하는 것이지만 `True`는 1로 인식하기에 카운트라고 볼 수 있습니다. 그렇다면, 미성년자 옆의 `~`은 무슨 의미일까요? 이건 `not`의 의미입니다. 18살 미만인 사람을 `True`라고 받아들이는 거죠. 

 

 

 

생존율 확인하기

# [5] 생존율
print(f"생존 비율: {round(np.mean(survived), 4)}")       # 생존 비율: 0.3838
print(f"생존율: {round(np.mean(survived) * 100, 2)}%")  # 생존율: 38.38%

 

그럼 생존율에 대해서 알아볼까요? `survived`는 사망(0), 생존(1)로 표현되는 컬럼입니다. 그렇기에 평균을 구하면 생존율을 구할 수 있는 거죠. 생존 비율은 0.3838이고, 생존율은 생존 비율에 100을 곱한 38.38%네요. 전체 인원 중 38.38%만 살아남았다는 말입니다. 

 

 

 

 

상관계수(correlaton coefficient)란 무엇인가?

이제 더이상 물러날 곳은 없습니다. 이제 통계에 대한 이야기를 더 해봐야 할 것 같아요. 상관계수란 두 변수가 함께 변하는 정도를 -1 ~ 1 사이의 숫자로 나타낸 거예요. 

  • +1에 가까움: 한쪽이 커지면 다른 쪽도 커짐 (양의 관계)
  • -1에 가까움: 한쪽이 커지면 다른 쪽은 작아짐 (음의 관계)
  • 0에 가까움: 두 변수 사이에 선형적인 관계가 거의 없음 (독립적)

수식으로는 두 변수의 공분산을 각각의 표준편차로 나눈 값으로 피어슨 상관계수라고 합니다. 그래서 표준편차로 나눠주기 때문에 단위가 나이와 요금이라고 해도 비교 가능한 -1 ~ 1 사이의 값으로 정규화됩니다. 

 

그렇다면 강함의 정도는 뭘까요? 

어느 수치 이상이면 강하고, 어느 수치 이하이면 약한걸까요? 그렇다면 정말 정말 좋겠지만 절대적인 기준은 없습니다. 하지만 통계학에서 흔히 쓰이는 대략적인 가이드라인은 존재해요.

  • 0.0 ~ 0.1: 거의 없음
  • 0.1 ~ 0.3: 약함
  • 0.3 ~ 0.5: 중간
  • 0.5 ~ 0.7: 강함
  • 0.7 ~ 0.9: 매우 강함
  • 0.9 ~ 1.0: 거의 완벽한 선형 관계

물론 이 기준은 도메인마다 다르게 적용됩니다. 물리 실험 데이터라면 0.9가 약하다고 느껴질 수 있고, 사람 관련 데이터는 0.3만 돼도 꽤 의미 있는 관계로 보기도 해요.

 

종속적, 독립적을 가르는 기준이 있을까요?

엄밀히 말하자면 상관계수가 0이라고 해서 완전히 독립이라고 할 수 없습니다. 상관계수는 선형 관계만 측정하기 때문에 비선형 관계(U자형)가 있어도 상관계수는 0에 가깝게 나올 수 있어요. 

 

실무적으로는 보통 

  • |r| < 0.1: 사실상 독립적이라고 봐도 무방
  • 0.3 < |r| < 0.5: 어느정도 종속적(연관성 있음)이라고 해석
  • |r| > 0.7:강하게 종속적

이라고 느슨하게 판단하지만 진짜 독립성을 통계적으로 검증하려면 카이제곱 검정이나 다른 비선형 관계 측정법(상호정보량 등)을 추가로 봐야 더 정확합니다. 이건 다음에 기회가 된다면 그때 이야기를 해보도록 할게요. 

 

자 개념은 충분히 알아봤으니 이제 직접 타이타닉 데이터에 적용해 볼까요?

 

 

 

타이타닉 데이터에서 강한 관계 찾아보기

지금부터 나이(age), 요금(fare), 객실 등급(pclass), 생존 여부(surivied), 가족 수(sibsp, parch) 등 타이타닉 데이터 안의 여러 변수들이 서로 얼마나 연관되어 있는지 직접 확인해 보도록 하겠습니다. 먼저 예상을 해보고 계산하고 결과를 해석하는 순서로 진행하겠습니다. 

 

age_full = df["age"].fillna(df["age"].mean()).to_numpy()  # 결측치를 평균으로 채움

corr = np.corrcoef(age_full, fare)
print(corr.round(3)) # [[1.    0.092] [0.092 1.   ]]

하지만 그전에 상관계수를 구하기 위해서 정리해야 할 것이 있어요. `age`의 경우 결측치가 존재하기 때문에 `fare`와 길이가 달라요. 그렇기 때문에 결측치를 제거하지 않고 다른 값으로 채운 `age_full`을 따로 만들어서 사용할게요.

 

나이가 많은 사람일수록 비싼 객실(높은 `fare`)을 탔을까요? 아니면 어린아이들이 보호자와 함께 비싼 객실에 탔을까요? 흠... 그럴 수도 있고~ 아닐 수도 있고~ 애매하네요. 직관적으로 알 수 없어요. 그럼 계산 결과를 한 번 봅시다. 

 

`fillna()`는 비어있는 값, 그러니까 결측치를 다른 값으로 채웁니다. 현재 코드에서는 결측치를 나이의 평균으로 채웠어요. `corrcoef()` 함수는 상관계수 행렬을 반환해요. 그렇기 때문에 `print()`의 결과를 보면 대각선을 기준으로 대칭인(좌우가 똑같은) 모습을 볼 수 있는데 이 결과를 해석해 보자면,

         age_full   fare
age_full [1.0,    0.092]
fare     [0.092,    1.0]

이러한 결과를 볼 수 있어요. 대각선은 자기 자신과의 상관계수이기 때문에 항상 1이고, 비대각선이 `age_full`, `fare` 사이의 실제 상관계수입니다. 행렬이 대칭인 이유는 `age_full-fare`의 상관계수와 `fare-age_full`의 상관계수가 같기 때문이에요.

 

이제 해석을 해봅시다. 나이와 요금은 0.092로 매우 약한 양의 관계가 나왔어요. '애매하다'라고 예상했던 게 맞았습니다. 나이와 요금은 거의 독립적인 변수라고 볼 수 있겠네요.

 

상관계수, 결측치 어쩌고저쩌고 이것저것 힘들었는데 이것만 보고 끝내기엔 너무 어정쩡하죠? 그래서 좀 더 관계를 찾아볼까 합니다. 3가지 정도를 더 비교해보고 싶어요. 먼저 필요한 컬럼들이 있기 때문에 그 녀석들부터 `numpy` 배열로 만들고, 결과를 예상해 보겠습니다. 

 

pclass = df["pclass"].to_numpy() # 객실 등급(1, 2, 3 등급)
sibsp = df["sibsp"].to_numpy()   # 형제자매/배우자 동승자 수
parch = df["parch"].to_numpy()   # 부모/자녀 동승자 수

 

  • `pclass` vs. `fare`
    `pcalss`는 숫자가 작을수록 좋은 등급(=1등급)이에요. 일반적으로 좋은 등급일수록 요금이 비쌀 거라고 예상할 수 있죠.
    즉, `pclass`가 작아질수록 `fare`는 커질 것 같으니 '음의 상관관계'가 나올 거라고 예상해 봅니다. 
  • `pclass` vs. `survived`
    `survived`는 0(사망)/1(생존)으로 인코딩 되어 있어요. 타이타닉 침몰 당시 '상류층 승객 생존율이 더 높았다.'는 이야기를 들어보신 적 있으신가요? 이게 사실이라면, 좋은 등급(`pclass` 숫자 작음)일수록 생존(1)과 더 관련 있을 테니 이번에도 '음의 상관관계'를 예상해 봅니다. 
  • `sibsp` vs. `parch`
    `sibsp`(형제자매/배우자 수)와 `parch`(부모/자녀 수)는 둘 다 '같이 탄 가족 규모'와 관련된 숫자네요. 가족 단위로 탑승한 사람이라면 형제자매도 많고 부모/자녀도 같이 탔을 가능성이 높겠죠. 그래서 이 둘은 '양의 상관관계'라고 예상해봅니다. 

 

print(f"pclass vs. fare: {np.corrcoef(pclass, fare)[0, 1].round(3)}")
print(f"pclass vs. survived: {np.corrcoef(pclass, survived)[0, 1].round(3)}")
print(f"sibsp vs. parch: {np.corrcoef(sibsp, parch)[0, 1].round(3)}")

# pclass vs. fare: -0.549
# pclass vs. survived: -0.338
# sibsp vs. parch: 0.415

 

 

결과를 확인하기 전에 `print()`를 잠깐 살펴보겠습니다. 

`np.corrcoef()`는 항상 2x2 행렬을 반환한다고 했었죠? 그래서 [0,1]로 행렬에서 실제 상관계수 값이 들어있는 위치만 콕 집어서 꺼내온 겁니다. 대각선은 항상 1이라 의미가 없으니, 매번 행렬 전체를 출력하지 않고 우리가 진짜 궁금한 숫자 하나만 깔끔하게 뽑아낸 거죠.

 

그럼 이제 결과를 확인해 봅시다.

  • `pclass` vs. `fare`
    예상대로 음의 상관관계가 나왔네요. 게다가 -0.549면 어느 정도 강한 음의 상관관계입니다. 1등급은 비싸고, 3등급은 저렴하다는 직관이 숫자로도 확인된 셈이네요.
  • `pclass` vs. `survived`
    예상한 결과로 나왔네요. 음의 상관관계가 나왔어요. -0.549보다는 약하지만 거의 없음 수준은 절대 아니고 역사적으로 알려진 사실과도 일치하는 결과네요. 여기서 잠깐 짚고 넘어가고 싶은 게 있는데, 말이 길어지니까 결과를 다 확인한 후에 이야기하도록 할게요.
  • `sibsp` vs. `parch`
    예상대로 양의 상관관계가 나왔네요. 대가족이 함께 탑승했다면 `sibsp`, `parch` 둘 다 같이 커지니까 자연스러운 결과죠. 다만 0.092나 음의 상관관계에 비해서는 확실히 더 종속적인 변수 쌍이라고 볼 수 있어요.

이 정도면 `age`, `fare`만 거의 독립적이고 나머지 세 쌍은 어느 정도 종속적이라고 부를만한 정도인 것 같네요.

 

아까 말했던 이야기를 해볼게요. 

 

 

상관관계가 있으면 인과관계도 있는 걸까요?

가장 유명한 예시를 하나 들어볼게요. 여름철에는 '아이스크림 판매량'과 '익사 사고 수'가 강한 양의 상관관계를 보입니다. 그렇다고 아이스크림을 먹어서 물에 빠지는 건 아니죠. '더운 날씨'라는 제3의 원인이 두 가지를 동시에 끌어올리는 겁니다. 더우니까 아이스크림도 많이 팔리고, 더우니까 수영장이나 바다에도 사람들이 많이 가서 익사 사고도 늘어납니다. 이렇게 두 변수 사이에 숨어서 둘 다에 영향을 주는 제 3의 변수를 '혼란변수(confounding variable)'라고 불러요. 

 

타이타닉 데이터로 보자면, `pclass`와 `survived`가 -0.0338의 상관관계를 보인다고 해서 '1등급 객실이라는 사실 자체가 생존의 직접적인 원인이다.'라고 땅땅땅 단정할 수 없어요. 실제로는 그 사이에 숨어있는 다른 요인들이 있을 수 있으니까요. 음.. 예를 들자면 1등급 객실이 갑판이나 구명보트와 더 가까운 위치에 있었다. 당시 '여성과 아이 먼저' 구조 원칙이 있었는데 1등급에 여성과 아이의 비중이 더 높았다. 상류층일수록 정보 접근이나 대응 속도가 빨랐다. 등 정말 다양한 가능성이 있습니다. 

 

즉, `pclass`에서 `survived`로 직접 화살표를 그릴 수 있는 게 아니라 그 사이에 다른 변수들이 끼어있을 가능성이 존재한다는 겁니다. 상관계수는 '둘이 같이 움직임'이라는 것만 알려주지, '왜 같이 움직이는지'는 알려주지 않아요. 길고 길었네요. 정리하자면, 상관관계가 있다고 해서 인과관계가 있다는 뜻은 아니에요. 진짜 원인을 알아내려면 다른 변수들을 통제한 실험 설계나 더 정교한 통계적 인과추론 기법이 필요합니다. 

 

 

 

상관계수는 이상치에 약하다?!

아까 잠시 언급했던 공식을 기억하시나요? 상관계수의 분자(=공분산)는 '각 값이 평균에서 얼마나 떨어져 있는지'를 곱해서 더한 겁니다. 그런데 평균에서 멀리 떨어진 값일수록 이 차이가 커지고 그만큼 전체 결과에 미치는 영향력도 커집니다. 이게 말로만 들으면 이해하기 어려우니까 예시를 들어보도록 하죠.

 

x = np.array([1, 2, 3, 4, 5])
y = np.array([2, 4, 5, 4, 5])
print(f"이상치 없음: {np.corrcoef(x, y)[0, 1].round(3)}")
# 이상치 없음: 0.775

x2 = np.append(x, 20)  # 극단적으로 큰 값 하나 추가
y2 = np.append(y, 100)  # 극단적으로 큰 값 하나 추가
print(f"이상치 있음: {np.corrcoef(x2, y2)[0, 1].round(3)}")
# 이상치 있음: 0.984

데이터 5개로 계산했을 때는 0.775였던 상관계수가 딱 하나의 극단적인 값을 추가했을 뿐인데 0.984까지 올라갔습니다. 데이터 개수가 적을수록 이상치 한두 개가 결과를 통째로 바꿔버릴 수 있다는 걸 알 수 있죠.

 

여기서 또 타이타닉과 연결을 해보자면, `fare`에는 대부분 10 ~ 50달러 사이의 승객들이 몰려잇지만 일부는 200 ~ 500달러가 넘는 최상위 클래스 승객들도 섞여 있어요. 이런 극단값 몇 개가 `age-fare`, `pclass-fare`의 상관계수를 실제보다 더 높이거나 낮추는 방향으로 끌어당겼을 가능성이 있습니다. 

 

그래서 상관계수 하나만 보고 '관계가 있다/없다'를 단정하기보다는 데이터 안에 극단적인 값들이 섞여있지 않은지도 함께 확인해야 합니다. 이렇게 말로만 들으면 아직 감이 잘 안 올 수 있는데 나중에 산점도를 직접 그려보면 이런 점들이 한눈에 보일 겁니다. 그때 다시 한번 보도록 하겠습니다.