🔥알림🔥
① 테디노트 유튜브 -
구경하러 가기!
② LangChain 한국어 튜토리얼
바로가기 👀
③ 랭체인 노트 무료 전자책(wikidocs)
바로가기 🙌
④ RAG 비법노트 LangChain 강의오픈
바로가기 🙌
⑤ 서울대 PyTorch 딥러닝 강의
바로가기 🙌
나라장터 입찰공고 데이터 분석 및 머신러닝 예측
나라장터에서 토건, 토목 등 다양한 시공에 대한 입찰 공고를 고지합니다.
시공사들은 예가에 가장 가깝게 입찰하여, 공사를 수주하게 됩니다.
시공사는 최대한 많은 공사를 입찰 받을 수록 회사에 큰 도움이 되기 때문에, 열심히 전자 입찰에 대한 스터디까지 진행하면서, 공사를 수주하기 위해 안간힘을 쓰고 있습니다.
투명하고 공정한 방식으로 입찰을 진행하기 때문에 거의 모든 업체에게 공평하게 기회가 돌아가야하지만, 입찰을 굉장히 많이 받는 업체가 존재합니다.
단순하게 운이 엄청 좋아서일까요?
궁금증을 해소해 보고자 한 번 분석을 진행하였습니다.
그리고, 재미삼아 백 테스팅을 통하여 모의 입찰도 진행해 봤습니다.
성공적인 입찰을 위한 데이터 분석 (토건, 토목, 시공 편)
주요 용어 정리
- 기초금액: 공사 입찰을 위한 기준 금액 (부가가치세가 합산된 금액)
- 예가: 기초금액±3% 으로 형성되며, 이 안에 들어오는 금액으로 입찰 해야함.(결국, 예가에 근접한 가격을 입찰해야함)
- 예정가격: 예가(%)를 기초금액에 곱한 가격
- 사정율: 예정가격을 기준으로 예정가격을 예측하는 비율. (1등 선정된 업체의 예가)
그 외 다소 생소한 용어들이 존재하나, 분석에 있어 주요한 용어만 설명합니다.
입찰 선정 방식
예가에 가장 근접한 기업이 입찰 받음. 단, 예가보다는 높은 금액을 입찰해야 합니다.
예시
기초금액: 1억원
입찰급액
- A기업: 9900만원
- B기업: 1억 1000만원
- C기업: 1억 2000만원
사정율
- A기업: 99.0%, (1억원 대비 99.0% 이므로)
- B기업: 101.0%, (1억원 대비 101.0% 이므로)
- C기업: 102.0%, (1억원 대비 102.0% 이므로)
시나리오 1) 예가: 1억 1500만원
- C기업 입찰. B기업이 예가에는 더 근접한 금액을 제시했지만, 예가보다 낮으므로, C기업이 입찰 받습니다.
데이터 분석 방식
- 나라장터에서 필요한 데이터 크롤링
pandas
,numpy
,matplotlib
등을 활용하여 분석 및 시각화 진행sklearn
,xgboost
,lgbm
을 활용하여 데이터 예측 수행
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import warnings
import matplotlib as mpl
# 유니코드 깨짐현상 해결
mpl.rcParams['axes.unicode_minus'] = False
# 나눔고딕 폰트 적용
plt.rcParams["font.family"] = 'NanumGothic'
# 경고 무시
warnings.filterwarnings('ignore')
%matplotlib inline
데이터 로드
train = pd.read_csv('data/train.csv')
컬럼 출력
train.columns
1. 예가
train['예가'].head()
예가에 대한 전처리 작업 수행. 필요한 숫자 소수점 4째자리 까지 추출
train['예가'] = train['예가'].str.extract(r'(\d+[.]\d+)')
예가를 object -> float로 변환
# float로 변경
train['예가'] = train['예가'].astype('float32')
예가는 97 ~ 103의 범위를 가져야 함.
이에 어긋나는 데이터는 drop
train.loc[(train['예가'] < 97) | (train['예가'] > 103), '예가']
drop_idx = train.loc[(train['예가'] < 97) | (train['예가'] > 103), '예가'].index
train = train.drop(drop_idx)
예가가 NaN인 값을 확인하고 drop
train.loc[train['예가'].isnull()]
drop_idx = train.loc[train['예가'].isnull()].index
train = train.drop(drop_idx)
예가에 대한 데이터 분포 확인
sns.distplot(train['예가'])
train['예가'].describe()
2. 가격
train.filter(regex='금액|가격').head()
가격에 대한 원 단위를 제거 및 ,를 제거합니다.
price_cols = train.filter(regex='금액|가격').columns
price_cols
train[price_cols].head()
,를 제거합니다.
for col in price_cols:
train[col] = train[col].str.replace(',', '')
train[col] = train[col].str.replace('원', '')
train[price_cols].head()
NaN값을 채워줍니다.
type을 int로 변경하기 전 NaN 값을 채워줍니다.
train[price_cols].isnull().sum()
train[price_cols] = train[price_cols].fillna(0)
train[price_cols].isnull().sum()
train[price_cols].head()
train[price_cols].info()
float 타입으로 가격 컬럼을 변환
train[price_cols] = train[price_cols].astype('float32')
train[price_cols].head()
train[price_cols].info()
NaN 값을 drop 하였으므로, index 를 초기화한다
train = train.reset_index(drop=True)
명시된 예정 가격과 예가 비율 * 기초금액과 가격이 과연 같을까?
train['기초금액'][:5]
train['예정가격'][:5]
train['예가'][:5]
기초금액 x 예가 = 예정가격 정말 일치할까? (왜냐하면, 소수 4째짜리까지만 반영되기 때문에 다를 수 있다)
10개만 테스트해 보겠다.
for i in range(10):
calculated_price = train['기초금액'][i] * train['예가'][i] / 100
printed_price = train['예정가격'][i]
diff = abs(calculated_price - printed_price)
print('명시된 가격과 계산된 금액의 차이: {:.2f} 원'.format(diff))
전체 평균 차이 계산
total_size = len(train)
diff = 0
for i in range(total_size):
calculated_price = train['기초금액'][i] * train['예가'][i] / 100
printed_price = train['예정가격'][i]
diff += abs(calculated_price - printed_price)
diff /= total_size
print('평균 오차 가격: {:.2f} 원'.format(diff))
금액&가격 컬럼간 corr() 확인
column들이 매우 높은 상관관계를 가지고 있다.
따라서, 이중 하나의 컬럼만 지정해서 활용하면 될 것으로 보입니다.
가장 base가 되는 기초금액 컬럼을 활용하겠습니다.
train[price_cols].corr()
sns.heatmap(train[price_cols].corr(), annot=True)
3. 날짜
train['입찰일시'].head()
NaN 값 확인
train['입찰일시'].isnull().sum()
날짜를 변환 (datetime)
train['입찰일시'] = pd.to_datetime(train['입찰일시'])
train['year'] = train['입찰일시'].dt.year
train['month'] = train['입찰일시'].dt.month
train['day'] = train['입찰일시'].dt.day
train['hour'] = train['입찰일시'].dt.hour
train['minute'] = train['입찰일시'].dt.minute
train['dayofweek'] = train['입찰일시'].dt.dayofweek
train['weekofyear'] = train['입찰일시'].dt.weekofyear
train['dayofyear'] = train['입찰일시'].dt.dayofyear
train['quarter'] = train['입찰일시'].dt.quarter
연도 확인
plt.figure(figsize=(10, 6))
sns.countplot(train['year'])
plt.title('연도별 공고 현황', fontsize=18)
plt.show()
분기별 공고 현황
plt.figure(figsize=(10, 6))
sns.countplot(train['quarter'])
plt.title('분기별 공고 현황', fontsize=18)
plt.show()
월별 공고 현황
plt.figure(figsize=(10, 6))
sns.countplot(train['month'])
plt.title('월별 공고 현황', fontsize=18)
plt.show()
일별 공고 현황
plt.figure(figsize=(10, 6))
sns.countplot(train['day'])
plt.title('일자별 공고 현황', fontsize=18)
plt.show()
시간대별 공고 현황
plt.figure(figsize=(10, 6))
sns.countplot(train['hour'])
plt.title('시간대별 공고 현황', fontsize=18)
plt.show()
분(minute)별 공고 현황
plt.figure(figsize=(10, 6))
sns.countplot(train['minute'])
plt.title('분별 공고 현황', fontsize=18)
plt.show()
요일별 공고 현황
- 0: 월요일 ~ 6: 일요일
- 일요일은 역시 공고가 없다.
- 토요일은 공고가 2건 있었다.
plt.figure(figsize=(10, 6))
sns.countplot(train['dayofweek'])
plt.title('요일별 공고 현황', fontsize=18)
plt.show()
4. 지역
train['지역'].value_counts()
지역을 , 기준으로 분리합니다.
area = train['지역'].str.split(',')
area.head()
최소 값과, 최대 값을 살펴봅니다.
area_count = area.apply(lambda x: len(x))
area_count.min(), area_count.max()
각각의 데이터를 분리하여 column을 만들도록 합니다.
area_split = train["지역"].str.split(",", n=5, expand=True)
area_split.iloc[[345, 12390, 1646, 16326]]
area_key = list(train['지역'].value_counts()[:18].keys())
area_key
data = np.zeros(shape=(len(area), 18))
data.shape
area_df = pd.DataFrame(columns=area_key, data=data)
지역과 지역 값 컬럼을 합칩니다
area_merged = pd.concat([area, area_df], axis=1)
area_merged.tail()
list에 지역이 포함된 경우 1로 체크합니다
def check_area(data):
for d in data['지역']:
data[d] = 1
return data
area = area_merged.apply(check_area, axis=1)
area.head()
area.iloc[1646]
지역은 drop합니다
area = area.drop('지역', 1)
area.head()
area.shape
train.shape
5. 업체별 사정율
사정율이란 개찰후 결정된 예정가격과 예비가격기초금액같의 차이를 백분율로 나타낸것이라고 정의 할 수 있습니다.
법률적인 개념은 아니지만 입찰담당자들 사이에 널리 쓰이는 개념입니다.
예를 들어
기초금액 : 100억
예정가격 : 101억이라면
사정율을 1%라고 합니다.
즉, 표면적으로 보이는 투찰율은 다르지만 사정율을 계산해서 보면 패턴을 볼 수 있다는 것이지요.
특정발주처나 특정업체의 입찰 패턴을 분석할 때 투찰율을 분석하면 아무런 결과를 얻을 수가 없습니다.
그러나 사정율로 분석을 하게 되면 패턴을 발견할 수도 있습니다.
복수예비가격을 사전에 발표하는 발주처의 성향을 분석할때도 금액만으로는 원하는 결과를 얻을수 없습니다.
각각의 예비가격에 대한 사정율을 계산해서 일정기간 분석해 보면 패턴이 있음을 알 수 있습니다.
train['업체별사정율'].head()
train.loc[train['업체별사정율'].str.contains('-'), '업체별사정율'].head(10)
( - ) 이루어진 데이터는 0으로 치환합니다
train.loc[train['업체별사정율'].str.contains('-'), '업체별사정율'] = np.nan
train['업체별사정율'] = train['업체별사정율'].fillna(0)
업체별 사정율의 데이터를 float로 변경합니다
train['업체별사정율'] = train['업체별사정율'].astype('float32')
7. 불필요한 컬럼 정리
train.columns
cols = ['업체명',
'기초금액',
'예가',
'업체별사정율',
'year',
'month',
'day',
'hour',
'minute',
'dayofweek',
'weekofyear',
'dayofyear',
'quarter',
]
train[cols].head()
train[cols].info()
df에 정리된 train 데이터와 area 데이터를 합칩니다
df = pd.concat([train[cols], area], axis=1)
df.head()
지역도 잘 합쳐져 있는지 확인합니다
df.iloc[:, 13:].tail()
train['지역'].tail()
업체별사정율 -> 사정율 (컬럼명 변환)
df = df.rename(columns={'업체별사정율': '사정율'})
df.head()
8. 사정율 - 예가 컬럼 생성 (1등 업체의 근접도 확인)
df['근접율'] = abs(df['예가'] - df['사정율'])
plt.figure(figsize=(10, 6))
sns.distplot(df['근접율'])
plt.title('근접율 분포도', fontsize=18)
plt.show()
df.loc[df['근접율'] > 30, '근접율'].head(20)
사정율이 나와 있지 않은 column은 우리가 0으로 채워 주었기 때문에 100 이상의 수치가 나온다.
해당 컬럼은 제외하고 다시 시각화
plt.figure(figsize=(10, 6))
sns.distplot(df.loc[df['근접율'] < 1, '근접율'])
plt.title('근접율 분포도', fontsize=18)
plt.show()
선정된 대다수의 업체가 근접율을 0.0 ~ 0.2 이하로 가져가고 있다는 점을 볼 수 있다
8. 상위 업체 사정율 분석
주로 활용할 유의미한 feature 정의
- 예가 (예측 label)
- 일자 (datetime) 관련 column
안타깝께도... 기타 활용할만한 지표는 크게 보이지 않는다.
잘 되는 업체의 사정율 확인
df['업체명'].value_counts().head(50)
시각화 함수화
def visualize_pct_time(data):
plt.figure(figsize=(10, 6))
d = data.groupby('year')['사정율'].mean()
sns.barplot(x=d.index, y=d)
plt.ylim(d.min() - 0.02, d.max() + 0.02)
plt.title('평균 사정율 (연도별)', fontsize=18)
plt.show()
print(d)
print('===='*10)
plt.figure(figsize=(10, 6))
d = data.groupby('quarter')['사정율'].mean()
sns.barplot(x=d.index, y=d)
plt.ylim(d.min() - 0.02, d.max() + 0.02)
plt.title('평균 사정율 (분기별)', fontsize=18)
plt.show()
print(d)
print('===='*10)
plt.figure(figsize=(10, 6))
d = data.groupby('month')['사정율'].mean()
sns.barplot(x=d.index, y=d)
plt.ylim(d.min() - 0.02, d.max() + 0.02)
plt.title('평균 사정율 (월별)', fontsize=18)
plt.show()
print(d)
print('===='*10)
plt.figure(figsize=(10, 6))
d = data.groupby('day')['사정율'].mean()
sns.barplot(x=d.index, y=d)
plt.ylim(d.min() - 0.02, d.max() + 0.02)
plt.title('평균 사정율 (일자별)', fontsize=18)
plt.show()
print(d)
print('===='*10)
plt.figure(figsize=(10, 6))
d = data.groupby('dayofweek')['사정율'].mean()
sns.barplot(x=d.index, y=d)
plt.ylim(d.min() - 0.02, d.max() + 0.02)
plt.title('평균 사정율 (요일별)', fontsize=18)
plt.show()
print(d)
print('===='*10)
plt.figure(figsize=(10, 6))
d = data.groupby('hour')['사정율'].mean()
sns.barplot(x=d.index, y=d)
plt.ylim(d.min() - 0.02, d.max() + 0.02)
plt.title('평균 사정율 (시간별)', fontsize=18)
plt.show()
print(d)
print('===='*10)
plt.figure(figsize=(10, 6))
d = data.groupby('minute')['사정율'].mean()
sns.barplot(x=d.index, y=d)
plt.ylim(d.min() - 0.02, d.max() + 0.02)
plt.title('평균 사정율 (분별)', fontsize=18)
plt.show()
print(d)
print('===='*10)
plt.figure(figsize=(10, 6))
d = data.groupby('weekofyear')['사정율'].mean()
sns.barplot(x=d.index, y=d)
plt.ylim(d.min() - 0.02, d.max() + 0.02)
plt.title('평균 사정율 (주차별)', fontsize=18)
plt.show()
print(d)
print('===='*10)
%%javascript
IPython.OutputArea.auto_scroll_threshold = 9999;
top 50개 업체 분석
df['업체명'].value_counts().head(50)
company = list(df['업체명'].value_counts().head(50).keys())
company[:5]
df.loc[df['업체명'].isin(company)].head()
df.loc[df['업체명'].isin(company)].shape
상위 50개 업체에 대한 사정율 분석
visualize_pct_time(df.loc[df['업체명'].isin(company)])
df.head()
9. 예측
- 사정율이 중요한 feature라 판단
- 따라서, 사정율이 0인 컬럼은 제외합니다
train_data = df.loc[df['사정율'] > 85]
train_data.shape
업체명은 drop 합니다
train_data = train_data.drop('업체명', 1)
지역은 서울, 전국을 포함하는 지표만 추립니다
train_data = train_data.loc[(train_data['서울'] == 1) | (train_data['전국'] == 1)]
area_drop_cols = ['전남', '경북', '경기', '경남', '강원', '전북', '충남', '충북', '부산', '제주', '대구',
'인천', '대전', '광주', '울산', '세종']
train_data = train_data.drop(area_drop_cols, axis=1)
예가, 사정율, 근접율은 사후에 나오는 지표 이므로 drop 합니다
train_feature = train_data.drop(['예가', '사정율', '근접율'], 1)
train_label = train_data['예가']
최종 Feature
train_feature.head()
모델링
from sklearn.model_selection import KFold
from xgboost import XGBRegressor
from lightgbm import LGBMRegressor
SEED = 123
# 코드 비공개 처리
pd.DataFrame(list(zip(train_feature.columns, xgb_model.feature_importances_))).sort_values(by=1, ascending=False)
def train_xgb(train_features, train_labels, test_features, num_split=3):
# 코드 비공개 처리
return preds
def train_lgbm(train_feature, train_label, test_feature, num_split=5):
# 코드 비공개 처리
return preds
def train_xgb(train_feature, train_label, test_feature, num_split=3):
# 코드 비공개 처리
return preds
from sklearn.model_selection import train_test_split
x_train, x_valid, y_train, y_valid = train_test_split(train_feature, train_label, random_state=321)
x_train.shape, x_valid.shape
lgbm_pred = train_lgbm(x_train, y_train, x_valid)
xgb_pred = train_xgb(x_train, y_train, x_valid)
pred = lgbm_pred * 0.6 + xgb_pred * 0.4
abs(pred - y_valid).mean()
검증 (시뮬레이션)
validation_df = df.iloc[y_valid.index][['예가', '사정율']].copy()
validation_df.head()
df.iloc[y_valid.index]['사정율'].head()
validation_df['pred'] = pred
validation_df.head()
괴리율 = 예측값 - 예가 (0보다 작으면 무조건 탈락)
validation_df['괴리율'] = validation_df['pred'] - validation_df['예가']
validation_df['선정여부'] = (validation_df['pred'] < validation_df['사정율']) & (validation_df['pred'] > validation_df['예가'])
validation_df.loc[validation_df['괴리율'] >= 0].sort_values(by='괴리율').head()
선정된 지표 확인
validation_df.loc[validation_df['선정여부'] ==True]
idx = validation_df.loc[validation_df['선정여부'] ==True].index
선정된 지표 (전체데이터)
df.iloc[idx]
댓글남기기