项目文件夹

文件
yifeim c018436588 [Example] add latent dirichlet allocation (#2883)
* add lda model

* tweak latent dirichlet allocation

* Update README.md

* Update README.md

* update example index

* update header

* minor tweak

* add example test

* update doc

* Update README.md

* Update README.md

* add partial_fit for free

* Update examples/pytorch/lda/lda_model.py

Co-authored-by: Quan (Andy) Gan <coin2028@hotmail.com>

* Update examples/pytorch/lda/example_20newsgroups.py

Co-authored-by: Quan (Andy) Gan <coin2028@hotmail.com>

* Update lda_model.py

* bugfix torch Gamma uses rate parameter

Co-authored-by: Yifei Ma <yifeim@amazon.com>
Co-authored-by: Quan (Andy) Gan <coin2028@hotmail.com>
2021-05-17 20:25:36 +08:00

231 行
7.8 KiB
Python

# Copyright 2021 Yifei Ma
# with references from "sklearn.decomposition.LatentDirichletAllocation"
# with the following original authors:
# * Chyi-Kwei Yau (the said scikit-learn implementation)
# * Matthew D. Hoffman (original onlineldavb implementation)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os, functools, warnings
import numpy as np, scipy as sp
import torch
import dgl
from dgl import function as fn
def _lbeta(alpha, axis):
return torch.lgamma(alpha).sum(axis) - torch.lgamma(alpha.sum(axis))
# Taken from scikit-learn. Worked better than uniform.
# Perhaps this is due to concentration around one.
_sklearn_random_init = torch.distributions.gamma.Gamma(100, 100)
def _edge_update(edges, step_size=1):
""" the Gibbs posterior distribution of z propto theta*beta.
As step_size -> infty, the result becomes MAP estimate on the dst nodes.
"""
q = edges.src['weight'] * edges.dst['weight']
marg = q.sum(axis=1, keepdims=True) + np.finfo(float).eps
p = q / marg
return {
'z': p * step_size,
'edge_elbo': marg.squeeze(1).log() * step_size,
}
def _weight_exp(z, ntype, prior):
"""Node weight is approximately normalized for VB along the ntype
direction.
"""
prior = prior + z * 0 # convert numpy to torch
gamma = prior + z
axis = 1 if ntype == 'doc' else 0 # word
Elog = torch.digamma(gamma) - torch.digamma(gamma.sum(axis, keepdims=True))
return Elog.exp()
def _node_update(nodes, prior):
return {
'z': nodes.data['z'],
'weight': _weight_exp(nodes.data['z'], nodes.ntype, prior)
}
def _update_all(G, ntype, prior, step_size, return_obj=False):
"""Follows Eq (5) of Hoffman et al., 2010.
"""
G_prop = G.reverse() if ntype == 'doc' else G # word
msg_fn = lambda edges: _edge_update(edges, step_size)
node_fn = lambda nodes: _node_update(nodes, prior)
G_prop.update_all(msg_fn, fn.sum('z','z'), node_fn)
if return_obj:
G_prop.update_all(msg_fn, fn.sum('edge_elbo', 'edge_elbo'))
G.nodes[ntype].data.update(G_prop.nodes[ntype].data)
return dict(G.nodes[ntype].data)
class LatentDirichletAllocation:
"""LDA model that works with a HeteroGraph with doc/word node types.
The model alters the attributes of G arbitrarily,
but always load word_z if needed.
This is inspired by [1] and its corresponding scikit-learn implementation.
References
---
[1] Matthew Hoffman, Francis Bach, David Blei. Online Learning for Latent
Dirichlet Allocation. Advances in Neural Information Processing Systems 23
(NIPS 2010).
[2] Reactive LDA Library blogpost by Yingjie Miao for a similar Gibbs model
"""
def __init__(
self, G, n_components,
prior=None,
step_size={'doc': 1, 'word': 1}, # use larger value to get MAP
word_rho=1, # use smaller value for online update
verbose=True,
):
self.n_components = n_components
if prior is None:
prior = {'doc': 1./n_components, 'word': 1./n_components}
self.prior = prior
self.step_size = step_size
self.word_rho = word_rho
self.word_z = self._load_or_init(G, 'word')['z']
self.verbose = verbose
def _load_or_init(self, G, ntype, z=None):
if z is None:
z = _sklearn_random_init.sample(
(G.num_nodes(ntype), self.n_components)
).to(G.device)
G.nodes[ntype].data['z'] = z
G.apply_nodes(
lambda nodes: _node_update(nodes, self.prior[ntype]),
ntype=ntype)
return dict(G.nodes[ntype].data)
def _e_step(self, G, reinit_doc=True, mean_change_tol=1e-3, max_iters=100):
"""_e_step implements doc data sampling until convergence or max_iters
"""
self._load_or_init(G, 'word', self.word_z)
if reinit_doc or ('weight' not in G.nodes['doc'].data):
self._load_or_init(G, 'doc')
for i in range(max_iters):
doc_z = dict(G.nodes['doc'].data)['z']
doc_data = _update_all(
G, 'doc', self.prior['doc'], self.step_size['doc'])
mean_change = (doc_data['z'] - doc_z).abs().mean()
if mean_change < mean_change_tol:
break
if self.verbose:
print(f'e-step num_iters={i+1} with mean_change={mean_change:.4f}')
return doc_data
transform = _e_step
def _m_step(self, G):
"""_m_step implements word data sampling and stores word_z stats
"""
# assume G.nodes['doc'].data has been up to date
word_data = _update_all(
G, 'word', self.prior['word'], self.step_size['word'])
# online update
self.word_z = (
(1-self.word_rho) * self.word_z
+self.word_rho * word_data['z']
)
return word_data
def partial_fit(self, G):
self._last_word_z = self.word_z
self._e_step(G)
self._m_step(G)
return self
def fit(self, G, mean_change_tol=1e-3, max_epochs=10):
for i in range(max_epochs):
self.partial_fit(G)
mean_change = (self.word_z - self._last_word_z).abs().mean()
if self.verbose:
print(f'epoch {i+1}, '
f'perplexity: {self.perplexity(G, False)}, '
f'mean_change: {mean_change:.4f}')
if mean_change < mean_change_tol:
break
return self
def perplexity(self, G, reinit_doc=True):
"""ppl = exp{-sum[log(p(w1,...,wn|d))] / n}
Follows Eq (15) in Hoffman et al., 2010.
"""
word_data = self._load_or_init(G, 'word', self.word_z)
if reinit_doc or ('weight' not in G.nodes['doc'].data):
self._e_step(G, reinit_doc)
doc_data = _update_all(
G, 'doc', self.prior['doc'], self.step_size['doc'],
return_obj=True)
# compute E[log p(docs | theta, beta)]
edge_elbo = (
doc_data['edge_elbo'].sum() / doc_data['z'].sum()
).cpu().numpy()
if self.verbose:
print(f'neg_elbo phi: {-edge_elbo:.3f}', end=' ')
# compute E[log p(theta | alpha) - log q(theta | gamma)]
doc_elbo = (
(-doc_data['z'] * doc_data['weight'].log()).sum(axis=1)
-_lbeta(self.prior['doc'] + doc_data['z'] * 0, axis=1)
+_lbeta(self.prior['doc'] + doc_data['z'], axis=1)
)
doc_elbo = (doc_elbo.sum() / doc_data['z'].sum()).cpu().numpy()
if self.verbose:
print(f'theta: {-doc_elbo:.3f}', end=' ')
# compute E[log p(beta | eta) - log q (beta | lambda)]
word_elbo = (
(-word_data['z'] * word_data['weight'].log()).sum(axis=0)
-_lbeta(self.prior['word'] + word_data['z'] * 0, axis=0)
+_lbeta(self.prior['word'] + word_data['z'], axis=0)
)
word_elbo = (word_elbo.sum() / word_data['z'].sum()).cpu().numpy()
if self.verbose:
print(f'beta: {-word_elbo:.3f}')
return np.exp(-edge_elbo - doc_elbo - word_elbo)
if __name__ == '__main__':
print('Testing LatentDirichletAllocation via task_example_test.sh ...')
tf_uv = np.array(np.nonzero(np.random.rand(20,10)<0.5)).T
G = dgl.heterograph({('doc','topic','word'): tf_uv.tolist()})
model = LatentDirichletAllocation(G, 5, verbose=False)
model.fit(G)
model.transform(G)
model.perplexity(G)