Пирожки в Go



    В продолжение эпопеи с дистрибутивно-семантическими пирожками (и в погоне за модными тенденциями) решил переписать веб-сервис с лапидарного Питона на прогрессивный 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

    Сервис для попробовать
    Поделиться публикацией
    Похожие публикации
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 6

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.