Skip to content

모듈패턴을 이용한 적합/예측 R 코드 리펙토링

License

Notifications You must be signed in to change notification settings

yongbin/refactoring-r

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

31 Commits
 
 
 
 
 
 
 
 

Repository files navigation

모듈패턴을 이용한 적합/예측 R 코드 리펙토링

직업적으로 코드를 작성하는 사람들은 일종에 정리벽이 있습니다. 그들은 자신의 책상이 엉망인건 참을 수 있지만, 지금 작업하는 코드가 잘 정리되어있지 않은것은 정말로 참기 힘들어 합니다. 물론 개인적인 취향과 별개로 영역을 명확하게 구분하고 의도를 잘 들어낸 가독성(redability) 높은 코드는 읽기쉽고, 구조적으로 서로 영향받는 영역이 적기때문에 문제가 발생할 가능성이 적습니다. 또한 이런 코드는 반복적으로 재사용하거나, 새로운 기능을 추가하기에도 유리합니다.

이 글은 제가 통계학을 전공하신분과 협업하는 과정에서 전달받은 적합, 예측을 수행하는 R 코드를 기존의 동작을 그대로 유지한 상태로 좀 더 손 쉽게 구조적으로 재사용하기 쉬운 형태로 개선시키면서 만났던 문제점과 그 문제를 나름대로 해결해 나가는 과정을 기록한 내용입니다.

 Copyright (c)  2015 Yongbin Yu.
Permission is granted to copy, distribute and/or modify this document under the
terms of the GNU Free Documentation License, Version 1.3 or any later version
published by the Free Software Foundation; with no Invariant Sections, no
Front-Cover Texts, and no Back-Cover Texts. A copy of the license is included
in the section entitled "GNU Free Documentation License".

1 Background

  • 우리는 약 2만건의 의류대여 정보를 가지고 있습니다.
  • 한 건의 의류 대여정보에는 키, 몸무게, 허리둘레와 같은 옷을 빌린 사람에 대한 실측 치수와 팔길이, 가슴둘래와 같은 대여된 옷의 실측 치수가 기록되어있습니다.
  • 우리는 이 정보를 이용해서 아직 옷을 대여하지 않은 사람의 신체 치수를 알때 그 사람에게 가장 잘 맞는 옷의 치수를 예측하고 싶습니다.
  • 필요한 예측의 결과는 남성, 여성 각각에 대한 상의, 하의 4가지 입니다.
  • 앞선 분석을 통해 각각의 예측결과에 영향을 주는 2가지의 신체 치수가 있으며 각 치수마다 영향을 주는 요소들을 알고 있습니다.

2 Original

제공받은 코드는 랜덤 포레스트(random forest) 분류 알고리즘을 이용해서 각각의 예측에 대해서 모델을 전처리 한 후 적합 시키는 부분과 접합한 모델을 통해 새로운 치수를 예측하는 부분으로 구성되어있습니다.

2.1 전처리및 적합

   # 전처리
   jacket_m<-query[(query$is_bestfit==1)
                   &(query$clothes_category=="jacket")
                   &(query$gender=="male"),]

   jacket_m$height<-as.numeric(jacket_m$height)
   jacket_m$weight<-as.numeric(jacket_m$weight)
   jacket_m$bust<-as.numeric(jacket_m$bust)
   jacket_m$waist<-as.numeric(jacket_m$waist)
   jacket_m$topbelly<-as.numeric(jacket_m$topbelly)
   jacket_m$arm<-as.numeric(jacket_m$arm)

   # 파생변수 생성
   jacket_m$BMI<-jacket_m$weight/(jacket_m$height/100)^2
   jacket_m$BBI<-jacket_m$height-jacket_m$weight-jacket_m$bust
   jacket_m$waist_height_ratio<-jacket_m$waist/jacket_m$height
   jacket_m$Ponderal_index<-jacket_m$weight/(jacket_m$height/100)^3
   jacket_m$BIA<-(1.1*jacket_m$weight)-(128*jacket_m$weight^2/jacket_m$height^2)

   # 랜덤 포레스트  적합
	 # male bust
   m_bust_rf<-randomForest(clothes_bust~height+weight+bust+waist
                           +topbelly+BMI+BBI+waist_height_ratio+BIA
                          ,data=jacket_m[jacket_m$ind==1,],importance=T)

	 # 남성 윗배
   m_topbelly_rf<-randomForest(clothes_topbelly~bust+weight+waist
                               +topbelly+Ponderal_index+BIA
                              ,data=jacket_m[jacket_m$ind==1,],importance=T)

   # ...
   # 같은 방식으로 male pants, female jacket, female skirt 에 대한 코드가 이어집니다.
   # ...

남성, 여성 각각의 상의, 하의 4가지 경우에 예측하는 변수가 각각 2가지 입니다. 모든 경우의 수마다 위의 코드가 반복됩니다. 하지만 랜덤 포레스트를 적합할때 설명변수의 묶음(feature)는 모두 조금씩 다릅니다.

2.2 예측

   jacket_m_size<-function(height,weight,bust,waist,topbelly,thigh,arm,leg){
     jacket_m[dim(jacket_m)[1]+1,]<-0
     jacket_m$height[dim(jacket_m)[1]]<-height
     jacket_m$weight[dim(jacket_m)[1]]<-weight
     jacket_m$bust[dim(jacket_m)[1]]<-bust
     jacket_m$waist[dim(jacket_m)[1]]<-waist
     jacket_m$topbelly[dim(jacket_m)[1]]<-topbelly
     jacket_m$thigh[dim(jacket_m)[1]]<-thigh
     jacket_m$arm[dim(jacket_m)[1]]<-arm
     jacket_m$leg[dim(jacket_m)[1]]<-leg
     jacket_m$BMI[dim(jacket_m)[1]]<-weight/(height/100)^2
     jacket_m$BBI[dim(jacket_m)[1]]<-height-weight-bust
     jacket_m$waist_height_ratio[dim(jacket_m)[1]]<-waist/height
     jacket_m$Ponderal_index[dim(jacket_m)[1]]<-weight/(height/100)^3
     jacket_m$BIA[dim(jacket_m)[1]]<-(1.1*weight)-(128*weight^2/height^2)
     jacket_m$ind[dim(jacket_m)[1]]<-2

     pred_bust<-predict(m_bust_rf,jacket_m[dim(jacket_m)[1],])
     pred_topbelly<-predict(m_topbelly_rf,jacket_m[dim(jacket_m)[1],])

     result<-data.frame(cbind(pred_bust,pred_topbelly))
     colnames(result)<-c("pred_bust","pred_topbelly")

     return(result)
   }

	 # ...
	 # 역시 같은 방식으로 pants_m_size, jacket_f_size, skirt_f_size 계속됨
	 # ...

코드 실행시킨 결과입니다.

> jacket_m_size(183,82,102,88,85,61,64,105)
    pred_bust pred_topbelly
187  102.0371      93.84677

> jacket_m_size(175,68,92,83,80,54,62,99)
    pred_bust pred_topbelly
187  98.14482      89.25981

즉 신체치수가 키 183cm, 몸무게 82kg, 가슴둘레(bust)가 102cm, 허리둘레(waist)가 88cm, 윗배둘레(topbelly)가 85cm, 허벅지 둘레(thight)가 61cm, 팔길이(arm)이 64cm, 다리길이(leg)가 105cm 인 남성의 자켓을 대여하려고 할때 자켓의 가슴둘레가 대략 107cm , 윗배둘레가 대략 94cm인 옷이 가장 어울릴 것으로 예측된다고 볼 수 있습니다.

제네릭 함수인 predict 를 이용해서 적합된 모델에서 예측값을 얻습니다. 재사용을 위해서 함수형태로 구현되어있지만, 마찬가지로 남성, 여성 각각 상의, 하의에 대응되는 함수가 각각 존재합니다.

3 Problem

기존 코드는 우리가 원하는 결과를 주고있지만, 구조적으로 몇가지 아쉬운점이 있었습니다.

  1. 중복된 코드가 너무 많이 존재함 : 완전하게 동일하지는 않지만 거의 유사한 코드가 8가지 경우의 수 각각에 대해서 존재하기 때문에 전체적으로 중복된 코드가 너무 많습니다.
  2. 변수의 사용이 전역적임 : 일부 함수로 조직화 되어있는 코드조차도 각종 변수에 전역적으로 접근하기떄문에 기능에 일부분을 독립적으로 수행하기 어렵습니다.
  3. 코드의 유연성이 떨어짐 : 모델 적합시점에 예측과 설명변수는 이후 추가적인 분석에 따라 변경될 여지가 있습니다. 하지만 현재 코드는 예측과 설명변수를 담고 있는 수식(formula)부가 코드에 결합되어있기 때문에 변경이 용이하지 않습니다.
  4. 가독성 : 길고 중복된 코드가 많으며 변수가 전역적으로 사용되고 있기 때문에 코드를 읽고 한눈에 전체적인 구조를 파악하기 어렵습니다.

따라서 기존 코드의 동작을 그대로 유지하면서, 위 문제를 개선하는 작업을 진행할 필요가 있습니다.

4 Refactoring

4.1 전처리

기존 코드에서 전처리 부분은 주로 DB에서 뽑은 CSV(comma-separated values) 원시자료를 데이터 프레임에 저장한뒤, 널값등을 처리하는 기본적인 전처리와 몇가지 간단한 파생변수를 생성하는 비교적 단순한 작업입니다.

R로 어떤 작업을 할때 data.frame 은 필수적이고 강력한 자료구조입니다. 하지만 기본 data.frame의 문법은 표현력에서 아쉬운점들이 많습니다. 많은 분들이 추천하시는 data.table 은 data.frame의 이런 아쉬운 표현력 문제를 해결해주며 data.frame과 호환성을 유지하며, 성능이 뛰어나고, 부수적으로 여러가지 강력한 기능들도 제공하는 멋진 모듈입니다. 개인적으로 10분이상 들여다 봐야할 자료는 정신건강을 위해서 읽어오는 부분에서 귀찮더라도 꼭 data.table로 처리합니다.

4.2 적합모델

우리는 남성,여성 각각의 상의, 하의에 대해서 각각 2개 총 8개의 모델을 가지고 있습니다. 기존 코드에서 모델 적합코드를 하나를 보기 좋게 정리해 보면 아래와 같습니다.

# male jacket - bust
randomForest( clothes_bust
             ~ height
             + weight
             + bust
             + waist
             + topbelly
             + BMI
             + BBI
             + waist_height_ratio
             + BIA
            ,data=data[ is_bestfit == T
                       & clothes_category == 'jacket'
                       & gender == 'male'
                       & ind == T ]
            ,importance=T
            ,na.action=na.omit )

randomForest 함수의 인자(parameter)는 다음과 같이 구성되어있습니다.

  • 첫번째 인자로 예측(Y)과 설명변수(feature)로 구성된 수식을 인자로 받습니다. 앞서 설명한것 처럼 이 수식의 구성은 모델마다 조금씩 달라집니다.
  • 두번째 인자는 모델에 적합시킬 자료입니다. 우리는 data.table 형식의 data 변수에 모든 자료를 저장했습니다. 이 자료에는 8가지 경우의 모든 자료를 다 가지고 있기 때문에 각각의 모델마다 다른 clothes_categorygender 를 지정해야합니다.
  • importance 와 NULL값 처리에 대한 na.action 인자는 모든 모델에서 동일합니다.

즉, 각각의 모델마다 달라지는 내용은 예측을 위한 수식과 자료를 불러오는 조건입니다. 기존 8개 코드에서 이 부가지 부분을 제외하면 모델을 적합시키는 모든 코드는 모두 동일한 함수 호출이 됩니다.

아직 이 부분을 어떻게 처리해야할지 아이디어는 없지만 일단 코드에서 수식을 구성하는 요소들과 자료를 불러오는 조건을 Tidy Data 형식으로 정리해 두겠습니다.

genderclothes_categoryguessparameter
malejacketbustheight
malejacketbustweight
malejacketbustbust
malejacketbustwaist
malejacketbusttopbelly
malejacketbustBMI
malejacketbustBBI
malejacketbustwaist_height_ratio
malejacketbustBIA
malejackettopbellybust
malejackettopbellyweight
malejackettopbellywaist
malejackettopbellytopbelly
malejackettopbellyPonderal_index
malejackettopbellyBIA
malepantsthightweight
malepantsthightwaist
malepantsthighttopbelly
malepantsthightthigh
malepantsthightBMI
malepantsthightBBI
malepantsthightBIA
malepantsthightwaist_height_ratio
malepantswaistweight
malepantswaistBIA
malepantswaistbust
malepantswaistwaist
malepantswaisttopbelly
malepantswaistthigh
malepantswaistBMI
malepantswaistBBI
malepantswaistwaist_height_ratio
malepantswaistPonderal_index
femalejacketbustweight
femalejacketbustbust
femalejacketbusttopbelly
femalejacketbustBMI
femalejacketbustwaist_height_ratio
femalejacketbustPonderal_index
femalejacketbustBIA
femalejackettopbellyweight
femalejackettopbellybust
femalejackettopbellytopbelly
femalejackettopbellyBMI
femalejackettopbellyBBI
femalejackettopbellyBIA
femaleskirthipweight
femaleskirthipbust
femaleskirthiptopbelly
femaleskirthipBMI
femaleskirthipBBI
femaleskirthipBIA
femaleskirtwaistweight
femaleskirtwaistwaist
femaleskirtwaistBIA
femaleskirtwaisthip
femaleskirtwaisttopbelly
femaleskirtwaistBMI
femaleskirtwaistBAI
femaleskirtwaistBBI
femaleskirtwaistPonderal_index

4.3 함수화1

일단 여기까지 진행된 코드들을 모아서 size 라는 간단한 함수를 만들어 봅시다.

size <- function(data, rf1, rf2, gender, category,
                 height,weight,bust,waist,topbelly,thigh,arm,leg) {

    arg <- data.frame( height = height
                     ,weight = weight
                     ,bust = bust
                     ,waist = waist
                     ,topbelly = topbelly
                     ,thigh = thigh
                     ,arm = arm
                     ,leg = leg
                     ,BMI = weight / ( height / 100 ) ^ 2
                     ,BBI = height - weight - bust
                     ,waist_height_ratio = waist / height
                     ,Ponderal_index = weight / ( height / 100 ) ^ 3
                     ,BIA = ( 1.1 * weight ) - ( 128 * weight ^ 2 / height ^ 2 )
                     ,ind = 2) # *

    p_bust     <- predict(rf1, arg)
    p_topbelly <- predict(rf2, arg)
    result     <- data.frame(bust = p_bust, topbelly = p_topbelly)

    return(result)
}

기존 코드의 jacket_m_size 함수보다 predict 에 전달하는 인자를 만들어내는 코드가 깔끔해 지긴 했지만 이 함수는 아직 많은 문제점을 가지고 있습니다. 특히 함수의 이름은 size 이지만 아직 이 함수는 남성 자켓에 대한 예측 밖에 처리할 수 없습니다. 왜냐하면 남성 자켓의 설명변수인 p_bustp_topbelly 만을 처리하도록 되어있고 다른 요청은 예측 변수들이 다르기 때문입니다. 그리고 모델을 만드는 부분역시 외부변수에 의존적입니다.

여러가지 문제가 많지만 우선 저는 여기까지 변경한 내용인 기존 코드와 동일한 결과를 돌려주는지를 먼저 확인하고 싶습니다.

# bust
rf1 <- randomForest( clothes_bust
             ~ height
             + weight
             + bust
             + waist
             + topbelly
             + BMI
             + BBI
             + waist_height_ratio
             + BIA
            ,data=data[ is_bestfit == T
                       & clothes_category == 'jacket'
                       & gender == 'male'
                       & ind == T ]
            ,importance=T
            ,na.action=na.omit )

# topbelly
rf2 <- randomForest( clothes_topbelly
             ~ bust
             + weight
             + waist
             + topbelly
             + Ponderal_index
             + BIA
            ,data=data[ is_bestfit == T
                       & clothes_category == 'jacket'
                       & gender == 'male'
                       & ind == T ]
            ,importance=T
            ,na.action=na.omit )

위에서 정리했던 내용을 바탕으로 rf1rf2 변수를 위와 같이 생성해 준 뒤, size 함수를 수행하면 다음과 같은 결과를 얻습니다.

> size( data, rf1, rf2, 'male', 'jacket',
+       183, 82, 102, 88, 85, 61, 64, 105 )
      bust topbelly
1 101.0411  93.6454

> size( data, rf1, rf2, 'male', 'jacket',
+       175, 68, 92, 83, 80, 54, 62, 99 )
      bust topbelly
1 98.25704 89.28921

기존 코드에서 받았던 결과와 동일합니다. 지금까지 손댄 부분이 결과에 영향을 끼치고 있지는 않은것 같습니다.

4.4 모델 함수

이 시점에서 가장 먼저 떠오른 개선은 랜덤 포레스트 모델을 생성해 주는 함수를 작성하는 일입니다. 기존 코드에서 각각의 모델을 생성하는 코드를 매번 적어주는 대신에, 모델을 생성해주는 함수를 작성하고 필요할때마다 그 함수를 호출하면 기존 코드에서 8번이나 반복되어있는 모델의 생성 코드를 제거할 수 있을 것 같습니다.

부수적으로 모델을 생성해주는 함수를 만들면 예측(Y)에 따른 설명변수의 구성이 변경되더라도 코드를 수정하지 않고 결과를 확인할수 있기때문에 좀 더 유연하게 코드를 재사용하는 효과도 기대할 수 있습니다.

이 문제를 좀 더 분석해 보도록 하겠습니다.

4.4.1 동적 수식 평가

다시한번 모델을 만드는 코드를 살펴봅시다.

# male jacket - bust
randomForest( clothes_bust
             ~ height
             + weight
             + bust
             + waist
             + topbelly
             + BMI
             + BBI
             + waist_height_ratio
             + BIA
            ,data=data[ is_bestfit == T
                       & clothes_category == 'jacket'
                       & gender == 'male'
                       & ind == T ]
            ,importance=T
            ,na.action=na.omit )

우선 우리는 각각의 모델마다 서로 다른 clothes_bust ~ height + weight + bust ... 이 수식부를 문자열 덩어리가 아니라 매번 다르게 생성할 수 있는 인자로 만드는 방법을 고민해야 합니다.

검색을 해본 결과 비슷한 고민을 하고 있는 사람을 찾았습니다. R의 기본함수 formula수식 문자열수식 으로 변환해줍니다. 수식 문자열은 벡터와 paste 함수를 잘 조합하면 적절하게 만들 수 있을 것 같습니다. 아이디어를 간단하게 시험해보면 아래와 같습니다.

# 분류(Y)
y        <- 'clothes_bust'
# 설명변수들
features <-  c('height', 'weight', 'bust', 'waist','topbelly',
               'BMI', 'BBI', 'waist_height_ratio', 'BIA')

# 설명변수들을 ' + ' 으로 묶어준다.
x <- paste(features, collapse =' + ')

# 분류와 설명변수들을 ' ~ ' 으로 묶어준다.
frm <- paste(y, x, sep=' ~ ')

identical(
    formula(paste("clothes_bust ~ height + weight + bust",
                  " + waist + topbelly + BMI + BBI",
                  " + waist_height_ratio + BIA"))
   ,formula(frm) )

# [1] TRUE

기본함수 pastecollapse 속성을 부여하면 벡터를 지정된 구분자 문자로 묶어줍니다. 위 코드는 설명변수들을 + 로 묶고 분류와 설명변수를 ~으로 묶어 수식 문자열로 만든뒤 그 문자열을 수식형식으로 변환했을때 기존 수식과 다르지 않다는것을 보여주고 있습니다.

4.4.2 인자 처리

적합모델에서 정리한 표를 우리가 실행할 함수에서 인자로 받도록 변경합니다. 이렇게 되면 기존 코드에서 코드와 결함되어 실행시점에 변경할 수 없던 있던 영역자료와 같이 사용자가 임의로 언제든 바꿀수 있는 영역 으로 변경되기 때문에 좀 더 유연하게 코드를 호출 할 수 있는 길이 열립니다.

parameter <- fread('parameter.csv', stringsAsFactors = F)

data.table 에서 제공하는 fread 함수는 read.csv 함수와 유사하지만 결과를 data.table로 자동 변환해 주기떄문에 편리합니다.

4.4.3 생성 함수

위 결과들을 조합하면 아래와 같이 rf_factory 함수를 만들 수 있습니다.

rf_factory <- function( data, lookup, gender, category, guess ) {

    y <- paste('clothes_', 'bust', sep='')
    x <- paste( lookup[gender == gender
                       & clothes_category == category
                       & guess == guess]$parameter, collapse =' + ')

    frm <- formula( paste(y, x, sep=' ~ ') )


    rf <- randomForest(
        frm
       ,data=data[ is_bestfit == T
                  & clothes_category == category
                  & gender == gender
                  & ind == T ]
       ,importance=T
       ,na.action=na.omit )

    return(rf)
}

이 함수는 전체 자료(data)와 자료의 적합과 관련된 정보를 담고 있는 인자(lookup)을 입력받고 ‘성별’,’종류’,’분류’를 지정하면 지정된 자료로 적합된 랜덤 포레스트 모델을 돌려줍니다. 사용 예는 아래와 같습니다.

> rf_factory(data, parameter, 'male','jacket', 'bust')

Call:
 randomForest(formula = frm,
              data = data[is_bestfit == T
                           & clothes_category == category
                           & gender == gender
                           & ind == T],
              importance = T,
              na.action = na.omit)

               Type of random forest: regression
                     Number of trees: 500
No. of variables tried at each split: 3

          Mean of squared residuals: 221.5875
                    % Var explained: 11.04

4.5 함수화2

다시한번 size 함수를 작성해 봅니다.

size <- function(data, parameter, gender, category,
                 height,weight,bust,waist,topbelly,thigh,arm,leg) {

    arg <- data.frame( height = height
                     ,weight = weight
                     ,bust = bust
                     ,waist = waist
                     ,topbelly = topbelly
                     ,thigh = thigh
                     ,arm = arm
                     ,leg = leg
                     ,BMI = weight / ( height / 100 ) ^ 2
                     ,BBI = height - weight - bust
                     ,waist_height_ratio = waist / height
                     ,Ponderal_index = weight / ( height / 100 ) ^ 3
                     ,BIA = ( 1.1 * weight ) - ( 128 * weight ^ 2 / height ^ 2 )
                     ,ind = 2) # *

    names      <- parameter[gender == gender & clothes_category == category ,unique(guess)]

    x          <- predict(rf_factory( data, parameter, gender, category, names[1]), arg) # Ugly
    y          <- predict(rf_factory( data, parameter, gender, category, names[2]), arg) # Ugly

    result        <- c(x,y)
    names(result) <- names

    return(result)
}

rf_factory 함수에서 인자목록을 변수로 전달받아서 동적으로 결과를 만들어내는 기법을 차용해서 인자로 받은 성별과, 종류에 따른 모델을 rf_factory 로 생성하고 돌려받은 결과의 이름역시 인자목록에서 찾아서 돌려주도록 개선했습니다.

이제 size 함수가 그 이름 처럼 모든 사이즈에 대한 질문을 답해줄수 있는 좀더 일반적인 함수의 역활을 수행할 수 있는 형태가 되었습니다. 그리고 모델의 생성부분도 외부영역에 의존하던 부분을 함수의 호출형태로 개선시켰습니다.

4.6 모듈패턴

이제 우리가 문제를 해결하는데 필요한 함수는 sizerf_factory 두개가 되었습니다. 이 함수를 작성한 저는 이 두 함수가 같이 협력해서 문제를 해결하고 있다는걸 알지만 사실 R의 환경(Environment) 상에서는 이 두 함수는 그냥 단지 존재하는 각각의 함수일 뿐입니다. 저는 조금 더 이 두 함수들이 목적에 맞게 더 잘 정리하고 싶습니다.

만약 다른 언어로 코딩을 해본 분들이라면, 이쯤에서 모두 비슷한 한가지 방법을 떠올리실꺼라고 생각합니다. 네 객체지향 프로그래밍(OOP: object oriented programming) 입니다.

R은 S3와 S4등 다양한 방법으로 객체지향 문법을 지원하고 있습니다. 아마 이 문법들을 조금 더 자세하게 공부하면 제가 고민하고 있는 문제를 좀 더 아름답게 해결할 수 있을것 같은 기대감이 생깁니다. 하지만, 저는 S3나 S4와 같은 R에서 제공하는 OOP 문법을 사용하지 않고 모듈패턴 이라는 방식으로 이 문제를 풀어보기로 했습니다. 제가 그렇게 결정한데는 몇가지 이유가 있습니다.

  1. 저는 현재 S3나 S4를 전혀 모르며 사용해본 적이 없습니다. 그리고 잠깐 문서를 살펴본 결과 30분 미만을 공부해서 잘 사용할 수 있을것 같지가 않습니다. 당장 보고이쓴 코드를 정리하고 싶긴 하지만 그렇다고 몇일씩 투자해서 새로운 문법을 따로 공부하고 싶지는 않습니다.
  2. 모듈패턴도 제한적이지만 우리가 OOP에서 얻을수 있는 캡슐화나 정보은닉을 제공해줍니다.
  3. 무엇보다도 모듈패턴은 추가적인 페키지가 필요없고 R의 기본 함수를 통해서 단순하게 구현이 가능합니다. 그리고 이미 잘 설명된 문서가 있습니다.

모듈 이란 외부에서 접근할 수 없는 데이터와 그 데이터를 제어하기 위한 함수로 구성된 구조물이며 패턴이란 정형화된 코딩 기법을 말합니다.[1] 모듈 패턴은 비단 R에서만 사용되는것이 아니라 범용적인 프로그래밍 언어에서 널리 사용되는 방법입니다. R을 이용해서 모듈 패턴을 구현하는 방법에 대한 자세한 설명과 예시는 위에 제시한 문서를 읽어보시면 됩니다.

지금까지 작성한 코드를 ocarina 라는 이름으로 모듈패턴으로 정리하면 다음과 같습니다.

ocarina <- function(data, lookup) {
    data   <- data
    lookup <- lookup

    rf <- function( gender, category, guess ) {

        y <- paste('clothes_', guess, sep='')
        x <- paste( lookup[gender == gender
                           & clothes_category == category
                           & guess == guess]$parameter, collapse =' + ')

        frm <- formula( paste(y, x, sep=' ~ ') )

        set.seed(1234)
        rf <- randomForest(
            frm
           ,data=data[ is_bestfit == T
                      & clothes_category == category
                      & gender == gender
                      & ind == T ]
           ,importance=T
           ,na.action=na.omit )

        return(rf)
    }

    size <- function( gender, category,
                     height,weight,bust,waist,topbelly,thigh,arm,leg) {

        arg <- data.frame( height = height
                         ,weight = weight
                         ,bust = bust
                         ,waist = waist
                         ,topbelly = topbelly
                         ,thigh = thigh
                         ,arm = arm
                         ,leg = leg
                         ,BMI = weight / ( height / 100 ) ^ 2
                         ,BBI = height - weight - bust
                         ,waist_height_ratio = waist / height
                         ,Ponderal_index = weight / ( height / 100 ) ^ 3
                         ,BIA = ( 1.1 * weight ) - ( 128 * weight ^ 2 / height ^ 2 )
                         ,ind = 2) # *

        names      <- lookup[gender == gender & clothes_category == category ,unique(guess)]

        x          <- predict(rf( gender, category, names[1]), arg) # Ugly
        y          <- predict(rf( gender, category, names[2]), arg) # Ugly

        result        <- c(x,y)
        names(result) <- names

        return(result)
    }

    return(list(rf = rf, size = size))
}

이렇게 작성된 모듈은 아래와 같은 방법으로 사용됩니다.

ocarina <- ocarina(data, parameter)
ocarina$rf('male','jacket', 'bust')
ocarina$size( 'male', 'jacket', 183, 82, 102, 88, 85, 61, 64, 105 )
ocarina$size( 'male', 'pants',  183, 82, 102, 88, 85, 61, 64, 105 )

4.7 은닉, 초기화

자료에서 소개하고 있지는 않지만 개인적으로 좀 더 궁리해본 결과 기존의 모듈 패턴을 발전시켜서 좀더 OOP와 유사한 모양을 가지도록 만들 수 있었습니다.

x <- function () {
    models <- list();


    a <- function() {
        models
    }

    .b <- function() {
        cat("wow\n")
    }

    .initialize <- function() {
        a()
    }

    .initialize()
    return(list(a = a))
}

제가 알아낸 사실은 다음과 같습니다.

  1. 모듈내부에 작성된 함수라 하더라도 return 에 포함하지 않으면 함수는 외부로 노출되지 않습니다. 위 예제에서 함수 a 는 외부에서 호출가능하지만, .b , .initialize 는 외부에서 호출 할 수 없습니다.(저는 코드에서 내부함수와 외부함수 의도를 드러내기 위해서 내부함수인 경우 함수이름 앞에 점(.)을 붙이는 방식으로 명명했습니다)
  2. 모듈 내부는 별도의 사전 영역(lexical scope)가 형성됩니다. 외부로 공개되지 않는 함수들도 내부에서는 호출이 가능합니다.
  3. 이 함수의 호출이 OOP의 객체 생성이라고 볼때, 맴버 변수와 메소드의 생성뿐만 하니라 특정 동작을 수행할 수 있습니다. 위 예에서 .initialize 함수의 호출은 OOP의 생성자 와 비슷한 방식의 동작이 됩니다.

4.8 성능

이제 꽤 코드가 형식을 갖추고 그럴듯 하게 동작하는것 처럼 보입니다. 하지만 반복적으로 코드를 테스트하고, 적합하는 자료를 테스트자료 대신에 실제 자료를 적용시켜 본 결과 한가지 문제가 발견되었습니다.

지금의 코드는 구현상 매 회 사이즈를 예측할때마다 랜덤 포레스트 모델 적합을 수행하는데 적합자료가 작을때는 크게 문제가 되지 않지만, 적합자료가 큰 경우 결과를 응답하는데 필요한 대부분의 시간을 모델을 적합하는데 사용하기 때문에 요청을 처리하는데 너무 오랜 시간이 걸린다는 점이었습니다.

또다시 이 문제를 해결하기 위해서 궁리하기 시작했습니다.

4.8.1 캐쉬

가장 먼저 떠오른 해결 방법은 모듈의 초기화 시점에 적절한 내부 변수에 필요한 모든 모델을 적합시킨뒤, 사이즈 예측시점마다 그 모델을 사용하도록 코드를 변경하는 것이었습니다.

4.8.2 리스트 자료형

위 방법을 구현하는데 첫번째 고민은 여러개의 모델을 어떻게 저장하느냐였습니다. 먼저 data.table 이나 named vector 를 떠올렸지만 둘 다 지원하는 스칼라 형식이 아니기 때문에 저장할 수 없었습니다. 한참을 검색하던 중 보통 비교등을 위해서 여러 모델을 다뤄야 할때는 list 자료형을 쓴다는 사실을 알게 되었습니다.

사실 저는 지금까지 R에서 리스트(list) 자료형이 왜 존재하는지 항상 의문이었습니다. 왜냐하면 보통 책에서는 리스트 자료형이 일반적인 프로그래밍 언어에서 제공하는 해쉬(hash), 혹은 사전(dictionary)와 유사한 자료형이라고 소개하고 있지만, 막상 리스트를 해쉬나 사전처럼 쓰는것은 경험상 너무 까다로웠습니다. 오히려 named vector 가 다루기도 간편하고 Perl 이나 Python 같은 언에서 사용하는 해쉬,사전 자료형과 유사하게 동작했기 때문입니다. 하지만 이번 작업을 통해서 리스트 자료형의 한가지 쓰임방식을 확실하게 알게 되었습니다. 리스트 자료형은 다른 자료형들과 다르게 담을수 있는 값의 형식에 제약이 없고 중첩된(nested) 구조로 자죠를 저장해야할때 아주 유용한 자료형입니다. 따라서 적합된 여러 모델들을 넣어두고 필요할때 꺼내쓰는 상황에서도 요긴하게 사용할 수 있습니다.

4.8.3 적제 함수

이제 생각한것을 코드로 표현해 보겠습니다.

ocarina <- function(data, parameter) {
    data      <- data
    parameter <- parameter
    models    <- list()
    lookup    <- data.frame()

    .initialize <- function() {
        lookup <<- unique( parameter[,.(gender, clothes_category, guess  ) ] )
        models <<- alply( lookup
                      ,1
                      ,function(df) { .rf(df$gender,df$clothes_category, df$guess) } )
    }

    # ...

    .initialize()
    return(list(guess = guess, male = male))
}

ocarina 모듈(객체)에 models 라는 리스트형의 맴버 변수를 추가했습니다. 그리고 생성시점에 호출되는 .initialize 함수에서는 기존에 사용했던 parameter 에서 모델로 생성되어야 하는 목록을 lookup 이라는 변수에 저장합니다. 저장된 결과는 아래와 같습니다.

genderclothes_categoryguess
malejacketbust
malejackettopbelly
malepantsthight
malepantswaist
femalejacketbust
femalejackettopbelly
femaleskirthip
femaleskirtwaist

우리는 이 data.frame 의 값을 인자로 우리가 만든 .rf (구 rf_factory) 함수를 호출해서 모델을 생성하고 그 결과를 models 리스트에 저장하고 싶습니다.

models <<- alply( lookup
              ,1
              ,function(df) { .rf(df$gender,df$clothes_category, df$guess) } )

plyralply 함수는 배열(a)를 받아서 리스트(l)로 돌려주는 함수입니다. 데이터 프레임을 인자로 받아서 리스트에 저장하고 싶다면 일감 dlply 함수가 떠오르겠지만, dlply 는 데이터 프레임을 나눠서 처리한뒤 리스트로 돌려주는데 적합한 함수이지 이 상황처럼 모든 행을 처리해서 결과를 리스트로 돌려주는 상황에는 적합하지 못합니다. plyr 모듈의 명명 방식때문에 처음에 쉽게 착각할 수 있는 내용입니다. 자세한 내용은 plyr의 배열(a) 계열 함수 대한 설명을 을 참고하시기 바랍니다.

위 코드를 통해 lookup 데이터 프레임의 각각의 행은 .rf 함수의 인자로 전달된 뒤, 그 돌아오는 결과가 models 에 저장됩니다. 이 코드에서 배정연산자가 <<- 로 사용된 이유는 상위 영역(Scope)에서 지정된 변수의 내용을 변경하기 때문입니다. 이 코드가 실행 된 뒤, models 변수의 내용은 아래와 같습니다.

$`1`

Call:
 randomForest(formula = frm,
                data = data[is_bestfit == T
                     & clothes_category == category
                     & gender == gender
                     & ind == T],
                importance = T,
                na.action = na.omit)

               Type of random forest: regression
                     Number of trees: 500
No. of variables tried at each split: 3

          Mean of squared residuals: 218.4277
                    % Var explained: 12.3

$`2`

Call:
 randomForest(formula = frm,
                data = data[is_bestfit == T
                    & clothes_category == category
                    & gender == gender
                    & ind == T],
                importance = T,
                na.action = na.omit)
               Type of random forest: regression
                     Number of trees: 500
No. of variables tried at each split: 3

          Mean of squared residuals: 7.155045
                    % Var explained: 94.01
...

$`8`

Call:
 randomForest(formula = frm,
                data = data[is_bestfit == T
                     & clothes_category == category
                     & gender == gender
                     & ind == T],
                importance = T,
                na.action = na.omit)
               Type of random forest: regression
                     Number of trees: 500
No. of variables tried at each split: 3

          Mean of squared residuals: 9.423527
                    % Var explained: 75.07

attr(,"split_type")
[1] "array"
attr(,"split_labels")
   gender clothes_category    guess
1:   male           jacket     bust
2:   male           jacket topbelly
3:   male            pants   thight
4:   male            pants    waist
5: female           jacket     bust
6: female           jacket topbelly
7: female            skirt      hip
8: female            skirt    waist

보시는것 처럼 1부터 8까지의 색인(index)에 각각의 모델이 저장된 리스트 자료형임을 알 수 있습니다. 또한 리스트 속성(attribute)으로로 plyr을 통해 나눠진 자료의 형식과 원 자료의 라벨을 split_type, split_labels 으로 제공하고 있습니다.

4.8.4 불러오는 함수

이제 필요한 모델은 모듈이 생성되는 시점에 모두 models 변수안에 적합되어 저장됩니다. 따라서 기존에 모델을 생성시키던 코드를 저장되어있는 모델을 찾아주는 코드로 변경해야합니다.

먼저 성별, 종류, 변수 인자를 받아서 모델을 찾아주는 함수 .model 을 다음과 같이 작성합니다.

.model <- function( gender, category, guess ) {

    idx <- which(lookup$gender == gender
                 & lookup$clothes_category == category
                 & lookup$guess == guess )

    return(models[[ idx ]] )

}

우리는 lookup 변수를 초기화 시점에 생성했으며, models 는 이 변수를 참조해서 모델들을 적합시켰기 때문에 역으로 이 변수를 참조하면 models 리스트에 저장된 모델의 키값을 찾을수 있다. 일반적으로 데이터 프레임에서 자료가 위치한 색인을 얻기 위해서는 which 함수를 사용하면 됩니다.

이제 기존에 .rf 를 직접 호출하던 함수를 아래와 같이 수정합니다.

guess <- function( gender, category,
                 height,weight,bust,waist,topbelly,thigh,arm,leg) {

    person <- data.frame( height = height
                     ,weight = weight
                     ,bust = bust
                     ,waist = waist
                     ,topbelly = topbelly
                     ,thigh = thigh
                     ,arm = arm
                     ,leg = leg
                     ,BMI = weight / ( height / 100 ) ^ 2
                     ,BBI = height - weight - bust
                     ,waist_height_ratio = waist / height
                     ,Ponderal_index = weight / ( height / 100 ) ^ 3
                     ,BIA = ( 1.1 * weight ) - ( 128 * weight ^ 2 / height ^ 2 )
                     ,ind = 2) # *

    names      <- parameter[gender == gender & clothes_category == category ,unique(guess)]
    result     <- sapply(names, function(name) {
        predict( .model( gender, category, name ) , person )
    })
    names(result) <- names

    return(result)
}

.rf 함수를 직접 호출하던 부분을 .model 함수로 교체해서 모듈의 생성시점에 미리 적합시켜둔 모듈을 불러도록 변경했습니다. 추가적으로 sapply 를 활용해서 result 를 만들어내는 코드를 조금 더 개선시켰습니다.

5 Final

지금까지 설명한 내용이 모두 적용된 최종 코드는 아래와 같습니다.

# List of packages for session
.packages = c("data.table", "plyr", "randomForest")

# Install CRAN packages (if not already installed)
.inst <- .packages %in% installed.packages()
if(length(.packages[!.inst]) > 0) install.packages(.packages[!.inst])

# Load packages into session
suppressMessages({
    lapply(.packages, require, character.only=TRUE)
})

ocarina <- function(data, parameter) {
    data      <- data
    parameter <- parameter
    models    <- list()
    lookup    <- data.frame()

    .initialize <- function() {
        lookup <<- unique( parameter[,.(gender, clothes_category, guess  ) ] )
        models <<- alply( lookup
                      ,1
                      ,function(df) { .rf(df$gender,df$clothes_category, df$guess) } )
    }

    .rf <- function( gender, category, guess ) {

        y <- paste('clothes_', guess, sep='')
        x <- paste( parameter[gender == gender
                              & clothes_category == category
                              & guess == guess]$parameter, collapse =' + ')

        frm <- formula( paste(y, x, sep=' ~ ') )

        set.seed(1234)
        rf <- randomForest(
            frm
           ,data=data[ is_bestfit == T
                      & clothes_category == category
                      & gender == gender
                      & ind == T ]
           ,importance=T
           ,na.action=na.omit )

        return(rf)
    }

    .model <- function( gender, category, guess ) {

        idx <- which(lookup$gender == gender
                     & lookup$clothes_category == category
                     & lookup$guess == guess )

        return(models[[ idx ]] )

    }

    guess <- function( gender, category,
                     height,weight,bust,waist,topbelly,thigh,arm,leg) {

        person <- data.frame( height = height
                         ,weight = weight
                         ,bust = bust
                         ,waist = waist
                         ,topbelly = topbelly
                         ,thigh = thigh
                         ,arm = arm
                         ,leg = leg
                         ,BMI = weight / ( height / 100 ) ^ 2
                         ,BBI = height - weight - bust
                         ,waist_height_ratio = waist / height
                         ,Ponderal_index = weight / ( height / 100 ) ^ 3
                         ,BIA = ( 1.1 * weight ) - ( 128 * weight ^ 2 / height ^ 2 )
                         ,ind = 2) # *

        names      <- parameter[gender == gender & clothes_category == category ,unique(guess)]
        result     <- sapply(names, function(name) {
            predict( .model( gender, category, name ) , person )
        })
        names(result) <- names

        return(result)
    }

    .size <- function( g, height,weight,bust,waist,topbelly,thigh,arm,leg) {

        categories <- unique( lookup[ gender == g, ]$clothes_category ) # Ugly

        result <- list()
        for( category in categories ) {
            result[[ category ]] <- guess( g
                                         ,category
                                         ,height,weight,bust,waist,topbelly,thigh,arm,leg )
        }

        return(result)
    }

    male <- function( height,weight,bust,waist,topbelly,thigh,arm,leg ) {
        return( .size('male', height,weight,bust,waist,topbelly,thigh,arm,leg ) )
    }

    female <- function( height,weight,bust,waist,topbelly,thigh,arm,leg ) {
        return( .size('female', height,weight,bust,waist,topbelly,thigh,arm,leg ) )
    }

    .initialize()
    return(list(guess = guess, male = male))
}

추가적으로 함수의 호출방식은 4가지 이지만 최종적으로 종단 사용자(end user)에게 필요한 사항은 남성,여성에 따른 옷의 신체 사이즈이기때문에 male, female 단축함수를 추가했습니다.

이 코드를 실행시킨 결과는 다음과 같습니다.

> parameter <- fread('parameter.csv', stringsAsFactors = F)
> x <- ocarina(data, parameter)
> x$guess( 'male', 'jacket', 183, 82, 102, 88, 85, 61, 64, 105 )
    bust topbelly
101.1670  93.5627
> x$guess( 'male', 'pants',  183, 82, 102, 88, 85, 61, 64, 105 )
  thight    waist
66.43063 87.41593

> x$male(  183, 82, 102, 88, 85, 61, 64, 105 )
$jacket
    bust topbelly
101.1670  93.5627

$pants
  thight    waist
66.43063 87.41593

기존 결과와 완전하게 동일한 결과를 얻음을 확인했습니다. 처음 제가 전달 받았던 약 500줄의 코드는 100줄 미만으로 정리가 되었고, 코드를 읽는 사람이 구조를 좀 더 파악할수 있도록 의도를 드려내서 작성을 했으며, 코드가 영향을 받는 영역과 인터페이스를 명확히 해서 손쉽게 다른환경에서도 재사용할 수 있도록 개선했고 진행하는 과정에서 구조적으로 성능을 개선할수 있는 간단한 기능도 추가했습니다. 이제 본격적으로 이 결과값이 의미가 있는지 혹은 예측을 더 향상시킬방법이 없는지 등을 고민하기 시작해햐하고 이 부분은 또 많은 공부가 필요하다고 느끼고 있습니다.

개인적으로 R에 흥미를 느끼고 여러가지 자료나 강연을 들으면서 혼자 공부를 시작한지 1년정도밖에 되지 않았고 책에서 본 내용들을 제가 가지고 있는 문제를 푸는데 적용해본것은 이번이 처음이었습니다. 작업을 진행해보고 느낀점은 R은 문제를 해결하는데 필요한 다양한 방법들을 제공하고 있기떄문에 각각의 방법들을 잘 아는것도 중요하지만 그 방법들이 사용되는 문맥 을 잘 짚어내는것이 그 방법을 내것으로 만드는데 더 중요하다는 것을 알게 되었습니다. 이렇게 장황하게 자료를 정리한 이유도 스스로 이번에 고민했던 문제의 문맥들을 오래동안 기억하고 싶어서기도 합니다. 내용중에 잘못된 내용이나 더 나은 의견, 혹은 다른 의견이 있다면 언제든지 편하게 제 메일([email protected]), 혹은 SNS를 통해서 알려주시기 바랍니다. 보내주신 내용은 참고해서 본문에 반영하도록 하겠습니다. 아무쪼록 저와 비슷한 고민을 하시는 분들에게 조금의 도움이 되었기를 바랍니다.

이 문서는 Emacs의 orgmode를 통해서 작성되었습니다. 문서의 전체내용은 Github에 GFDL로 공개되어있습니다. 문서에서 다루고 있는 자료와 분석 코드는 별도의 사용권 고지하에 추후 공개할 예정입니다.

6 Thanks

이 문서를 완성하는데 아래와 같이 많은 분들이 도움을 주셨습니다.

  • 김상진 : 문제의 분석과 자료의 탐색, 주요 설명변수의 분석, 다양한 모델의 적용등의 초반 통계관련 모든 작업에 자발적으로 참여해서 적극적으로 도와주셨습니다.
  • 한만일 : 문제 및 요구사항을 정리하고 분석에서 초기 분석의 내용에 대한 피드백 및 필드테스트에 도움을 주셨습니다.
  • 조성재 : 베타리딩을 진행해 주셨고 문서 본문의 오탈자와 문서 라이센스의 오기등을 바로잡아주셨습니다.
  • 이철희 : 베타리딩을 진행해 주셨고 전체적인 글의 내용과 목적등에 대한 의견을 보내주셨습니다. 특히 수식 재사용과 관련해서는 update() 함수를 추천해주셨습니다.

[1]: R을 이용한 데이터 처리 & 분석 실무 / 길벗 서민구 117p

About

모듈패턴을 이용한 적합/예측 R 코드 리펙토링

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published