Repository: bab2min/tomotopy (language: python)

Issue:
Eigen file not found during pip installation
When running
`pip install tomotopy`

I get the following output:
```Collecting tomotopy
  Using cached tomotopy-0.12.7.tar.gz (1.3 MB)
  Preparing metadata (setup.py) ... done
Requirement already satisfied: numpy>=1.11.0 in /Users/path/to/project/venv/lib/python3.12/site-packages (from tomotopy) (1.26.4)
Building wheels for collected packages: tomotopy
^[[A  Building wheel for tomotopy (setup.py) ... error
  error: subprocess-exited-with-error

  × python setup.py bdist_wheel did not run successfully.
  │ exit code: 1
  ╰─> [92 lines of output]
      running bdist_wheel
      running build
      running build_py
      creating build
      creating build/lib.macosx-14.0-arm64-cpython-312
      creating build/lib.macosx-14.0-arm64-cpython-312/tomotopy
      copying tomotopy/_version.py -> build/lib.macosx-14.0-arm64-cpython-312/tomotopy
      copying tomotopy/coherence.py -> build/lib.macosx-14.0-arm64-cpython-312/tomotopy
      copying tomotopy/__init__.py -> build/lib.macosx-14.0-arm64-cpython-312/tomotopy
      copying tomotopy/_call_utils.py -> build/lib.macosx-14.0-arm64-cpython-312/tomotopy
      copying tomotopy/_summary.py -> build/lib.macosx-14.0-arm64-cpython-312/tomotopy
      copying tomotopy/utils.py -> build/lib.macosx-14.0-arm64-cpython-312/tomotopy
      copying tomotopy/label.py -> build/lib.macosx-14.0-arm64-cpython-312/tomotopy
      copying tomotopy/_show_progress.py -> build/lib.macosx-14.0-arm64-cpython-312/tomotopy
      running egg_info
      writing tomotopy.egg-info/PKG-INFO
      writing dependency_links to tomotopy.egg-info/dependency_links.txt
      writing requirements to tomotopy.egg-info/requires.txt
      writing top-level names to tomotopy.egg-info/top_level.txt
      reading manifest file 'tomotopy.egg-info/SOURCES.txt'
      reading manifest template 'MANIFEST.in'
      warning: no files found matching '*.c' under directory 'src'
      warning: no files found matching 'LICENSE.txt'
      adding license file 'LICENSE'
      writing manifest file 'tomotopy.egg-info/SOURCES.txt'
      /Users/path/to/project/venv/lib/python3.12/site-packages/setuptools/command/build_py.py:207: _Warning: Package 'tomotopy.viewer' is absent from the `packages` configuration.
      !!

              ********************************************************************************
              ############################
              # Package would be ignored #
              ############################
              Python recognizes 'tomotopy.viewer' as an importable package[^1],
              but it is absent from setuptools' `packages` configuration.

              This leads to an ambiguous overall configuration. If you want to distribute this
              package, please make sure that 'tomotopy.viewer' is explicitly added
              to the `packages` configuration field.

              Alternatively, you can also rely on setuptools' discovery methods
              (for example by using `find_namespace_packages(...)`/`find_namespace:`
              instead of `find_packages(...)`/`find:`).

              You can read more about "package discovery" on setuptools documentation page:

              - https://setuptools.pypa.io/en/latest/userguide/package_discovery.html

              If you don't want 'tomotopy.viewer' to be distributed and are
              already explicitly excluding 'tomotopy.viewer' via
              `find_namespace_packages(...)/find_namespace` or `find_packages(...)/find`,
              you can try to use `exclude_package_data`, or `include-package-data=False` in
              combination with a more fine grained `package-data` configuration.

              You can read more about "package data files" on setuptools documentation page:

              - https://setuptools.pypa.io/en/latest/userguide/datafiles.html


              [^1]: For Python, any directory (with suitable naming) can be imported,
                    even if it does not contain any `.py` files.
                    On the other hand, currently there is no concept of package data
                    directory, all directories are treated like packages.
              ********************************************************************************

      !!
        check.warn(importable)
      copying tomotopy/auto_labeling_code.rst -> build/lib.macosx-14.0-arm64-cpython-312/tomotopy
      copying tomotopy/auto_labeling_code_with_porter.rst -> build/lib.macosx-14.0-arm64-cpython-312/tomotopy
      copying tomotopy/documentation.kr.rst -> build/lib.macosx-14.0-arm64-cpython-312/tomotopy
      copying tomotopy/documentation.rst -> build/lib.macosx-14.0-arm64-cpython-312/tomotopy
      creating build/lib.macosx-14.0-arm64-cpython-312/tomotopy/viewer
      copying tomotopy/viewer/__init__.py -> build/lib.macosx-14.0-arm64-cpython-312/tomotopy/viewer
      copying tomotopy/viewer/template.html -> build/lib.macosx-14.0-arm64-cpython-312/tomotopy/viewer
      copying tomotopy/viewer/viewer_server.py -> build/lib.macosx-14.0-arm64-cpython-312/tomotopy/viewer
      running build_ext
      building '_tomotopy' extension
      creating build/temp.macosx-14.0-arm64-cpython-312
      creating build/temp.macosx-14.0-arm64-cpython-312/src
      creating build/temp.macosx-14.0-arm64-cpython-312/src/Labeling
      creating build/temp.macosx-14.0-arm64-cpython-312/src/TopicModel
      creating build/temp.macosx-14.0-arm64-cpython-312/src/python
      clang -fno-strict-overflow -Wsign-compare -Wunreachable-code -fno-common -dynamic -DNDEBUG -g -O3 -Wall -isysroot /Library/Developer/CommandLineTools/SDKs/MacOSX14.sdk -DMODULE_NAME=PyInit__tomotopy -Iinclude "-I/Users/path/to/project/venv/lib/python3.12/site-packages/numpy/core/include" "-I/Users/path/to/project/venv/include" -I/opt/homebrew/opt/python@3.12/Frameworks/Python.framework/Versions/3.12/include/python3.12 -c src/Labeling/FoRelevance.cpp -o build/temp.macosx-14.0-arm64-cpython-312/src/Labeling/FoRelevance.o -std=c++1y -O3 -fpermissive -stdlib=libc++ -Wno-unused-variable -Wno-switch -DTOMOTOPY_ISA=arm64 -arch arm64
      In file included from src/Labeling/FoRelevance.cpp:4:
      In file included from src/Labeling/FoRelevance.h:4:
      In file included from src/Labeling/Labeler.h:4:
      In file included from src/Labeling/../TopicModel/TopicModel.hpp:5:
      In file included from src/Labeling/../TopicModel/../Utils/Dictionary.h:9:
      src/Labeling/../TopicModel/../Utils/serializer.hpp:10:10: fatal error: 'Eigen/Dense' file not found
      #include <Eigen/Dense>
               ^~~~~~~~~~~~~
      1 error generated.
      error: command '/usr/bin/clang' failed with exit code 1
      [end of output]

  note: This error originates from a subprocess, and is likely not a problem with pip.
  ERROR: Failed building wheel for tomotopy
  Running setup.py clean for tomotopy
Failed to build tomotopy
ERROR: Could not build wheels for tomotopy, which is required to install pyproject.toml-based projects```

I'm running this on an Apple M3 Pro.


Discussion / hints:
I faced the same error and I went down the route of building from source.
After cloning the repo, run the following commands from within the `tomotopy` folder:
```
git clone https://gitlab.com/libeigen/eigen
cd eigen
git checkout tags/3.4.0
cd ..
mkdir include
mv eigen/Eigen/ include/
rm -rf eigen-git-mirror/
git clone https://github.com/bab2min/EigenRand
cd EigenRand
git checkout tags/v0.4.1
cd ..
mv EigenRand/EigenRand include/
git clone https://github.com/mapbox/variant
cd variant
git checkout tags/v1.1.3
cd ..
mv variant/include/mapbox include/
```
Now, run: `python setup.py install`.

This took me past the error that this issue talks about.
Found these steps in the github workflow files that @bab2min (thanks!) had added. 
cc: @teresaibarra  

(all hail `grep`!) 

Fix patch:
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index ff889b6..58351a0 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -106,13 +106,13 @@ jobs:
           auditwheel repair dist/*-${{ matrix.cp }}-linux_*.whl
           TWINE_USERNAME=${{ secrets.PYPI_USERNAME }} TWINE_PASSWORD=${{ secrets.PYPI_PASSWORD }} /opt/python/${{ matrix.cp }}/bin/python -m twine upload wheelhouse/*.whl
 
-  build_macos_11:
+  build_macos_13:
     name: Build for macOS 13
     runs-on: macOS-13
     strategy:
       max-parallel: 4
       matrix:
-        python-version: [3.8, 3.9, "3.10", 3.11]
+        python-version: [3.8, 3.9, "3.10", 3.11, 3.12]
         cpu-arch: [x86_64, arm64]
 
     steps:
@@ -123,7 +123,7 @@ jobs:
         python-version: ${{ matrix.python-version }}
     - name: Install dependencies
       run: |
-        python -m pip install --upgrade pip
+        python -m pip install --upgrade pip setuptools
         wget https://gitlab.com/libeigen/eigen/-/archive/3.4.0/eigen-3.4.0.tar.gz
         tar -zxvf eigen-3.4.0.tar.gz
         rm eigen-3.4.0.tar.gz
diff --git a/.github/workflows/generate_documentation.yml b/.github/workflows/generate_documentation.yml
index 9faa18e..c6a792b 100644
--- a/.github/workflows/generate_documentation.yml
+++ b/.github/workflows/generate_documentation.yml
@@ -41,15 +41,15 @@ jobs:
         mv variant/include/mapbox include/
     - name: build
       run: |
-        /opt/python/cp38-cp38/bin/python -m pip install numpy==`/opt/python/cp38-cp38/bin/python .github/workflows/numpy_version.py`
-        /opt/python/cp38-cp38/bin/python -m pip install pdoc3==0.8.4
+        /opt/python/cp39-cp39/bin/python -m pip install numpy==`/opt/python/cp39-cp39/bin/python .github/workflows/numpy_version.py`
+        /opt/python/cp39-cp3/bin/python -m pip install pdoc3==0.8.4
         export TOMOTOPY_LANG=${{ matrix.language }}
-        /opt/python/cp38-cp38/bin/python setup.py install
+        /opt/python/cp39-cp39/bin/python setup.py install
     - name: gen doc
       run: |
-        export TOMOTOPY_VER="`/opt/python/cp38-cp38/bin/python -m pip show tomotopy | grep Version | cut -d' ' -f2`"
+        export TOMOTOPY_VER="`/opt/python/cp39-cp39/bin/python -m pip show tomotopy | grep Version | cut -d' ' -f2`"
         export TOMOTOPY_LANG=${{ matrix.language }}
-        /opt/python/cp38-cp38/bin/python -m pdoc --html tomotopy
+        /opt/python/cp39-cp39/bin/python -m pdoc --html tomotopy
         sed -i -E "s/documentation<\/title>/documentation (v${TOMOTOPY_VER})<\/title>/" html/tomotopy/*.html
         sed -i -E 's/<\/title>/<\/title><link rel="icon" type="image\/png" sizes="192x192" href="\/tomotopy\/favicon-192x192.png">/' html/tomotopy/*.html
         sed -i -E 's/(<p><img alt="" src="https:\/\/badge.fury.io\/py\/tomotopy.svg"><\/p>)/<script async defer src="https:\/\/buttons.github.io\/buttons.js"><\/script> <p><img alt="" src="https:\/\/badge.fury.io\/py\/tomotopy.svg"> <a class="github-button" href="https:\/\/github.com\/bab2min\/tomotopy" data-size="large" data-show-count="true" aria-label="Star bab2min\/tomotopy on GitHub">Star<\/a> <a class="github-button" href="https:\/\/github.com\/bab2min\/tomotopy\/issues" data-size="large" data-show-count="true" aria-label="Issue bab2min\/tomotopy on GitHub">Issue<\/a> <\/p>/' html/tomotopy/*.html
diff --git a/MANIFEST.in b/MANIFEST.in
index d78ed38..c83de7a 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,4 +1,4 @@
 recursive-include src *.c *.cpp *.h *.hpp
-recursive-include include *.h *.hpp
+recursive-include include *
 recursive-include tomotopy *.py *.rst *.html
 include LICENSE.txt
\ No newline at end of file
diff --git a/README.kr.rst b/README.kr.rst
index 9b542ee..e0ed65b 100644
--- a/README.kr.rst
+++ b/README.kr.rst
@@ -7,7 +7,7 @@ tomotopy
 .. image:: https://zenodo.org/badge/186155463.svg
    :target: https://zenodo.org/badge/latestdoi/186155463
 
-🎌
+🌐
 `English`_,
 **한국어**.
 
@@ -305,6 +305,15 @@ tomotopy의 Python3 예제 코드는 https://github.com/bab2min/tomotopy/blob/ma
 
 역사
 -------
+* 0.13.0 (2024-08-05)
+    * 신규 기능
+        * 토픽 모델 뷰어인 `tomotopy.viewer.open_viewer()`의 주요 기능이 완성되었습니다.
+        * `tomotopy.LDAModel.get_hash()`가 추가되었습니다. 모델의 128bit 해시를 구해줍니다.
+        * `ngram_list` 인자가 `tomotopy.utils.SimpleTokenizer`에 추가되었습니다.
+    * Bug fixes
+        * `Corpus.concat_ngrams` 호출 후에 `spans`이 비일관적인 버그가 수정되었습니다.
+        * `tomotopy.LDAModel.load()`와 `tomotopy.LDAModel.save()`의 병목을 최적화하여 속도를 10배 이상 개선했습니다.
+
 * 0.12.7 (2023-12-19)
     * 신규 기능
         * 토픽 모델 뷰어인 `tomotopy.viewer.open_viewer()`가 추가되었습니다.
diff --git a/README.rst b/README.rst
index 64e8bf4..2eda302 100644
--- a/README.rst
+++ b/README.rst
@@ -7,7 +7,7 @@ tomotopy
 .. image:: https://zenodo.org/badge/186155463.svg
    :target: https://zenodo.org/badge/latestdoi/186155463
 
-🎌
+🌐
 **English**,
 `한국어`_.
 
@@ -309,6 +309,15 @@ meaning you can use it for any reasonable purpose and remain in complete ownersh
 
 History
 -------
+* 0.13.0 (2024-08-05)
+    * New features
+        * Major features of Topic Model Viewer `tomotopy.viewer.open_viewer()` are ready now.
+        * `tomotopy.LDAModel.get_hash()` is added. You can get 128bit hash value of the model.
+        * Add an argument `ngram_list` to `tomotopy.utils.SimpleTokenizer`.
+    * Bug fixes
+        * Fixed inconsistent `spans` bug after `Corpus.concat_ngrams` is called.
+        * Optimized the bottleneck of `tomotopy.LDAModel.load()` and `tomotopy.LDAModel.save()` and improved its speed more than 10 times.
+
 * 0.12.7 (2023-12-19)
     * New features
         * Added Topic Model Viewer `tomotopy.viewer.open_viewer()`
diff --git a/document/document_header.html b/document/document_header.html
index f1da7b3..d7e5c80 100644
--- a/document/document_header.html
+++ b/document/document_header.html
@@ -10,8 +10,9 @@
 <a class="homelink" rel="home" title="tomotopy Home" href="/tomotopy" style="display:block; font-size:2em; font-weight:bold; color:#555; padding-bottom:.5em; border-bottom:1px solid silver;"> <img src="/tomotopy/tomoto.png" alt="" style="height:1.5em;"> tomotopy </a>
 <a id='lang-en' href="../en/index.html">English</a> <a id='lang-kr' href="../kr/index.html">한국어</a> 
 <div id="version-link">
-<span>v0.12.7</span>
+<span>v0.13.0</span>
 <ul>
+	<li><a href='/tomotopy/v0.13.0/en'>v0.13.0</a></li>
 	<li><a href='/tomotopy/v0.12.7/en'>v0.12.7</a></li>
 	<li><a href='/tomotopy/v0.12.6/en'>v0.12.6</a></li>
 	<li><a href='/tomotopy/v0.12.5/en'>v0.12.5</a></li>
diff --git a/setup.py b/setup.py
index e42b98c..25b9ea7 100644
--- a/setup.py
+++ b/setup.py
@@ -199,14 +199,14 @@ def build_extension(self, ext):
     arch_levels = ['none', 'sse2', 'avx2']
 elif platform.system() == 'Darwin': 
     if tomotopy_cpu_arch == 'arm64':
-        arch_levels = ['aarch64']
+        arch_levels = ['arm64']
     elif build_for_manyplatform: 
         arch_levels = ['sse2', 'avx', 'avx2']
     else:
         arch_levels = ['sse2']
 elif build_for_manyplatform:
     if tomotopy_cpu_arch in ('arm64', 'aarch64'):
-        arch_levels = ['aarch64']
+        arch_levels = ['arm64']
 else:
     arch_levels = ['native']
 
@@ -237,7 +237,7 @@ def build_extension(self, ext):
 
 for arch in arch_levels:
     if len(arch_levels) > 1:
-        if arch in ('none', 'native', 'aarch64'):
+        if arch in ('none', 'native', 'arm64'):
             module_name = '_tomotopy_none'
         else:
             module_name = '_tomotopy_' + arch
diff --git a/src/TopicModel/CTModel.hpp b/src/TopicModel/CTModel.hpp
index 06f9a7c..45381f2 100644
--- a/src/TopicModel/CTModel.hpp
+++ b/src/TopicModel/CTModel.hpp
@@ -243,6 +243,7 @@ namespace tomoto
 	public:
 		DEFINE_SERIALIZER_AFTER_BASE_WITH_VERSION(BaseClass, 0, numBetaSample, numTMNSample, topicPrior);
 		DEFINE_TAGGED_SERIALIZER_AFTER_BASE_WITH_VERSION(BaseClass, 1, 0x00010001, numBetaSample, numTMNSample, topicPrior);
+		DEFINE_HASHER_AFTER_BASE(BaseClass, numBetaSample, numTMNSample, topicPrior);
 
 		CTModel(const CTArgs& args)
 			: BaseClass(args)
diff --git a/src/TopicModel/DMRModel.hpp b/src/TopicModel/DMRModel.hpp
index cd0bcc7..ecc2ee5 100644
--- a/src/TopicModel/DMRModel.hpp
+++ b/src/TopicModel/DMRModel.hpp
@@ -364,6 +364,7 @@ namespace tomoto
 	public:
 		DEFINE_SERIALIZER_AFTER_BASE_WITH_VERSION(BaseClass, 0, sigma, alphaEps, metadataDict, lambda);
 		DEFINE_TAGGED_SERIALIZER_AFTER_BASE_WITH_VERSION(BaseClass, 1, 0x00010001, sigma, alphaEps, metadataDict, lambda, multiMetadataDict);
+		DEFINE_HASHER_AFTER_BASE(BaseClass, sigma, alphaEps, metadataDict, lambda, multiMetadataDict);
 
 		DMRModel(const DMRArgs& args)
 			: BaseClass(args), sigma(args.sigma), alphaEps(args.alphaEps)
diff --git a/src/TopicModel/DTModel.hpp b/src/TopicModel/DTModel.hpp
index bd5d546..ab93707 100644
--- a/src/TopicModel/DTModel.hpp
+++ b/src/TopicModel/DTModel.hpp
@@ -22,6 +22,7 @@ namespace tomoto
 		Eigen::Matrix<WeightType, -1, -1> numByTopicWord; // Dim: (Topic * Time, Vocabs)
 		//ShareableMatrix<WeightType, -1, -1> numByTopicWord; // Dim: (Topic * Time, Vocabs)
 		DEFINE_SERIALIZER(numByTopic, numByTopicWord);
+		DEFINE_HASHER(numByTopic, numByTopicWord);
 	};
 
 	template<TermWeight _tw, typename _RandGen,
@@ -496,6 +497,8 @@ namespace tomoto
 			T, shapeA, shapeB, shapeC, alphaVar, etaVar, phiVar, alphas, etaByDoc, phi);
 		DEFINE_TAGGED_SERIALIZER_AFTER_BASE_WITH_VERSION(BaseClass, 1, 0x00010001, 
 			T, shapeA, shapeB, shapeC, alphaVar, etaVar, phiVar, alphas, etaByDoc, phi);
+		DEFINE_HASHER_AFTER_BASE(BaseClass,
+			T, shapeA, shapeB, shapeC, alphaVar, etaVar, phiVar, alphas, etaByDoc, phi);
 
 		GETTER(T, size_t, T);
 		GETTER(NumDocsByT, std::vector<uint32_t>, numDocsByTime);
diff --git a/src/TopicModel/GDMRModel.hpp b/src/TopicModel/GDMRModel.hpp
index f7f7863..8ad685b 100644
--- a/src/TopicModel/GDMRModel.hpp
+++ b/src/TopicModel/GDMRModel.hpp
@@ -412,6 +412,7 @@ namespace tomoto
 	public:
 		DEFINE_SERIALIZER_AFTER_BASE_WITH_VERSION(BaseClass, 0, sigma0, degreeByF, mdCoefs, mdIntercepts);
 		DEFINE_TAGGED_SERIALIZER_AFTER_BASE_WITH_VERSION(BaseClass, 1, 0x00010001, sigma0, orderDecay, degreeByF, mdCoefs, mdIntercepts, mdMax);
+		DEFINE_HASHER_AFTER_BASE(BaseClass, sigma0, orderDecay, degreeByF, mdCoefs, mdIntercepts, mdMax);
 
 		GDMRModel(const GDMRArgs& args)
 			: BaseClass(args), sigma0(args.sigma0), orderDecay(args.orderDecay), degreeByF(args.degrees)
diff --git a/src/TopicModel/HDPModel.hpp b/src/TopicModel/HDPModel.hpp
index f9821a8..0f4008e 100644
--- a/src/TopicModel/HDPModel.hpp
+++ b/src/TopicModel/HDPModel.hpp
@@ -19,6 +19,7 @@ namespace tomoto
 		size_t totalTable = 0;
 
 		DEFINE_SERIALIZER_AFTER_BASE(ModelStateLDA<_tw>, numTableByTopic, totalTable);
+		DEFINE_HASHER_AFTER_BASE(ModelStateLDA<_tw>, numTableByTopic, totalTable);
 	};
 
 	template<TermWeight _tw, typename _RandGen,
@@ -457,6 +458,7 @@ namespace tomoto
 	public:
 		DEFINE_SERIALIZER_AFTER_BASE_WITH_VERSION(BaseClass, 0, gamma);
 		DEFINE_TAGGED_SERIALIZER_AFTER_BASE_WITH_VERSION(BaseClass, 1, 0x00010001, gamma);
+		DEFINE_HASHER_AFTER_BASE(BaseClass, gamma);
 
 		HDPModel(const HDPArgs& args)
 			: BaseClass(args), gamma(args.gamma)
diff --git a/src/TopicModel/HLDAModel.hpp b/src/TopicModel/HLDAModel.hpp
index 32650b1..41b16d9 100644
--- a/src/TopicModel/HLDAModel.hpp
+++ b/src/TopicModel/HLDAModel.hpp
@@ -18,6 +18,7 @@ namespace tomoto
 			int32_t parent = 0, sibling = 0, child = 0;
 
 			DEFINE_SERIALIZER(numCustomers, level, parent, sibling, child);
+			DEFINE_HASHER(numCustomers, level, parent, sibling, child);
 
 			NCRPNode* getParent() const
 			{
@@ -118,6 +119,7 @@ namespace tomoto
 			Vector nodeWLikelihoods; //
 
 			DEFINE_SERIALIZER(nodes, levelBlocks);
+			DEFINE_HASHER(nodes, levelBlocks);
 
 			template<bool _makeNewPath = true>
 			void calcNodeLikelihood(Float gamma, size_t levelDepth)
@@ -317,6 +319,12 @@ namespace tomoto
 			ModelStateLDA<_tw>::serializerWrite(ostr);
 			nt->serializerWrite(ostr);
 		}
+
+		uint64_t computeHash(uint64_t seed) const
+		{
+			seed = ModelStateLDA<_tw>::computeHash(seed);
+			return nt->computeHash(seed);
+		}
 	};
 
 	template<TermWeight _tw, typename _RandGen,
@@ -596,6 +604,7 @@ namespace tomoto
 	public:
 		DEFINE_SERIALIZER_AFTER_BASE_WITH_VERSION(BaseClass, 0, gamma);
 		DEFINE_TAGGED_SERIALIZER_AFTER_BASE_WITH_VERSION(BaseClass, 1, 0x00010001, gamma);
+		DEFINE_HASHER_AFTER_BASE(BaseClass, gamma);
 
 		HLDAModel(const HLDAArgs& args)
 			: BaseClass(args), gamma(args.gamma)
diff --git a/src/TopicModel/HPAModel.hpp b/src/TopicModel/HPAModel.hpp
index 5683264..bea886b 100644
--- a/src/TopicModel/HPAModel.hpp
+++ b/src/TopicModel/HPAModel.hpp
@@ -21,6 +21,7 @@ namespace tomoto
 		Eigen::Matrix<WeightType, -1, -1> numByTopic1_2;
 
 		DEFINE_SERIALIZER_AFTER_BASE(ModelStateLDA<_tw>, numByTopicWord, numByTopic, numByTopic1_2);
+		DEFINE_HASHER_AFTER_BASE(ModelStateLDA<_tw>, numByTopicWord, numByTopic, numByTopic1_2);
 	};
 
 	template<TermWeight _tw, typename _RandGen,
@@ -439,6 +440,7 @@ namespace tomoto
 	public:
 		DEFINE_SERIALIZER_AFTER_BASE_WITH_VERSION(BaseClass, 0, K2, subAlphas, subAlphaSum);
 		DEFINE_TAGGED_SERIALIZER_AFTER_BASE_WITH_VERSION(BaseClass, 1, 0x00010001, K2, subAlphas, subAlphaSum);
+		DEFINE_HASHER_AFTER_BASE(BaseClass, K2, subAlphas, subAlphaSum);
 
 		HPAModel(const HPAArgs& args)
 			: BaseClass(args, false), K2(args.k2)
diff --git a/src/TopicModel/LDA.h b/src/TopicModel/LDA.h
index 3d8afe0..4b8d588 100644
--- a/src/TopicModel/LDA.h
+++ b/src/TopicModel/LDA.h
@@ -93,6 +93,12 @@ namespace tomoto
 			if (!ostr.write((const char*)this->data(), sizeof(_Scalar) * this->size()))
 				throw std::ios_base::failure(std::string("writing type '") + typeid(_Scalar).name() + std::string("' is failed"));
 		}
+
+		uint64_t computeHash(uint64_t seed) const
+		{
+			seed = serializer::computeHashMany(seed, (uint32_t)this->rows(), (uint32_t)this->cols());
+			return serializer::computeFastHash(this->data(), sizeof(_Scalar) * this->size(), seed);
+		}
 	};
 
 	template<typename _Base, TermWeight _tw>
diff --git a/src/TopicModel/LDAModel.hpp b/src/TopicModel/LDAModel.hpp
index 153a42a..f62b002 100644
--- a/src/TopicModel/LDAModel.hpp
+++ b/src/TopicModel/LDAModel.hpp
@@ -65,6 +65,7 @@ namespace tomoto
 		//Eigen::Matrix<WeightType, -1, -1> numByTopicWord; // Dim: (Topic, Vocabs)
 		ShareableMatrix<WeightType, -1, -1> numByTopicWord; // Dim: (Topic, Vocabs)
 		DEFINE_SERIALIZER(numByTopic, numByTopicWord);
+		DEFINE_HASHER(numByTopic, numByTopicWord);
 	};
 
 	namespace flags
@@ -958,6 +959,8 @@ namespace tomoto
 		DEFINE_TAGGED_SERIALIZER_WITH_VERSION(1, 0x00010001, vocabWeights, alpha, alphas, eta, K, etaByWord,
 			burnIn, optimInterval);
 
+		DEFINE_HASHER(vocabWeights, alpha, alphas, eta, K, /*etaByWord,*/ burnIn, optimInterval);
+
 		LDAModel(const LDAArgs& args, bool checkAlpha = true)
 			: BaseClass(args.seed), K(args.k), alpha(args.alpha[0]), eta(args.eta)
 		{
diff --git a/src/TopicModel/LLDAModel.hpp b/src/TopicModel/LLDAModel.hpp
index d6e5d0a..48540a5 100644
--- a/src/TopicModel/LLDAModel.hpp
+++ b/src/TopicModel/LLDAModel.hpp
@@ -107,6 +107,7 @@ namespace tomoto
 	public:
 		DEFINE_SERIALIZER_AFTER_BASE_WITH_VERSION(BaseClass, 0, topicLabelDict);
 		DEFINE_TAGGED_SERIALIZER_AFTER_BASE_WITH_VERSION(BaseClass, 1, 0x00010001, topicLabelDict);
+		DEFINE_HASHER_AFTER_BASE(BaseClass, topicLabelDict);
 
 		LLDAModel(const LDAArgs& args)
 			: BaseClass(args)
diff --git a/src/TopicModel/MGLDAModel.hpp b/src/TopicModel/MGLDAModel.hpp
index 6a6f8a8..9e29635 100644
--- a/src/TopicModel/MGLDAModel.hpp
+++ b/src/TopicModel/MGLDAModel.hpp
@@ -370,6 +370,7 @@ namespace tomoto
 	public:
 		DEFINE_SERIALIZER_AFTER_BASE_WITH_VERSION(BaseClass, 0, alphaL, alphaM, alphaML, etaL, gamma, KL, T);
 		DEFINE_TAGGED_SERIALIZER_AFTER_BASE_WITH_VERSION(BaseClass, 1, 0x00010001, alphaL, alphaM, alphaML, etaL, gamma, KL, T);
+		DEFINE_HASHER_AFTER_BASE(BaseClass, alphaL, alphaM, alphaML, etaL, gamma, KL, T);
 
 		MGLDAModel(const MGLDAArgs& args)
 			: BaseClass(args), KL(args.kL), T(args.t),
diff --git a/src/TopicModel/PAModel.hpp b/src/TopicModel/PAModel.hpp
index b4ccc1d..72795dd 100644
--- a/src/TopicModel/PAModel.hpp
+++ b/src/TopicModel/PAModel.hpp
@@ -19,6 +19,7 @@ namespace tomoto
 		Vector subTmp;
 
 		DEFINE_SERIALIZER_AFTER_BASE(ModelStateLDA<_tw>, numByTopic1_2, numByTopic2);
+		DEFINE_HASHER_AFTER_BASE(ModelStateLDA<_tw>, numByTopic1_2, numByTopic2);
 	};
 
 	template<TermWeight _tw, typename _RandGen,
@@ -364,6 +365,7 @@ namespace tomoto
 	public:
 		DEFINE_SERIALIZER_AFTER_BASE_WITH_VERSION(BaseClass, 0, K2, subAlphas, subAlphaSum);
 		DEFINE_TAGGED_SERIALIZER_AFTER_BASE_WITH_VERSION(BaseClass, 1, 0x00010001, K2, subAlphas, subAlphaSum);
+		DEFINE_HASHER_AFTER_BASE(BaseClass, K2, subAlphas, subAlphaSum);
 
 		PAModel(const PAArgs& args)
 			: BaseClass(args), K2(args.k2)
diff --git a/src/TopicModel/PLDAModel.hpp b/src/TopicModel/PLDAModel.hpp
index 9b981f7..90f26ff 100644
--- a/src/TopicModel/PLDAModel.hpp
+++ b/src/TopicModel/PLDAModel.hpp
@@ -111,6 +111,7 @@ namespace tomoto
 	public:
 		DEFINE_SERIALIZER_AFTER_BASE_WITH_VERSION(BaseClass, 0, topicLabelDict, numLatentTopics, numTopicsPerLabel);
 		DEFINE_TAGGED_SERIALIZER_AFTER_BASE_WITH_VERSION(BaseClass, 1, 0x00010001, topicLabelDict, numLatentTopics, numTopicsPerLabel);
+		DEFINE_HASHER_AFTER_BASE(BaseClass, topicLabelDict, numLatentTopics, numTopicsPerLabel);
 
 		PLDAModel(const PLDAArgs& args)
 			: BaseClass(args.setK(1)),
diff --git a/src/TopicModel/PTModel.hpp b/src/TopicModel/PTModel.hpp
index 3b3b0ad..36a2e1f 100644
--- a/src/TopicModel/PTModel.hpp
+++ b/src/TopicModel/PTModel.hpp
@@ -266,6 +266,7 @@ namespace tomoto
 	public:
 		DEFINE_SERIALIZER_AFTER_BASE_WITH_VERSION(BaseClass, 0, numPDocs, lambda);
 		DEFINE_TAGGED_SERIALIZER_AFTER_BASE_WITH_VERSION(BaseClass, 1, 0x00010001, numPDocs, lambda);
+		DEFINE_HASHER_AFTER_BASE(BaseClass, numPDocs, lambda);
 
 		GETTER(P, size_t, numPDocs);
 
diff --git a/src/TopicModel/SLDAModel.hpp b/src/TopicModel/SLDAModel.hpp
index b3e749e..89f3365 100644
--- a/src/TopicModel/SLDAModel.hpp
+++ b/src/TopicModel/SLDAModel.hpp
@@ -348,6 +348,7 @@ namespace tomoto
 	public:
 		DEFINE_SERIALIZER_AFTER_BASE_WITH_VERSION(BaseClass, 0, F, responseVars, mu, nuSq);
 		DEFINE_TAGGED_SERIALIZER_AFTER_BASE_WITH_VERSION(BaseClass, 1, 0x00010001, F, responseVars, mu, nuSq);
+		DEFINE_HASHER_AFTER_BASE(BaseClass, F, mu, nuSq);
 
 		SLDAModel(const SLDAArgs& args)
 			: BaseClass(args), F(args.vars.size()), varTypes(args.vars),
diff --git a/src/TopicModel/TopicModel.hpp b/src/TopicModel/TopicModel.hpp
index 54a4253..3572c53 100644
--- a/src/TopicModel/TopicModel.hpp
+++ b/src/TopicModel/TopicModel.hpp
@@ -224,6 +224,8 @@ namespace tomoto
 		virtual void loadModel(std::istream& reader, 
 			std::vector<uint8_t>* extra_data = nullptr) = 0;
 
+		virtual std::array<uint64_t, 2> getHash() const = 0;
+
 		virtual std::unique_ptr<ITopicModel> copy() const = 0;
 
 		virtual const DocumentBase* getDoc(size_t docId) const = 0;
@@ -577,7 +579,7 @@ namespace tomoto
 			{
 				for (size_t i = 0; i < doc.words.size(); ++i)
 				{
-					auto w = doc.words[i];
+					auto w = doc.words[doc.wOrder.empty() ? i : doc.wOrder[i]];
 					if (w >= realV) continue;
 					auto& cnt = cnts[w];
 					std::string word;
@@ -943,6 +945,17 @@ namespace tomoto
 			static_cast<_Derived*>(this)->_loadModel(reader, extra_data);
 			static_cast<_Derived*>(this)->prepare(false);
 		}
+
+		std::array<uint64_t, 2> getHash() const override
+		{
+			std::array<uint64_t, 2> ret;
+			ret[0] = dict.computeHash(0);
+			const std::string s = static_cast<const _Derived*>(this)->tmid().str() + static_cast<const _Derived*>(this)->twid().str();
+			ret[0] = serializer::computeHashMany(ret[0], s, realV, globalStep, docs.size());
+			ret[1] = globalState.computeHash(0);
+			ret[1] = static_cast<const _Derived*>(this)->computeHash(ret[1]);
+			return ret;
+		}
 	};
 
 }
diff --git a/src/Utils/Dictionary.cpp b/src/Utils/Dictionary.cpp
index b1f0d8d..0890a84 100644
--- a/src/Utils/Dictionary.cpp
+++ b/src/Utils/Dictionary.cpp
@@ -50,6 +50,11 @@ namespace tomoto
         }
     }
 
+    uint64_t Dictionary::computeHash(uint64_t seed) const
+	{
+        return serializer::computeHashMany(seed, id2word);
+	}
+
     void Dictionary::swap(Dictionary& rhs)
     {
         std::swap(dict, rhs.dict);
diff --git a/src/Utils/Dictionary.h b/src/Utils/Dictionary.h
index e6e09d4..a61b390 100644
--- a/src/Utils/Dictionary.h
+++ b/src/Utils/Dictionary.h
@@ -50,6 +50,8 @@ namespace tomoto
 		
 		void serializerRead(std::istream& reader);
 
+		uint64_t computeHash(uint64_t seed) const;
+
 		void swap(Dictionary& rhs);
 
 		void reorder(const std::vector<Vid>& order);
diff --git a/src/Utils/MultiNormalDistribution.hpp b/src/Utils/MultiNormalDistribution.hpp
index 8e0d96c..4234253 100644
--- a/src/Utils/MultiNormalDistribution.hpp
+++ b/src/Utils/MultiNormalDistribution.hpp
@@ -58,6 +58,7 @@ namespace tomoto
 			}
 
 			DEFINE_SERIALIZER_CALLBACK(onRead, mean, cov);
+			DEFINE_HASHER(mean, cov);
 		private:
 			void onRead() 
 			{
diff --git a/src/Utils/serializer.cpp b/src/Utils/serializer.cpp
index beca9be..c4a0d1b 100644
--- a/src/Utils/serializer.cpp
+++ b/src/Utils/serializer.cpp
@@ -139,5 +139,28 @@ namespace tomoto
 			} while (h.trailing_cnt);
 			return ret;
 		}
+
+		uint64_t computeFastHash(const void* data, size_t size, uint64_t seed)
+		{
+			for (size_t i = 0; i < size / 4; ++i) 
+			{
+				uint32_t x = ((const uint32_t*)data)[i];
+				x = ((x >> 16) ^ x) * 0x45d9f3b;
+				x = ((x >> 16) ^ x) * 0x45d9f3b;
+				x = (x >> 16) ^ x;
+				seed ^= x + 0x9e3779b9 + (seed << 6) + (seed >> 2);
+			}
+
+			if (size % 4)
+			{
+				uint32_t x = 0;
+				memcpy(&x, (const char*)data + (size / 4) * 4, size % 4);
+				x = ((x >> 16) ^ x) * 0x45d9f3b;
+				x = ((x >> 16) ^ x) * 0x45d9f3b;
+				x = (x >> 16) ^ x;
+				seed ^= x + 0x9e3779b9 + (seed << 6) + (seed >> 2);
+			}
+			return seed;
+		}
 	}
 }
diff --git a/src/Utils/serializer.hpp b/src/Utils/serializer.hpp
index b81c1a5..e28eb99 100644
--- a/src/Utils/serializer.hpp
+++ b/src/Utils/serializer.hpp
@@ -240,6 +240,16 @@ namespace tomoto
 			return v;
 		}
 
+		template<typename _Ty>
+		inline uint64_t computeHash(uint64_t seed, const _Ty& v)
+		{
+			return Serializer<
+				typename std::remove_const<typename std::remove_reference<_Ty>::type>::type
+			>{}.hash(seed, v);
+		}
+
+		uint64_t computeFastHash(const void* data, size_t size, uint64_t seed = 0);
+
 		inline void writeMany(std::ostream& ostr)
 		{
 			// do nothing
@@ -295,6 +305,22 @@ namespace tomoto
 			return m == first.m;
 		}
 
+		inline uint64_t computeHashMany(uint64_t seed)
+		{
+			return seed;
+		}
+
+		template<typename _FirstTy, typename ... _RestTy>
+		inline typename std::enable_if<
+			!is_key<typename std::remove_reference<_FirstTy>::type>::value,
+			uint64_t
+		>::type computeHashMany(uint64_t seed, _FirstTy&& first, _RestTy&&... rest)
+		{
+			seed = computeHash(seed, std::forward<_FirstTy>(first));
+			seed = computeHashMany(seed, std::forward<_RestTy>(rest)...);
+			return seed;
+		}
+
 		template<size_t>
 		struct version_holder {};
 
@@ -359,6 +385,11 @@ namespace tomoto
 				if (!istr.read((char*)&v, sizeof(_Ty)))
 					throw std::ios_base::failure(std::string("reading type '") + typeid(_Ty).name() + std::string("' is failed"));
 			}
+
+			uint64_t hash(uint64_t seed, const _Ty& v)
+			{
+				return computeFastHash(&v, sizeof(_Ty), seed);
+			}
 		};
 
 		template<typename _Ty>
@@ -373,6 +404,11 @@ namespace tomoto
 			{
 				v.serializerRead(istr);
 			}
+
+			uint64_t hash(uint64_t seed, const _Ty& v)
+			{
+				return v.computeHash(seed);
+			}
 		};
 
 		template<typename _Ty>
@@ -451,6 +487,13 @@ namespace tomoto
 				if (!istr.read((char*)v.data(), sizeof(_Ty) * rows * cols))
 					throw std::ios_base::failure(std::string("reading type '") + typeid(_Ty).name() + std::string("' is failed"));
 			}
+
+			uint64_t hash(uint64_t seed, const VTy& v)
+			{
+				seed = computeHash(seed, (uint32_t)v.rows());
+				seed = computeHash(seed, (uint32_t)v.cols());
+				return computeFastHash(v.data(), sizeof(_Ty) * v.size(), seed);
+			}
 		};
 
 		template<typename _Ty>
@@ -473,6 +516,13 @@ namespace tomoto
 				if (!istr.read((char*)v.data(), sizeof(_Ty) * rows * cols))
 					throw std::ios_base::failure(std::string("reading type '") + typeid(_Ty).name() + std::string("' is failed"));
 			}
+
+			uint64_t hash(uint64_t seed, const VTy& v)
+			{
+				seed = computeHash(seed, (uint32_t)v.rows());
+				seed = computeHash(seed, (uint32_t)v.cols());
+				return computeFastHash(v.data(), sizeof(_Ty) * v.size(), seed);
+			}
 		};
 
 		template<typename _Ty>
@@ -503,6 +553,12 @@ namespace tomoto
 				if (!istr.read((char*)v.data(), sizeof(_Ty) * size))
 					throw std::ios_base::failure(std::string("reading type '") + typeid(_Ty).name() + std::string("' is failed"));
 			}
+
+			uint64_t hash(uint64_t seed, const VTy& v)
+			{
+				seed = computeHash(seed, (uint32_t)v.size());
+				return computeFastHash(v.data(), sizeof(_Ty) * v.size(), seed);
+			}
 		};
 
 		template<typename _Ty>
@@ -521,6 +577,16 @@ namespace tomoto
 				v.resize(size);
 				for (auto& e : v) Serializer<_Ty>{}.read(istr, e);
 			}
+
+			uint64_t hash(uint64_t seed, const VTy& v)
+			{
+				seed = computeHash(seed, (uint32_t)v.size());
+				for (auto& e : v)
+				{
+					seed = computeHash(seed, e);
+				}
+				return seed;
+			}
 		};
 
 		template<typename _Ty, size_t n>
@@ -541,6 +607,12 @@ namespace tomoto
 				if (!istr.read((char*)v.data(), sizeof(_Ty) * size))
 					throw std::ios_base::failure(std::string("reading type '") + typeid(_Ty).name() + std::string("' is failed"));
 			}
+
+			uint64_t hash(uint64_t seed, const VTy& v)
+			{
+				seed = computeHash(seed, (uint32_t)v.size());
+				return computeFastHash(v.data(), sizeof(_Ty) * v.size(), seed);
+			}
 		};
 
 		template<typename _Ty, size_t n>
@@ -559,6 +631,16 @@ namespace tomoto
 				if (n != size) throw std::ios_base::failure(text::format("the size of array must be %zd, not %zd", n, size));
 				for (auto& e : v) Serializer<_Ty>{}.read(istr, e);
 			}
+
+			uint64_t hash(uint64_t seed, const VTy& v)
+			{
+				seed = computeHash(seed, (uint32_t)v.size());
+				for (auto& e : v)
+				{
+					seed = computeHash(seed, e);
+				}
+				return seed;
+			}
 		};
 
 		template<typename _Ty>
@@ -579,6 +661,12 @@ namespace tomoto
 				if (!istr.read((char*)v.data(), sizeof(_Ty) * size))
 					throw std::ios_base::failure(std::string("reading type '") + typeid(_Ty).name() + std::string("' is failed"));
 			}
+
+			uint64_t hash(uint64_t seed, const VTy& v)
+			{
+				seed = computeHash(seed, (uint32_t)v.size());
+				return computeFastHash(v.data(), sizeof(_Ty) * v.size(), seed);
+			}
 		};
 
 		template<typename _Ty1, typename _Ty2>
@@ -594,6 +682,13 @@ namespace tomoto
 			{
 				readMany(istr, v.first, v.second);
 			}
+
+			uint64_t hash(uint64_t seed, const VTy& v)
+			{
+				seed = computeHash(seed, v.first);
+				seed = computeHash(seed, v.second);
+				return seed;
+			}
 		};
 
 		template<typename _Ty1, typename _Ty2>
@@ -615,6 +710,8 @@ namespace tomoto
 					v.emplace(readFromStream<std::pair<_Ty1, _Ty2>>(istr));
 				}
 			}
+
+			// not support hash
 		};
 
 		template<typename _Ty1, typename _Ty2>
@@ -636,6 +733,16 @@ namespace tomoto
 					v.emplace(readFromStream<std::pair<_Ty1, _Ty2>>(istr));
 				}
 			}
+
+			uint64_t hash(uint64_t seed, const VTy& v)
+			{
+				seed = computeHash(seed, (uint32_t)v.size());
+				for (auto& e : v)
+				{
+					seed = computeHash(seed, e);
+				}
+				return seed;
+			}
 		};
 
 		template<typename _Ty>
@@ -651,6 +758,11 @@ namespace tomoto
 			{
 				_Ty::serializerRead(v, istr);
 			}
+
+			uint64_t hash(uint64_t seed, const VTy& v)
+			{
+				return _Ty::serializerHash(v, seed);
+			}
 		};
 
 		class BlockStreamBuffer : public std::basic_streambuf<char>
@@ -763,6 +875,11 @@ void serializerWrite(std::ostream& ostr) const\
 	tomoto::serializer::writeMany(ostr, __VA_ARGS__);\
 }
 
+#define DEFINE_HASHER(...) uint64_t computeHash(uint64_t seed) const\
+{\
+	return tomoto::serializer::computeHashMany(seed, __VA_ARGS__);\
+}
+
 #define DEFINE_SERIALIZER_WITH_VERSION(v,...) void serializerRead(tomoto::serializer::version_holder<v>, std::istream& istr)\
 {\
 	tomoto::serializer::readMany(istr, __VA_ARGS__);\
@@ -793,6 +910,12 @@ void serializerWrite(std::ostream& ostr) const\
 	tomoto::serializer::writeMany(ostr, __VA_ARGS__);\
 }
 
+#define DEFINE_HASHER_AFTER_BASE(base, ...) uint64_t computeHash(uint64_t seed) const\
+{\
+	seed = base::computeHash(seed);\
+	return tomoto::serializer::computeHashMany(seed, __VA_ARGS__);\
+}\
+
 #define DEFINE_SERIALIZER_AFTER_BASE_WITH_VERSION(base, v, ...) void serializerRead(tomoto::serializer::version_holder<v> _v, std::istream& istr)\
 {\
 	base::serializerRead(_v, istr);\
diff --git a/src/python/handler/PyUtils.h b/src/python/handler/PyUtils.h
index 22230b9..1b18f83 100644
--- a/src/python/handler/PyUtils.h
+++ b/src/python/handler/PyUtils.h
@@ -13,6 +13,8 @@
 #include <deque>
 #include <future>
 
+#define PY_SSIZE_T_CLEAN 1
+
 #ifdef _DEBUG
 	#undef _DEBUG
 	#include <Python.h>
diff --git a/src/python/handler/docs.h b/src/python/handler/docs.h
index 5b295a2..a104e11 100644
--- a/src/python/handler/docs.h
+++ b/src/python/handler/docs.h
@@ -776,10 +776,14 @@ flush : bool
 )"");
 
 DOC_SIGNATURE_EN_KO(LDA_get_word_forms__doc__,
-    "get_word_forms(self)",
+    "get_word_forms(self, idx)",
     u8R""()"",
 u8R""()"");
 
+DOC_SIGNATURE_EN_KO(LDA_get_hash__doc__,
+    "get_hash(self)",
+    u8R""()"",
+    u8R""()"");
 
 
 DOC_VARIABLE_EN_KO(LDA_tw__doc__,
diff --git a/src/python/handler/py_LDA.cpp b/src/python/handler/py_LDA.cpp
index c7c1b1a..efc57d0 100644
--- a/src/python/handler/py_LDA.cpp
+++ b/src/python/handler/py_LDA.cpp
@@ -589,7 +589,7 @@ static PyObject* LDA_summary(TopicModelObject* self, PyObject* args, PyObject* k
 	});
 }
 
-static PyObject* LDA_get_word_forms(TopicModelObject* self, PyObject* args, PyObject* kwargs)
+static PyObject* LDA_getWordForms(TopicModelObject* self, PyObject* args, PyObject* kwargs)
 {
 	size_t idx = -1;
 	static const char* kwlist[] = { "idx", nullptr};
@@ -612,6 +612,15 @@ static PyObject* LDA_get_word_forms(TopicModelObject* self, PyObject* args, PyOb
 	});
 }
 
+static PyObject* LDA_getHash(TopicModelObject* self, PyObject* args, PyObject* kwargs)
+{
+	return py::handleExc([&]()
+	{
+		std::array<uint64_t, 2> hash = self->inst->getHash();
+		return PyObject_CallMethod((PyObject*)&PyLong_Type, "from_bytes", "y#s", (const char*)hash.data(), sizeof(hash), "big");
+	});
+}
+
 static PyObject* LDA_copy(TopicModelObject* self)
 {
 	return py::handleExc([&]()
@@ -812,7 +821,8 @@ static PyMethodDef LDA_methods[] =
 	{ "copy", (PyCFunction)LDA_copy, METH_NOARGS, LDA_copy__doc__},
 	{ "_update_vocab", (PyCFunction)LDA_update_vocab, METH_VARARGS | METH_KEYWORDS, ""},
 	{ "summary", (PyCFunction)LDA_summary, METH_VARARGS | METH_KEYWORDS, LDA_summary__doc__},
-	{ "get_word_forms", (PyCFunction)LDA_get_word_forms, METH_VARARGS | METH_KEYWORDS, LDA_get_word_forms__doc__},
+	{ "get_word_forms", (PyCFunction)LDA_getWordForms, METH_VARARGS | METH_KEYWORDS, LDA_get_word_forms__doc__},
+	{ "get_hash", (PyCFunction)LDA_getHash, METH_VARARGS | METH_KEYWORDS, LDA_get_hash__doc__},
 	{ nullptr }
 };
 
diff --git a/tomotopy/__init__.py b/tomotopy/__init__.py
index 83e0fca..668b36c 100644
--- a/tomotopy/__init__.py
+++ b/tomotopy/__init__.py
@@ -76,6 +76,60 @@ class ParallelScheme(IntEnum):
 import tomotopy.label as label
 import tomotopy.viewer as viewer
 
+def _get_all_model_types():
+    import _tomotopy
+    types = []
+    for name in dir(_tomotopy):
+        if name.endswith('Model') and not name.startswith('_'):
+            types.append(getattr(_tomotopy, name))
+    return types
+
+def load_model(path:str):
+    '''
+..versionadded:: 0.13.0
+
+Load any topic model from the given file path.    
+
+Parameters
+----------
+path : str
+    The file path to load the model from.
+
+Returns
+-------
+model : LDAModel or its subclass
+    '''
+    model_types = _get_all_model_types()
+    for model_type in model_types:
+        try:
+            return model_type.load(path)
+        except:
+            pass
+    raise ValueError(f'Cannot load model from {path}')
+
+def loads_model(data:bytes):
+    '''
+..versionadded:: 0.13.0
+
+Load any topic model from the given bytes data.
+
+Parameters
+----------
+data : bytes
+    The bytes data to load the model from.
+
+Returns
+-------
+model : LDAModel or its subclass
+    '''
+    model_types = _get_all_model_types()
+    for model_type in model_types:
+        try:
+            return model_type.loads(data)
+        except:
+            pass
+    raise ValueError(f'Cannot load model from the given data')
+
 import os
 if os.environ.get('TOMOTOPY_LANG') == 'kr':
     __doc__ = """`tomotopy` 패키지는 Python에서 사용가능한 다양한 토픽 모델링 타입과 함수를 제공합니다.
diff --git a/tomotopy/_version.py b/tomotopy/_version.py
index 3fb5034..2d7893e 100644
--- a/tomotopy/_version.py
+++ b/tomotopy/_version.py
@@ -1,1 +1,1 @@
-__version__ = '0.12.7'
+__version__ = '0.13.0'
diff --git a/tomotopy/documentation.kr.rst b/tomotopy/documentation.kr.rst
index 989c8d0..d9bad4a 100644
--- a/tomotopy/documentation.kr.rst
+++ b/tomotopy/documentation.kr.rst
@@ -386,6 +386,15 @@ tomotopy의 Python3 예제 코드는 https://github.com/bab2min/tomotopy/blob/ma
 
 역사
 -------
+* 0.13.0 (2024-08-05)
+    * 신규 기능
+        * 토픽 모델 뷰어인 `tomotopy.viewer.open_viewer()`의 주요 기능이 완성되었습니다.
+        * `tomotopy.LDAModel.get_hash()`가 추가되었습니다. 모델의 128bit 해시를 구해줍니다.
+        * `ngram_list` 인자가 `tomotopy.utils.SimpleTokenizer`에 추가되었습니다.
+    * Bug fixes
+        * `Corpus.concat_ngrams` 호출 후에 `spans`이 비일관적인 버그가 수정되었습니다.
+        * `tomotopy.LDAModel.load()`와 `tomotopy.LDAModel.save()`의 병목을 최적화하여 속도를 10배 이상 개선했습니다.
+
 * 0.12.7 (2023-12-19)
     * 신규 기능
         * 토픽 모델 뷰어인 `tomotopy.viewer.open_viewer()`가 추가되었습니다.
diff --git a/tomotopy/documentation.rst b/tomotopy/documentation.rst
index 9f69b35..968a04f 100644
--- a/tomotopy/documentation.rst
+++ b/tomotopy/documentation.rst
@@ -389,6 +389,15 @@ meaning you can use it for any reasonable purpose and remain in complete ownersh
 
 History
 -------
+* 0.13.0 (2024-08-05)
+    * New features
+        * Major features of Topic Model Viewer `tomotopy.viewer.open_viewer()` are ready now.
+        * `tomotopy.LDAModel.get_hash()` is added. You can get 128bit hash value of the model.
+        * Add an argument `ngram_list` to `tomotopy.utils.SimpleTokenizer`.
+    * Bug fixes
+        * Fixed inconsistent `spans` bug after `Corpus.concat_ngrams` is called.
+        * Optimized the bottleneck of `tomotopy.LDAModel.load()` and `tomotopy.LDAModel.save()` and improved its speed more than 10 times.
+
 * 0.12.7 (2023-12-19)
     * New features
         * Added Topic Model Viewer `tomotopy.viewer.open_viewer()`
diff --git a/tomotopy/utils.py b/tomotopy/utils.py
index 51afc4a..428c35c 100644
--- a/tomotopy/utils.py
+++ b/tomotopy/utils.py
@@ -1,3 +1,7 @@
+from typing import Optional, List
+
+import re
+
 '''
 Submodule `tomotopy.utils` provides various utilities for topic modeling. 
 `tomotopy.utils.Corpus` class helps manage multiple documents easily. 
@@ -191,7 +195,13 @@ def concat_ngrams(self, cands, delimiter='_'):
 
 class SimpleTokenizer:
     '''`SimpleTokenizer` provided a simple word-tokenizing utility with an arbitrary stemmer.'''
-    def __init__(self, stemmer=None, pattern:str=None, lowercase=True):
+    def __init__(self, 
+                 stemmer = None, 
+                 pattern:str = None, 
+                 lowercase = True, 
+                 ngram_list:Optional[List[str]] = None,
+                 ngram_delimiter:str = '_',
+                 ):
         '''Parameters
 ----------
 stemmer : Callable[str, str]
@@ -205,26 +215,81 @@ def __init__(self, stemmer=None, pattern:str=None, lowercase=True):
 
 .. include:: ./auto_labeling_code_with_porter.rst
 '''
-        import re
         self._pat = re.compile(pattern or r"""[^\s.,;:'"?!<>(){}\[\]\\/`~@#$%^&*|]+""")
         if stemmer and not callable(stemmer):
             raise ValueError("`stemmer` must be callable.")
         self._stemmer = stemmer or None
         self._lowercase = lowercase
+        self._ngram_pat = None
+        self._ngram_delimiter = ngram_delimiter
+        if ngram_list:
+            self.build_ngram_pat(ngram_list)
+
+    def build_ngram_pat(self, ngram_list:List[str]):
+        ngram_vocab = {}
+        patterns = []
+
+        for ngram in ngram_list:
+            if self._lowercase:
+                ngram = ngram.lower()
+            words = self._pat.findall(ngram)
+            if len(words) < 2:
+                continue
+            chrs = []
+            for word in words:
+                if self._stemmer is not None:
+                    word = self._stemmer(word)
+                try:
+                    wid = ngram_vocab[word]
+                except KeyError:
+                    wid = chr(len(ngram_vocab) + 256)
+                    ngram_vocab[word] = wid
+                chrs.append(wid)
+            patterns.append(''.join(chrs))
+        
+        if patterns:
+            self._ngram_pat = re.compile('|'.join(sorted(patterns, key=lambda x: len(x), reverse=True)))
+            self._ngram_vocab = ngram_vocab
 
     def _tokenize(self, raw:str):
-        if self._stemmer:
-            for g in self._pat.finditer(raw if self._lowercase else raw):
-                start, end = g.span(0)
-                word = g.group(0)
-                if self._lowercase: word = word.lower()
-                yield self._stemmer(word), start, end - start
-        else:
-            for g in self._pat.finditer(raw if self._lowercase else raw):
-                start, end = g.span(0)
-                word = g.group(0)
-                if self._lowercase: word = word.lower()
+        if self._ngram_pat is None:
+            for g in self._pat.finditer(raw):
+                start, end = g.span()
+                word = g.group()
+                if self._lowercase: 
+                    word = word.lower()
+                if self._stemmer is not None:
+                    word = self._stemmer(word)
                 yield word, start, end - start
+        else:
+            all_words = []
+            all_spans = []
+            chrs = []
+            for g in self._pat.finditer(raw):
+                all_spans.append(g.span())
+                word = g.group()
+                if self._lowercase: 
+                    word = word.lower()
+                if self._stemmer is not None:
+                    word = self._stemmer(word)
+                all_words.append(word)
+                try:
+                    chrs.append(self._ngram_vocab[word])
+                except KeyError:
+                    chrs.append(' ')
+            chrs = ''.join(chrs)
+            for g in self._ngram_pat.finditer(chrs):
+                s, e = g.span()
+                is_space = all(raw[ns:ne].isspace() for (_, ns), (ne, _) in zip(all_spans[s:e-1], all_spans[s+1:e]))
+                if not is_space:
+                    continue
+                all_words[s] = self._ngram_delimiter.join(all_words[s:e])
+                all_words[s+1:e] = [None] * (e - s - 1)
+                all_spans[s] = (all_spans[s][0], all_spans[e-1][1])
+
+            for (s, e), word in zip(all_spans, all_words):
+                if word is None: continue
+                yield word, s, e - s
 
     def __call__(self, raw:str, user_data=None):
         is_iterable = False
diff --git a/tomotopy/viewer/__init__.py b/tomotopy/viewer/__init__.py
index 36580a1..b4ad035 100644
--- a/tomotopy/viewer/__init__.py
+++ b/tomotopy/viewer/__init__.py
@@ -1,1 +1,18 @@
+'''
+..versionadded:: 0.13.0
+
+`tomotopy.viewer` is a module for visualizing the tomotopy's topic model using a web browser. It provides a simple way to visualize the topic model and its results interactively.
+But it is not recommended to use it in a production web service, because it uses python's built-in `http.server` module to serve.
+
+The following is a simple example of how to use the viewer:
+::
+
+    import tomotopy as tp
+    mdl = tp.load_model('a_trained_model.bin')
+    tp.viewer.open_viewer(mdl, port=9999)
+    # open http://localhost:9999 in your web browser
+
+For more details, please refer to the `tomotopy.viewer.viewer_server.open_viewer` function.
+'''
+
 from .viewer_server import open_viewer
diff --git a/tomotopy/viewer/__main__.py b/tomotopy/viewer/__main__.py
new file mode 100644
index 0000000..7e0277c
--- /dev/null
+++ b/tomotopy/viewer/__main__.py
@@ -0,0 +1,16 @@
+import tomotopy as tp
+from tomotopy.viewer import open_viewer
+
+def main(args):
+    model = tp.load_model(args.model)
+    open_viewer(model, args.host, args.port, args.browser_title, args.model + '.json', read_only=args.read_only)
+
+if __name__ == '__main__':
+    import argparse
+    parser = argparse.ArgumentParser()
+    parser.add_argument('model')
+    parser.add_argument('--browser-title')
+    parser.add_argument('--host', default='localhost')
+    parser.add_argument('-p', '--port', type=int, default=9999)
+    parser.add_argument('-r', '--read-only', action='store_true')
+    main(parser.parse_args())
diff --git a/tomotopy/viewer/template.html b/tomotopy/viewer/template.html
index 9d86dae..1459f28 100644
--- a/tomotopy/viewer/template.html
+++ b/tomotopy/viewer/template.html
@@ -19,18 +19,44 @@
       .topic-color-{{i}}-040 {background-color: rgba{{(*c, 0.40)}}; {{style_text}} }
       {% end %}
 
+      .topic-title input {width:100%;}
+
       .list-group-item.active a {color: #fff; }
       .topic-word-dist-container {overflow-x: scroll; margin: 0; padding: 0; list-style: none; white-space: nowrap; }
-      .topic-word-dist-container>li {display: inline-block; width: 25%; max-width: 15em;}
+      .topic-word-dist-container>li {display: inline-block; width: 25%; max-width: 15em; overflow-x: hidden;}
       .word-dist th, .topic-dist th {width:1%; white-space: nowrap;}
 
       #document-content .card {height:100%;}
       #document-content-body p {text-shadow: -1px 0 rgba(255, 255, 255, 0.75), 1px 0 rgba(255, 255, 255, 0.75), 0 -1px rgba(255, 255, 255, 0.75), 0 1px rgba(255, 255, 255, 0.75);}
       #document-content-body p span.active {font-weight: bold;}
       #document-content-body table tr.active {background-color: #ccf;}
+      #document-content-title {font-weight: bold;}
+
+      .topic-overlap {font-size: 80%; border-collapse: collapse; table-layout: fixed;}
+      .topic-overlap th, .topic-overlap td {margin:0; padding:0; text-align: center; border: 1px solid #333; overflow: hidden;}
+      .topic-overlap th span, .topic-overlap td span {display: block; width: 2em; height: 2em;}
+      .topic-overlap td {cursor: pointer;}
+      .topic-pair {border-collapse: collapse; margin-bottom: 0.5em;}
+      .topic-pair th {font-size:105%; font-weight: bold; text-align: center; border-top: 1px solid #ccc; border-left: 1px solid #ccc; border-right: 1px solid #ccc; background: #eee;}
+      .topic-pair.active th {background: #89f;}
+      .topic-pair td {border-bottom: 1px solid #ccc; border-left: 1px solid #ccc; border-right: 1px solid #ccc; padding:0 0.5em 1em;}
+      .topic-pair td span {display:inline-block; margin-right: 0.5em;}
+
+      .chart-item {border:1px solid #ccc; padding: 1em;}
+
+      .download-csv {float: right;}
+      .download-csv svg {width: 1em; height: 1em; vertical-align: middle;}
     </style>
   </head>
   <body style="background-color: #eee;">
+    
+    <svg style="display:none" xmlns="http://www.w3.org/2000/svg">
+      <defs><symbol id="csv-icon" viewBox="0 0 56 64">
+        <path clip-rule="evenodd" d="m5.106 0c-2.802 0-5.073 2.272-5.073 5.074v53.841c0 2.803 2.271 5.074 5.073 5.074h45.774c2.801 0 5.074-2.271 5.074-5.074v-38.605l-18.903-20.31h-31.945z" fill="#45b058" fill-rule="evenodd"/><path d="m20.306 43.197c.126.144.198.324.198.522 0 .378-.306.72-.703.72-.18 0-.378-.072-.504-.234-.702-.846-1.891-1.387-3.007-1.387-2.629 0-4.627 2.017-4.627 4.88 0 2.845 1.999 4.879 4.627 4.879 1.134 0 2.25-.486 3.007-1.369.125-.144.324-.233.504-.233.415 0 .703.359.703.738 0 .18-.072.36-.198.504-.937.972-2.215 1.693-4.015 1.693-3.457 0-6.176-2.521-6.176-6.212s2.719-6.212 6.176-6.212c1.8.001 3.096.721 4.015 1.711zm6.802 10.714c-1.782 0-3.187-.594-4.213-1.495-.162-.144-.234-.342-.234-.54 0-.361.27-.757.702-.757.144 0 .306.036.432.144.828.739 1.98 1.314 3.367 1.314 2.143 0 2.827-1.152 2.827-2.071 0-3.097-7.112-1.386-7.112-5.672 0-1.98 1.764-3.331 4.123-3.331 1.548 0 2.881.467 3.853 1.278.162.144.252.342.252.54 0 .36-.306.72-.703.72-.144 0-.306-.054-.432-.162-.882-.72-1.98-1.044-3.079-1.044-1.44 0-2.467.774-2.467 1.909 0 2.701 7.112 1.152 7.112 5.636.001 1.748-1.187 3.531-4.428 3.531zm16.994-11.254-4.159 10.335c-.198.486-.685.81-1.188.81h-.036c-.522 0-1.008-.324-1.207-.81l-4.142-10.335c-.036-.09-.054-.18-.054-.288 0-.36.323-.793.81-.793.306 0 .594.18.72.486l3.889 9.992 3.889-9.992c.108-.288.396-.486.72-.486.468 0 .81.378.81.793.001.09-.017.198-.052.288z" fill="#fff"/><g clip-rule="evenodd" fill-rule="evenodd"><path d="m56.001 20.357v1h-12.8s-6.312-1.26-6.128-6.707c0 0 .208 5.707 6.003 5.707z" fill="#349c42"/><path d="m37.098.006v14.561c0 1.656 1.104 5.791 6.104 5.791h12.8l-18.904-20.352z" fill="#fff" opacity=".5"/></g>
+      </symbol></defs>
+      <use href="#csv-icon"/>
+    </svg>
+
     <div class="" style="background-color: #fff;">
       <main class="p-3">
         <ul class="nav nav-tabs" id="menu" role="tablist">
@@ -43,6 +69,17 @@
           <li class="nav-item" role="presentation">
             <a class="nav-link {{"active" if action == "topic" else ""}}" id="topic-tab" href="/topic">Topic</a>
           </li>
+          <li class="nav-item" role="presentation">
+            <a class="nav-link {{"active" if action == "topic-rel" else ""}}" id="topic-rel-tab" href="/topic-rel">Topic Relation</a>
+          </li>
+          {% if available.get('metadata') %}
+          <li class="nav-item" role="presentation">
+            <a class="nav-link {{"active" if action == "metadata" else ""}}" id="metadata-tab" href="/metadata">Metadata</a>
+          </li>
+          <li class="nav-item" role="presentation">
+            <a class="nav-link {{"active" if action == "tdf-map" else ""}}" id="tdf-map-tab" href="/tdf-map">TDF Map</a>
+          </li>
+          {% end %}
         </ul>
         <div class="tab-content" id="tab-content">
           {% if action == 'overview' %}
@@ -85,13 +122,30 @@ <h3>All documents in the model ({{total_docs}})</h3>
             <h3>Filtered documents ({{filtered_docs}} / {{total_docs}})</h3>
             {% end %}
             <form id="document-filter">
+              <div class="row">
+                <div class="input-group">
+                  {% if available.get('metadata') %}
+                  <div class="input-group-text">  
+                    <select class="form-select form-select-sm" id="document-filter-metadata" name="m">
+                      <option value="-1">All metadata</option>
+                      {% for i, v in enumerate(model.metadata_dict) %}
+                      <option value="{{i}}" {{"selected" if i == filter_metadata else ""}}>{{v}}</option>
+                      {% end %}
+                    </select>
+                  </div>
+                  {% end %}
+                  <span class="input-group-text">Keywords: </span>
+                  <input type="text" class="form-control" id="document-filter-keyword" name="sq" value="{{filter_keyword}}">
+                  <button class="btn btn-outline-secondary" type="submit">Search</button>
+                </div>
+              </div>
               <div class="mb-3 row">
                 <div class="col-sm-7">
                   <div class="input-group mb-3">
                     <span class="input-group-text">Show only documents where the proportion of</span>
                     <select class="form-select" name="t">
                       {% for k in range(model.k) %}
-                      <option value="{{k}}" {{"selected" if k == filter_target else ""}}>Topic #{{k}}</option>
+                      <option value="{{k}}" {{"selected" if k == filter_target else ""}}>Topic {{get_topic_label(k, id_suffix=True)}}</option>
                       {% end %}
                     </select>
                     <span class="input-group-text">&gt;=</span>
@@ -103,14 +157,14 @@ <h3>Filtered documents ({{filtered_docs}} / {{total_docs}})</h3>
                   <select class="form-select" name="s" id="document-sort-key">
                     <option value="-1" {{"selected" if -1 == sort_key else ""}}>Order by DocId</option>
                     {% for k in range(model.k) %}
-                    <option value="{{k}}" {{"selected" if k == sort_key else ""}}>Order by Topic #{{k}}</option>
+                    <option value="{{k}}" {{"selected" if k == sort_key else ""}}>Order by Topic {{get_topic_label(k, id_suffix=True)}}</option>
                     {% end %}
                   </select>
                 </div>
               </div>
             </form>
             <div class="row">
-              <div class="col">
+              <div class="col d-md-block" id="document-list">
                 <ul class="list-group">
                   {% for doc in documents %}
                   <li class="list-group-item list-group-item-action d-flex justify-content-between align-items-start doc-item" id="doc-item-{{doc.doc_id}}">
@@ -126,12 +180,14 @@ <h3>Filtered documents ({{filtered_docs}} / {{total_docs}})</h3>
                   {% end %}
                 </ul>
                 {% if total_pages > 1 %}
-                {% set args = f'&s={sort_key}&t={filter_target}&v={filter_value}' %}
+                {% set args = f'&s={sort_key}&t={filter_target}&v={filter_value}&sq={filter_keyword}&m={filter_metadata}' %}
                 <form id="document-pagination">
                   <input type="hidden" name="p">
                   <input type="hidden" name="s" value="{{sort_key}}">
                   <input type="hidden" name="t" value="{{filter_target}}">
                   <input type="hidden" name="v" value="{{filter_value}}">
+                  <input type="hidden" name="sq" value="{{filter_keyword}}">
+                  <input type="hidden" name="m" value="{{filter_metadata}}">
                   <nav aria-label="Page navigation">
                     <ul class="pagination justify-content-center">
                       {% if page > 0 %}
@@ -164,6 +220,7 @@ <h3>Filtered documents ({{filtered_docs}} / {{total_docs}})</h3>
                 <div class="card">
                   <div class="card-header">
                     <span id="document-content-title"></span>
+                    <span id="document-metadata"></span>
                     <button type="button" class="btn-close float-end" id="document-content-close" aria-label="Close"></button>
                   </div>
                   <div class="card-body">
@@ -174,15 +231,23 @@ <h3>Filtered documents ({{filtered_docs}} / {{total_docs}})</h3>
             </div>
           </div>
           {% elif action == 'topic' %}
+          <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
           <div class="tab-pane fade show active mx-1 my-3" id="topic-tab-pane" role="tabpanel" aria-labelledby="topic-tab">
+            <h3>Top words in each topic <a class="download-csv" href="/d/topic-words.csv?n=100"><svg><use href="#csv-icon" /></svg></a></h3>
             <ul class="topic-word-dist-container">
               {% for topic in topics %}
               <li name="topic-{{topic.topic_id}}">
-                <h4 class="topic-color-{{topic.topic_id % 40}}-050 p-1">Topic #{{topic.topic_id}}</h4>
+                <h4 class="topic-title topic-color-{{topic.topic_id % 40}}-050 p-1" data-id="{{topic.topic_id}}" data-bs-toggle="tooltip" data-bs-title="{{get_topic_label(topic.topic_id, "Topic ", True)}}">
+                  <span class="topic-label">{{get_topic_label(topic.topic_id, "Topic ")}}</span>
+                  <input class="topic-label-editable" type="text" style="display: none;">
+                  {% if not read_only %}
+                  <button type="button" class="btn btn-sm btn-secondary float-end" style="font-size:50%">Rename</button>
+                  {% end %}
+                </h4>
                 <table class="table table-striped table-hover word-dist">
-                  {% for word, dist in topic.words %}
+                  {% for word, id, dist in topic.words %}
                   <tr>
-                    <th scope="row">{{word}}</th>
+                    <th scope="row" data-bs-toggle="tooltip" data-bs-title="{{model.get_word_forms(id)[0][0]}} (ID: {{id}})">{{word}}</th>
                     <td>
                       <div class="progress flex-grow-1 word-dist-bar" data-bs-toggle="tooltip" data-bs-title="{{dist:.5%}}">
                         <div class="progress-bar" role="progressbar" style="width: {{dist / max_dist:.5%}}">{{dist:.3%}}</div>
@@ -193,9 +258,291 @@ <h4 class="topic-color-{{topic.topic_id % 40}}-050 p-1">Topic #{{topic.topic_id}
                 </table>
               </li>
               {% end %}
-          </ul>
-          <div class="d-grid gap-2">
-            <a href="/topic?top_n={{top_n + 10}}" class="btn btn-primary" role="button">More words...</a>
+            </ul>
+            <div class="d-grid gap-2">
+              <a href="/topic?top_n={{top_n + 10}}" class="btn btn-primary" role="button">More words...</a>
+            </div>
+            <div class="row" style="margin-top: 1em;">
+              {% set col = "col-md-6" if top1_topic_dist_by_metadata else "" %}
+              <div class="chart-item {{col}}" style="height:45vh;">
+                <h3>Distribution of Document Top1 Topics <a class="download-csv" href="/d/document-top1-topic.csv"><svg><use href="#csv-icon" /></svg></a></h3>
+                <canvas id="chart-all"></canvas>
+                <script>
+                  const topic_labels = {{get_all_topic_labels()}};
+                  (function(){
+                    var data = {{top1_topic_dist}};
+                    const ctx = document.getElementById('chart-all');
+                    const chart = new Chart(ctx, {
+                      type: 'bar',
+                      data: {
+                        labels: data.map((_, i) => topic_labels[i]),
+                        datasets: [{
+                          label: 'Num. of Document Top1 Topic',
+                          data: data,
+                        }]
+                      },
+                      options: {
+                        scales: {
+                          y: {
+                            beginAtZero: true
+                          }
+                        }
+                      }
+                    });
+                  }());
+                </script>
+              </div>
+              {% if top1_topic_dist_by_metadata %}
+              <div class="chart-item {{col}}" style="height:45vh;">
+                <h3>Distribution of Document Top1 Topics by Metadata <a class="download-csv" href="/d/document-top1-topic.csv?m=1"><svg><use href="#csv-icon" /></svg></a></h3>
+                <canvas id="chart-metadata"></canvas>
+                <script>
+                  (function(){
+                    const labels = {{[k for k, _ in top1_topic_dist_by_metadata.items()]}};
+                    const data = {{[d for _, d in top1_topic_dist_by_metadata.items()]}};
+                    const ctx = document.getElementById('chart-metadata');
+                    var datasets = [];
+                    for (var i in labels) {
+                      datasets.push({
+                        label: labels[i],
+                        data: data[i],
+                      });
+                    }
+                    const chart = new Chart(ctx, {
+                      type: 'bar',
+                      data: {
+                        labels: data[0].map((_, i) => topic_labels[i]),
+                        datasets: datasets,
+                      },
+                      options: {
+                        scales: {
+                          x: {
+                            stacked: true,
+                          },
+                          y: {
+                            beginAtZero: true,
+                            stacked: true,
+                          }
+                        }
+                      }
+                    });
+                  }());
+                </script>
+              </div>
+              {% end %}
+            </div>
+          </div>
+          {% elif action == 'topic-rel' %}
+          <div class="tab-pane fade show active mx-1 my-3" id="topic-tab-pane" role="tabpanel" aria-labelledby="topic-tab">
+            <div class="row">
+              <div class="col-md-6">
+                <h3>Word overlap between topics <button id="topic-pair-reset" class="btn btn-primary float-end" type="button">Reset</button></h3>
+                <div style="overflow-x: auto; width: 100%;">
+                  <table class="topic-overlap" id="topic-overlap-table">
+                    <tr>
+                      <th></th>
+                      {% for i in range(len(overlaps)) %}
+                      <th data-i="{{i}}" data-j="-1"><span>{{i}}</span></th>
+                      {% end %}
+                    </tr>
+                    {% for j in range(len(overlaps)) %}
+                    <tr>
+                      <th data-i="-1" data-j="{{j}}"><span>{{j}}</span></th>
+                      {% for i in range(len(overlaps)) %}
+                      <td data-i="{{i}}" data-j="{{j}}" data-val="{{overlaps[i, j]}}" style="background:rgb{{scale_color(overlaps[i, j] ** 0.67)}}" data-bs-toggle="tooltip" data-bs-title="{{get_topic_label(i, id_suffix=True)}} - {{get_topic_label(j, id_suffix=True)}}: {{overlaps[i, j]:.2%}}"></td>
+                      {% end %}
+                    </tr>
+                    {% end %}
+                  </table>
+                </div>
+              </div>
+              <div class="col-md-6" id="most-similar-topic-pairs">
+                {% for n, (i, j) in enumerate(similar_pairs) %}
+                <table class="topic-pair" data-i="{{i}}" data-j="{{j}}" data-val="{{overlaps[i, j]}}" style="{{"display:none" if n >= len(overlaps) else ""}}">
+                  <tr>
+                    <th colspan="2"><small>{{get_topic_label(i, id_suffix=True)}} - {{get_topic_label(j, id_suffix=True)}}:</small> {{overlaps[i, j]:.2%}}</th>
+                  </tr>
+                  <tr>
+                    <td><b>{{get_topic_label(i, prefix="Topic ", id_suffix=True)}}:</b>
+                      {% for word, p in model.get_topic_words(i, top_n=10) %}
+                      <span>{{word}}<small>({{p:.2%}})</small></span>
+                      {% end %}
+                    </td>
+                    <td><b>{{get_topic_label(j, prefix="Topic ", id_suffix=True)}}:</b>
+                      {% for word, p in model.get_topic_words(j, top_n=10) %}
+                      <span>{{word}}<small>({{p:.2%}})</small></span>
+                      {% end %}
+                    </td>
+                  </tr>
+                </table>
+                {% end %}
+              </div>
+            </div>
+          </div>
+          {% elif action == 'metadata' %}
+          <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
+          <div class="tab-pane fade show active mx-1 my-3" id="metadata-tab-pane" role="tabpanel" aria-labelledby="metadata-tab">
+            <div class="input-group">
+              <div class="input-group-text">
+                <label for="show-conf-interval">Show Confidence Interval</label>
+                  <input type="checkbox" id="show-conf-interval">
+                </label>
+              </div>
+              <span class="input-group-text">Confidence</span>
+              <input type="text" class="form-control" id="confidence" value="0.9">
+              <div class="input-group-text">
+                <label for="opacity">Opacity from Data Density</label>
+                  <input type="checkbox" id="opacity" checked>
+                </label>
+              </div>
+            </div>
+            <form method="get" id="numeric-metadata-range">
+            {% for i, (s, e) in enumerate(numeric_metadata) %}
+            <div class="input-group">
+              <span class="input-group-text">Numeric Metadata #{{i}}: Range({{s}} - {{e}})</span>
+              <div class="input-group-text">
+                <label for="axis-{{i}}">Show this metadata on axis</label>
+                  <input type="radio" id="axis-{{i}}" name="axis" value="{{i}}" {{"checked" if i == axis else ""}}>
+                </label>
+              </div>
+              <input type="text" class="form-control range-value" id="range-start-{{i}}" value="{{range_start[i]}}">
+              <span class="input-group-text"> - </span>
+              <input type="text" class="form-control range-value" id="range-end-{{i}}" value="{{range_end[i]}}">
+              {% if i == len(numeric_metadata) - 1 %}
+              <button class="btn btn-outline-secondary" type="submit">Update</button>
+              {% end %}
+            </div>
+            {% end %}
+            <input type="hidden" name="x" id="all-range-value">
+            </form>
+
+            <div id="chart-view" class="row">
+              {% for topic in range(cats.shape[2]) %}
+              <div class="chart-item col-md-6">
+                <h3>{{get_topic_label(topic, prefix="Topic ", id_suffix=True)}}: <small>{{", ".join(w for w, _ in model.get_topic_words(topic, 5))}}</small></h3>
+                <canvas id="chart-{{topic}}"></canvas>
+              </div>
+              {% end %}
+            </div>
+            
+            <script>
+              var tdf_data = [
+                {% for topic in range(cats.shape[2]) %}
+                {{cats[:, :, topic].tolist()}},
+                {% end %}
+              ];
+              var category_data_density = [
+                {% for d in data_density %}
+                {{d.tolist()}},
+                {% end %}
+              ];
+              var tdf_ci_p = [];
+              var tdf_ci_lb = [];
+              var tdf_ci_ub = [];
+              var x_values = {{x_values.tolist()}};
+              var x_labels = {{x_labels}};
+              var category_labels = {{categorical_metadata}};
+            </script>
+            <script>
+              var charts = [];
+              for (var i in tdf_data) {
+                var data = tdf_data[i];
+                const ctx = document.getElementById('chart-' + i);
+
+                var datasets = [];
+                for (var c in category_labels) {
+                  datasets.push({
+                    label: category_labels[c],
+                    cubicInterpolationMode: 'monotone',
+                    data: data[c],
+                    borderWidth: 2,
+                    pointStyle: false,
+                    segment: {
+                      borderColor: (ctx) => {
+                        // if opacity is checked 
+                        if (!document.querySelector('#opacity').checked) {
+                          return undefined;
+                        }
+                        const cid = ctx.datasetIndex;
+                        const density = category_data_density[cid];
+                        const orig_color = datasets[cid].borderColor;
+                        const alpha = (density[ctx.p0DataIndex] + density[ctx.p1DataIndex]) / 2;
+                        return 'rgba(' + orig_color.substring(4, orig_color.length - 1) + ', ' + alpha + ')';
+                      },
+                      borderDash: (ctx) => {
+                        if (!document.querySelector('#opacity').checked) {
+                          return undefined;
+                        }
+                        const cid = ctx.datasetIndex;
+                        const density = category_data_density[cid];
+                        const orig_color = datasets[cid].borderColor;
+                        const alpha = (density[ctx.p0DataIndex] + density[ctx.p1DataIndex]) / 2;
+                        if (alpha < 0.25) {
+                          return [2, 6];
+                        } else if (alpha < 0.5) {
+                          return [4, 4];
+                        } else if (alpha < 0.75) {
+                          return [6, 2];
+                        }
+                        return undefined;
+                      },
+                    },
+                  });
+                }
+              
+                const chart = new Chart(ctx, {
+                  type: 'line',
+                  data: {
+                    labels: x_values,
+                    datasets: datasets
+                  },
+                  options: {
+                    scales: {
+                      x: {
+                        ticks: {
+                          callback: function(val, index) {
+                            return x_labels[index];
+                          },
+                        }
+                      },
+                      y: {
+                        beginAtZero: true
+                      }
+                    },
+                    plugins: {
+                      legend: {
+                        labels: {
+                          filter: function(item, chart) {
+                            return item.datasetIndex < category_labels.length;
+                          }
+                        }
+                      },
+                      tooltip: {
+                        filter: function(item) {
+                          return item.datasetIndex < category_labels.length;
+                        }
+                      },
+                    }
+                  }
+                });
+                charts.push(chart);
+              }
+            </script>
+          </div>
+          {% elif action == 'tdf-map' %}
+          <div class="tab-pane fade show active mx-1 my-3" id="tdf-map-tab-pane" role="tabpanel" aria-labelledby="tdf-map-tab">
+            <h3>Topic Distribution Function Map</h3>
+            <div class="row">
+              <div class="text-center">
+                <img src="/d/tdf-map-legend.png?x={{x_axis}}&y={{y_axis}}&w=800&h=40&s={{contour_interval}}" style="border: 1px solid #333; max-width:800px; width:100%;">
+              </div>
+              {% for topic in range(model.k) %}
+              <div class="col-md-4 col-sm-6">
+                <h4>{{get_topic_label(topic, prefix="Topic ", id_suffix=True)}}</h4>
+                <img src="/d/tdf-map-{{topic}}.png?x={{x_axis}}&y={{y_axis}}&w={{width}}&h={{height}}&s={{contour_interval}}&smooth={{int(smooth)}}" class="img-fluid" alt="Map for {{get_topic_label(topic, prefix="Topic ", id_suffix=True)}}">
+              </div>
+              {% end %}
+            </div>
           </div>
           {% end %}
         </div>
@@ -209,34 +556,62 @@ <h4 class="topic-color-{{topic.topic_id % 40}}-050 p-1">Topic #{{topic.topic_id}
     (function(){
       const tooltip_list = [...document.querySelectorAll('[data-bs-toggle="tooltip"]')].map(e => new bootstrap.Tooltip(e));
 
+      function view_document(doc_id) {
+        if (doc_id === null) {
+          // hide document view
+          document.getElementById('document-content').style.display = 'none';
+          document.getElementById('document-list').classList.remove('d-none');
+          return;
+        } 
+        // remove active class
+        document.querySelectorAll('.doc-item').forEach((el) => {
+          el.classList.remove('active');
+        });
+        // add active class
+        document.getElementById(`doc-item-${doc_id}`).classList.add('active');
+        // show loading
+        document.getElementById('document-content-title').innerHTML = `Doc ${doc_id}`;
+        document.getElementById('document-metadata').innerHTML = '';
+        document.getElementById('document-content-body').innerHTML = '<div class="d-flex align-items-center"><strong>Loading...</strong><div class="spinner-border ms-auto" role="status" aria-hidden="true"></div></div>';
+        document.getElementById('document-content').style.display = 'block';
+        document.getElementById('document-list').classList.add('d-none');
+
+        fetch(`/api/document/${doc_id}`).then((res) => res.text()).then((html) => {
+          document.getElementById('document-content-body').innerHTML = html;
+          const tooltip_list = [...document.querySelectorAll('#document-content-body [data-bs-toggle="tooltip"]')].map(e => new bootstrap.Tooltip(e));
+          document.querySelectorAll('#document-content-body meta').forEach((el) => {
+            const key = el.getAttribute('name');
+            const value = el.getAttribute('content');
+            document.getElementById('document-metadata').innerHTML += `<span class="badge bg-secondary">${key}: ${value}</span> `;
+          });
+        }).catch((err) => {
+          document.getElementById('document-content-body').innerHTML = `<p class="text-danger">${err}</p>`;
+        })
+      }
+
       document.querySelectorAll('.document-view').forEach((el) => {
         el.addEventListener('click', (e) => {
           //e.preventDefault();
-          const doc_id = el.getAttribute('href').substring(5);
-          // remove active class
-          document.querySelectorAll('.doc-item').forEach((el) => {
-            el.classList.remove('active');
-          });
-          // add active class
-          document.getElementById(`doc-item-${doc_id}`).classList.add('active');
-          // show loading
-          document.getElementById('document-content-title').innerHTML = `Doc ${doc_id}`;
-          document.getElementById('document-content-body').innerHTML = '<div class="d-flex align-items-center"><strong>Loading...</strong><div class="spinner-border ms-auto" role="status" aria-hidden="true"></div></div>';
-          document.getElementById('document-content').style.display = 'block';
-
-          fetch(`/api/document/${doc_id}`).then((res) => res.text()).then((html) => {
-            document.getElementById('document-content-body').innerHTML = html;
-            const tooltip_list = [...document.querySelectorAll('#document-content-body [data-bs-toggle="tooltip"]')].map(e => new bootstrap.Tooltip(e));
-          }).catch((err) => {
-            document.getElementById('document-content-body').innerHTML = `<p class="text-danger">${err}</p>`;
-          })
+          //const doc_id = el.getAttribute('href').substring(5);
+          //view_document(doc_id);
         });
       });
 
       if (document.querySelector('#document-content-close')) {
         document.querySelector('#document-content-close').addEventListener('click', (e) => {
           e.preventDefault();
-          document.getElementById('document-content').style.display = 'none';
+          //view_document(null);
+          location.hash = '';
+        });
+
+        // when user go history back
+        window.addEventListener('popstate', (e) => {
+          // test if #doc-xxx exists
+          if (location.hash.startsWith('#doc-')) {
+            view_document(location.hash.substring(5));
+          } else {
+            view_document(null);
+          }
         });
       }
 
@@ -266,6 +641,12 @@ <h4 class="topic-color-{{topic.topic_id % 40}}-050 p-1">Topic #{{topic.topic_id}
         });
       }
 
+      if (document.querySelector('#document-filter-metadata')) {
+        document.querySelector('#document-filter-metadata').addEventListener('change', (e) => {
+          document.querySelector('#document-filter').submit();
+        });
+      }
+
       // attach event handler to element with .topic-action dynamically
       document.addEventListener("click", function(e){
         const target = e.target.closest(".topic-action");
@@ -282,6 +663,188 @@ <h4 class="topic-color-{{topic.topic_id % 40}}-050 p-1">Topic #{{topic.topic_id}
         });
       });
 
+      if (document.querySelector('.topic-title')) {
+        document.querySelectorAll('.topic-title').forEach((el) => {
+          const topic_id = el.getAttribute('data-id');
+          el.querySelector('button').addEventListener('click', (e) => {
+            const button = el.querySelector('button');
+            const label = el.querySelector('.topic-label');
+            const input = el.querySelector('.topic-label-editable');
+            button.style.display = 'none';
+            label.style.display = 'none';
+            input.style.display = '';
+            input.value = label.textContent;
+            input.focus();
+          });
+
+          function update_topic_label() {
+            const button = el.querySelector('button');
+            const label = el.querySelector('.topic-label');
+            const input = el.querySelector('.topic-label-editable');
+            button.style.display = '';
+            label.style.display = '';
+            input.style.display = 'none';
+            label.textContent = input.value;
+            fetch(`/api/topic/${topic_id}/label`, {
+              method: 'POST',
+              headers: {
+                'Content-Type': 'application/json',
+              },
+              body: JSON.stringify({label: input.value}),
+            }).then((res) => res.json()).then((json) => {
+              setTimeout(() => window.location.reload(), 50);
+            });
+          }
+          el.querySelector('.topic-label-editable').addEventListener('blur', update_topic_label);
+          el.querySelector('.topic-label-editable').addEventListener('keydown', (e) => {
+            if (e.key === 'Enter') {
+              update_topic_label();
+            }
+          });
+        });
+      }
+
+      if (document.querySelector('#topic-overlap-table')) {
+        const num_topics = document.querySelectorAll('#topic-overlap-table tr').length - 1;
+        document.querySelectorAll('#topic-overlap-table td').forEach((el) => {
+          el.addEventListener('click', (e) => {
+            const topic_i = el.getAttribute('data-i');
+            const topic_j = el.getAttribute('data-j');
+            if (topic_i == '-1' || topic_j == '-1') return;
+            const list = document.querySelector('#most-similar-topic-pairs');
+            [...list.children]
+              .sort((a, b) => {
+                const a_selected = a.getAttribute('data-j') == topic_j ? 1 : 0;
+                const b_selected = b.getAttribute('data-j') == topic_j ? 1 : 0;
+                if (b_selected > a_selected) return 1;
+                if (b_selected < a_selected) return -1;
+                return parseFloat(b.getAttribute('data-val')) - parseFloat(a.getAttribute('data-val'));
+              })
+              .forEach((node, idx) => {
+                if (node.getAttribute('data-j') == topic_j) {
+                  node.classList.add('active');
+                } else {
+                  node.classList.remove('active');
+                }
+                node.style.display = node.getAttribute('data-i') == topic_i ? '' : 'none';
+                list.appendChild(node);
+              });
+          });
+        });
+        document.querySelector('#topic-pair-reset').addEventListener('click', (e) => {
+          const list = document.querySelector('#most-similar-topic-pairs');
+          [...list.children]
+            .sort((a, b) => parseFloat(b.getAttribute('data-val')) - parseFloat(a.getAttribute('data-val')))
+            .forEach((node, idx) => {
+              const is_triu = parseInt(node.getAttribute('data-i')) < parseInt(node.getAttribute('data-j'));
+              node.style.display = idx < (num_topics - 1) * 2 && is_triu ? '' : 'none';
+              list.appendChild(node);
+            });
+        });
+      }
+
+      function get_confidence_interval_data(p, done, cid = 0) {
+        if (typeof tdf_ci_lb[cid] !== 'undefined' && tdf_ci_p[cid] == p) {
+          if (cid + 1 < category_labels.length) {
+            get_confidence_interval_data(p, done, cid + 1);
+          } else {
+            done();
+          }
+          return;
+        }
+
+        fetch(`/api/conf-interval/${cid}/${p}`).then((res) => res.json()).then((json) => {
+          const cid = json.data.cid;
+          const p = json.data.p;
+          const lbs = json.data.lbs;
+          const ubs = json.data.ubs;
+          tdf_ci_p[cid] = p;
+          tdf_ci_lb[cid] = lbs;
+          tdf_ci_ub[cid] = ubs;
+          if (cid + 1 < category_labels.length) {
+            get_confidence_interval_data(p, done, cid + 1);
+          } else {
+            done();
+          }
+        });
+      }
+
+      function updateConfInterval() {
+        const show_conf_interval = document.querySelector('#show-conf-interval').checked;
+        if (show_conf_interval) {
+            const p = parseFloat(document.querySelector('#confidence').value);
+            if (isNaN(p) || p <= 0 || p >= 1) {
+              alert('Confidence should be a number between 0 and 1.');
+              return;
+            }
+            get_confidence_interval_data(p, () => {
+              for (var i in charts) {
+                const chart = charts[i];
+                const data = chart.data;
+                const datasets = data.datasets;
+                datasets.splice(category_labels.length);
+                for (var cid in category_labels) {
+                  const lb = tdf_ci_lb[cid][i];
+                  const ub = tdf_ci_ub[cid][i];
+                  for (var lv = 0; lv < 3; ++lv) {
+                    const backgroundColor = datasets[cid].backgroundColor.replace(/, *0\.\d+\)/, lv > 0 ? ', 0.1)' : ', 0.15)');
+                    datasets.push({
+                      label: category_labels[cid] + ' LB',
+                      data: lb.map((v, x) => (v * (3 - lv) + tdf_data[i][cid][x] * lv) / 3),
+                      fill: cid,
+                      backgroundColor: backgroundColor,
+                      borderColor: 'transparent',
+                      pointStyle: false,
+                    });
+                    datasets.push({
+                      label: category_labels[cid] + ' UB',
+                      data: ub.map((v, x) => (v * (3 - lv) + tdf_data[i][cid][x] * lv) / 3),
+                      fill: cid,
+                      backgroundColor: backgroundColor,
+                      borderColor: 'transparent',
+                      pointStyle: false,
+                    });
+                  }
+                }
+                chart.update();
+              }
+            });
+          } else {
+            for (var i in charts) {
+              const chart = charts[i];
+              const data = chart.data;
+              const datasets = data.datasets;
+              datasets.splice(category_labels.length);
+              chart.update();
+            }
+          }
+      }
+
+      if (document.querySelector('#show-conf-interval')) {
+        document.querySelector('#show-conf-interval').addEventListener('change', updateConfInterval);
+        document.querySelector('#confidence').addEventListener('change', updateConfInterval);
+      }
+
+      if (document.querySelector('#opacity')) {
+        document.querySelector('#opacity').addEventListener('change', (e) => {
+          for (var i in charts) {
+            const chart = charts[i];
+            chart.update();
+          }
+        });
+      }
+
+      if (document.querySelector('#numeric-metadata-range')) {
+        document.querySelector('#numeric-metadata-range').addEventListener('submit', (e) => {
+          const range_values = [];
+          document.querySelectorAll('.range-value').forEach((el) => {
+            range_values.push(el.value);
+          });
+          document.querySelector('#all-range-value').value = range_values.join(',');
+          return true;
+        });
+      }
+
     }());
     </script>
   </body>
diff --git a/tomotopy/viewer/viewer_server.py b/tomotopy/viewer/viewer_server.py
index 7e5b766..6e62f14 100644
--- a/tomotopy/viewer/viewer_server.py
+++ b/tomotopy/viewer/viewer_server.py
@@ -3,6 +3,12 @@
 import io
 import urllib.parse
 import html
+import math
+import json
+import csv
+import io
+import functools
+from collections import defaultdict, Counter
 from dataclasses import dataclass
 import traceback
 import http.server
@@ -27,24 +33,157 @@ class Topic:
     topic_id: int
     words: list
 
-def hue2rgb(h:float):
+def hue2rgb(h:float, b:float = 1):
     h = h % 6.0
+    b = min(max(b, 0), 1)
     if h < 1:
-        return (255, int(255 * h), 0)
+        return (int(255 * b), int(255 * h * b), 0)
     elif h < 2:
-        return (int(255 * (2 - h)), 255, 0)
+        return (int(255 * (2 - h) * b), int(255 * b), 0)
     elif h < 3:
-        return (0, 255, int(255 * (h - 2)))
+        return (0, int(255 * b), int(255 * (h - 2) * b))
     elif h < 4:
-        return (0, int(255 * (4 - h)), 255)
+        return (0, int(255 * (4 - h) * b), int(255 * b))
     elif h < 5:
-        return (int(255 * (h - 4)), 0, 255)
+        return (int(255 * (h - 4) * b), 0, int(255 * b))
+    else:
+        return (int(255 * b), 0, int(255 * (6 - h) * b))
+
+def scale_color(s:float, scale='log'):
+    if scale == 'log':
+        s = min(max(s, 0) + 1e-4, 1)
+        s = max(math.log(s) + 4, 0) * (6 / 4)
     else:
-        return (255, 0, int(255 * (6 - h)))
+        s = min(max(s, 0), 1)
+        s *= 6
+    if s < 1:
+        return hue2rgb(4, s * 0.6)
+    elif s < 5:
+        return hue2rgb(5 - s, (s - 1) / 4 * 0.4 + 0.6)
+    else:
+        t = int((s - 5) * 255)
+        return (255, t, t)
+
+def colorize(a, colors):
+    a = a * (len(colors) - 1)
+    l = np.floor(a).astype(np.int32)
+    r = np.clip(l + 1, 0, len(colors) - 1)
+    a = a - l
+    result = colors[l] + (colors[r] - colors[l]) * a[..., None]
+    return result
+
+def draw_contour_map(arr, interval, smooth=True):
+    def _refine(a, smooth, cut=0.15, scale=0.6):
+        if smooth:
+            s = np.zeros_like(a, dtype=np.float32)
+            a = np.pad(a, (2, 2), 'edge')
+
+            # approximated 5x5 gaussian filter
+
+            s += a[2:-2, 2:-2] * 41
+
+            s += a[1:-3, 2:-2] * 26
+            s += a[3:-1, 2:-2] * 26
+            s += a[2:-2, 1:-3] * 26
+            s += a[2:-2, 3:-1] * 26
+
+            s += a[1:-3, 1:-3] * 16
+            s += a[3:-1, 3:-1] * 16
+            s += a[1:-3, 3:-1] * 16
+            s += a[3:-1, 1:-3] * 16
+
+            s += a[4:, 2:-2] * 7
+            s += a[:-4, 2:-2] * 7
+            s += a[2:-2, 4:] * 7
+            s += a[2:-2, :-4] * 7
+            
+            s += a[1:-3, 4:] * 4
+            s += a[3:-1, :-4] * 4
+            s += a[4:, 3:-1] * 4
+            s += a[:-4, 1:-3] * 4
+
+            s += a[4:, 4:] * 1
+            s += a[:-4, :-4] * 1
+            s += a[4:, :-4] * 1
+            s += a[:-4, 4:] * 1
+
+            s /= 273
+
+            a = (s - cut) / scale
+        return a.clip(0, 1)
+
+    cv = np.floor(arr / interval)
+    contour_map = np.zeros_like(arr, dtype=np.float32)
+    contour_map[:-1] += cv[:-1] != cv[1:]
+    contour_map[1:] += cv[:-1] != cv[1:]
+    contour_map[:, :-1] += cv[:, :-1] != cv[:, 1:]
+    contour_map[:, 1:] += cv[:, :-1] != cv[:, 1:]
+    contour_map = _refine(contour_map, smooth, cut=0.25, scale=0.65)
+
+    cv = np.floor(arr / (interval * 5))
+    contour_map2 = np.zeros_like(arr, dtype=np.float32)
+    contour_map2[:-1] += cv[:-1] != cv[1:]
+    contour_map2[1:] += cv[:-1] != cv[1:]
+    contour_map2[:, :-1] += cv[:, :-1] != cv[:, 1:]
+    contour_map2[:, 1:] += cv[:, :-1] != cv[:, 1:]
+    contour_map2 = _refine(contour_map2, smooth, cut=0.15, scale=0.6)
+    contour_map = (contour_map + contour_map2) / 2
+
+    return contour_map
 
 topic_colors = [hue2rgb((i * 7) / 40 * 6) for i in range(40)]
 topic_styles = [(i * 7 // 40) % 3 for i in range(40)]
 
+def find_best_labels_for_range(start, end, max_labels):
+    dist = end - start
+    unit = 1
+    for i in range(10):
+        u = 10 ** (-i)
+        if dist < u:
+            continue
+        r = dist - round(dist / u) * u
+        if abs(r) < u * 0.1:
+            unit = u
+            break
+    steps = round(dist / unit)
+    while steps > max_labels:
+        unit *= 10
+        steps = round(dist / unit)
+
+    if steps <= max_labels / 5:
+        unit /= 5
+        steps = round(dist / unit)
+    elif steps <= max_labels / 2:
+        unit /= 2
+        steps = round(dist / unit)
+    
+    s = int(math.floor(start / unit))
+    e = int(math.ceil(end / unit))
+    return [unit * i for i in range(s, e + 1)]
+
+def estimate_confidence_interval_of_dd(alpha, p=0.95, samples=16384):
+    rng = np.random.RandomState(0)
+    alpha = np.array(alpha, dtype=np.float32)
+    mean = alpha / alpha.sum()
+    t = rng.dirichlet(alpha, samples).astype(np.float32)
+    t.sort(axis=0)
+    cnt = int(samples * (1 - p))
+    i = (t[-cnt:] - t[:cnt]).argmin(0)
+
+    o = np.array([np.searchsorted(t[:, i], m, 'right') for i, m in enumerate(mean)])
+    i = np.maximum(i, o - (samples - cnt))
+
+    lb = t[i, np.arange(len(alpha))]
+    ub = t[i - cnt, np.arange(len(alpha))]
+    return lb, ub
+ 
+def is_iterable(obj):
+    try:
+        iter(obj)
+        return True
+    except:
+        return False
+
 class DocumentFilter:
     def __init__(self, model, max_cache_size=5) -> None:
         self.model = model
@@ -52,9 +191,11 @@ def __init__(self, model, max_cache_size=5) -> None:
         self._cached = {}
         self._cached_keys = []
     
-    def _sort_and_filter(self, sort_key:int, filter_target:int, filter_value:float):
+    def _sort_and_filter(self, sort_key:int, filter_target:int, filter_value:float, filter_keyword:tuple, filter_metadata:str):
         results = []
         for i, doc in enumerate(self.model.docs):
+            if filter_keyword and not all(kw in doc.raw.lower() for kw in filter_keyword): continue
+            if filter_metadata is not None and doc.metadata != filter_metadata: continue
             dist = doc.get_topic_dist()
             if dist[filter_target] < filter_value: continue
             if sort_key >= 0:
@@ -67,37 +208,49 @@ def _sort_and_filter(self, sort_key:int, filter_target:int, filter_value:float):
         else:
             return [i for _, i in sorted(results, reverse=True)]
 
-    def _get_cached_filter_result(self, sort_key:int, filter_target:int, filter_value:float):
-        if sort_key < 0 and filter_value <= 0:
+    def _get_cached_filter_result(self, sort_key:int, filter_target:int, filter_value:float, filter_keyword:str, filter_metadata:str):
+        filter_keyword = tuple(filter_keyword.lower().split())
+        if sort_key < 0 and filter_value <= 0 and not filter_keyword and filter_metadata is None:
             # return None for no filtering nor sorting
             return None
-        key = (sort_key, filter_target, filter_value)
+        key = (sort_key, filter_target, filter_value, filter_keyword, filter_metadata)
         if key in self._cached:
             return self._cached[key]
         else:
-            result = self._sort_and_filter(sort_key, filter_target, filter_value)
+            result = self._sort_and_filter(sort_key, filter_target, filter_value, filter_keyword, filter_metadata)
             if len(self._cached_keys) >= self.max_cache_size:
                 del self._cached[self._cached_keys.pop(0)]
             self._cached[key] = result
             self._cached_keys.append(key)
             return result
 
-    def get(self, sort_key:int, filter_target:int, filter_value:float, index:slice):
+    def get(self, sort_key:int, filter_target:int, filter_value:float, filter_keyword:str, filter_metadata:str, index:slice):
         # return (doc_indices, total_docs_filtered)
-        result = self._get_cached_filter_result(sort_key, filter_target, filter_value)
+        result = self._get_cached_filter_result(sort_key, filter_target, filter_value, filter_keyword, filter_metadata)
         if result is None:
             return list(range(index.start, min(index.stop, len(self.model.docs)))), len(self.model.docs)
         else:
             return result[index], len(result)
-    
+
 
 class ViewerHandler(http.server.SimpleHTTPRequestHandler):
 
-    handlers = [
+    get_handlers = [
         (r'/?', 'overview'),
         (r'/document/?', 'document'),
         (r'/topic/?', 'topic'),
+        (r'/topic-rel/?', 'topic_rel'),
+        (r'/metadata/?', 'metadata'),
+        (r'/tdf-map/?', 'tdf_map'),
         (r'/api/document/(\d+)', 'api_document'),
+        (r'/api/conf-interval/(\d+)/([0-9.]+)', 'api_conf_interval'),
+        (r'/d/topic-words\.csv', 'download_topic_words'),
+        (r'/d/document-top1-topic\.csv', 'download_document_top1_topic'),
+        (r'/d/tdf-map-([0-9]+|legend).png', 'download_tdf_map'),
+    ]
+
+    post_handlers = [
+        (r'/api/topic/(\d+)/label', 'api_update_topic_label'),
     ]
 
     num_docs_per_page = 30
@@ -110,11 +263,88 @@ def title(self):
     def model(self):
         return self.server.model
     
+    @property
+    def model_hash(self):
+        hex_chr = hex(self.model.get_hash())[2:]
+        if len(hex_chr) < 32:
+            hex_chr = '0' * (32 - len(hex_chr)) + hex_chr
+        return hex_chr
+
+    @property
+    def available(self):
+        ret = {}
+        if 'GDMR' in type(self.model).__name__:
+            ret['metadata'] = True
+        return ret
+    
     @property
     def tomotopy_version(self):
         import tomotopy
         return tomotopy.__version__
 
+    @property
+    def user_config(self):
+        return self.get_user_config(None)
+    
+    @property
+    def read_only(self):
+        return self.server.read_only
+
+    def get_topic_label(self, k, prefix='', id_suffix=False):
+        label = self.get_user_config(('topic_label', k))
+        if label is None:
+            label = f'{prefix}#{k}'
+        elif id_suffix:
+            label += f' #{k}'
+        return label
+
+    def get_all_topic_labels(self, prefix='', id_suffix=False):
+        return [self.get_topic_label(k, prefix, id_suffix) for k in range(self.model.k)]
+
+    def get_user_config(self, key):
+        if self.server.user_config is None:
+            if self.server.user_config_file:
+                try:
+                    self.server.user_config = json.load(open(self.server.user_config_file, 'r', encoding='utf-8'))
+                    model_hash_in_config = self.server.user_config.get('model_hash')
+                    if model_hash_in_config is not None and model_hash_in_config != self.model_hash:
+                        print(f'User config file is for a different model. Ignoring the file.')
+                        self.server.user_config = {}
+                except FileNotFoundError:
+                    self.server.user_config = {}
+            else:
+                self.server.user_config = {}
+            self.server.user_config['model_hash'] = self.model_hash
+        if key is None:
+            return self.server.user_config
+        
+        if isinstance(key, str) or not is_iterable(key):
+            key = [key]
+        
+        obj = self.server.user_config
+        for k in key:
+            obj = obj.get(str(k))
+            if obj is None:
+                return obj
+        return obj
+    
+    def set_user_config(self, key, value):
+        self.get_user_config(key)
+
+        if isinstance(key, str) or not is_iterable(key):
+            key = [key]
+
+        obj = self.server.user_config
+        for k in key[:-1]:
+            k = str(k)
+            if k not in obj:
+                obj[k] = {}
+            obj = obj[k]
+        obj[str(key[-1])] = value
+
+        if self.server.user_config_file:
+            json.dump(self.server.user_config, open(self.server.user_config_file, 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
+
     def render(self, **kwargs):
         local_vars = {}
         for k in dir(self):
@@ -131,7 +361,7 @@ def do_GET(self):
         parsed = urllib.parse.urlparse(self.path)
         path, query = parsed.path, parsed.query
         self.arguments = {k:v[0] if len(v) == 1 else v for k, v in urllib.parse.parse_qs(query).items()}
-        for pattern, handler in self.handlers:
+        for pattern, handler in self.get_handlers:
             m = re.fullmatch(pattern, path)
             if m:
                 try:
@@ -143,6 +373,22 @@ def do_GET(self):
                 return
         self.send_error(404)
 
+    def do_POST(self):
+        parsed = urllib.parse.urlparse(self.path)
+        path, query = parsed.path, parsed.query
+        self.arguments = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
+        for pattern, handler in self.post_handlers:
+            m = re.fullmatch(pattern, path)
+            if m:
+                try:
+                    getattr(self, 'post_' + handler)(*m.groups())
+                except:
+                    self.send_error(500)
+                    traceback.print_exc()
+                    self.wfile.write(traceback.format_exc().encode())
+                return
+        self.send_error(404)
+
     def get_overview(self):
         import tomotopy._summary as tps
         buf = io.StringIO()
@@ -179,12 +425,22 @@ def get_document(self):
         sort_key = int(self.arguments.get('s', '-1'))
         filter_target = int(self.arguments.get('t', '0'))
         filter_value = float(self.arguments.get('v', '0'))
+        filter_keyword = self.arguments.get('sq', '')
+        filter_metadata = int(self.arguments.get('m', '-1'))
         page = int(self.arguments.get('p', '0'))
         
+        if not self.available.get('metadata') or filter_metadata < 0:
+            filter_metadata = -1
+            md = None
+        else:
+            md = self.model.metadata_dict[filter_metadata]
+
         doc_indices, filtered_docs = self.server.filter.get(
             sort_key, 
             filter_target, 
             filter_value / 100, 
+            filter_keyword,
+            md,
             slice(page * self.num_docs_per_page, (page + 1) * self.num_docs_per_page)
         )
         total_pages = (filtered_docs + self.num_docs_per_page - 1) // self.num_docs_per_page
@@ -199,14 +455,34 @@ def get_document(self):
         self.render(action='document',
                     page=page,
                     total_pages=total_pages,
-                    filtered_docs=filtered_docs if filter_value > 0 else None,
+                    filtered_docs=filtered_docs if filter_value > 0 or filter_keyword or filter_metadata >= 0 else None,
                     total_docs=total_docs,
                     documents=documents,
                     sort_key=sort_key,
                     filter_target=filter_target,
                     filter_value=filter_value,
+                    filter_keyword=filter_keyword,
+                    filter_metadata=filter_metadata,
                     )
     
+    def prepare_topic_doc_stats(self):
+        all_cnt = Counter([doc.get_topics(1)[0][0] for doc in self.model.docs])
+        top1_topic_dist = [all_cnt[i] for i in range(self.model.k)]
+
+        try:
+            has_metadata = len(self.model.docs[0].metadata) > 1
+        except:
+            has_metadata = False
+        
+        if has_metadata:
+            top1_topic_dist_by_metadata = defaultdict(Counter)
+            for doc in self.model.docs:
+                top1_topic_dist_by_metadata[doc.metadata][doc.get_topics(1)[0][0]] += 1
+            for k, cnt in top1_topic_dist_by_metadata.items():
+                top1_topic_dist_by_metadata[k] = [cnt[i] for i in range(self.model.k)]
+
+        return top1_topic_dist, top1_topic_dist_by_metadata if has_metadata else None
+
     def get_topic(self):
         top_n = int(self.arguments.get('top_n', '10'))
         alpha = float(self.arguments.get('alpha', '0.0'))
@@ -219,21 +495,125 @@ def get_topic(self):
             top_words = (-weighted_topic_word_dist).argsort()[:, :top_n]
             max_dist = topic_word_dist.max()
             for k, top_word in enumerate(top_words):
-                topic_words = [(self.model.vocabs[w], topic_word_dist[k, w]) for w in top_word]
+                topic_words = [(self.model.vocabs[w], w, topic_word_dist[k, w]) for w in top_word]
                 topics.append(Topic(k, topic_words))
         else:
             for k in range(self.model.k):
-                topic_words = self.model.get_topic_words(k, top_n)
-                max_dist = max(max_dist, topic_words[0][1])
+                topic_words = self.model.get_topic_words(k, top_n, return_id=True)
+                max_dist = max(max_dist, topic_words[0][-1])
                 topics.append(Topic(k, topic_words))
         
+        top1_topic_dist, top1_topic_dist_by_metadata = self.prepare_topic_doc_stats()
+
         self.send_response(200)
         self.send_header('Content-type', 'text/html')
         self.end_headers()
         self.render(action='topic',
                     topics=topics,
                     max_dist=max_dist,
-                    top_n=top_n,)
+                    top_n=top_n,
+                    top1_topic_dist=top1_topic_dist,
+                    top1_topic_dist_by_metadata=top1_topic_dist_by_metadata,
+                    )
+
+    def get_topic_rel(self):
+        topic_word_dist = np.stack([self.model.get_topic_word_dist(k) for k in range(self.model.k)])
+        overlaps = np.minimum(topic_word_dist[:, None], topic_word_dist[None]).sum(-1)
+        similar_pairs = np.stack(np.unravel_index((-np.triu(overlaps, 1)).flatten().argsort(), overlaps.shape), -1)
+        similar_pairs = similar_pairs[similar_pairs[:, 0] != similar_pairs[:, 1]]
+        most_similars = (2 * np.eye(len(overlaps)) - overlaps).argsort()[:, :-1]
+
+        self.send_response(200)
+        self.send_header('Content-type', 'text/html')
+        self.end_headers()
+        self.render(action='topic-rel',
+                    overlaps=overlaps,
+                    similar_pairs=similar_pairs,
+                    most_similars=most_similars,
+                    )
+
+    def prepare_metadata(self):
+        axis = int(self.arguments.get('axis', '0'))
+        x = self.arguments.get('x', '')
+        resolution = int(self.arguments.get('r', '33'))
+        numeric_metadata = self.model.metadata_range
+        if axis < 0 or axis >= len(numeric_metadata):
+            axis = 0
+
+        if x:
+            x = list(map(float, x.split(',')))
+            x = list(zip(x[::2], x[1::2]))
+        else:
+            x = [((s, e) if i == axis else (s, s)) for i, (s, e) in enumerate(numeric_metadata)]
+        
+        start, end = zip(*x)
+        num = [resolution if i == axis else 1 for i in range(len(x))]
+        squeeze_axis = tuple(i for i in range(len(x)) if i != axis)
+        return start, end, num, squeeze_axis, axis, numeric_metadata
+
+    def compute_data_density(self, x_values, axis, categorical_metadata):
+        dist = defaultdict(list)
+        for d in self.model.docs:
+            dist[d.metadata].append(d.numeric_metadata[axis])
+        
+        s, e = self.model.metadata_range[axis]
+        kernel_size = (e - s) / (self.model.degrees[axis] + 1)
+
+        densities = []
+        for c in categorical_metadata:
+            points = np.array(dist[c], dtype=np.float32)
+            density = np.exp(-((x_values[:, None] - points) / kernel_size) ** 2).sum(-1)
+            density /= density.max()
+            densities.append(density)
+        return densities
+
+    def get_metadata(self):
+        (start, end, num, squeeze_axis, axis, numeric_metadata
+         ) = self.prepare_metadata()
+        max_labels = int(self.arguments.get('max_labels', '15'))
+        categorical_metadata = self.model.metadata_dict
+        
+        x_values = np.linspace(start[axis], end[axis], num[axis], dtype=np.float32)
+        data_density = self.compute_data_density(x_values, axis, categorical_metadata)
+        boundaries = np.array(find_best_labels_for_range(x_values[0], x_values[-1], max_labels))
+        t = (np.searchsorted(boundaries, x_values, 'right') - 1).clip(0)
+        x_labels = [f'{boundaries[t[i]]:g}' if i == 0 or t[i - 1] != t[i] else '' for i in range(len(x_values))]
+
+        cats = np.stack([self.model.tdf_linspace(start, end, num, metadata=c).squeeze(squeeze_axis) for c in categorical_metadata])
+        
+        self.send_response(200)
+        self.send_header('Content-type', 'text/html')
+        self.end_headers()
+        self.render(action='metadata',
+                    categorical_metadata=categorical_metadata,
+                    numeric_metadata=numeric_metadata,
+                    range_start=start,
+                    range_end=end,
+                    x_values=x_values,
+                    x_labels=x_labels,
+                    axis=axis,
+                    cats=cats,
+                    data_density=data_density,
+                    )
+
+    def get_tdf_map(self):
+        x = int(self.arguments.get('x', '0'))
+        y = int(self.arguments.get('y', '1'))
+        width = int(self.arguments.get('w', '640'))
+        height = int(self.arguments.get('h', '480'))
+        contour_interval = float(self.arguments.get('s', '0.2'))
+        smooth = bool(int(self.arguments.get('smooth', '1')))
+        
+        self.send_response(200)
+        self.send_header('Content-type', 'text/html')
+        self.end_headers()
+        self.render(action='tdf-map',
+                    x_axis=x,
+                    y_axis=y,
+                    width=width,
+                    height=height,
+                    contour_interval=contour_interval,
+                    smooth=smooth,)
 
     def get_api_document(self, doc_id):
         doc_id = int(doc_id)
@@ -252,11 +632,21 @@ def get_api_document(self, doc_id):
             chunks.append(html.escape(raw[last:]))
         html_cont = '<p>' + ''.join(chunks).strip().replace('\n', '<br>') + '</p>'
         
+        meta = []
+        if hasattr(doc, 'metadata'):
+            meta.append(f'<meta name="metadata" content="{html.escape(doc.metadata)}">')
+        if hasattr(doc, 'multi_metadata'):
+            meta.append(f'<meta name="multi_metadata" content="{html.escape(repr(doc.multi_metadata))}">')
+        if hasattr(doc, 'numeric_metadata'):
+            meta.append(f'<meta name="numeric_metadata" content="{html.escape(repr(doc.numeric_metadata.tolist()))}">')
+        if meta:
+            html_cont = '\n'.join(meta) + '\n' + html_cont
+
         chunks = ['<table class="table table-striped table-hover topic-dist">']
         for topic_id, dist in doc.get_topics(top_n=-1):
             chunks.append(
 f'''<tr class="topic-action" data-topic-id="{topic_id}">
-    <th scope="row"><a href="javascript:void(0);">Topic # {topic_id}</a></th>
+    <th scope="row"><a href="javascript:void(0);">{self.get_topic_label(topic_id, prefix="Topic ", id_suffix=True)}</a></th>
     <td><div class="progress flex-grow-1" data-bs-toggle="tooltip" data-bs-title="{dist:.5%}">
         <div class="progress-bar topic-color-{topic_id % 40}" role="progressbar" style="width: {dist:.5%}">{dist:.3%}</div>
     </div></td>
@@ -268,6 +658,183 @@ def get_api_document(self, doc_id):
         self.end_headers()
         self.wfile.write(html_cont.encode())
 
+    def get_api_conf_interval(self, cid, p=0.95):
+        cid = int(cid)
+        p = float(p)
+        (start, end, num, squeeze_axis, axis, numeric_metadata
+         ) = self.prepare_metadata()
+        categorical_metadata = self.model.metadata_dict
+        alphas = np.exp(self.model.tdf_linspace(start, end, num, metadata=categorical_metadata[cid], normalize=False).squeeze(squeeze_axis))
+        lbs = []
+        ubs = []
+        for alpha in alphas:
+            lb, ub = estimate_confidence_interval_of_dd(alpha, p=p, samples=10000)
+            lbs.append(lb)
+            ubs.append(ub)
+        lbs = np.stack(lbs, axis=-1).tolist()
+        ubs = np.stack(ubs, axis=-1).tolist()
+
+        self.send_response(200)
+        self.send_header('Content-type', 'application/json')
+        self.end_headers()
+        
+        self.wfile.write(json.dumps({'data':{'cid':cid, 'p':p, 'lbs': lbs, 'ubs': ubs}}, ensure_ascii=False).encode())
+
+    def post_api_update_topic_label(self, topic_id):
+        if self.read_only:
+            self.send_error(403)
+            return
+        topic_id = int(topic_id)
+        label = self.arguments.get('label', '') or None
+        self.set_user_config(('topic_label', topic_id), label)
+        self.send_response(200)
+        self.send_header('Content-type', 'application/json')
+        self.end_headers()
+        self.wfile.write(json.dumps({'topic_id':topic_id, 'label':label}, ensure_ascii=False).encode())
+
+    def get_download_topic_words(self):
+        n = int(self.arguments.get('n', '10'))
+        csv_buf = io.StringIO()
+        writer = csv.writer(csv_buf)
+        headers = ['']
+        words = []
+        for k in range(self.model.k):
+            headers.append(self.get_topic_label(k, prefix='Topic ', id_suffix=True))
+            headers.append('Prob.')
+            words.append(self.model.get_topic_words(k, top_n=n))
+
+        writer.writerow(headers)
+        for i in range(n):
+            row = [i + 1]
+            for k in range(self.model.k):
+                row.extend(words[k][i])
+            writer.writerow(row)
+
+        self.send_response(200)
+        self.send_header('Content-type', 'text/csv')
+        self.send_header('Content-Disposition', 'attachment; filename="topic-words.csv"')
+        self.end_headers()
+        self.wfile.write(csv_buf.getvalue().encode('utf-8-sig'))
+
+    def get_download_document_top1_topic(self):
+        metadata = int(self.arguments.get('m', '0'))
+        csv_buf = io.StringIO()
+        writer = csv.writer(csv_buf)
+        top1_topic_dist, top1_topic_dist_by_metadata = self.prepare_topic_doc_stats()
+        if metadata:
+            headers = ['', *self.model.metadata_dict]
+            writer.writerow(headers)
+            for k in range(self.model.k):
+                row = [self.get_topic_label(k, prefix='Topic ', id_suffix=True)]
+                for m in self.model.metadata_dict:
+                    row.append(top1_topic_dist_by_metadata[m][k])
+                writer.writerow(row)
+        else:
+            headers = ['', 'All']
+            writer.writerow(headers)
+            for k, cnt in enumerate(top1_topic_dist):
+                writer.writerow([self.get_topic_label(k, prefix='Topic ', id_suffix=True), cnt])
+        
+        self.send_response(200)
+        self.send_header('Content-type', 'text/csv')
+        if metadata:
+            self.send_header('Content-Disposition', 'attachment; filename="document-top1-topic-by-metadata.csv"')
+        else:
+            self.send_header('Content-Disposition', 'attachment; filename="document-top1-topic.csv"')
+        self.end_headers()
+        self.wfile.write(csv_buf.getvalue().encode('utf-8-sig'))
+
+    def __eq__(self, other):
+        return self.model.get_hash() == other.model.get_hash()
+
+    def __hash__(self):
+        return self.model.get_hash()
+
+    @functools.lru_cache(maxsize=128)
+    def cached_tdf_linspace(self, start, end, num, metadata=""):
+        return self.model.tdf_linspace(start, end, num, metadata=metadata)
+
+    @functools.lru_cache(maxsize=128)
+    def cache_tdf_map_img(self, topic_id, x, y, w, h, r, contour_interval, smooth):
+        from PIL import Image
+        start, end = zip(*r)
+        num = [1] * len(start)
+        num[x] = w
+        num[y] = h
+
+        metadata = int(self.arguments.get('m', '0'))
+        metadata = self.model.metadata_dict[metadata] if metadata >= 0 else ""
+
+        td = self.cached_tdf_linspace(tuple(start), tuple(end), tuple(num), metadata)
+        td = td.transpose([-1, y, x] + [i for i in range(len(start)) if i not in (x, y)]).squeeze()
+        max_val = np.log(td.max() + 1e-9) - 1
+        min_val = -7
+        if topic_id == 'legend':
+            logits = np.linspace(min_val, max_val, w)[None]
+            logits = np.repeat(logits, h, 0)
+            smooth = False
+        else:
+            logits = np.log(td[topic_id] + 1e-9)
+            logits = logits[::-1] # invert y-axis
+        scaled = (logits - min_val) / (max_val - min_val)
+        scaled = np.clip(scaled, 0, 1)
+
+        contour_map = draw_contour_map(logits, contour_interval, smooth)
+
+        colors = np.array([
+            [0, 0, 0],
+            [0, 0, 0.7],
+            [0, 0.75, 0.75],
+            [0, 0.8, 0],
+            [0.85, 0.85, 0],
+            [1, 0, 0],
+            [1, 1, 1],
+        ], dtype=np.float32)
+        colorized = colorize(scaled, colors)
+        if topic_id == 'legend':
+            is_sub_grid = contour_map[0] < 1
+            contour_map[:-int(h * 0.32)] = 0
+            contour_map[:-int(h * 0.16), is_sub_grid] = 0
+            contour_map = contour_map.clip(0, 1)
+        colorized *= 1 - contour_map[..., None]
+        img = Image.fromarray((colorized * 255).astype(np.uint8), 'RGB')
+        img_buf = io.BytesIO()
+        img.save(img_buf, format='PNG')
+        img_buf.seek(0)
+        return img_buf.read()
+
+    def get_download_tdf_map(self, topic_id):
+        if not hasattr(self.model, 'tdf_linspace'):
+            self.send_error(404)
+            return
+        if topic_id == 'legend':
+            pass
+        else:
+            topic_id = int(topic_id)
+            if topic_id >= self.model.k:
+                self.send_error(404)
+                return
+        
+        x = int(self.arguments.get('x', '0'))
+        y = int(self.arguments.get('y', '1'))
+        w = int(self.arguments.get('w', '640'))
+        h = int(self.arguments.get('h', '480'))
+        contour_interval = float(self.arguments.get('s', '0.2'))
+        smooth = bool(int(self.arguments.get('smooth', '1')))
+
+        r = self.arguments.get('r', '')
+        if r:
+            r = list(map(float, x.split(',')))
+            r = list(zip(x[::2], x[1::2]))
+        else:
+            r = self.model.metadata_range
+
+        img_buf = self.cache_tdf_map_img(topic_id, x, y, w, h, tuple(r), contour_interval, smooth)
+        self.send_response(200)
+        self.send_header('Content-type', 'image/png')
+        self.end_headers()
+        self.wfile.write(img_buf)
+
 def _repl(m):
     if m.group().startswith('{{'):
         inner = m.group(1)
@@ -312,7 +879,30 @@ def _prepare_template():
     compiled_template = compile('\n'.join(codes), 'template.html', 'exec')
     return compiled_template
 
-def open_viewer(model, host='localhost', port=80, title=None):
+def open_viewer(model, host='localhost', port=80, title=None, user_config_file=None, read_only=False):
+    '''
+Run a server for topic model viewer
+
+Parameters
+----------
+model: tomotopy.LDAModel or its derived class
+    A trained topic model instance to be visualized.
+host: str
+    The host name to bind the server. Default is 'localhost'.
+port: int
+    The port number to bind the server. Default is 80.
+title: str
+    The title of the viewer in a web browser. Default is the class name of the model.
+user_config_file: str
+    The path to a JSON file to store the user configurations. Default is `None`. If None, the user configurations are not saved.
+read_only: bool
+    If True, the viewer will be read-only, that is the user cannot change topic labels. Default is False.
+
+Note
+----
+It is not recommended to use it in a production web service, 
+because this uses python's built-in `http.server` module which is not designed for high-performance production environments.
+    '''
     import tomotopy as tp
     if not isinstance(model, tp.LDAModel):
         raise ValueError(f'`model` must be an instance of tomotopy.LDAModel, but {model!r} was given.')
@@ -326,7 +916,9 @@ def open_viewer(model, host='localhost', port=80, title=None):
         httpd.title = title
         httpd.model = model
         httpd.template = template
+        httpd.user_config_file = user_config_file
+        httpd.user_config = None
+        httpd.read_only = read_only
         httpd.filter = DocumentFilter(model)
         print(f'Serving a topic model viewer at http://{httpd.server_address[0]}:{httpd.server_address[1]}/')
         httpd.serve_forever()
-


Test patch:
diff --git a/.github/workflows/deploy_test.yml b/.github/workflows/deploy_test.yml
index 157c2b9..0eb6efe 100644
--- a/.github/workflows/deploy_test.yml
+++ b/.github/workflows/deploy_test.yml
@@ -105,13 +105,13 @@ jobs:
           auditwheel repair dist/*-${{ matrix.cp }}-linux_*.whl
           TWINE_USERNAME=${{ secrets.TEST_PYPI_USERNAME }} TWINE_PASSWORD=${{ secrets.TEST_PYPI_PASSWORD }} /opt/python/${{ matrix.cp }}/bin/python -m twine upload --repository testpypi wheelhouse/*.whl
 
-  build_macos_11:
+  build_macos_13:
     name: Build for macOS 13
     runs-on: macOS-13
     strategy:
       max-parallel: 4
       matrix:
-        python-version: [3.8, 3.9, "3.10", 3.11]
+        python-version: [3.8, 3.9, "3.10", 3.11, 3.12]
         cpu-arch: [x86_64, arm64]
 
     steps:
@@ -122,7 +122,7 @@ jobs:
         python-version: ${{ matrix.python-version }}
     - name: Install dependencies
       run: |
-        python -m pip install --upgrade pip
+        python -m pip install --upgrade pip setuptools
         wget https://gitlab.com/libeigen/eigen/-/archive/3.4.0/eigen-3.4.0.tar.gz
         tar -zxvf eigen-3.4.0.tar.gz
         rm eigen-3.4.0.tar.gz
diff --git a/.github/workflows/pull_request_test.yml b/.github/workflows/pull_request_test.yml
index 7153d4a..19e1453 100644
--- a/.github/workflows/pull_request_test.yml
+++ b/.github/workflows/pull_request_test.yml
@@ -102,13 +102,13 @@ jobs:
         name: Artifacts ${{ matrix.cp }}
         path: artifacts/build.tgz
 
-  build_macos_11:
+  build_macos_13:
     name: Build for macOS 13
     runs-on: macOS-13
     strategy:
       max-parallel: 4
       matrix:
-        python-version: [3.8, 3.9, "3.10", 3.11]
+        python-version: [3.9, "3.10", 3.11, 3.12]
         cpu-arch: ["x86_64", "arm64"]
 
     steps:
@@ -119,7 +119,7 @@ jobs:
         python-version: ${{ matrix.python-version }}
     - name: Install dependencies
       run: |
-        python -m pip install --upgrade pip
+        python -m pip install --upgrade pip setuptools
         wget https://gitlab.com/libeigen/eigen/-/archive/3.4.0/eigen-3.4.0.tar.gz
         tar -zxvf eigen-3.4.0.tar.gz
         rm eigen-3.4.0.tar.gz