[Project] 예술의전당 좌석 그룹핑·가격 수립 (2)

기존 '좌석 그룹핑 · 가격' 추정
Oct 04, 2023
[Project] 예술의전당 좌석 그룹핑·가격 수립 (2)
프로젝트
프로젝트명
전체 기간
2023.08.23 ~ 2023.09.27 (5주)
진행단계
단계명
(2) 기존 '좌석 그룹핑 · 가격' 모델 분석
수행 기간
2023.00.00 ~ 2023.00.00 (00일)
활용 데이터 (출처)
- 예술의전당 공연예매데이터 (빅콘테스트 2023 제공) - 코로나 거리두기 정보 (질병관리청, 보건복지부)
분석 방법
- 데이터 전처리 : pandas, numpy, math, regex(정규표현식) - 시각화 : plotly.express, matplotlib을 활용한 3d scatter plot - 군집분석 : KNN, K-means, kneed, silhouette, mse
새로운 좌석 그룹핑에는 ‘좌석의 선호도’를 반영해야 한다. ‘좌석의 선호도’는 특정 가격대에서 해당 좌석이 예매될 확률로 표현하려 한다. 이를 위해서 공연별로 ‘객단가(전체 좌석의 평균가격)’, ‘좌석의 가격’, ‘예매여부’를 알아야 한다.
기존의 데이터셋은 공연별로 판매된 좌석의 정보만 있고, 가격정보가 불완전하다. 다음은 기존의 불완전한 가격정보를 보완하고 좌석별로 부여됐을 등급을 추정하는 과정이다.
 

1. 기존 가격/등급 현황

기존의 가격/등급 정책

  • 등급 : 공연에 따라서 등급의 개수는 2~5가지로 부여된다. (R, S, A, B, C)
  • 예매 : 예술의전당 유료회원에 한해서 pre-open으로 티켓이 더 빨리 오픈된다. (공연에 따라서 pre-open이 없기도 하다.)
  • 할인 : 회원등급에 따른 결제 할인 외에도 장애인, 다자녀, 국가유공자, 청년(싹틔우미 당일할인), 신용카드 등 다양한 할인정책이 있다.
관련 자료 (펼쳐보기)
notion image
notion image
notion image
 
 

가격 추정의 필요성

  • 문제1 : 데이터셋에는 판매된 좌석만 포함되어 있다.
  • 문제2 : 데이터셋에는 판매된 좌석의 가격이 정확하지 않다. 최종판매가격인 price. price가 0원인 데이터가 많다. (전체 몇%)
 

2. 좌석의 가격 추정 (1) : 판매된 좌석의 할인전 가격

공연예매데이터 에서 가격(price)은 할인이 적용된 최종 판매 가격이다. 실제 적용된 할인율 중에서는 초대권과 같이 100%인 경우도 있다. ‘가격에 따른 예매율’을 구할 때 최종판매가격을 사용한다면 학습에 방해가 될 것으로 예상되므로, 할인종류(discount_type), 멤버십종류(membership_type)을 활용하여 할인 전 원래의 책정 가격을 역산하려 한다.
code (펼쳐 보기)
class Performance: instance_cnt = 0 def __init__(self, df): Performance.instance_cnt += 1 self.instance_cnt = Performance.instance_cnt self.p_val = 2 self.df = df self.perform_time = df['전체공연시간'].iloc[0] self.get_original_price() self.best_n_neighbors_1 = None self.best_n_neighbors_2 = None def get_original_price(self): """ discount_type에서 할인율을 추출하고 역산해서 '할인전금액'을 df에 컬럼으로 추가하는 함수 """ # 할인이 적용되기 전 가격 추정 self.df['할인율'] = self.df['discount_type'].str.extract('(\d+)%') self.df['할인율'] = self.df['할인율'].fillna(0).astype(int) / 100 self.df['price'] = self.df['price'].fillna(0) self.df['할인전가격'] = (self.df['price'].fillna(0) // (1 - self.df['할인율'])).round(-2).astype(int) self.df['할인전가격'] = self.df['할인전가격'].fillna(0) # 원가격추정 컬럼 추가 (5000원 단위로) self.df['원가격추정'] = ((self.df['할인전가격'] + 2500) // 5000 * 5000).copy() self.priced_seat = self.df[(self.df['원가격추정'] > 0)] self.unpriced_seat = self.df[(self.df['원가격추정'] == 0)] self.priced_rate = round((self.priced_seat.shape[0] / self.df.shape[0]), 3) self.booked_rate = round(self.df['예매여부'].mean(), 3)

중복 제거

  • cancel 된 좌석의 경우 예매시점이 여러개 존재한다.
    • 취소표는 데이터프레임에서 고려하지 않기로 하고 제외했다.
  • cancel 되지 않은 좌석의 경우에도 예매시점이 중복된 경우가 있었다.
    • 해당 경우는 시스템 오류로 판단하고 가장 최근 trans_date (거래일시) 를 기준으로 가장 최근 데이터만 남겼다.
    •  

활인율 추출

  • 단일 구매 건에서 여러 개의 membership_type 중에서 한 가지만 discount_type 에 명시되어 있는 것을 확인할 수 있었다.
  • discount_type을 value_counts() ⇒ 대부분 초대권, 기획사판매, ‘00%’가 포함된 할인유형
  • 정규표현식을 이용해서 % 앞에 있는 숫자를 추출하고, 0~1 사이의 소수점 표기로 변환해서 discount_rate를 구했다.
    •  

할인 전 원가격 추정

  • 할인율 역산 : original_price = price * 1 / discount_rate
  • 가격 보정 : 추정된 원가격이 ‘153556.013원’ 처럼 나누어 떨어지지 않는 경우가 발생했다. 일반적으로 예술의전당에서 판매되는 티켓의 가격은 1000원, 5000원대로 나누어 떨어지므로 이를 반영해서 추정된 원가격을 5000원 단위로 보정했다.
 

3. 좌석의 가격 추정 (2) : 판매되지 않은 좌석의 가격

기존 공연예매데이터에는 판매된 좌석에 대한 데이터만 포함되어 있다. 공연별로 모두 2505개의 좌석에 대한 가격 데이터를 포함한 데이터프레임을 만들기 위해 데이터프레임을 merge 후 knn모델을 활용하여 인접 판매 좌석의 가격으로 판매되지 않은 좌석의 가격을 추정했다.
code (펼쳐 보기)
class Performance: instance_cnt = 0 def __init__(self, df): Performance.instance_cnt += 1 self.instance_cnt = Performance.instance_cnt self.p_val = 2 self.df = df self.perform_time = df['전체공연시간'].iloc[0] self.get_original_price() self.best_n_neighbors_1 = None self.best_n_neighbors_2 = None def get_best_n_neighbors_1(self): """ 일반석 : knn 가격 추정 모델의 적절한 n_neighbors값을 찾는 함수 """ X = self.priced_seat.loc[self.priced_seat['층'] != '합창석', ['X', 'Y', 'Z']] y = self.priced_seat.loc[self.priced_seat['층'] != '합창석', '원가격추정'] // 1000 cv_scores = [] for n in range(1, min(50, (X.shape[0]*9//10)) + 1): model = KNeighborsRegressor(n_neighbors=n, weights='distance', p=self.p_val) scores = cross_val_score(model, X, y, cv=10, scoring='neg_mean_squared_error') cv_scores.append(scores.mean()) self.best_n_neighbors_1 = np.argmax(cv_scores) + 1 def get_best_n_neighbors_2(self): """ 합창석 : knn 가격 추정 모델의 적절한 n_neighbors값을 찾는 함수 """ X = self.priced_seat.loc[self.priced_seat['층'] == '합창석', ['X', 'Y', 'Z']] y = self.priced_seat.loc[self.priced_seat['층'] == '합창석', '원가격추정'] // 1000 cv_scores = [] n_splits = min(len(X), 5) if len(X) < 2: # 샘플 수가 2개 미만인 경우 예외 처리 self.best_n_neighbors_2 = 1 return for n in range(1, min(50, len(X)) + 1): model = KNeighborsRegressor(n_neighbors=n, weights='distance', p=self.p_val) scores = cross_val_score(model, X, y, cv=n_splits, scoring='neg_mean_squared_error') cv_scores.append(scores.mean()) self.best_n_neighbors_2 = np.argmax(cv_scores) + 1 if cv_scores else 1 def estimate_price(self): """ knn모델에 추정된 n_neighbors값을 적용해서 판매되지 않은 티켓의 가격을 추정하는 함수 """ # 가격을 하나도 알 수 없는 경우 종료 if self.priced_rate == 0: self.best_n_neighbors_1 = 1 self.best_n_neighbors_2 = 1 self.mean_price = self.max_price = self.min_price = 0 return # 1) 합창석이 아닌 좌석 self.get_best_n_neighbors_1() # 1-1) 3층에 데이터가 있을 경우 : 1, 2, 3층 전체로 knn수행 if self.priced_seat.loc[self.priced_seat['층']=='3층'].shape[0] != 0 : X_train = self.priced_seat.loc[self.priced_seat['층'] != '합창석', ['X', 'Y', 'Z']] y_train = self.priced_seat.loc[self.priced_seat['층'] != '합창석', '원가격추정'] // 1000 model = KNeighborsRegressor(n_neighbors=self.best_n_neighbors_1, weights='distance', p=self.p_val) model.fit(X_train, y_train) y_pred = model.predict(self.unpriced_seat.loc[self.unpriced_seat['층'] != '합창석', ['X', 'Y', 'Z']]) self.df.loc[(self.df['층'] != '합창석') & (self.df['원가격추정'] == 0), '원가격추정'] = y_pred * 1000 # 1-2) 3층은 가격데이터가 아예 없을 경우 else : # 1, 2층은 knn으로 추정 X = self.priced_seat.loc[(self.priced_seat['층'] != '합창석') & (self.priced_seat['층'] != '3층'), ['X', 'Y', 'Z']] y = self.priced_seat.loc[(self.priced_seat['층'] != '합창석') & (self.priced_seat['층'] != '3층'), '원가격추정'] // 1000 model = KNeighborsRegressor(n_neighbors=self.best_n_neighbors_1, weights='distance', p=self.p_val) model.fit(X, y) y_pred = model.predict(self.unpriced_seat.loc[(self.unpriced_seat['층'] != '합창석') & (self.unpriced_seat['층'] != '3층'), ['X', 'Y', 'Z']]) self.df.loc[(self.df['층'] != '합창석') & (self.df['층'] != '3층') & (self.df['원가격추정'] == 0), '원가격추정'] = y_pred * 1000 # 3층은 1, 2층 가격 비율로 적용 temp_price_1 = self.df.loc[(self.df['블록'] == 'A블록') & ((self.df['층']=='1층') | (self.df['층']=='1층'))].groupby('층').agg({'원가격추정':'mean'}).iloc[0,0] temp_price_2 = self.df.loc[(self.df['블록'] == 'A블록') & ((self.df['층']=='2층') | (self.df['층']=='2층'))].groupby('층').agg({'원가격추정':'mean'}).iloc[0,0] temp_price_3 = temp_price_2**2 // temp_price_1 self.df.loc[(self.df['층'] == '3층'), '원가격추정'] = temp_price_3 # 혹시 1 2 3층의 추정결과가 2500원 이하일 경우 2500원으로 self.df.loc[(self.df['층'] != '합창석') & (self.df['원가격추정'] < 2500), '원가격추정'] = 2500 # 2) 합창석 좌석 if self.unpriced_seat.loc[self.unpriced_seat['층']=='합창석'].shape[0] != 0: # 2-1) 합창석 자리를 하나도 모를 경우 if self.priced_seat.loc[self.priced_seat['층']=='합창석'].shape[0] == 0: lowest_price = self.df.loc[(self.df['층']=='3층'), '원가격추정'].min() self.df.loc[self.df['층']=='합창석', '원가격추정'] = lowest_price # 2-2) 일부 자리는 알 경우, 자체적으로 knn 수행으로 가격 추정 else: self.get_best_n_neighbors_2() X = self.priced_seat.loc[self.priced_seat['층'] == '합창석', ['X', 'Y', 'Z']] y = self.priced_seat.loc[self.priced_seat['층'] == '합창석', '원가격추정'] // 1000 model = KNeighborsRegressor(n_neighbors=self.best_n_neighbors_2, weights='distance', p=self.p_val) model.fit(X, y) y_pred = model.predict(self.unpriced_seat.loc[self.unpriced_seat['층'] == '합창석', ['X', 'Y', 'Z']]) self.df.loc[(self.df['층'] == '합창석') & (self.df['원가격추정'] == 0), '원가격추정'] = y_pred * 1000 # 3) 블록별로 이상치 보정하기 threshold = 1.75 # 이상치 판단 기준 self.df['층블록'] = self.df['층'].astype(str) + self.df['블록'].astype(str) self.df['z_score'] = self.df.groupby('층블록')['원가격추정'].transform(lambda x: np.abs(stats.zscore(x))).round(2).fillna(0) temp_dict = self.df[self.df['층'] != '합창석'].groupby(['층', '블록'])['원가격추정'].median().to_dict() mask = (self.df['층'] != '합창석') & (self.df['z_score'] > threshold) self.df.loc[mask, '원가격추정'] = self.df[mask].apply(lambda row: temp_dict.get((row['층'], row['블록']), row['원가격추정']), axis=1) self.df = self.df.drop('층블록', axis=1) self.df['원가격추정'] = ((self.df['원가격추정'].fillna(2500) + 2500) // 5000 * 5000).astype(int)
class Performance: instance_cnt = 0 def __init__(self, df): Performance.instance_cnt += 1 self.instance_cnt = Performance.instance_cnt self.p_val = 2 self.df = df self.perform_time = df['전체공연시간'].iloc[0] self.get_original_price() self.best_n_neighbors_1 = None self.best_n_neighbors_2 = Noneclass Performance: instance_cnt = 0 def __init__(self, df): Performance.instance_cnt += 1 self.instance_cnt = Performance.instance_cnt self.p_val = 2 self.df = df self.perform_time = df['전체공연시간'].iloc[0] self.get_original_price() self.best_n_neighbors_1 = None self.best_n_neighbors_2 = Noneclass Performance: instance_cnt = 0 def __init__(self, df): Performance.instance_cnt += 1 self.instance_cnt = Performance.instance_cnt self.p_val = 2 self.df = df self.perform_time = df['전체공연시간'].iloc[0] self.get_original_price() self.best_n_neighbors_1 = None self.best_n_neighbors_2 = None
151개 공연에 대한 좌석별 평균가격 시각화
151개 공연에 대한 좌석별 평균가격 시각화

데이터프레임 merge

  • 모든 공연별로 2505개의 행을 갖도록 판매되지 않은 좌석의 데이터를 추가해줬다 (판매가격은 0원으로).
 

가격 추정 (knn모델)

  • 합창석이 아닌 좌석 (1, 2, 3층)
    • train_data(‘할인전가격'을 기준으로 0원, 혹은 NaN인 데이터)와 test_data(그렇지 않은 데이터)를 나눈다.
    • knn모델을 생성하고, train_data로 cross-validation을 통해 적절한 n_neighbors값을 찾는다.
    • 위에서 찾아낸 최적의 n_neighbors값을 적용한 knn모델로 test_data에 대한 적절한 가격을 예측한다. weight=’distance’로 설정하여 가까운 좌석들의 가격에 더 많은 영향을 받도록 한다.
  • 3층 좌석
    • 3층 좌석이 판매가 하나도 되지 않는 경우가 있다.
    • 1, 2층은 가운데는 높은 가격, 양옆은 낮은 가격이지만 3층은 전체가 하나의 가격 및 등급으로 묶인다. 3층은 항상 1, 2층보다 낮은 가격이다.
    • 1층과 2층의 블록 중 평균가격이 가장 낮은 블록을 각각 A_1, A_2 로 한다. A_1_mean_price, A_2_mean_price를 구해서 비율로 나눈 갓으로 price를 맵핑했다.
  • 합창석 좌석
    • 합창석은 KNN에 포함할 경우 거리상 가장 가까운 1층의 R, S석의 값으로 맵핑이 된다. (weight = ‘distance’) ⇒ 합창석은 거의 모든 공연에서 B, C석에 해당되는데 R, S석으로 모델이 인식하게 된다.
    • 합창석 판매가 많은 공연의 판매가격을 시각화해서 살펴봄 ⇒ ‘열’, ‘넘버’ 단위로는 가격이 바뀌지 않고, ‘블록’ 단위로 나뉘는 것을 확인했다.
    • 합창석은 1)의 KNN모델과는 별개로 ‘블록’별로 최빈값으로 해당 ‘블록’을 모두 맵핑하기로 결정했다.
    • 만약 합창석이 하나도 판매되지 않은 좌석이라면 3층의 중간값으로 맵핑하기로 했다.
 

가격 보정

  • 블록 별 이상치
    • 각 블록별로 좌석 가격들의 z-score를 구해서 z-score가 2 이상인 데이터는 해당 블록의 최빈값으로 다시 보정했다.
  • 단위 확산
    • 판매된 좌석의 가격추정과 마찬가지로, 불필요한 노이즈를 없애기 위해 모두 5000원 단위로 가격을 보정했다.
 

4. 좌석 등급 추정

예술의전당 콘서트홀의 좌석 등급은 공연 및 기획자에 따라 등급이 3~5개로 나뉘어 부여된다 (5개인 경우 C, B, A, S, R 등급). 좌석별 추정된 가격을 기반으로 K-means군집분석 알고리즘을 활용하여 아래와 같이 등급을 추정했다.
code (펼쳐 보기)
class Performance: instance_cnt = 0 def __init__(self, df): Performance.instance_cnt += 1 self.instance_cnt = Performance.instance_cnt self.p_val = 2 self.df = df self.perform_time = df['전체공연시간'].iloc[0] self.get_original_price() self.best_n_neighbors_1 = None self.best_n_neighbors_2 = None def estimate_cluster_kmeans(self): # 중복되는 값이 없도록 난수를 더해서 노이즈 만들기 self.df['rand'] = np.random.rand(self.df.shape[0]) self.df['원가격추정_rand'] = self.df['원가격추정'] + self.df['rand'] # 군집화 모델 생성 (K-means) X = self.df[['원가격추정_rand']] inertia = [] k_range = range(1, 11) for k in k_range: kmeans = KMeans(n_clusters=k, random_state=42, n_init='auto') kmeans.fit(X) inertia.append(kmeans.inertia_) # 적절한 K값 찾기 (elbow point) kneedle = KneeLocator(k_range[1:], inertia[1:], curve='convex', direction='decreasing') self.best_k = kneedle.elbow # 찾은 K값을 적용해서 원등급 추정하기 while self.best_k <= 6: kmeans = KMeans(n_clusters=self.best_k, random_state=42, n_init='auto') self.df['원등급추정'] = kmeans.fit_predict(X) cluster_means = self.df.groupby('원등급추정').agg({'seat':'count', '원가격추정':'mean', '예매여부':'mean'}).reset_index() cluster_means = cluster_means.sort_values(by='원가격추정') grade_mapping = {grade: idx for idx, grade in enumerate(cluster_means['원등급추정'])} self.df['원등급추정'] = (self.df['원등급추정'].map(grade_mapping) + 1) temp_min = min((x for x in cluster_means['원가격추정'] if x > 0), default=None) self.class_price_ratio = list((cluster_means['원가격추정'] / temp_min).round(2)) if temp_min else [0 for _ in range(len(cluster_means))] self.class_price = list(cluster_means['원가격추정'].round().astype(int)) self.class_seats_cnt = list(cluster_means['seat']) # 합창석의 예매되지 않은 좌서들이 그룹핑에 섞여있으면 k+=1 해서 다시 군집분석 수행 if (self.class_price[0] != 0) and (self.class_seats_cnt[0] > 274) and (self.df['원가격추정'].min() == 0): self.best_k += 1 else: break # 원등급, 등급별 가격의 비율, 군집분석의 실루엣점수 self.df = self.df.drop(['원가격추정_rand', 'rand'], axis=1) self.class_booked_ratio = list(cluster_means['예매여부'].round(4)) self.silhouette_score = silhouette_score(self.df[['원가격추정']], self.df['원등급추정']) self.mean_price = self.df['원가격추정'].mean().round(2) self.max_price = self.df['원가격추정'].max() self.min_price = self.df['원가격추정'].min()
(특정 공연에 대한) 좌석별 등급 추정 시각화
(특정 공연에 대한) 좌석별 등급 추정 시각화

추정가격을 기준으로 군집화 (K-means)

  • 적절한 군집의 개수(K값) 찾기
    • 공연별로 분할된 (원가격이 추정된) 데이터프레임을 K-means 군집분석 모델에 입력했다.
    • kneedle 라이브러리를 활용하여 자동으로 k(군집의 개수)에 따른 inertia_(군집의 오차)를 분석하고 elbow-point(적절한 군집의 개수)를 찾도록 했다.
    • 이 때, K-means 모델의 특성상 높은 확률로 K=2를 최적의 군집개수라고 리턴하게 된다. K=2로 나누어진 군집을 파악한 결과 비현실적인 등급이 부여된다 (ex. 합창석과 1층 앞쪽 가운데가 같은 등급으로 묶인다). 따라서, K를 3이상으로 한정하여 적절한 K를 찾도록 했다.
  • 등급 부여
    • 라벨링된 군집은 군집의 평균 가격에 따라 등급을 부여했다.
 
Share article

김지민(kjmn1105)