리사이클 혁명 시대다.
사출기에 쓰레기를 넣으면
예쁜 작품이 나오지만,
데이터는 그렇지 않다.
쓰레기같은 데이터를 넣으면
쓰레기같은 결과가 나오게된다.
그만큼 데이터의 질은 데이터 분석 업무에서
중요한 요소가 된다.
때문에 어떤 데이터를 수집하는지,
전처리는 어떤식으로 수행하는지 신중해야한다.
[데이터 정의]
수집데이터는 당일 날짜에 게재되어있는
70,000건 이상의 채용공고 데이터이다.
워크넷은 사람인과 잡코리아 등 여러 플랫폼에서
올라오는 채용공고도 랜딩할 수 있도록 연결해주는데,
이번 분석의 경우 워크넷 API를 활용하기 때문에
민간업체의 공고는 수집되지 않은 채로 진행하였다.
워크넷 채용공고는 민간 업체의 것과 특성이 다르다.
보통 대기업, 중견기업의 공고보다 군소업체에서 구하는 직무가 더 많은 비중을 차지했다.
아래 워드 클라우드를 보면...
그래서인지 메인 이용층은 40~50대 중장년층들이다.
[수집]
단번에 워크넷 DB에 접근하여 수집하는
파이프라인을 만들면 정말 편할 일이지만..
공모전 참가자 입장에서 이를 바라는 건 욕심이다.
그래서 일반적인 스크래핑 코드를 작성해
7만건 정도의 데이터를 수집했다.
문제는 수집 시 리퀘스트를 너무 많이하면 제한에 걸려
한 번에 깔쌈하게 수집이 모두 되지 않았다.
서버에 부담을 최대한 줄이면서
수집도 할 수 있는 방법을 생각해야했다.
1. 필요한 데이터만 솎아내기
워크넷 피쳐링크 를 보면 정말 많은 데이터를
수집할 수 있게 되어있지만,
모든 데이터를 수집하기보단
우리에게 필요한 데이터를 선별적으로
선택하는 작업을 먼저 거쳤다.
필요한 데이터와 필요한 이유를 다음과 같이 정의했다.
DB로 쌓이는 것을 의도하였고, 더 알아보기 쉬우라고 SQL 코드로 적는다.
CREATE TABLE job_simple
(wantedauthno CHAR(16) NOT NULL PRIMARY KEY , # 구인인증번호
company VARCHAR(64) NOT NULL, # 회사명
saltpnm VARCHAR(64) NOT NULL, # 임금형태
minsal INT, # 최소임금액
maxsal INT, # 최대임금액
region VARCHAR(20), # 근무지역
holidaytpnm VARCHAR(10), # 근무형태
career VARCHAR(10), # 경력
jobscd CHAR(6) NOT NULL); # 직종코드 3단계
CREATE TABLE job_specific
(wantedauthno CHAR(16) NOT NULL PRIMARY KEY,
jobsnm VARCHAR(64) NOT NULL, # 모집직종
wantedtitle VARCHAR(255) NOT NULL, # 공고제목
reljobsnm VARCHAR(64) NOT NULL, # 관련직종
jobcont VARCHAR(1000), # 직무내용
emptpnm VARCHAR(50), # 고용형태
edunm VARCHAR(20), # 학력
forlang VARCHAR(20), # 외국어
major VARCHAR(50), # 전공
certificate VARCHAR(50), # 자격증
compabl VARCHAR(50), # 컴퓨터 활용능력
pfcond VARCHAR(50), # 우대조건
etcpfcond VARCHAR(255), # 기타우대조건
etchopecont VARCHAR(1000), # 기타안내
nearline VARCHAR(20), # 인근전철역
workday VARCHAR(255)); # 근무시간 형태
[전처리]
전처리에 시간을 많이 투자했다.
자연어 데이터에다가 html 태그가 많다보니 처리할 것이 많았다.
큰 분류로 세 가지 클래스를 만들었다.
1. regex_tool
class regex_tool:
def __init__(self):
pass
# Regex Filtering #
def ppc_time(self,x):
p = re.compile('[0-9].*년|[0-9].*(\.|월|\/|일)|[0-9].*(\:|시|분)')
return p.sub('',x)
def ppc_e_address(self,x):
p = re.compile('[a-zA-Z0-9]*@[a-zA-Z0-9]*\.[a-zA-Z0-9]*|((https?://|www|WWW).*?( |$))')
return p.sub('',x)
def ppc_phone(self,x):
p = re.compile('[0-9]{2,3}-[0-9]{3,4}-[0-9]{4}')
return p.sub('',x)
def ppc_tag(self,x):
p = re.compile(''|>|<|"|&apos|#NAME?')
return p.sub('',x)
def ppc_special(self,x):
p = re.compile('[^가-힣0-9a-zA-Z\s]|\r|\n|\t')
return p.sub(' ',x)
모든 feature에서 출몰하는 전처리 대상 데이터를 불러다 쓸 수 있는 필터링 클래스를 만들었다.
2. 채용목록 전처리
class ppc_job_simple(regex_tool):
def __init__(self, df):
self.df = df
def ppc_job(self):
df = self.df
df = self.ppc_nan(df)
df['회사명'] = df['회사명'].apply(self.ppc_co_name)
df = self.ppc_wage(df)
df = self.ppc_domain(df)
df.drop(columns=['직종코드','직종코드3','임금형태','최소임금액','최대임금액'],inplace=True)
df.drop_duplicates(subset=['구인인증번호'], keep= random.choice(['first','last']),inplace=True, ignore_index=True)
return df
# 결측치 현황을 제공하고 선택적으로 결측치를 처리할 수 있는 함수 #
def ppc_nan(self, df):
nan_col = df.columns[df.isna().any()].tolist()
for col in nan_col:
print({col:df[col].value_counts(dropna=False)})
print('최빈값 대체 = 1 / 삭제 = 2')
if input() == '1':
df[col].fillna(df[col].mode()[0], inplace=True)
else:
df.dropna(subset=[col], inplace=True)
return df
# 회사명 간소화 및 통일 #
def ppc_co_name(self, co_name):
p = re.compile('\(주\)|주식회사|\(사\)|\(유\)|\(합\)|\(자\)|\(협\)|\(의\)')
return p.sub('',co_name).strip()
############# 중략 ###############
# 직종코드 #
def ppc_domain(self,df):
domain_code = pd.read_csv('data/직종코드.csv',encoding='utf-8')[['직종코드3','직종명3','직종명2','직종명1']]
df = df.merge(domain_code,how='inner',left_on='직종코드',right_on='직종코드3') #직종코드 사전에 없는 직종코드는 삭제
df.dropna(subset=['직종코드3'],inplace=True)
df['직종명3'] = df['직종명3'].apply(self.ppc_special)
df['직종명2'] = df['직종명2'].apply(self.ppc_special)
df['직종명1'] = df['직종명1'].apply(self.ppc_special)
return df
채용목록 API를 처리하는 코드다.
결측치 처리와 임금 단위 통일, 직종코드를 기준으로 JOIN하고 직종명을 살려줬다.
3. 채용상세 전처리
class ppc_job_specific(regex_tool):
def __init__(self,df):
self.df = df
def ppc_job_specific(self):
df = self.df
df = self.ppc_nan(df)
df['모집직종'] = df['모집직종'].apply(self.ppc_area)
df['구인제목'] = df['구인제목'].apply(self.ppc_title)
df['관련직종'] = df['관련직종'].apply(self.ppc_related_area)
df['직무내용'] = df['직무내용'].apply(self.ppc_content)
df['학력'] = df['학력'].apply(self.ppc_education)
df['외국어'] = df['외국어'].apply(self.ppc_foreign_language)
df['자격면허'] = df['자격면허'].apply(self.ppc_license)
df['우대조건'] = df['우대조건'].apply(self.ppc_preferential)
df['기타안내'] = df['기타안내'].apply(self.ppc_info_etc)
df['고용형태'] = df['고용형태'].apply(self.ppc_work_form)
df['기타우대조건'] = df['기타우대조건'].apply(self.ppc_preferential_etc)
df['인근전철역'] = df['인근전철역'].apply(self.ppc_subway)
df['근무시간/형태'] = df['근무시간/형태'].apply(self.ppc_work__time)
return df
# 결측치 처리 #
def ppc_nan(self, df):
df.fillna('', inplace=True)
return df
# 모집직종 #
def ppc_area(self,job_area):
p = re.compile('\([0-9]*\)$')
job_area = p.sub('',job_area)
job_area = self.ppc_special(job_area)
return job_area
# 구인제목 #
def ppc_title(self,job_title):
job_title = self.ppc_time(job_title)
job_title = self.ppc_special(job_title)
return job_title
# 관련 직종 #
def ppc_related_area(self, job_related_area):
job_related_area = self.ppc_special(job_related_area)
return job_related_area
# 직무 내용 #
def ppc_content(self, job_content):
job_content = self.ppc_time(job_content)
job_content = self.ppc_tag(job_content)
job_content = self.ppc_phone(job_content)
job_content = self.ppc_e_address(job_content)
job_content = self.ppc_special(job_content)
return job_content.replace(' ',' ')
################## 중략 ###################
# 근무시간 #
def ppc_work__time(self, job_work_time):
p = re.compile('주.*[0-9]일.*근무')
job_work_time = p.sub('',job_work_time)
p = re.compile('[0-9]{2}:[0-9]{2}')
job_work_time = p.sub(lambda x: x.group().replace(':','시 ')+'분',job_work_time)
job_work_time = self.ppc_tag(job_work_time)
job_work_time = self.ppc_special(job_work_time)
p = re.compile('평균근무시간')
return p.sub('',job_work_time)
채용상세 API를 처리하는 코드다.
결측치 처리와
데이터 통합<ex. 기간의 정함이 있는 -> 정규직> 토큰 개수를 줄이기 위해서
regex_tool로 클렌징을 하였다.
4. 데이터셋 만들기
class make_dataset(path_controller):
def __init__(self,args):
self.today = datetime.today().strftime('%Y%m%d')
self.mode = args.mode
def load_dataset(self):
self.preprocessor()
if self.mode == 'train':
dataset_path = self._get_preprocessed_dataset_path()
elif self.mode == 'recommend':
dataset_path = self._get_preprocessed_recommend_dataset_path()
else:
raise ValueError('mode is not correct')
dataset = pickle.load(dataset_path.open('rb'))
return dataset
def preprocessor(self):
if not self._get_preprocessed_folder_path().is_dir():
self._get_preprocessed_folder_path().mkdir(parents=True)
dataset_path = self._get_preprocessed_dataset_path()
recommend_dataset_path = self._get_preprocessed_recommend_dataset_path()
if dataset_path.is_file() and recommend_dataset_path.is_file():
print('datasets are already exist')
return
job_simple, job_specific = self.import_file()
job_simple = ppc_job_simple(job_simple).ppc_job()
job_specific = ppc_job_specific(job_specific).ppc_job_specific()
df = job_simple.merge(job_specific,how='inner',on='구인인증번호')
df, label, label_to_index = self.make_label(df)
df = self.make_setence(df)
df = self.stopword(df)
df = self.ppc_large_space(df)
with recommend_dataset_path.open('wb') as f:
pickle.dump(df, f)
dataset = self.make_samples(df,label)
train, val, test = self.split_dataset(dataset)
dataset = {'train':train,
'val':val,
'test':test,
'label_to_index':label_to_index}
with dataset_path.open('wb') as f:
pickle.dump(dataset, f)
return
def split_dataset(self,dataset):
train, test = train_test_split(dataset, test_size=0.2, random_state=42)
train, val = train_test_split(train, test_size=0.2, random_state=42)
return train, val, test
def stopword(self,df):
stopwords = self.import_stopword()
df = df.apply(lambda x: self.make_stopword(stopwords,x))
return df
def import_stopword(self):
with open('data/stopwords.txt','r',encoding='utf-8') as f:
stopwords = f.read().replace('\n','')
stopwords = stopwords.split(',')
return stopwords
def make_stopword(self,stopwords, x):
tmp = []
tok = x.split(' ')
for word in tok:
if word not in stopwords:
tmp.append(word)
x = ' '.join(tmp)
return x
def make_samples(self,df,label):
dataset = []
for i in zip(df,label):
dataset.append(list(i))
return dataset
def make_setence(self,df):
df.set_index('구인인증번호',inplace=True)
df = df.apply(lambda x : ' '.join(x),axis=1)
return df
def make_label(self,df):
label = df.pop('직종명1')
label_to_idx = {u: i for i, u in enumerate(label.unique())}
idx_to_label = {i: u for i, u in enumerate(label.unique())}
label = label.map(label_to_idx)
return df, label, label_to_idx
def import_file(self):
job_simple_path, job_specific_path = self._get_rawdata_datasets_path()
job_simple = pd.read_csv(job_simple_path,encoding='utf-8')
job_specific = pd.read_csv(job_specific_path, encoding='utf-8')
return job_simple, job_specific
def ppc_large_space(self,df):
p = re.compile(' {2,9999999}')
df = df.apply(lambda x: p.sub(' ',x))
return df
2, 3단계에서 전처리 한 데이터를 하나의 문장처럼 합치고,
고빈도로 등장하는 stopword 사전을 만들어 제거해주었다.
fine-tuning을 위한 데이터 분리,
마지막으로 데이터셋을 import 할 수 있게 사용성 증가 처리까지 해주었다.
이제 main에서 이 dataset 모듈을 불러와주면 끝!
전처리는 가장 중요한 단계였다.
우리가 뽑은 feature를 하나하나 따로 처리해주고
처리하고 쓰레기가 있나 구경하고
처리하고 또 쓰레기가 나와서 구경했다.
오늘 내일 쌓이는 데이터가 아니기 때문에
전처리도 일관성이 있고 일반화 시킬 수 있도록 코드를 짜려고 참 노력했다
'Data Analysis > [Project] Job Description RecSys' 카테고리의 다른 글
[일자리 추천시스템 개발하기] ch.3 제품 개발 기획 (0) | 2023.09.13 |
---|---|
[일자리 추천시스템 개발하기] ch.2 MVP 정의와 아이디어 (0) | 2023.08.23 |
[일자리 추천시스템 개발하기] ch.1 발단과 발상 (0) | 2023.08.20 |