[통계/세션] 4회차 - 데이터셋으로 실습 ( 수업 흐름대로, 의식 흐름대로 )

통계 4회차 세션에서는 지금까지 배운(?) 내용을 가지고 h&m 데이터로 실습을 해보는 시간이었다.
튜터님이 초반 데이터 전처리 하는 부분을 보니 정말 사람에 따라 스타일이 다르다는 것이 확연히 느껴졌다.
"나는 어떤 스타일로 성장하게 될까?" 싶은 생각도 들면서..."오 이 방법은 알아둬야겠다" 싶은 것들이 몇 가지 있었어서 기록해보려고 한다. 아.. 이거 너무 긴데, 다시 볼까 싶네😵‍💫

 

기본세팅

from scipy import stats
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import platform

pd.set_option("display.float_format","{:.2f}".format)
# OS에 따라 다른 폰트 지정
if platform.system() == 'Darwin':   # macOS
    plt.rcParams['font.family'] = 'AppleGothic'
elif platform.system() == 'Windows':  # Windows
    plt.rcParams['font.family'] = 'Malgun Gothic'
else:  # Linux (예: Colab, Ubuntu)
    plt.rcParams['font.family'] = 'NanumGothic'

이거는 기본셋팅인데, 맥과 윈도우 모두 별도 주석 필요없이 사용 가능!!

pd.set_option("display.float_format","{:.2f}".format)

이 부분은 데이터 타입(?)이 float 이면 형식을 지정해주는 거라고 했다. {:.2f} : 소수 둘째자리 까지

 

 

가설 검정 전 데이터 전처리 과정

merge 

데이터셋이 3개라서 테이블 병합이 필요했다. 튜터님 방식을 보는데, 신기했다. 한꺼번에 3개 테이블 합체!

# 데이터 조인
df = pd.merge( 
        pd.merge(
            transaction_df,
            article_df,
            on = 'article_id',
            how = 'left'
        ),
        customer_df,
        on = 'customer_id',
        how = 'left'
)

처음엔 조금 헷깔렸는데, 직접 내가 일렬로 작성해보고 저렇게 보기좋게 세팅해보니 이해가 됐다.

# 데이터 조인

df = pd.merge(pd.merge(transaction_df, article_df, on = 'article_id', how = 'left'), customer_df, on = 'customer_id', how = 'left')

크게 보면은 쉽게 이해가 된다. 내부 merge구문이 하나의 테이블이라고 생각해보면,

df = pd.merge(테이블1, 테이블2, on=' ', how=' ') 이렇게 된다!!

 

다음 info 한 번 찍어도 보고

 

결측치 대체

신기했던 게 여기서도 있었다. 결측치가 있는 컬럼만 불러오기?!!

nan_col = df.columns[df.isnull().sum()>0]

해석해보면 : df에서 null 값이 있는 걸 찾아서 그걸 컬럼만(즉, 조건의 True인 것들) 불러온다는 말 이다.

다시 생각해보면, 준석 튜터님과도 많이 했던 구조이다. 불린 조건 + 대괄호 인덱싱! 맞나?

하지만, 앞에 .columns 가 새롭게 다가와서 인상 깊었다. 결측치가 있는 컬럼들은 뭐가 있는지 확인하는 절차이다.

df[nan_col].head() : 찍어보고 데이터(결측치 포함된 컬럼) 확인

 

세션 시간을 돌아보면, 데이터 프레임의 컬럼들을 가지고 요래저래 지지고 볶았던 것 같기도...?

 

이제는 본격적인 결측치 처리

df['detail_desc'] = df['detail_desc'].fillna('-')
df['FN'] = df['FN'].fillna(0)
df['Active'] = df['Active'].fillna(0)
df['club_member_status'] = df['club_member_status'].fillna('-')

해석해보면 : detail 컬럼에서 결측치를 '-' 로 채우고 & FN 컬럼은 0으로 채우고 & Active 컬럼도 0으로 채우고 & club_member_status 컬럼은 '-'로 채우기로 한다!

 

그럼 fashion_news_frequency 컬럼은 어떻게 대체 할거야? FN 이랑 밀접한 관계가 있잖아 !

df.loc[df['FN'] == 0, 'fashion_news_frequency'] = 'NONE'
df.loc[df['FN'] == 1, 'fashion_news_frequency'] = df.loc[df['FN'] == 1, 'fashion_news_frequency'].fillna('Regularly')

윗 줄은 FN = 0 (즉, 패션뉴스 비구독) 인 사람들의 fashion_news_frequency 값을 'NONE'로 대체한다는 말인 것 같은데,,,왜냐하면 구독도 안하는데, 알림주기 설정을 했겠냐면서~~

아래 줄은...어디보자👀... 패션뉴스를 구독한 사람들의 알림주기 설정은 NONE이 아닌 값 중 최빈값은 'Regularly'로 대체!!인듯하다.

 

마지막으로, age 의 결측치 처리 = age 의 중앙값(median)으로 대체

df['age'] = df['age'].fillna(df['age'].median())

 

결측치가 채워졌나 확인해보고

df.isna().sum().sum()

모든 컬럼에서의 결측치 개수 == 0

 

 

변수 구분하기

본격적인 통계 실습을 앞두고 있다.

우리가 가진 데이터를 3가지로 나눠서 확인해보면 좋다.

  • 범주형
  • 연속형
  • 순위형 : 이 데이터에는 없다.

우선, 수치형(연속형) 데이터

# 숫자로 되어 있는 변수 구분하기
numeric_df = df.select_dtypes(include='number')

이 코드처럼 작성하면 데이터타입이 number인 것들만 포함해서 묶을 수 있다. 이따가 exclude= 도 나올껄?

# 오 !! .T를 하면 컬럼명이 길때, 아래처럼 인덱스/컬럼 위치를 바꿔서 한번에 볼수가 있구나!!!
numeric_df.head().T

head( ) 까지만 하면 컬럼이 상단 위에 쭈욱 배치되어 옆으로 넘겨야만 확인이 되는데(컬럼이 많고 길 때), .T 를 옆에 살짝 붙여주면 아래와 같이 인덱스처럼 세로로 나열된 것을 볼 수 있다. (오! 한 눈에 파악하기 쉽겠군!)

구우웃!!

numeric_df 컬럼 한번 확인해주고,

numeric_df.columns

 

여기서 연속형(숫자로된 데이터) 내에서 연속형 변수와 범주형 변수를 다시 나눠준다.

# 연속형 변수
continuous_columns = ['price', 'age']
# 범주형 변수
categorical_columns = ['sales_channel_id', 'product_code',
       'product_type_no', 'graphical_appearance_no', 'colour_group_code',
       'perceived_colour_value_id', 'perceived_colour_master_id',
       'department_no', 'index_group_no', 'section_no', 'garment_group_no',
       'FN', 'Active']

 

df.select_dtypes(exclude='number').head().columns

이거는 전체 데이터에서 찐 '범주형 변수(문자 데이터)' 컬럼들을 뽑아내는 거! exclude= ' ' 

그런다음에 저기 위에 연속형 중 범주형 변수들과 합쳐주자!!

categorical_columns.extend(['product_type_name',
       'product_group_name', 'graphical_appearance_name', 'colour_group_name',
       'perceived_colour_value_name', 'perceived_colour_master_name',
       'department_name', 'index_code', 'index_name', 'index_group_name',
       'section_name', 'garment_group_name',
       'club_member_status', 'fashion_news_frequency'])

.extend() 매서드를 사용해서 리스트를 연장했다. (하나추가는 .append()를 사용하겠지만)


연속형 변수

 - 기술통계

df[continuous_columns].describe()

# 이상치가 제거된 가격/나이 히스토그램 뽑아보기
df[continuous_columns].hist()

이상치가 제거된 가격/나이 히스토그램

다음 절차는 표준화

표준화는 평균이 0이면서, 분산이 1이 되게끔 만드는 것!

from sklearn.preprocessing import StandardScaler

scaler = StandardScaler()
df['price_scaled'] = scaler.fit_transform(df[['price']])
df['age_scaled'] = scaler.fit_transform(df[['age']])

찐 '수치형' 변수를 표준화 시켜주는 작업 같아 보이는군! 각각 요소들을 살펴보자 👀

이 코드는 price, age 두 변수를 각각 표준화(z-score 변환) 해서 새로운 컬럼 price_scaled, age_scaled를 만든 것이다.

df[['price_scaled', 'age_scaled']].describe()

▼ 자세히 (with GPT)

더보기

1. from sklearn.preprocessing import StandardScaler

  • scikit-learn 라이브러리에서 StandardScaler 클래스를 불러오는 코드예요.
  • StandardScaler는 데이터를 평균 0, 표준편차 1로 변환(표준화)해 줍니다.

2. scaler = StandardScaler()

  • StandardScaler 객체를 생성한 거예요.
  • 이 객체를 가지고 데이터를 학습(fit)하고 변환(transform)할 수 있습니다.

3. df['price_scaled'] = scaler.fit_transform(df[['price']])

  • df[['price']]: price 컬럼을 **DataFrame 형태(2차원)**로 선택합니다. (Series로 하면 에러 나니까 [['price']] 처리를 해줌)
  • fit : price 컬럼의 평균과 표준편차를 계산
  • transform : 각 값을 (값 - 평균) / 표준편차를 계산 
  • 변환된 결과를 df['price_scaled']라는 새로운 컬럼에 저장
  • 즉, price 변수를 표준화한 값을 담은 새로운 컬럼을 만드는 작업이에요.

4. df['age_scaled'] = scaler.fit_transform(df[['age']])

  • 위와 같은 과정이지만 이번엔 age 컬럼에 대해 같은 작업을 합니다.
  • , age 변수를 표준화한 담은 age_scaled 컬럼이 추가됩니다.

🚨 주의사항

같은 scaler 객체를 다시 쓰면서 fit_transform을 하면, 앞에 했던 price의 평균/표준편차 정보가 덮어씌워져서 사라진다고 한다.
실무에서는 보통 이렇게 씁니다: 기억하자!

scaler = StandardScaler()
df[['price_scaled', 'age_scaled']] = scaler.fit_transform(df[['price', 'age']])

 

보통 연속형 변수의 경우는 히스토그램(hist)를 뽑아보는데...다음을 필요없이 그냥 그래프만!

df[['price_scaled', 'age_scaled']].hist()

표준화 해도 분포 모양이 바뀌진 얂고 동일한 스케일로 만들어준다고 이해하면 된다.

표준화된 가격/나이 히스토그램

 

이상치 제거

(항상 이상치를 제거해야 하는 것은 아님.) 이상치르 볼 때는 모다? box plot !

sns.boxplot(df[['price_scaled','age_scaled']])

표준화된 가격/나이 컬럼 이상치 확인

IQR 방식으로 이상치를 제거해보자!

여기서도 재밌었다. ~ (물결) 특수문자로 부정을 표현

# IQR 방식의 이상치 제거
q_1 = np.percentile(df['price'], q=25)
q_3 = np.percentile(df['price'], q=75)

iqr = q_3 - q_1

lower_whisker = q_1 - 1.5*iqr
upper_whisker = q_3 + 1.5*iqr

# ~ 는 부정 : 즉, 이상치가 아닌 애들 ~()
df_processed = df.loc[~((df['price']<lower_whisker) | (df['price']>upper_whisker))]

이렇게 IQR 방식으로 가격 컬럼의 이상치를 제거해주었다. 여기서 내가 궁금한 점은(항상 헷깔려서) 마지막 줄 .loc의 역할이다.

▼ .loc 의 역할과 예시

더보기

✅ .loc의 역할

  • 라벨(label)을 기준으로 행/열을 선택하는 방법입니다.
  • 그래서 조건식과 자주 쓰이긴 하지만, 꼭 조건식이랑만 쓰는 건 아니에요.

📌 사용 예시

1) 조건식과 함께 (가장 흔한 경우)

df.loc[df['price'] > 1000]

👉 price가 1000보다 큰 행만 가져옴.

 

2) 특정 행 라벨로 가져오기

df.loc[5]

👉 인덱스 라벨이 5인 행 가져오기.
(숫자 5라는 "위치"가 아니라 라벨이 5인 행이에요 → 그래서 iloc랑 차이가 남)

 

3) 행과 열 동시에 지정

df.loc[0:3, ['price', 'age']]

👉 인덱스 라벨 0~3 구간, 그리고 price, age 컬럼만 가져오기.

 

4) 특정 값 변경에도 활용

df.loc[df['price'] < 0, 'price'] = 0

👉 price가 0보다 작은 값들은 전부 0으로 바꾸기.

 

✅ .loc와 DataFrame

  • .loc[...]를 쓰면 기본적으로 DataFrame이나 Series 형태로 결과가 나와요.
    • 조건식 넣으면: 여러 행이 걸리니까 DataFrame
    • 특정 열 하나만 지정하면: Series
    • 행/열 딱 하나만 지정하면: 스칼라 값

즉, .loc = "무조건 DataFrame"이라고 생각하면 약간 위험해요.
👉 상황에 따라 DataFrame / Series / 스칼라가 될 수 있음.

 

📌 요약

  • .loc는 라벨 기반 인덱싱 도구.
  • 조건식이랑 많이 쓰이지만, 조건 전용은 아님.
  • 반환값은 상황에 따라 DataFrame / Series / 값 하나가 될 수 있음.

 

상관계수

plt.scatter(df['age'], df['price'])

가격/나이 산점도

df[continuous_columns].corr()

상관계수를 뽑아봄

예상한대로 상관계수가 0.04로 상관관계가 거의 없다.

 

 

범주형 변수

df_processed[categorical_columns].head().T

 

df_processed['FN'].value_counts()

각 컬럼들 value_counts() 확인해보면서, 어떤 컬럼을 사용해볼지 결정해보자!

# 컬럼이 많은 거 같으면 잘 안씀. value_counts() 찍어보고 적당한 것을 고르기!!!
# garment_group_name, perceived_colour_master_name, 

use_cols = ['sales_channel_id','FN','Active','garment_group_name','perceived_colour_master_name','club_member_status','fashion_news_frequency']

이렇게 사용하기로 하고, use_cols 변수에 저.장!

# 오오오,,, 이렇게 막대그래프 나타낼수 있군!!! 위에 use_cols 에 있는 모든 컬럼 찍어보자!
df['garment_group_name'].value_counts().plot(kind='barh')

이렇게 밸류카운츠 뒤에 바로 .polt(kind=' ')를 붙여서 뽑을수가!!? 

# 오! 이렇게 막대그래프 나타낼수 있군!!! 위에 use_cols 에 있는 모든 컬럼 찍어보자!
for col in use_cols:
    plt.figure()     # 매번 도화지를 새로 만들어줘~ 느낌!! 없으면 마지막 하나만 나와요.
    df[col].value_counts().plot(kind='barh')

for 반복문을 사용해서 컬럼들의 막대그래프를 모두 한 번에 뽑아봄.😎 

▼ 막대 그래프 확인

fashion_news_frequency, club_member_status는 null값을 제외하고 한 쪽으로 치우쳐져 있어서 가설 검점 대상에서 제외해야겠다.

 


가설검정

1. 패션구독여부에 따라 평균구매금액이 달라지는가? - t-검정
2. 같은 카테고리 내에서 색마다 평균구매금액이 달라지는가? - ANOVA 검정
3. 카테고리와 색은 독립적인가?
    - 카테고리별로 선호되는 색이 다를까?
        - 독립적이면, 카테고리별로 색상 선호 비율이 동일
        - 독립적이지 않으면, 카테고리별로 색상 선호 비율이 다를지도..!!

 

🎯 가설검정 단계

STEP 1: 가설 설정
  - H₀: 귀무가설  : 패션 구독여부에 따른 평균구매금액 차이는 없다. 
  - H₁: 대립가설  : 패션 구독여부에 따른 평균구매금액 차이는 있다. 

STEP 2: 검정 방법 선택
- 독립표본 t-검정
  - 전제조건 확인 
    - 정규성 -> 표본수가 충분히 크므로 정규성 가정 완화
    - 등분산성 : levene's test 진행 

STEP 3: 유의수준 결정
  - 보통 **α = 0.05** 사용  

STEP 4: 검정통계량 & p-value 계산
  - 표본 → 검정통계량 → p-value  

STEP 5: 결론
  - p ≤ α → 귀무가설 기각  
  - p > α → 귀무가설 채택(유지)

 

 

1. 패션뉴스 구독여부에 따라 평균 구매금액이 달라지는가?

데이터 처리

# -- 지금은 구매 데이터를 기준으로 데이터를 병합해씀(merge). 이렇게 되면 여러번 구매한 고객의 데이터가 여러번 들어가게 됨. 
# 고객의 주문 수에 따라서 치우침. 고객별로 패션 구독여부와 평균 구매금액을 먼저 구해야 된다.

customer_price_mean = df_processed.groupby(['customer_id', 'FN'])['price'].mean()
customer_price_df = pd.DataFrame(customer_price_mean)

customer_price_df.reset_index(inplace=True)
customer_price_df

# 값들을 불러온다.
fn_0 = customer_price_df.loc[customer_price_df['FN'] == 0, 'price'].values
fn_1 = customer_price_df.loc[customer_price_df['FN'] == 1, 'price'].values

 

등분산 검정 : Levene 검정

stat, p = stats.levene(fn_0, fn_1)
print(stat, p)

if p > 0.05:
    print("등분산 가정 만족(일반 t-test 사용 가능)")
else:
    print("등분산 가정 불만족(Welch's t-test 권장)")

p-value가 0.05 보다 작다!

# Welch t-test : 등분산성이 깨졌기 때문 (양측검정)
t_stat, p_value = stats.ttest_ind(fn_0, fn_1, equal_var=False)  # False 하면 됨.
t_stat, p_value

p-value가 0.05보다 작기 때문에, 패션뉴스 구독여부에 따라 평균 구매금액이 차이가 난다(대립가설)!!

그러면 다음에 뭐가 더 큰지 알아봐야 해!! (단측검정)

# Welch t-test : 등분산성이 깨졌기 때문 (단측검정)
t_stat, p_value = stats.ttest_ind(fn_0, fn_1, equal_var=False, alternative='less')  # False 하면 됨.
t_stat, p_value

  • alternative = 'greater' : 대립가설이 fn_0 >= fn_1
  • alternative = 'less' : 대립가설이 fn_0 =< fn_1

p-value가 1로 0.05 보다 크기 때문에 귀무가설 채택  fn_0 > fn_1 라는 것을 의미! 이상하네.,, 한 번 확인해보면

np.mean(fn_0)
> np.float64(0.024887151212367637)
np.mean(fn_1)
> np.float64(0.02442841793114605)

정말로 fn_0이 더 컸네...

>>> 가설1에 대한 결론 :  패션뉴스 구독하지 않은 사람들의 평균 구매금액이 더 크다. 

2. 같은 카테고리 내에서 색마다 평균 구매금액의 차이가 있을까?

STEP 1: 가설 설정
  - H₀: 귀무가설  : 같은 카테고리 내에서 색상에 따른 평균구매금액 차이는 없다. 
  - H₁: 대립가설  : 같은 카테고리 내에서 색상에 따른 평균구매금액 차이는 있다. 

STEP 2: 검정 방법 선택
- 일원 ANOVA
  - 전제조건 확인 
    - 정규성 -> 표본수가 충분히 크므로 정규성 가정 완화
    - 등분산성

STEP 3: 유의수준 결정
  - 보통 **α = 0.05** 사용  

STEP 4: 검정통계량 & p-value 계산
  - 표본 → 검정통계량 → p-value  

STEP 5: 결론
  - p ≤ α → 귀무가설 기각  
  - p > α → 귀무가설 채택(유지)
data_jersy = df_processed.loc[df_processed['garment_group_name']=='Jersey Fancy']

데이터 처리

Jersey Fancy 안에서도 색상이 다양한데~ 그 색마다 평균 구매금액이 과연 다를까? 를 확인하는 과정!

data_anova = data_jersy.groupby(['customer_id', 'perceived_colour_master_name'])['price_scaled'].mean()
data_anova = pd.DataFrame(data_anova).reset_index()
data_anova

색상별로 구매자 수를 확인해보자.

data_anova.groupby('perceived_colour_master_name').count()

▼ 출력결과

더보기

결과 중에서 Unknown과 1000개 미만인 것들은 제외하는 게 낫겠다!

df_anova= data_anova.loc[~data_anova['perceived_colour_master_name'].isin(['Metal', 'Lilac Purple', 'Unknown', 'undefined'])]

해당 색상들은 빼주었다.

df_anova.loc[df_anova['perceived_colour_master_name']=='White', 'price_scaled'].values
grouped = [g['price_scaled'].values for name, g in df_anova.groupby('perceived_colour_master_name')]
grouped

색상별로 price_scaled 리스트가 들어감!! 대충 요렇게?

 

 

등분산성 검정

오 이것도 처음보는 것이당! 

([a, b, c])  --> (*grouped)
grouped[0], grouped[1], grouped[2]..... grouped[13] : 색상 14개 여서 그거를 *grouped 로 대체

a, b, c 색상의 price_scaled 여러 색상을 한번에 하려니깐 이렇게 됨.

# 등분산 검정
stat, p = stats.levene(*grouped)
stat, p
> (np.float64(171.95657802525636), np.float64(0.0))

p-value가 0.05 미만이므로 등분산성 가정 불만족, Welch ANOVA 권장함!

 

Welch ANOVA

# welch ANOVA
import pingouin as pg
result = pg.welch_anova(dv='price_scaled', between='perceived_colour_master_name', data=df_anova)

dv = (dependent variable) : 비교 대상

between = : 어떤 걸 비교하고 있지? 색상 컬럼!(perceived ~name)

result

p=value 0 근사(0.0보다 한참 작음) -> 귀무가설 기각 -> 같은 카테고리(jersey fancy)안에서 색에 따른 평균 구매금액의 차이가 있다.

 

사후 검정: 투키검정 또는 games howel(등분산성❌ 일때)

지금은 등분산성을 만족하지 못하니깐 games howel로 사후 검정 진행(사후검정표)

result = pg.pairwise_gameshowell(dv='price_scaled', between='perceived_colour_master_name', data=df_anova)
result

사후검정표 (총 91개의 조합)

📌 해석을 해보면 : 각각의 조합에 대해서 t-검정을 하는게 사후검정

 - 베이지와 블랙을 비교할 때 차이가 있냐 없냐는 p-value로 확인(p-value가 0.05 보다 작으면 차이가 있다)하고,

 - 그 차이가 얼마만큼이냐는 diff 를 보고 음수면 뒤에꺼, 양수면 앞에꺼가 더 크구나! 생각해 볼 수있다. 

▼ 평균 구매금액이 음수가 있는 이유?

더보기

표준화를 하면 데이터가 평균 0, 표준편차 1로 맞춰지는데,
이때 평균보다 작은 값은 음수, 큰 값은 양수로 나타나는 게 자연스러운 현상이다.

 

jersey fancy 카테고리 안에는 전체 평균 구매금액보다는 낮은 금액들의 상품들이 있어서 대부분 음수값인 것이다.

>>> 가설2에 대한 결론 : 평균 구매금액의 차이가 있다.

3. 카테고리와 색은 독립적인가? - 카이제곱 독립검정

  - 카테고리별로 선호되는 색이 다를까?

  - 카테코리별로 선호되는 색상이 다르면 독립적이지 않다고 보고, 카테고리와 상관없이 선호되는 색상이 같으면 독립적이라고 함!

from scipy.stats import chi2_contingency

#1. 교차표를 만들어야 함
ct = pd.crosstab(df['index_group_name'],df['perceived_colour_master_name'])
ct

✅ crosstab 을 사용하면 교차표 라는 것이 만들어짐!!

🤔 해석을 해보면 : 베이지 색상의 유아동 옷을 주문한 건수가 804 건이다.

전체로 보나 유아동 옷으로 보나 색상별 구매 비율이 같아야 독립적인 것이다.
즉, 카테고리(옷)와 색상 사이에서 서로 영향을 주지 않기 때문에 독립적 이라고 할 수 있다 !

카이제곱 검정

#2. 카이제곱 검정 실행
result = chi2_contingency(ct)
result
>>> 가설3 - 카이제곱 검정 결론: p-value가 0 이므로 두 변수는 독립적이지 않다. 즉, 상호 영향이 있다.

 

연관성 정도가 얼마냐를 보는 게 Cramer's V !!

def cramers_v(contingency_table):
    chi2 = chi2_contingency(contingency_table).statistic
    n = ct.sum().sum()
    r = ct.shape[0]
    c = ct.shape[1]
    v = np.sqrt(chi2/(n*min(r-1,c-1)))
    return v

cramers_v(ct)
> np.float64(0.13962337880821765)

 

>>> 가설3 Cramer's V 결론 : 값이 0.1 이상이므로 약한 상관관계를 갖는다고 할 수 있다.

 

끝.