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