В продолжение эпопеи с дистрибутивно-семантическими пирожками (и в погоне за модными тенденциями) решил переписать веб-сервис с лапидарного Питона на прогрессивный Go. Заодно был вынужден перенести и всю «интеллектуальную» часть (благо, не бином Ньютона). Сделать это оказалось куда проще и приятней, чем предполагал в начале. Впрочем, на медово-синтаксическом празднике жизни не обошлось без ложки дёгтя — самая быстрая гошная «числодробилка», какую смог найти (mat из gonum) таки уступила по скорости питоновской связке numba + numpy.

Чтобы осуществить задуманное, надо было:

  • загрузить word2vec модель из бинарника ;
  • прочитать модель с пирожками;
  • подключить морфологический анализатор ;
  • пристегнуть простенький фронтэнд к нехитрому бэкеэнду.

Загрузка word2vec модели


Здесь всё просто — читаем из бинарника словарь и вектора к нему с попутной нормализацией векторов и формированием отображения (map) слово — индекс вектора. Отображение даёт быстрое вытаскивание вектора по слову. Нормализация экономит время при вычислении косинусной близости — сравнение слов сводится к скалярному произведению, а сравнение «мешков» (bag of words) к умножению матриц.

Код
type W2VModel struct {
	Words   int		
	Size    int 			
	Vocab   []string
	WordIdx map[string]int
	Vec     [][]float32
}

func (m *W2VModel) Load(fn string) {
	file, err := os.Open(fn)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Fscanf(file, "%d", &m.Words)
	fmt.Fscanf(file, "%d", &m.Size)

	var ch string
	m.Vocab = make([]string, m.Words)
	m.Vec = make([][]float32, m.Words)
	m.WordIdx = make(map[string]int)
	for b := 0; b < m.Words; b++ {
		m.Vec[b] = make([]float32, m.Size)
		fmt.Fscanf(file, "%s%c", &m.Vocab[b], &ch)
		m.WordIdx[m.Vocab[b]] = b
		binary.Read(file, binary.LittleEndian, m.Vec[b])

		length := 0.0
		for _, v := range m.Vec[b] {
			length += float64(v * v)
		}
		length = math.Sqrt(length)

		for i, _ := range m.Vec[b] {
			m.Vec[b][i] /= float32(length)
		}
	}
	file.Close()
}

Чтение «поэтической» модели


Тут ещё проще — вчитать заблаговременно созданный в Питоне JSON-файл в структуры и слайсы Go — легче лёгкого, главное не забывать про заглавные буквы в именах полей. А чтобы всё просчитывалось быстрей, штампуем матрицы из мешков-пирожков не отходя от кассы.

Код
type PoemModel struct {
	Poems    []string   `json:"poems"`
	Bags     [][]string `json:"bags"`
	W2V      W2VModel
	Vectors  [][][]float32
	Matrices []mat.Matrix
}

func (pm *PoemModel) LoadJsonModel(fileName string) error {
	file, err := ioutil.ReadFile(fileName)
	if err != nil {
		return err
	}

	err = json.Unmarshal(file, pm)
	if err != nil {
		return err
	}
	return nil
}

func (pm *PoemModel) Matricize() {
	pm.Matrices = make([]mat.Matrix, len(pm.Bags))
	for idx, bag := range pm.Bags {
		data, rows := pm.TokenVectorsData(bag)
		pm.Matrices[idx] = mat.NewDense(rows, pm.W2V.Size, data).T()
	}
}

Морфологический анализатор


Мир не без добрых людей — нашёлся хороший человек, который перевёл pymorphy2 на Go. Пришлось, правда, подрихтовать пару строк в исходниках, ибо устанавливать морфологические словари пакетным менеджером питона, а потом их же через питон искать — идея, мягко выражаясь, не комильфо. От греха подальше закинул словари (вместе с подрихтованным анализатором) к себе в проект.

«Интеллектуальная» часть


Токенизатор — осуществляет перевод слов в нормальную форму (лемматизация), добавляет к ним соответствующие (word2vec модели) грамматические суффкисы (NOUN, VERB, ADJ и т.п.) и отсеивает стоп-слова (всякие местоимения, предлоги, частицы).

Код
func (pm *PoemModel) TokenizeWords(words []string) []string {
	POS_TAGS := map[string]string {
		"NOUN": "_NOUN",
		"VERB": "_VERB", "INFN": "_VERB", "GRND": "_VERB", "PRTF": "_VERB", "PRTS": "_VERB",
		"ADJF": "_ADJ", "ADJS": "_ADJ",
		"ADVB": "_ADV",
		"PRED": "_ADP",
	}

	STOP_TAGS := map[string]bool {"PREP": true, "CONJ": true, "PRCL": true, "NPRO": true, "NUMR": true}

	result := make([]string, 0, len(words))

	for _, w := range words {
		_, morphNorms, morphTags := morph.Parse(w)
		if len(morphNorms) == 0 {
			continue
		}

		suffixes := make(map[string]bool) // added suffixes

		for i, tags := range morphTags {
			norm := morphNorms[i]
			tag := strings.Split(tags, ",")[0]
			_, hasStopTag := STOP_TAGS[tag]
			if hasStopTag {
				break
			}

			suffix, hasPosTag := POS_TAGS[tag]
			_, hasSuffix := suffixes[suffix]
			if hasPosTag && ! hasSuffix {
				result = append(result, norm + suffix)
				suffixes[suffix] = true
			}
		}
	}

	return result
}

Поиск семантически «резонирующих» пирожков получается последовательным перемножением матрицы векторов, сформированных из слов запроса, со всеми матрицами пирожков, изготовленных при загрузке модели. Результат каждого произведения (т.е. матрица) суммируется и нормализуется делением на количество слов-векторов в перемножаемых матрицах, полученные «резонансные» числа (заблаговременно привязанные к индексам пирожков) сортируются по убыванию, давая топ самых-самых.

Код
func (pm *PoemModel) SimilarPoemsMx(queryWords []string, topN int) []string {
	simPoems := make([]string, 0, topN)
	tokens := pm.TokenizeWords(queryWords)
	queryData, queryVecsN := pm.TokenVectorsData(tokens)
	if len(tokens) == 0 || topN <= 0 || queryVecsN == 0{
		return simPoems
	}

	queryMx := mat.NewDense(queryVecsN, pm.W2V.Size, queryData)

	type PoemSimilarity struct {
		Idx	int
		Sim float64
	}

	sims := make([]PoemSimilarity, len(pm.Bags))

	for idx, _ := range pm.Bags {
		var resMx mat.Dense
		bagMx := pm.Matrices[idx]
		_, poemVecsN := bagMx.Dims()
		resMx.Mul(queryMx, bagMx)
		sim := mat.Sum(&resMx)

		if poemVecsN > 0 {
			sim /= float64(poemVecsN + queryVecsN)
		}

		sims[idx].Idx = idx
		sims[idx].Sim = sim
	}

	sort.Slice(sims, func (i, j int) bool {
		return sims[i].Sim > sims[j].Sim
	})

	for i := 0; i < topN; i ++ {
		simPoems = append(simPoems, pm.Poems[sims[i].Idx])
	}

	return simPoems
}

Веб-сервис


Для реализации веб-части воспользовался пакетом gin-gonic — роутер, статика, CORS — все дела.

Проект на Github

Сервис для попробовать

Комментарии (5)


  1. drafterleo Автор
    04.12.2017 12:05

    Вот интересные люди — навтыкали минусов, а за что — неясно. Чего не так-то?


    1. MikeLP
      04.12.2017 18:22

      Может они в детстве пирожками траванулись


  1. beatcracker
    04.12.2017 21:40

    Волшебно! Было бы здорово, если бы сервис мог принимать GET запросы, чтобы было можно давать ссылки вида http://87.117.9.189:8085? полиморфизм .


    1. drafterleo Автор
      04.12.2017 23:47

      Пуркуа бы и не па… метемпсихоз


      1. beatcracker
        05.12.2017 01:49

        Потому, что про инкапсуляцию сервис ничего не знает :). Вообще это кмк дико виральная штука. Ей бы ботов, чтобы генерировали пирожки по ключевому слову и можно ловить свои 15 минут славы, а-ля Кот или хлеб: нейросеть ответит на вопрос, что изображено на фото.