본문 바로가기

Data Analysis

기계학습 - Spark(4) - Word2Vec

Word2Vec에 대한 기술자료들을 찾아봤는데, 소개중심 또는 추상적인 이론중심으로 설명을 한데다  설명 또한 자세하지 않아 개발자 입장에서 이해하기 힘들다. 그래서 Spark 소스를  직접 분석해보기로 했다.


우선 .fit함수부터 시작했다.

scala> val w2v = new Word2Vec().setInputCol("text").setOutputCol("result").setVectorSize(3).setMinCount(0)

w2v: org.apache.spark.ml.feature.Word2Vec = w2v_d6ac8192b87f


scala> val model = w2v.fit(df)


Word2Vec객체는 내려받은 소스 폴더에서 찾아보니. "/spark/mllib/src/main/scala/org/apache/spark/ml/feature" 디렉토리 안에 Word2Vec.scala 파일에 정의 되어 있다. 소스파일을 열어 .fit 함수를 가보면...

  override def fit(dataset: Dataset[_]): Word2VecModel = {

    transformSchema(dataset.schema, logging = true)

    val input = dataset.select($(inputCol)).rdd.map(_.getAs[Seq[String]](0))

    val wordVectors = new feature.Word2Vec()

      .setLearningRate($(stepSize))

      .setMinCount($(minCount))

      .setNumIterations($(maxIter))

      .setNumPartitions($(numPartitions))

      .setSeed($(seed))

      .setVectorSize($(vectorSize))

      .setWindowSize($(windowSize))

      .setMaxSentenceLength($(maxSentenceLength))

      .fit(input)

    copyValues(new Word2VecModel(uid, wordVectors).setParent(this))

  }


feature.Word2Vec객체를 감싸고 있다. 실재 객체는  다음의 폴더에 있는 녀석이다. "/spark/mllib/src/main/scala/org/apache/spark/mllib/feature" 


다시 해당 소스파일을 열어 .fit 함수를 가보면... 

  def fit[S <: Iterable[String]](dataset: RDD[S]): Word2VecModel = {


    learnVocab(dataset)


    createBinaryTree()


    val sc = dataset.context


    val expTable = sc.broadcast(createExpTable())

    val bcVocab = sc.broadcast(vocab)

    val bcVocabHash = sc.broadcast(vocabHash)

    try {

      doFit(dataset, sc, expTable, bcVocab, bcVocabHash)

    } finally {

      expTable.destroy(blocking = false)

      bcVocab.destroy(blocking = false)

      bcVocabHash.destroy(blocking = false)

    }

  }


아마도 2.x로 버전업을 하면서 이전 버전의 소스를 감싸는 작업을 한 듯 하다. 어쟀든 첫번째 코드인 learnVocab함수 호출부터 보자. 전체코드는 양이 많으니 설명으로 대체하겠다. 이 함수로 넘겨지는 변수(여기서는 dataset)는 일종의 2차원 배열로 문장별로 단어를 쪼개 배열로 구성한 것이다. 이것을 "문장별"를 구분짓는 차원을 걷어낸 단어로만 이루어진 일차원 배열로 바꾸고 이를 다시 중복된 것을 하나로 묶으면서 빈도수를 센다. 이 때 .setMinCount 함수로 정의된 값보다 작은 빈도수를 가지는 단어는 대상에서 제거된다. 그리고 빈도수가 큰 것부터 작은 것으로 정렬을 한다.


다음줄의 createBinaryTree함수는 함수 이름대로 이진트리를 구성한다. 이진 트리를 구성하기 위해 고정배열을 사용했다. 빠른 처리 속도를 얻기 위해서라고 생각된다. 어떤 이진 트리를 구성하는지 보았다. learnVocab함수를 통해 단어 빈도수를 기준으로 내림차순으로 정렬된 사실을 상기하자. 이진 트리는 맨 오른쪽 즉, 가장 빈도수가 작은 단어부터 시작을 한다.


우선 자식노드로 가장 작은 빈도수, 그 다음으로 작은 빈도수의 단어 두 개를 놓고 부모노드에는 가상의 노드를 두고 두 빈도수를 합한 값을 빈도수로 설정한다. 이 때 소스코드를 보면 "binary"라는 변수로 배열을 할당 해 두었는데, 오른쪽 자식노드와 짝지워 값을 1로 설정한다. 왜, 1로 설정하는 이유는 오른쪽 노드라는 것을 표시하며 나중에 리프노드를 탐색하는 경로로 사용되고 이게 리프노드의 코드값 즉, 어떤 단어의 코드값이 된다.


다시 이진트리 구성을 보면, 이 다음에 오는 단어의 빈도수가 앞서 두 단어의 부모노드의 빈도수보다 크게 되면, 부모노드가 새로운 자식노드가 된다. 이 부분을 이해할 때, 아래부터 트리를 쌓아간다고 보면 된다. 그리고 같은 형제끼리는 빈도수가 작은게 왼쪽에 위치한다. 이해를 돕기 위해 아래의 그림을 그려봤다.


입력되는 단어와 빈도수를 (a, 8)(b,7)(c,4)(d,3)(e,2)이라고 하자.



최종적으로 구축된 모습은 아래와 같다. 여기서 빨깐색 테두리는 앞서 얘기한 binary배열중 1로 설정된 것이다.


이진 트리가 구축이 되면 각 단어별로 할단된 구성체중의 "code"값과 "codeLen"값을 설정하는데, 빨간색으로 표시된 것은 1로 아닌 것은 0으로 된다.  예를 들면 단어 d의 "code"값은  1,1,0이 되고 "codeLen"값은 3이 된다.


다음의 .broadcast명령등을 통해 분산처리 준비를 하고 doFit 함수를 호출한다. "doFit"함수는 코드길이로 봐서 핵심적인 함수인데, 직관적으로 이해되지는 않는다. 이 함수는 Skip-gram알고리즘을 구현했다. 좀더 살펴보고 이해가 필요하다. 이번에는 이해되는데까지만 적도록 한다.


이 함수에서는 학습을 위해 학습용 문장들을 사용한다. 해당 문장을 구성하는 단어들중 대상이 되는 단어들 하나씩 어떤 값을 계산하는데, 그 값은 해당 단어의 앞뒤에 위치하는 또 다른 단어와 같이 계산된다. 예를 들면 어떤 문장이 있다. "I am a korean have been lived in seoul" 이라는 문장에서 korean이라는 단어를 학습하고자 할 때, (am, korean) (a, korean) (korean, have) (korean, been)과 같은 쌍들이 반복해서 계산된다. 


여기서 어떤 단어의 앞으로 몇번째 부터 뒤로 몇번째 단어까지 고려되어야 하는지는 무작위로 결정된다. 즉, 같은 단어 같은 문장이어도 비교되는 단어 쌍들이 바뀔 수 있다. 바로 이 부분이 중요한 것 같다. 어떤 단어들이 가까이 놓일수록 같이 계산될 확률은 상당히 높지만, 멀리 떨어지는 경우에는 같이 계산되지 않을 경우가 많아지게 되고 이 것이 어떤 영향을 가져오는 것 같다.


또한 앞서 트리가 구성되었을 때, 자신이 트리상 얼마큼 깊게 있는지도 계산에 영향을 주는 것 같다. 어떤 단어의 빈도수가 높으면 이진 트리에서 얕은 곳에 위치하고 빈도수가 적으면 깊게 위치한다.


어쨌든, 어떤 값을 계산하는데, 아래 코드 처럼 사용한 함수중의 하나를 따라가보니, 

var f = blas.sdot(vectorSize, syn0, l1, 1, syn1, l2, 1)


.sdot는 Third party에서 개발된 오픈소스라 해당 GIT저장소 소스를 보니 세상에나 Fortran이다. 딱 코드보고 알았다. 얼마만에 보는지...