forcemax's

Facebook에서 공개한 fasttext 를 가지고 놀던중 text classification에 대한 예제는 존재하지만, tag prediction에 대한 예제가 없어서 직접 작성해 보기로 했습니다.

tag prediction에 대한 내용은 다음 논문에서 볼 수 있습니다.

Bag of Tricks for Efficient Text Classification

[2] A. Joulin, E. Grave, P. Bojanowski, T. Mikolov, Bag of Tricks for Efficient Text Classification

@article{joulin2016bag,
  title={Bag of Tricks for Efficient Text Classification},
  author={Joulin, Armand and Grave, Edouard and Bojanowski, Piotr and Mikolov, Tomas},
  journal={arXiv preprint arXiv:1607.01759},
  year={2016}
}

먼저 tag prediction을 하기 위해서 Training 데이터를 가져와야하는데, Training 데이터는 Yahoo에서 공개한 YFCC100M이라는 1억개 짜리 Flickr 데이터입니다. 먼저 Yahoo에 로그인한 다음에 본인의 AWS 정보를 적어주면 s3cmd 명령어를 통해서 S3에 있는 데이터를 다운 받을 수 있습니다. 이 과정은 생략하겠습니다. 압축된 데이터 총 용량은 14GB입니다.

다운로드가 완료되면 다음과 같은 파일 목록을 확인할 수 있습니다.

forcemax@forcemax-envy14:~/development/tagpred$ ls -ahl ../yfcc100m/

합계 17G

drwxrwxr-x  2 forcemax forcemax 4.0K 10월  7 17:57 .

drwxrwxr-x 19 forcemax forcemax 4.0K 10월  4 17:16 ..

-rw-rw-r--  1 forcemax forcemax 6.9K  9월 30 03:35 WebscopeReadMe.txt

-rw-rw-r--  1 forcemax forcemax 2.6G  9월 30 03:40 yfcc100m_autotags-v1.bz2

-rw-rw-r--  1 forcemax forcemax 1.1G  9월 30 03:35 yfcc100m_dataset-0.bz2

-rw-rw-r--  1 forcemax forcemax 1.1G  9월 30 03:35 yfcc100m_dataset-1.bz2

-rw-rw-r--  1 forcemax forcemax 1.1G  9월 30 03:35 yfcc100m_dataset-2.bz2

-rw-rw-r--  1 forcemax forcemax 1.1G  9월 30 03:35 yfcc100m_dataset-3.bz2

-rw-rw-r--  1 forcemax forcemax 1.1G  9월 30 03:35 yfcc100m_dataset-4.bz2

-rw-rw-r--  1 forcemax forcemax 1.1G  9월 30 03:35 yfcc100m_dataset-5.bz2

-rw-rw-r--  1 forcemax forcemax 1.4G  9월 30 03:35 yfcc100m_dataset-6.bz2

-rw-rw-r--  1 forcemax forcemax 1.4G  9월 30 03:37 yfcc100m_dataset-7.bz2

-rw-rw-r--  1 forcemax forcemax 1.4G  9월 30 03:37 yfcc100m_dataset-8.bz2

-rw-rw-r--  1 forcemax forcemax 1.4G  9월 30 03:37 yfcc100m_dataset-9.bz2

-rw-rw-r--  1 forcemax forcemax 2.0G  9월 30 03:46 yfcc100m_hash.bz2


파일 중에서 우리는 yfcc100m_dataset-0에서 yfcc100m_dataset-9까지(이하 dataset 파일) 사용할 것입니다. 먼저 bzip2 명령으로 압축을 풀어 둡니다. (오래걸립니다)

forcemax@forcemax-envy14:~/development/tagpred$ bzip2 -d ../yfcc100m/*dataset*.bz2


논문 내용에 100번 이상 나오지 않는 단어는 제거한다고 되어 있으니 이를 위한 작업을 합니다.

먼저 dataset 파일에서 word count를 구해야 하는데, 압축이 해제된 dataset 파일이 총 45GB 입니다. 데이터량이 너무 많기때문에 시스템 성능을 최대한 사용하기 위해서 python의 multiprocessing 라이브러리를 사용합니다.

참고로 dataset 파일은 tab(\t)로 구분되어 있으며 다음과 같은 필드로 구성되어 있습니다. 이 중에서 우리는 Title, Description, User Tag를 사용할 것입니다.

   * Photo/video identifier

   * User NSID

   * User nickname

   * Date taken

   * Date uploaded

   * Capture device

   * Title

   * Description

   * User tags (comma-separated)

   * Machine tags (comma-separated)

   * Longitude

   * Latitude

   * Accuracy

   * Photo/video page URL

   * Photo/video download URL

   * License name

   * License URL

   * Photo/video server identifier

   * Photo/video farm identifier

   * Photo/video secret

   * Photo/video secret original

   * Extension of the original photo

   * Photos/video marker (0 = photo, 1 = video)


다음 코드를 보면 Title, Description, User Tag를 파일에서 추출한 다음 내용에 있는 Percent-encoding 을 제거하고 User Tag는 Comma로 구분, Title과 Description은 공백으로 구분하여 word count를 계산하도록 하였습니다.

def wordcount_worker(path):

    print('wordcount worker started : %s' % path)


    wordcount = collections.Counter()

    count = 0

    words = []


    with open(path) as f:

        for line in f:

            count += 1

            sline = line.split('\t')


            # user tag

            words += [k.strip() for k in clean_str(urllib.parse.unquote(sline[8])).replace('+', '_').split(',') if k.strip() != '']

            # title & description

            words += [k.strip() for k in clean_str(urllib.parse.unquote_plus(sline[6] + ' ' + sline[7])).split() if k.strip() != '']


            if count % 100000 == 0:

                try:

                    words[:] = (v for v in words if v != '')

                except ValueError:

                    pass


                wordcount.update(words)

                words[:] = []

            if count % 1000000 == 0:

                print('%s : line %d passed' % (path, count))


    print('wordcount worker finished : %s' % path)


    return wordcount


위 코드를 multiprocessing 라이브러리를 사용하여 구동시키는데, dataset 파일당 하나의 thread가 동작하도록 하였습니다. 다만 dataset 파일 하나의 크기가 크다보니 메모리 사용량이 상당히 커서 12GB RAM이 장착된 제 PC에서는 동시에 2개만 돌리도록 하였습니다.

        wordcount = collections.Counter()

        with Pool(processes = 2) as pool:

            jobs = pool.imap_unordered(wordcount_worker, files)


            for res in jobs:

                wordcount.update(res)


전체 dataset 파일에 대한 word count를 구한 후에 100번 이상 발생한 단어만 뽑습니다.

        keepwords = set()

        for k in wordcount.keys():

            if wordcount[k] >=  100:

                keepwords.add(k)


이제 전체 dataset 파일에서 keepwords에 포함된 단어만 남기고 나머지 단어는 제거한 후에 별도 파일로 저장합니다.

def clean_data(tags, titles, descriptions):

    string = ""

    for t, ti, desc in zip(tags, titles, descriptions):

        t_tags = clean_str(urllib.parse.unquote(t)).replace('+', '_').split(',')

        t_tags = [k.strip() for k in t_tags if k.strip() in keepwords]

        t_tags = ['__label__'+k for k in t_tags]


        t_titles = clean_str(urllib.parse.unquote_plus(ti))

        t_titles = [k.strip() for k in t_titles.split() if k.strip() in keepwords]


        t_descriptions = clean_str(urllib.parse.unquote_plus(desc))

        t_descriptions = [k.strip() for k in t_descriptions.split() if k.strip() in keepwords]


        if len(t_titles) < 1 and len(t_descriptions) < 1:

            continue

        if len(t_tags) < 1:

            continue

        if len(t_tags) == 1 and t_tags[0] == '__label__':

            continue


        string += "%s %s %s\n" % (' '.join(t_tags), ' '.join(t_titles), ' '.join(t_descriptions))

    return string


def clean_worker(path):

    print("clean worker started : %s" % path)


    tags, titles, descriptions = ([] for i in range(3))

    count = total_count = 0

    with open(path + '_cleaned', 'w') as w:

        with open(path) as f:

            for line in f:

                count += 1

                total_count += 1


                sline = line.split('\t')

                titles.append(sline[6])

                descriptions.append(sline[7])

                tags.append(sline[8])


                if count == CLEANED_TRAIN_FILE_WRITE_INTERVAL:

                    w.write("%s" % clean_data(tags, titles, descriptions))

                    print("%s line processed : %d" % (path, total_count))

                    tags[:], titles[:], descriptions[:] = ([] for i in range(3))

                    count = 0

            if len(tags) > 0:

                w.write("%s" % clean_data(tags, titles, descriptions))


    print("clean worker finished : %s" % path)


위 코드 역시 multiprocessing 라이브러리를 사용하여 구동시키는데, 전체 word count를 구하는 작업보다는 메모리 사용량이 적으므로 최대한 많은 thread를 사용하도록 합니다.

    with Pool(processes=6) as pool:

        jobs = pool.imap_unordered(clean_worker, files)


        for res in jobs:

           pass


위 내용에 대한 전체 코드는 다음 주소에서 확인할 수 있습니다.

https://gist.github.com/forcemax/a6b5885fea859b43763f7712e82d546b


Intel i7-6700hq, 12GB RAM, SSD를 사용한 시스템에서 실행시키면 약 2시간 가량 소요됩니다.


데이터가 준비 되었으니 이제 fasttext를 이용하여 training을 합니다.

먼저 train을 위한 데이터가 아직 10개의 dataset 파일로 분리되어 있으니 하나의 파일로 합쳐줍니다.

forcemax@forcemax-envy14:~/development/tagpred$ ls -alh ../yfcc100m/*_cleaned

-rw-rw-r-- 1 forcemax forcemax 799M 10월 13 16:05 ../yfcc100m/yfcc100m_dataset-0_cleaned

-rw-rw-r-- 1 forcemax forcemax 799M 10월 13 16:05 ../yfcc100m/yfcc100m_dataset-1_cleaned

-rw-rw-r-- 1 forcemax forcemax 799M 10월 13 16:03 ../yfcc100m/yfcc100m_dataset-2_cleaned

-rw-rw-r-- 1 forcemax forcemax 798M 10월 13 16:02 ../yfcc100m/yfcc100m_dataset-3_cleaned

-rw-rw-r-- 1 forcemax forcemax 798M 10월 13 15:48 ../yfcc100m/yfcc100m_dataset-4_cleaned

-rw-rw-r-- 1 forcemax forcemax 799M 10월 13 15:48 ../yfcc100m/yfcc100m_dataset-5_cleaned

-rw-rw-r-- 1 forcemax forcemax 1.6G 10월 13 15:51 ../yfcc100m/yfcc100m_dataset-6_cleaned

-rw-rw-r-- 1 forcemax forcemax 1.6G 10월 13 15:51 ../yfcc100m/yfcc100m_dataset-7_cleaned

-rw-rw-r-- 1 forcemax forcemax 1.6G 10월 13 15:52 ../yfcc100m/yfcc100m_dataset-8_cleaned

-rw-rw-r-- 1 forcemax forcemax 1.6G 10월 13 15:52 ../yfcc100m/yfcc100m_dataset-9_cleaned


forcemax@forcemax-envy14:~/development/tagpred$ cat ../yfcc100m/*_cleaned > train.txt


forcemax@forcemax-envy14:~/development/tagpred$ ls -alh train.txt
-rw-rw-r-- 1 forcemax forcemax 11G 10월 13 16:36 train.txt

forcemax@forcemax-envy14:~/development/tagpred$ wc train.txt
   56251384  1196784449 11608667425 train.txt


논문 내용을 보면 Train set, Validation set, Test set으로 데이터를 구분하는데, 구분하는 기준이 없어서 일단 전체 데이터를 Train Set이라고 생각하고 Training 합니다.

논문에 나온 내용인 hidden unit 200, bigram, epoch 5로 training합니다. 제 PC에서는 loss가 negative sampling 외에는 training이 안됩니다. RAM이 더 큰 장비에서는 hierarchical softmax를 사용할 수도 있을 것이라 생각됩니다. 그러나, softmax는 테스트 안해보시는게 좋을겁니다. label이 몇십만개 단위이기 때문에 training에 너무 많은 시간이 소요됩니다. (fasttext는 컴파일 해두셨겠죠?)

forcemax@forcemax-envy14:~/development/tagpred$ time ../fastText/fasttext supervised -input train.txt -output yfcc100m -minCount 1 -dim 200 -lr 0.05 -wordNgrams 2 -bucket 10000000 -epoch 5 -loss ns -thread 6

Read 1253M words
Number of words:  440477
Number of labels: 484657
Progress: 100.0%  words/sec/thread: 711835  lr: 0.000000  loss: 1.112073  eta: 0h0m 

real 28m21.726s
user 147m34.340s
sys 2m29.752s


논문 내용에는 vocabulary size가 297,141이고 tag가 312,116인데 Train set, Validation set, Test set으로 구분을 안해줘서 더 많은 값이 나왔네요. 제 시스템에서 28분이 넘게 소요되는데, 논문에는 13분 정도 소요됐다고 나옵니다. 논문에서 사용한 시스템은 20 thread를 사용했으니...

최종 loss가 1.112073인데 epoch을 늘려주면 좀 더 줄어듭니다. 논문에 나온 파라미터를 그대로 사용했더니 결과가 저렇게 되네요. 이 부분은 직접 확인해 보시기 바랍니다.


논문에서 테스트한 값을 직접 넣어서 predict 해보겠습니다.

forcemax@forcemax-envy14:~/development/tagpred$ echo "christmas" | ../fastText/fasttext predict yfcc100m.bin - 

__label__christmas


여기까지 입니다. 작성된 코드의 성능을 개선하거나 잘못된 부분이 있으면 알려주세요. ^^


*** 2016년 10월 18일에 fasttext github에 preprocessed YFCC100M 데이터가 올라왔습니다.

해당 데이터는 https://research.facebook.com/research/fasttext/ 페이지에서 다운 받을 수 있습니다. 압축된 상태로 7.5GB 이고, 압축을 해제하면 18GB 입니다.

Train Set, Validation Set, Test Set으로 구분되어 있으므로 바로 fasttext를 이용해서 training 할 수 있습니다.

또한, 10월 18일자로 fasttext의 supervised 명령에 minCountLabel 옵션이 추가되었습니다. 논문에 나온 내용인 100번 이상 나오지 않는 단어를 제거하고 training 하려면 minCountLabel 옵션과 minCount 옵션을 모두 100으로 지정하면 됩니다.