Quantcast
Channel: PostgreSQL Deep Dive
Viewing all 94 articles
Browse latest View live

パラレルスキャンのスケーラビリティ調査とFlame Graphsによるプロファイリング可視化

$
0
0
先月、弊社にデータベース系の研究をしていた中国人留学生がインターンに来ており、その彼にお願いしてPostgreSQLのパラレルクエリのスケーラビリティの調査と、プロファイリング+可視化のツールとしてFlameGraphを使ってもらいました。

大学のスケジュールの関係上、インターンの期間が急遽、3週間から2週間に短縮されてしまったため、結果をきちんとまとめたり追試をしたりといったところまでは到達できなかったのですが、個人的にもそれなりに面白いアウトプットになったと思いますので、簡単にご紹介したいと思います。

なお、細かい手順の詳細などは、インターンに参加していた学生さんのGithubにまとまっています。参考文献に載せておきますので、興味のある方はそちらも参照してください。(本テストと直接関係のない内容も含まれています)

■テストの背景


PostgreSQLの9.6develにパラレルシーケンシャルスキャンが実装されたのは、みなさんご承知のことと思います。
上記のブログでは機材の関係上4コアのマシンでしかテストが出来なかったのですが、ぜひ機会を見つけてもっと多くのコアのサーバで動かしてみたい、と考えていました。

と同時に、上記のエントリの中でPCIe Flashを使った時に、「1コア→4コア」でパフォーマンスが必ずしも4倍になっていませんでした(ほぼ2倍程度)。それが気になっており、機会を見つけてもう少し本格的に検証してみたいと考えていました。何らかのボトルネックがあるのであればそれを特定して、可能であれば改善方法を検討したいと考えていました。

あと、このインターンシップの直前にFlameGraphsというツールを知ったこともあり、それをPostgreSQLで試してみたかった、というのも理由の一つになります。

■デザイン


テストのデザインは以下の通りです。
  • pgbenchで作成されるテーブルを使ってパラレルシーケンシャルスキャンを実行する。
  • データは基本的にメモリ(共有バッファまたはOSキャッシュ)に乗るようにする。
  • パラレル無しから、並列度1、2、4・・・と増やしていきパフォーマンスを計測する。
  • その時にI/Oがネックになっていないことを確認する。

■ハードウェアのスペックと設定


ハードウェアは、今回はSoftlayerのベアメタルサーバを使いました。

ハードウェアのスペックは以下の通りです。6コアCPUが2発のっており、12コア24スレッドのマシンで、メモリは64GB積んでいます。

OS CentOS6.6-64
RAM 8x8GB Micron 8GB DDR4 1Rx4
Processor 2.4GHz Intel Xeon-Haswell (E5-2620-V3-HexCore)
Processor 2.4GHz Intel Xeon-Haswell (E5-2620-V3-HexCore)
Motherboard SuperMicro X10DRU-i+
Remote Mgmt Card Aspeed AST2400 - Onboard
Network Card SuperMicro AOC-UR-i4XT
Backplane SuperMicro BPN-SAS-815TQ
Power Supply SuperMicro PWS-751P-1R
Driver controller Mainboard Onboard
Security Device SuperMicro AOM-TPM-9655V

OSの設定は変更していません。I/Oスケジューラもデフォルトのままになっています。(今回のテストはI/O出ない前提なので)

■ソフトウェアバージョン、変更点と設定


PostgreSQLはGitから取得できる開発中の9.6develを使っており、以下の時点のコードベースです。

commit 7bea19d0a9d3e6975418ffe685fb510bd31ab434
Author: Robert Haas
Date: Fri Feb 26 16:33:37 2016 +0530

現在のPostgreSQLの並列度の決定は、先のエントリでも確認した通り、テーブルサイズに比例して増加します。しかし、このロジックだと12並列で動かすためには1TB以上のテーブルサイズが必要となってしまい、現実的ではありません。

そのため、以下の修正をソースコードに行い、並列度を max_parallel_degree に強制的に設定できるように修正しています。また、バックグラウンドワーカープロセスのデフォルト値も8→32に修正しています。

diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index 870a46c..2f1eea2 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -686,6 +686,8 @@ create_parallel_paths(PlannerInfo *root, RelOptInfo *rel)
break;
}

+ parallel_degree = max_parallel_degree;
+
/* Add an unordered partial path based on a parallel sequential scan. */
add_partial_path(rel, create_seqscan_path(root, rel, NULL, parallel_degree));

diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index ea5a09a..3de1dda 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -2392,7 +2392,7 @@ static struct config_int ConfigureNamesInt[] =
NULL,
},
&max_worker_processes,
- 8, 1, MAX_BACKENDS,
+ 32, 1, MAX_BACKENDS,
check_max_worker_processes, NULL, NULL
},


今回変更した postgresql.conf の項目は以下です。

shared_buffers = 128MB

■スキーマ設計、データサイズ、ツール


スキーマはpgbenchデータベースで作成されるテーブルで、この中の pgbench_accounts テーブルを対象にクエリを実行します。

postgres=# \d+
List of relations
Schema | Name | Type | Owner | Size | Description
--------+------------------+-------+----------+---------+-------------
public | pgbench_accounts | table | postgres | 25 GB |
public | pgbench_branches | table | postgres | 104 kB |
public | pgbench_history | table | postgres | 0 bytes |
public | pgbench_tellers | table | postgres | 904 kB |
(4 rows)

postgres=#

スケールファクタは2000、テーブルは25GBですので、共有バッファまたはOSキャッシュに乗り切るサイズです。(物理メモリは64GB)

■テストクエリ


実行するクエリは以下の通りです。

SET max_parallel_degree TO <並列度>;
EXPLAIN (ANALYZE true, VERBOSE true, BUFFERS true) select * from pgbench_accounts where filler = 'foo';
pgbench_accountsテーブルのfillerカラムに条件を設定し、テーブルをフルスキャンするクエリを実行します。(レコードそのものはヒットしないので、結果は0件となります)

■結果


以下のグラフが、実行時間(ミリ秒)とパフォーマンス(非並列の時のパフォーマンスを1として比較)のグラフになります。





また、以下が非並列、並列度1、2、12の時のFlameGraphです。

Non-Parallel (details):
2000m-0.svg


Parallel Degree = 1 (details):
2000m-1.svg


Parallel Degree = 2 (details):
2000m-2.svg


Parallel Degree = 12 (details):
2000m-12.svg

■まとめ


結果のグラフ見ると分かりますが、並列度を12、つまり物理コア数と同じ値にした時に、クエリの実行時間が綺麗にほぼ1/12になっていることが分かります。

よって、今回のテストではリニアなCPUスケーラビリティを確認できたと言えるでしょう。FlameGraphsを見ても、気になるところは特に見つかりませんでした。

今後機会があれば、JOINを含め、もう少し違ったワークロードでの検証をしてみたいと思います。

では、また。

■参考文献



データ分析用ライブラリ MADlib を使って PostgreSQL で機械学習する

$
0
0
MADlibは、現代的なデータ分析には欠かせない回帰分析やデータマイニングのアルゴリズムが実装されているオープンソースのライブラリです。

MADlibを導入することによって、これらのアルゴリズムをPostgreSQLのユーザ定義関数の形で使うことのでき、データベースサーバの内部でデータ分析の処理できるようになります。

今回は、このMADlibの導入方法から動作確認、ロジスティック回帰分析における簡単な使い方までをご紹介します。

■MADlibとは何か


MADlibは、もともとはGreenplumというPostgreSQLをベースにしたMPP製品(DWH用RDBMS)を開発していた企業が開発していたライブラリで、Greenplumで利用できるように開発されていたものでした。

2015年9月に、Greenplum(を買収したEMC)がMADlib(や他のソフトウェア類)をApache Foundationに寄贈し、MADlib は Apache Incubator のプロジェクトとなりました。

そして、4月6日に Apache Incubator のプロジェクトになって最初の GA(Generally Available) リリースである1.9がリリースされました。
冒頭にも述べたように、MADlibはデータ分析用の統計解析や機械学習のアルゴリズムを集めたライブラリで、以下のようなアルゴリズムが実装されています(抜粋)。
  • Linear Regression
  • Logistic Regression
  • Multinomial Logistic Regression
  • Cox Proportional Hazards Regression
  • Principal Component Analysis (PCA)
  • Association Rules (Apriori)
  • Decision Trees
  • Random Forest
  • Clustering (K-means)
  • Naïve Bayes
  • Support Vector Machines (SVM)
これらのアルゴリズムをPostgreSQL(やGreenplum、HAWQ)のSQLから扱えるようにするライブラリで、以下のようなアーキテクチャとなっています。


このようなアーキテクチャを取ることによって、分析するデータをデータベースから取り出すことなくデータベース内で処理できるようになります。

なお、1月に開催された FOSDEM'16 でも MADlib が紹介されていたようですので、そちらのプレゼン資料も参考にしてください。
今回は、この MADlib を PostgreSQL 9.5 と連携させて動作確認、簡単な使い方の確認までを行ってみようと思います。

■動作確認環境


今回の動作確認環境は以下の通りです。
  • CentOS 6.6 (x86_64)
  • PostgreSQL 9.5.2 (コミュニティ版RPM)
  • cmake 2.8.12
cmakeはyumコマンドでインストールできます。


# yum install -y cmake

MADlibは1.9ブランチの最新のコードを使っていますが、オリジナルのコードには
  • ビルドスクリプト内で呼び出しているリダイレクト用URLが利用不可能でインストール失敗。
  • PostgreSQL 9.5で動作させる場合に一部関数定義に不具合でインストール後のテストに失敗。
という問題があっため、修正して使っています。

ソースコードは以下から確認・取得可能です。

■ソースコードからのビルド


それではソースコードからビルドします。PostgreSQL 9.5が既にインストールされていることを前提としています。

手順をスクリプトにしたものは以下にありますので、詳細はそちらを参照してください。
基本的には Installation Guide に沿っていますので、そちらも参考にしてください。
まず、git cloneしてソースコードを取得し、バージョン1.9系のブランチに切り替えます。


[root@localhost madlib]# git clone https://github.com/snaga/incubator-madlib.git
Initialized empty Git repository in /tmp/madlib/incubator-madlib/.git/
remote: Counting objects: 23090, done.
remote: Compressing objects: 100% (55/55), done.
remote: Total 23090 (delta 21), reused 0 (delta 0), pack-reused 23032
Receiving objects: 100% (23090/23090), 15.32 MiB | 1.23 MiB/s, done.
Resolving deltas: 100% (15912/15912), done.
[root@localhost madlib]# cd incubator-madlib
[root@localhost incubator-madlib]# git checkout v1.9
Branch v1.9 set up to track remote branch v1.9 from origin.
Switched to a new branch 'v1.9'
[root@localhost incubator-madlib]# git log -1
commit a3c1eab2a92dd19c8a3f098e59cf56b8b974a3bb
Merge: a152c25 054136b
Author: Satoshi Nagayasu
Date: Sun Apr 10 12:26:28 2016 +0900

Merge pull request #3 from snaga/fix/v1.9-pg9.5

Fix to add support for PostgreSQL 9.5.
[root@localhost incubator-madlib]# ls
CMakeLists.txt LICENSE RELEASE_NOTES cmake doc methods
DISCLAIMER NOTICE ReadMe.txt configure examples pom.xml
HAWQ_Install.txt README.md ReadMe_Build.txt deploy licenses src
[root@localhost incubator-madlib]#

次にconfigureスクリプトを実行します。この時、OSバンドル版のPythonとPostgreSQL 9.5を見つけられるように明示的にPATHを通して実行します。



[root@localhost incubator-madlib]# env PATH=/usr/bin:/usr/pgsql-9.5/bin:$PATH ./configure
-- The C compiler identification is GNU 4.4.7
-- The CXX compiler identification is GNU 4.4.7
(...)
-- Could NOT find Boost
-- No sufficiently recent version (>= 1.47) of Boost was found. Will download.
-- Found PythonInterp: /usr/bin/python (found version "2.6.6")
-- Found PostgreSQL: /usr/pgsql-9.5/bin/postgres
-- Found PostgreSQL_9_5: /usr/pgsql-9.5/bin/postgres
>> Adding PostgreSQL 9.5 (x86_64) to target list...
-- Could NOT find Greenplum (missing: GREENPLUM_EXECUTABLE)
-- Could NOT find HAWQ (missing: HAWQ_EXECUTABLE)
(...)
-- Configuring done
-- Generating done
-- Build files have been written to: /tmp/madlib/incubator-madlib/build
[root@localhost incubator-madlib]#

configureが終わったら、buildディレクトリに移動してmakeを実行します。すると、依存するライブラリ類をダウンロードしてビルドが始まります。


[root@localhost incubator-madlib]# cd build/
[root@localhost build]# make
[ 0%] Performing download step (download, verify and extract) for 'EP_boost'
-- downloading...
src='http://sourceforge.net/projects/boost/files/boost_1_47_0.tar.gz'
dst='/disk/disk1/snaga/madlib/incubator-madlib/build/third_party/downloads/boost_1_47_0.tar.gz'
timeout='none'
-- [download 0% complete]
-- [download 1% complete]
(...)
[ 28%] Validating and copying svec_util/src/pg_gp/sql/svec_test.sql_in
[ 28%] Validating and copying svec_util/src/pg_gp/svec_util.sql_in
[ 29%] Validating and copying stemmer/src/pg_gp/porter_stemmer.sql_in
[ 29%] Built target sqlFiles_postgresql
Scanning dependencies of target madlib_postgresql_9_5
[ 29%] Building CXX object src/ports/postgres/9.5/CMakeFiles/madlib_postgresql_9_5.dir/__/__/__/modules/linear_systems/sparse_linear_systems.cpp.o
[ 29%] Building CXX object src/ports/postgres/9.5/CMakeFiles/madlib_postgresql_9_5.dir/__/__/__/modules/linear_systems/dense_linear_systems.cpp.o
[ 29%] Building CXX object src/ports/postgres/9.5/CMakeFiles/madlib_postgresql_9_5.dir/__/__/__/modules/assoc_rules/assoc_rules.cpp.o
(...)
[ 98%] Validating and copying svec_util/src/pg_gp/sql/svec_test.sql_in
[100%] Validating and copying svec_util/src/pg_gp/svec_util.sql_in
[100%] Validating and copying stemmer/src/pg_gp/porter_stemmer.sql_in
[100%] Built target sqlFiles_hawq
[root@localhost build]#

makeが終わったら、make packageコマンドでRPMファイルを作成します。


[root@localhost build]# make package
[ 1%] Built target EP_boost
[ 2%] Built target EP_eigen
(...)
CPack: Create package
CPackRPM: Will use USER specified spec file: /tmp/madlib/incubator-madlib/deploy/madlib.spec.in
CPack: - package: /tmp/madlib/incubator-madlib/build/madlib-1.9-Linux.rpm generated.
[root@localhost build]# ls
CMakeCache.txt Makefile doc third_party
CMakeFiles _CPack_Packages install_manifest.txt
CPackConfig.cmake cmake_install.cmake madlib-1.9-Linux.rpm
CPackSourceConfig.cmake deploy src
[root@localhost build]#

これでRPMパッケージの完成です。

■インストール


作成したRPMパッケージをrpmコマンドでインストールします。


[root@localhost build]# rpm -ivh madlib-1.9-Linux.rpm
Preparing... ########################################### [100%]
1:madlib ########################################### [100%]
[root@localhost build]#

■データベースへのデプロイと回帰テスト


それでは、MADlibをPostgreSQLのデータベースで使えるようにセットアップします。

まずデータベースを作成します。ここではtestdbとしています。

[snaga@localhost ~]$ createdb -U postgres -h 127.0.0.1 testdb

データベースを作成したら、MADlibをデータベースにデプロイします。デプロイするにはmadpackコマンドにinstallオプションを渡して実行します。

以下はtestdbで使えるようにMADlibをインストールしているところです。接続するデータベースにはホスト127.0.0.1、ポート5432、データベース名testdb、ユーザ名postgresを指定しています。


[snaga@localhost ~]$ /usr/local/madlib/bin/madpack -s madlib -p postgres -c postgres@127.0.0.1:5432/testdb install
madpack.py : INFO : Detected PostgreSQL version 9.5.
madpack.py : INFO : *** Installing MADlib ***
madpack.py : INFO : MADlib tools version = 1.9 (/usr/local/madlib/Versions/1.9/bin/../madpack/madpack.py)
madpack.py : INFO : MADlib database version = None (host=127.0.0.1:5432, db=testdb, schema=madlib)
madpack.py : INFO : Testing PL/Python environment...
madpack.py : INFO : > Creating language PL/Python...
madpack.py : INFO : > PL/Python environment OK (version: 2.6.6)
madpack.py : INFO : Installing MADlib into MADLIB schema...
madpack.py : INFO : > Creating MADLIB schema
madpack.py : INFO : > Creating MADLIB.MigrationHistory table
madpack.py : INFO : > Writing version info in MigrationHistory table
madpack.py : INFO : > Creating objects for modules:
madpack.py : INFO : > - array_ops
madpack.py : INFO : > - bayes
madpack.py : INFO : > - crf
(...)
madpack.py : INFO : > - sample
madpack.py : INFO : > - summary
madpack.py : INFO : > - kmeans
madpack.py : INFO : > - pca
madpack.py : INFO : > - validation
madpack.py : INFO : MADlib 1.9 installed successfully in MADLIB schema.
[snaga@localhost ~]$

「MADlib 1.9 installed successfully in MADLIB schema」と表示されたらインストール成功ですが、インストールされたものが正しく動作するかどうか、madpackコマンドで回帰テストを実行します(install-checkオプション)。


[snaga@localhost ~]$ /usr/local/madlib/bin/madpack -s madlib -p postgres -c postgres@127.0.0.1:5432/testdb install-check
madpack.py : INFO : Detected PostgreSQL version 9.5.
TEST CASE RESULT|Module: array_ops|array_ops.sql_in|PASS|Time: 98 milliseconds
TEST CASE RESULT|Module: bayes|gaussian_naive_bayes.sql_in|PASS|Time: 137 milliseconds
TEST CASE RESULT|Module: bayes|bayes.sql_in|PASS|Time: 299 milliseconds
(...)
TEST CASE RESULT|Module: pca|pca_project.sql_in|PASS|Time: 533 milliseconds
TEST CASE RESULT|Module: pca|pca.sql_in|PASS|Time: 2098 milliseconds
TEST CASE RESULT|Module: validation|cross_validation.sql_in|PASS|Time: 1672 milliseconds
[snaga@localhost ~]$

すべてのテストに「PASS」と出れば問題ありません。

なお、PostgreSQL 9.5でinstall-checkを実行している際、以下のようなエラーが出る可能性がありますが、ここでは特に気にしなくて構いません(手元の9.5の環境ではこのエラーが出て、9.4では再現しませんでした)。


TEST CASE RESULT|Module: validation|cross_validation.sql_in|PASS|Time: 1741 milliseconds
madpack.py : ERROR : SQL command failed:
SQL: DROP OWNED BY madlib_19_installcheck CASCADE;
ERROR: could not open relation with OID 551341

最後に管理者でGRANTを発行して、他のユーザもmadlibスキーマのオブジェクトを使えるように設定します。(MADlib関連のオブジェクトはすべてmadlibスキーマにインストールされます)


testdb=# GRANT ALL ON SCHEMA madlib TO public;
GRANT
testdb=#

■ロジスティック回帰のサンプルによる動作確認


それでは、MADlibの Quick Start Guide for Users からロジスティック回帰のサンプルを動かしてみます。
まず、学習用データのテーブルを作成します。


testdb=> CREATE TABLE patients( id INTEGER NOT NULL,
testdb(> second_attack INTEGER,
testdb(> treatment INTEGER,
testdb(> trait_anxiety INTEGER);
CREATE TABLE
testdb=> INSERT INTO patients VALUES
testdb-> (1, 1, 1, 70),
testdb-> (3, 1, 1, 50),
testdb-> (5, 1, 0, 40),
testdb-> (7, 1, 0, 75),
testdb-> (9, 1, 0, 70),
testdb-> (11, 0, 1, 65),
testdb-> (13, 0, 1, 45),
testdb-> (15, 0, 1, 40),
testdb-> (17, 0, 0, 55),
testdb-> (19, 0, 0, 50),
testdb-> (2, 1, 1, 80),
testdb-> (4, 1, 0, 60),
testdb-> (6, 1, 0, 65),
testdb-> (8, 1, 0, 80),
testdb-> (10, 1, 0, 60),
testdb-> (12, 0, 1, 50),
testdb-> (14, 0, 1, 35),
testdb-> (16, 0, 1, 50),
testdb-> (18, 0, 0, 45),
testdb-> (20, 0, 0, 60);
INSERT 0 20
testdb=> \d patients
Table "public.patients"
Column | Type | Modifiers
---------------+---------+-----------
id | integer | not null
second_attack | integer |
treatment | integer |
trait_anxiety | integer |

testdb=> select * from patients limit 5;
id | second_attack | treatment | trait_anxiety
----+---------------+-----------+---------------
1 | 1 | 1 | 70
3 | 1 | 1 | 50
5 | 1 | 0 | 40
7 | 1 | 0 | 75
9 | 1 | 0 | 70
(5 rows)

testdb=>

この例は患者の例で、treatmentとtrait_anxietyが説明変数で、treatmentは anger control の治療を受けたどうか(カテゴリカル変数)、trait_anxietyは不安症の度合い(数値が高いほど度合が高い連続型変数)です。second_attackは被説明変数で「1年以内に二度目の発作が起こったかどうか」です。

詳細は以下を参照してください。
それでは、このデータを使って学習させてみます。


testdb=> SELECT madlib.logregr_train(
testdb(>'patients', -- source table
testdb(>'patients_logregr', -- output table
testdb(>'second_attack', -- labels
testdb(>'ARRAY[1, treatment, trait_anxiety]', -- features
testdb(> NULL, -- grouping columns
testdb(> 20, -- max number of iteration
testdb(>'irls' -- optimizer
testdb(> );
logregr_train
---------------

(1 row)

testdb=>

上記の例では、学習用データのテーブルを patients 、学習した結果の出力を patients_logregr テーブル、被説明変数を second_attack 、説明変数を切片(1)と treatment, trait_anxiety に指定して学習させています。

その他の引数の詳細は以下を参照してください。
学習させた結果の各係数などは以下のように出力用に指定したテーブルから取得することができます。


testdb=> \x on
Expanded display is on.
testdb=> SELECT * from patients_logregr;
-[ RECORD 1 ]------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
coef | {-6.36346994178187,-1.02410605239327,0.119044916668606}
log_likelihood | -9.41018298388876
std_err | {3.21389766375091,1.17107844860318,0.0549790458269303}
z_stats | {-1.9799852414576,-0.874498248699553,2.1652779686892}
p_values | {0.0477051870698109,0.381846973530448,0.0303664045046153}
odds_ratios | {0.00172337630923231,0.359117354054955,1.12642051220895}
condition_no | 326.081922791559
num_rows_processed | 20
num_missing_rows_skipped | 0
num_iterations | 5
variance_covariance | {{10.3291381930635,-0.474304665195729,-0.171995901260048},{-0.474304665195729,1.37142473278283,-0.00119520703381591},{-0.171995901260048,-0.00119520703381591,0.00302269548003971}}

testdb=>

また、以下のように各説明変数の係数やp値だけを表形式で表示することもできます。


testdb=> \x off
Expanded display is off.
testdb=> SELECT unnest(array['intercept', 'treatment', 'trait_anxiety']) as attribute,
testdb-> unnest(coef) as coefficient,
testdb-> unnest(std_err) as standard_error,
testdb-> unnest(z_stats) as z_stat,
testdb-> unnest(p_values) as pvalue,
testdb-> unnest(odds_ratios) as odds_ratio
testdb-> FROM patients_logregr;
attribute | coefficient | standard_error | z_stat | pvalue | odds_ratio
---------------+-------------------+--------------------+--------------------+--------------------+---------------------
intercept | -6.36346994178187 | 3.21389766375091 | -1.9799852414576 | 0.0477051870698109 | 0.00172337630923231
treatment | -1.02410605239327 | 1.17107844860318 | -0.874498248699553 | 0.381846973530448 | 0.359117354054955
trait_anxiety | 0.119044916668606 | 0.0549790458269303 | 2.1652779686892 | 0.0303664045046153 | 1.12642051220895
(3 rows)

testdb=>

それではこの学習したモデルを使って予測をしてみましょう。

ここでは再度学習用データを使ってテストすることでトレーニングエラーを確認してみます。

実行するクエリは以下です。second_attack_predictは二回目の心臓発作が起こるかどうかの予測、second_attack_boolは実際に起こったかどうかのフラグ、correctは予測が当たったかどうかのフラグです。


WITH TEMP AS (
SELECT p.id,
madlib.logregr_predict(coef, ARRAY[1, treatment, trait_anxiety]) as second_attack_predict,
CASE p.second_attack
WHEN 1 THEN true
ELSE false
END AS second_attack_bool
FROM patients p, patients_logregr f
)
SELECT id,
second_attack_predict,
second_attack_bool,
second_attack_predict = second_attack_bool AS correct
FROM temp
ORDER BY id;

実行すると以下のような結果が得られ、20件中15件は予測が当たっていることが分かります。


testdb=> WITH TEMP AS (
testdb(> SELECT p.id,
testdb(> madlib.logregr_predict(coef, ARRAY[1, treatment, trait_anxiety]) as second_attack_predict,
testdb(> CASE p.second_attack
testdb(> WHEN 1 THEN true
testdb(> ELSE false
testdb(> END AS second_attack_bool
testdb(> FROM patients p, patients_logregr f
testdb(> )
testdb-> SELECT id,
testdb-> second_attack_predict,
testdb-> second_attack_bool,
testdb-> second_attack_predict = second_attack_bool AS correct
testdb-> FROM temp
testdb-> ORDER BY id;
id | second_attack_predict | second_attack_bool | correct
----+-----------------------+--------------------+---------
1 | t | t | t
2 | t | t | t
3 | f | t | f
4 | t | t | t
5 | f | t | f
6 | t | t | t
7 | t | t | t
8 | t | t | t
9 | t | t | t
10 | t | t | t
11 | t | f | f
12 | f | f | t
13 | f | f | t
14 | f | f | t
15 | f | f | t
16 | f | f | t
17 | t | f | f
18 | f | f | t
19 | f | f | t
20 | t | f | f
(20 rows)

testdb=>

■まとめ


以上、駆け足ではありましたが、MADlibの紹介から導入方法、簡単な使い方までを紹介してきました。

大量データを処理する場合、データベースからデータを取り出さずに(=クライアントに転送せずに)処理できる「In-Database処理」には非常に大きなアドバンテージがあります。今後はこのようなテクノロジーがさらに普及するのではないかと考えています。

この領域に興味がある方は、ぜひ一度試してみてください。PostgreSQLの可能性がより広がると思います。

では。

5月28日(土)にPostgreSQLアンカンファレンスを開催します

$
0
0
5月28日(土)にPostgreSQLアンカンファレンスを開催いたします。
PostgreSQL関連何でもアリのごった煮感溢れるカジュアルな感じのイベントですので、PostgreSQLに興味のある方はぜひご参加いただければと思います。

技術的な知見だけではなく、コミュニティの人々と知り合いになるチャンスでもあります。お申込みは上記ATNDからどうぞ。

では。

形態素解析を使ってPostgreSQLに保存された文章データから話題を抽出する

$
0
0
PythonやPL/Python、PostgreSQLを使ってデータ分析をIn-Database処理させるのがマイブームです。

今回は、データベース内に保存された文章のテキストデータから単語の出現頻度を使って話題になっているトピックを抽出する、という処理を行ってみます。
  • テキストを形態素解析する
  • 形態素解析した結果をJSONBで取得する
  • JSONBデータを対象に集計処理を行う
  • 上記すべてをサーバサイドで実行する
といったことをPostgreSQLを使って処理してみます。

■データの準備


今回も東京カレンダーの「東京女子図鑑」からの文章をサンプルとして使ってみます。
今回は、docidという主キーとテキストを値としてdoctextカラムに持つテーブルを作成し、そこにテキストを保存しておくようにします。今回のテキストは約4,000文字あります。

snaga=# \d docs
Table "public.docs"
Column | Type | Modifiers
---------+---------+-----------
docid | integer | not null
doctext | text |
Indexes:
"docs_pkey" PRIMARY KEY, btree (docid)

snaga=# SELECT docid, length(doctext) FROM docs;
docid | length
-------+--------
1 | 4423
(1 row)

snaga=# SELECT docid, substring(doctext,0,60) AS doctext FROM docs;
docid | doctext
-------+---------------------------------------------------------------------------------------------------------------------
1 | 20代後半頃から、同期が1人また1人と、会社を辞めていきました。辞める理由はいろいろありますが、病んでしまった子もいれ
(1 row)

snaga=#

■形態素解析を行うユーザ定義関数を作成する


まず、テキストを入力として受け取り、形態素解析した結果をJSONB型として返却するユーザ定義関数を作成します。

形態素解析には MeCab を、ユーザ定義関数には PL/Python を、PL/Python と MeCab のバインディングには Mecab-Python を使います。環境は以前のエントリと同様ですので、そちらを参考に準備してください。PostgreSQLのバージョンは9.5です。
MeCabのPythonバインディングについては、以下を参照してください。 ユーザ定義関数は以下のようになります。


CREATE OR REPLACE FUNCTION mecab_tokenize_jsonb(string text)
RETURNS SETOF jsonb
AS $$
import MeCab
import json
import plpy
import sys

def mecab_text2array(string):
a = []
m = MeCab.Tagger("-Ochasen")

"""
Mecabに渡すためにはunicodeではなくutf-8である必要がある。
Mecabから戻ってきたらunicodeに戻す。

また、Mecabはエンコード済みのutf-8文字列へのポインタを返すので、
on-the-flyでutf-8に変換するのではなく、変数として保持しておく
必要がある。(でないとメモリ領域がGCで回収されてデータが壊れる)

参照:
http://shogo82148.github.io/blog/2012/12/15/mecab-python/
"""
enc_string = string
node = m.parseToNode(enc_string)
while node:
n = {}
n['surface'] = node.surface.decode('utf-8')
n['feature'] = node.feature.decode('utf-8').split(",")
n['cost'] = node.cost
a.append(n)
node = node.next
return a

for w in mecab_text2array(string):
yield(json.dumps(w))

$$ LANGUAGE plpythonu;
ポイントは
  • MeCabの持っているデータ型をそのままJSONエレメントとして返却。
    • 「surface」はトークンにした文字列、「feature」はその文字列の属性情報。
  • jsonb型を行で返却する関数として定義。
あたりになります。

■文章を品詞に分解する


それでは doctext カラムに保存されているテキストを形態素解析してみます。


SELECT
docid,
mecab_tokenize_jsonb(doctext)
FROM
docs;
mecab_tokenize_jsonb()の引数に doctext カラムを渡してSELECT文を実行すると、以下のようにトークンごとの情報が返却され、品詞の情報をJSONBで取得できるようになります。


docid | mecab_tokenize_jsonb
-------+-----------------------------------------------------------------------------------------------------------------------------------------------------------
1 | {"cost": 0, "feature": ["BOS/EOS", "*", "*", "*", "*", "*", "*", "*", "*"], "surface": ""}
1 | {"cost": 27956, "feature": ["名詞", "数", "*", "*", "*", "*", "*"], "surface": "20"}
1 | {"cost": 28475, "feature": ["名詞", "接尾", "助数詞", "*", "*", "*", "代", "ダイ", "ダイ"], "surface": "代"}
1 | {"cost": 33141, "feature": ["名詞", "副詞可能", "*", "*", "*", "*", "後半", "コウハン", "コーハン"], "surface": "後半"}
1 | {"cost": 38173, "feature": ["名詞", "接尾", "副詞可能", "*", "*", "*", "頃", "ゴロ", "ゴロ"], "surface": "頃"}
1 | {"cost": 37905, "feature": ["助詞", "格助詞", "一般", "*", "*", "*", "から", "カラ", "カラ"], "surface": "から"}
(...)
1 | {"cost": 4436512, "feature": ["名詞", "代名詞", "一般", "*", "*", "*", "彼", "カレ", "カレ"], "surface": "彼"}
1 | {"cost": 4441497, "feature": ["助詞", "並立助詞", "*", "*", "*", "*", "や", "ヤ", "ヤ"], "surface": "や"}
1 | {"cost": 4436788, "feature": ["記号", "読点", "*", "*", "*", "*", "、", "、", "、"], "surface": "、"}
1 | {"cost": 4440439, "feature": ["名詞", "一般", "*", "*", "*", "*", "上司", "ジョウシ", "ジョーシ"], "surface": "上司"}
1 | {"cost": 4440813, "feature": ["助詞", "連体化", "*", "*", "*", "*", "の", "ノ", "ノ"], "surface": "の"}
1 | {"cost": 4443900, "feature": ["名詞", "一般", "*", "*", "*", "*", "おかげ", "オカゲ", "オカゲ"], "surface": "おかげ"}
1 | {"cost": 4444601, "feature": ["助詞", "格助詞", "一般", "*", "*", "*", "で", "デ", "デ"], "surface": "で"}
(...)
1 | {"cost": 4543115, "feature": ["動詞", "自立", "*", "*", "五段・ワ行促音便", "連用形", "思う", "オモイ", "オモイ"], "surface": "思い"}
1 | {"cost": 4540257, "feature": ["助動詞", "*", "*", "*", "特殊・マス", "基本形", "ます", "マス", "マス"], "surface": "ます"}
1 | {"cost": 4537422, "feature": ["記号", "句点", "*", "*", "*", "*", "。", "。", "。"], "surface": "。"}
1 | {"cost": 4535886, "feature": ["BOS/EOS", "*", "*", "*", "*", "*", "*", "*", "*"], "surface": ""}
(2718 rows)

■単語ごとの出現頻度を集計する


それでは、ここまでの結果を使って単語ごとの出現頻度を集計してみます。

JSONBのデータからキーを指定してエレメントを取り出すには「->」を使います。


json_obj->'key_name'

この場合はエレメントがJSONB型で返却されます(JSONBの内部に再帰的にアクセスしたい場合)。text型で取り出したい場合には「->>」を使います。


json_obj->>'key_name'

また、配列の要素へアクセスしたい場合には、「->0」のように添字で指定することで取得することができます。


json_obj->'array_name'->>0

詳細は以下のマニュアルを参照してください。
今回のデータは、JSONBのデータの中に surface をキーとして値にトークンの文字列が、feature をキーとして値に属性情報(品詞の種別など)が配列で入っています。

よって、JSONBからトークンと品詞種別を取り出し、この2つをGROUP BYのキーとしてCOUNTで出現頻度の集計をしてみます。


WITH doc AS (
SELECT
docid,
mecab_tokenize_jsonb(doctext) t
FROM
docs
)
SELECT
docid,
t->>'surface' as surface,
t->'feature'->>0 as feature,
count(*)
FROM
doc
GROUP BY
docid,
t->>'surface',
t->'feature'->>0
ORDER BY
4 DESC;

このクエリを実行して出現頻度順にトークンを並べると、以下のような結果が得られます。


docid | surface | feature | count
-------+------------------------------+---------+-------
1 | 、 | 記号 | 175
1 | の | 助詞 | 133
1 | て | 助詞 | 104
1 | 。 | 記号 | 103
1 | に | 助詞 | 81
1 | た | 助動詞 | 73
1 | が | 助詞 | 65
(...)
1 | の | 名詞 | 14
1 | 銀座 | 名詞 | 14
1 | 私 | 名詞 | 13
1 | なっ | 動詞 | 13
1 | って | 助詞 | 13
(...)
1 | 感覚 | 名詞 | 1
1 | 食べ | 動詞 | 1
1 | あぁ | 感動詞 | 1
1 | 出す | 動詞 | 1
1 | 詰まっ | 動詞 | 1
(787 rows)


■出現している品詞の種類を調べる


先に集計したランキングを見ると、当たり前ですが記号や助詞の出現頻度が高く、このままではあまり面白い結果になりません。そのため、簡単な方法として品詞の種別でフィルタリングすることを考えてみます。

まずは、出現している品詞の種別の一覧を確認してみましょう。


WITH doc AS (
SELECT
docid,
mecab_tokenize_jsonb(doctext) t
FROM
docs
)
SELECT
DISTINCT t->'feature'->>0 as feature
FROM
doc

品詞種別の一覧を見てみると、12種類の品詞が出てきているようです。


feature
---------
助動詞
名詞
接頭詞
助詞
形容詞
副詞
連体詞
動詞
感動詞
BOS/EOS
接続詞
記号
(12 rows)

■品詞の種別を絞ってランキングを作成する


それでは、最後に「名詞」に絞って表示してみましょう。

以下のクエリでは名詞に絞った上で、ひらがな1文字または2文字の結果も省いています。


WITH doc AS (
SELECT
docid,
mecab_tokenize_jsonb(doctext) t
FROM
docs
)
SELECT
docid,
t->>'surface' as surface,
t->'feature'->>0 as feature,
count(*)
FROM
doc
WHERE
t->'feature'->>0 IN ('名詞')
AND
regexp_replace(t->>'surface', '^[あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよわをんがぎぐげござじずぜぞだぢづでどばびぶべぼぱぴぷぺぽっゃゅょ]{1,2}$', '') <>''
GROUP BY
docid,
t->>'surface',
t->'feature'->>0
ORDER BY
4 DESC;

このクエリを実行すると、以下の結果が得られます。


docid | surface | feature | count
-------+------------------------------+---------+-------
1 | 方 | 名詞 | 15
1 | 銀座 | 名詞 | 14
1 | 私 | 名詞 | 13
1 | 人 | 名詞 | 11
1 | 上司 | 名詞 | 10
1 | 彼 | 名詞 | 9
1 | 歳 | 名詞 | 8
1 | 女性 | 名詞 | 8
1 | 恵比寿 | 名詞 | 7
1 | 店 | 名詞 | 7
1 | 笑 | 名詞 | 7
1 | 同期 | 名詞 | 6
1 | 大人 | 名詞 | 6
1 | 女 | 名詞 | 6
(...)
1 | 最中 | 名詞 | 3
1 | 年上 | 名詞 | 3
1 | 桟敷 | 名詞 | 3
1 | 外資 | 名詞 | 3
1 | 1 | 名詞 | 3
1 | 理由 | 名詞 | 3
1 | 箱 | 名詞 | 3
1 | 前 | 名詞 | 3
1 | 秋田 | 名詞 | 3
(...)
1 | 子供 | 名詞 | 1
1 | 赤 | 名詞 | 1
1 | 幕間 | 名詞 | 1
1 | 確か | 名詞 | 1
1 | ビーフ | 名詞 | 1
1 | 700 | 名詞 | 1
(433 rows)

このラインキングを見ると、どのようなトピックがどの程度話題になっているかを、おおまかに見てとることができます。

例えば、
  • 恵比寿より銀座の方が言及が多い(恵比寿から銀座に引っ越した話なので当然と言えば当然)
  • 彼より上司の方が言及が多い
  • 誰よりも自分自身(私)の話が多い
などといったことがこの集計から分かるようになります。

■まとめ


というわけで、今回はPostgreSQL内部に保存したテキストに自然言語処理を適用して、言及されている話題を抽出してみる方法を簡単にご紹介しました。

データベース内に保存されている数値データやコードなどの分析に加えて、自然言語処理によって通常の文章から情報を抽出できるようになると、データ分析がさらに楽しくなると思います。

より技術的に進んだ手法も応用できる領域もいろいろあると思いますので、興味のある方はぜひチャレンジしてみていただければと思います。

では、また。

「10 Reasons to Start Your Analytics Project with PostgreSQL(アナリティクスをPostgreSQLで始めるべき10の理由)」のスライド公開しました

$
0
0
先週末、香港で開催された HKOSCon 2016でのセッション「10 Reasons to Start Your Analytics Project with PostgreSQL(アナリティクスをPostgreSQLで始めるべき10の理由)」のスライドを公開しました。


目次は以下のような感じです。

  • Collecting Data / Database Federation (データ収集、データベース連携)
  • Building Data Warehouse and Data Mart (データウェアハウス・データマート構築)
  • Writing Queries / SQL Features (クエリ作成、SQLの機能)
  • Performance (パフォーマンス)
  • In-Database Analytics (In-Database分析)

基本的な機能を紹介するセッションでしたのであまり技術的な詳細には踏み込んでいませんが、PostgreSQLの機能を「アナリティクス」という側面からまとめられた例はあまりないと思いますので、PostgreSQLをデータ分析に活用したいと考えている方にはぜひご覧いただければと思います。

では。

パラレル処理可能な集約関数をPL/Pythonで作成する

$
0
0
先日、次期メジャーバージョンの9.6のbeta2がリリースされました。
9.6では、集約関数やJOINなどもパラレルクエリに対応しており、パラレル処理されるようになっていますので、みなさんはもちろんパラレルクエリをゴリゴリと検証されている最中かと思います。

また、言うまでもないことですが、パラレルクエリはデータ分析のためにあり、データ分析といえばPythonなわけです。

本エントリでは、そんなパラレルクエリとデータ分析大好きな方たちに向けて、パラレル処理が可能な集約関数をPL/Pythonで作成する方法を紹介します。

前提としているバージョンは、PostgreSQL 9.6 beta2 です。

■PL/Pythonでの集約関数の作成


パラレルクエリに関係のない通常の集約関数をPL/Pythonで作成する方法については以前書きましたので、以下のエントリを参照してください。
本エントリは、この内容を理解していることが前提となります。

■パラレル対応の集約関数とは?


では、パラレルクエリでパラレル実行可能な集約関数の作り方を見てみましょう。

まずは、パラレルクエリに対応した集約関数がどのように作成されているのかを見てみます。

ここではサンプルとしてavg関数を見ていきます(avg関数はPL/PythonではなくCで書かれた組み込みの集約関数です)。

関連するマニュアルとしては、以下の項目を参照してください。

■集約avgの定義


以下の例はint4を引数として取る場合のavg集約関数の定義です。


testdb=# \x
Expanded display is on.
testdb=# select * from pg_aggregate where aggtransfn::text = 'int4_avg_accum';
-[ RECORD 1 ]----+-------------------
aggfnoid | pg_catalog.avg
aggkind | n
aggnumdirectargs | 0
aggtransfn | int4_avg_accum
aggfinalfn | int8_avg
aggcombinefn | int4_avg_combine
aggserialfn | -
aggdeserialfn | -
aggmtransfn | int4_avg_accum
aggminvtransfn | int4_avg_accum_inv
aggmfinalfn | int8_avg
aggfinalextra | f
aggmfinalextra | f
aggsortop | 0
aggtranstype | 1016
aggserialtype | 0
aggtransspace | 0
aggmtranstype | 1016
aggmtransspace | 0
agginitval | {0,0}
aggminitval | {0,0}

testdb=#

これを見て分かるのは、どうやら int4_avg_accum 、 int8_avg 、 int4_avg_combine 、 int4_avg_accum_inv という関数が作成されているようだ、ということです。

それでは、これらの関数が何をしているのかを見てみることにします。

■関数


上記で出てきた関数名でバックエンドのソースコードを検索すると、 src/backend/utils/adt/numeric.c に関数定義を見つけることができます。

それぞれの関数について処理内容を確認すると、以下のようになります。

Datum int4_avg_accum(PG_FUNCTION_ARGS)
整数を加算していく関数。各ワーカーによって実行される。

ArrayType *transarray と int32 newval を引数として受け取る。transarray から Int8TransTypeData *transdata を取り出して、 count と sum を計算(加算)する。

Datum int4_avg_combine(PG_FUNCTION_ARGS)
複数のワーカーの計算結果をまとめる関数。リーダーによって実行される。

ArrayType *transarray1 と ArrayType *transarray2 を引数として受け取る。transarray2 の中身を transarray1 に加算して、transarray1 を返却する。

Datum int4_avg_accum_inv(PG_FUNCTION_ARGS)
平均の計算のための積算処理を逆向きに処理する関数。

ウィンドウ関数で使用される場合に、パフォーマンス改善のために使用される。パラレルクエリとは関係はない。

Datum int8_avg(PG_FUNCTION_ARGS)
最終的な結果を計算して返却する関数。リーダーによって実行される。

ArrayType *transarray を引数として受け取って、その中から Int8TransTypeData *transdata を取り出す。transdata から総計 sum とレコード数 count を取り出して、numeric_div 関数を使って平均値を計算、返却する。

■データ型



typedef struct Int8TransTypeData
{
int64 count;
int64 sum;
} Int8TransTypeData;
平均の計算のために必要な中間結果(総和とレコード数)を保持する構造体。

avg関数では、平均値を計算するため、中間結果(state)としてレコード数と値の総和を保持している。

■パラレル処理可能なmin関数をPL/Pythonで実装する


ここまでで、インターフェースと処理内容はなんとなく見えてきましたので、実際にパラレル処理可能なmin関数をPL/Pythonで作ってみましょう。

まず、pyminという集約関数を以下のように定義します。

CREATE AGGREGATE pymin (float8)
(
sfunc = float8_pymin,
stype = float8,
combinefunc = float8_pymin_combine,
parallel = safe
);
非パラレルの集約関数の定義と異なっているのは、combinefunc の定義と parallel = safe の定義です。

次に、state更新関数を以下のように作成します。

state更新関数は、stateを第一引数に、新しい値を第二引数として受け取り、更新されたstateを返却する関数として実装します。今回の場合は、新しい値の方が小さかったらそちらを新しくstateとして返却します。

CREATE FUNCTION float8_pymin(s float8, n float8)
RETURNS float8
AS $$
global s

if n is not None:
if s is None or n < s:
s = n
return s
$$
LANGUAGE plpython2u;
次に、各ワーカーが処理した結果をまとめるためのcombine関数を実装します。

combine関数は、二つのstateを受け取り、それを一つのstateにまとめて返却します。今回の場合は2つのfloat8を受け取り、より小さい方を返却します。

CREATE FUNCTION float8_pymin_combine(s1 float8, s2 float8)
RETURNS float8
AS $$
global s1, s2

if s1 is None:
s1 = s2
elif s1 > s2:
s1 = s2
return s1
$$
LANGUAGE plpython2u;
これらの関数を作成して、実際に動作確認をしてみます。

以下は、generate_series()関数を使って100万行のテーブルを作成した後、そのテーブルに対して今回のpymin関数を実行している様子です。

EXPLAIN ANALYZEの結果を見ると、ワーカープロセスが起動してパラレル処理が行われていることが分かりますし、組み込みのmin関数と比較しても結果が正しく返っていることが分かります。

testdb=# CREATE TABLE t1 AS
testdb-# SELECT generate_series(1,1000000) c1;
SELECT 1000000
testdb=#
testdb=# SHOW force_parallel_mode;
force_parallel_mode
---------------------
off
(1 row)

testdb=# SHOW max_parallel_workers_per_gather;
max_parallel_workers_per_gather
---------------------------------
2
(1 row)

testdb=# SELECT relname,reloptions FROM pg_class WHERE relname = 't1';
relname | reloptions
---------+------------
t1 |
(1 row)

testdb=# EXPLAIN (VERBOSE,ANALYZE) SELECT pymin(c1) FROM t1;
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------------
Finalize Aggregate (cost=128841.73..128841.74 rows=1 width=8) (actual time=3869.709..3869.710 rows=1 loops=1)
Output: pymin((c1)::double precision)
-> Gather (cost=128841.02..128841.23 rows=2 width=8) (actual time=3869.458..3869.479 rows=3 loops=1)
Output: (PARTIAL pymin((c1)::double precision))
Workers Planned: 2
Workers Launched: 2
-> Partial Aggregate (cost=127841.02..127841.03 rows=1 width=8) (actual time=3850.664..3850.665 rows=1 loops=3)
Output: PARTIAL pymin((c1)::double precision)
Worker 0: actual time=3847.931..3847.933 rows=1 loops=1
Worker 1: actual time=3835.468..3835.469 rows=1 loops=1
-> Parallel Seq Scan on public.t1 (cost=0.00..9126.56 rows=470156 width=4) (actual time=0.351..1123.692 rows=333333 loops=3)
Output: c1
Worker 0: actual time=0.195..1193.261 rows=359114 loops=1
Worker 1: actual time=0.754..1075.905 rows=314994 loops=1
Planning time: 0.340 ms
Execution time: 3873.790 ms
(16 rows)

testdb=# SELECT pymin(c1) FROM t1;
pymin
-------
1
(1 row)

testdb=# SELECT min(c1) FROM t1;
min
-----
1
(1 row)

testdb=#

■パラレル処理可能なavg関数をPL/Pythonで実装する


次にavg関数相当を実装してみます。

avg関数は、min関数と違い、終了するときにfinal関数で平均値を計算するための計算処理が必要となります。そのため、stateを保持する方法もminと少し異なります。

まず、以下のようにpyavg関数を定義します。pyminとの違いは、stype(stateのためのデータ型)が float8[] になっていること、および finalfunc が定義されていることです。

CREATE AGGREGATE pyavg (float8)
(
sfunc = float8_pyavg,
stype = float8[],
combinefunc = float8_pyavg_combine,
finalfunc = float8_pyavg_final,
parallel = safe
);
次に、新しい値を受け取ってstateを更新する float8_pyavg を以下のように作成します。

ここで気を付けて欲しいのは、stateの要素0番目は総和であり、要素1番目はレコード数である、ということです。final関数では、これらを使って平均値を計算することになります。

CREATE FUNCTION float8_pyavg(s float8[], n float8)
RETURNS float8[]
AS $$
global s

if n is not None:
if s is None:
s = []
s.append(0)
s.append(0)
# s[0]:sum, s[1]:count
s[0] = s[0] + n
s[1] = s[1] + 1
return s
$$
LANGUAGE plpython2u;
次はワーカーの結果をまとめるcombine関数です。avgの場合、ワーカーの結果をまとめるためには、単に加算するだけで問題ありません。

CREATE FUNCTION float8_pyavg_combine(s1 float8[], s2 float8[])
RETURNS float8[]
AS $$
global s1, s2

if s1 is None:
s1 = []
s1.append(s2[0])
s1.append(s2[1])
else:
s1[0] += s2[0]
s1[1] += s2[1]
return s1
$$
LANGUAGE plpython2u;
そして最後にfinal関数で、平均値を計算して返却します。

CREATE FUNCTION float8_pyavg_final(s float8[])
RETURNS float8
AS $$
global s

# s[0]:sum, s[1]:count
return s[0]/s[1]
$$
LANGUAGE plpython2u;
そして、この集約関数を実行すると以下のような結果が得られます。

今回作ったpyavg関数はパラレル実行されており、結果についても組み込みのavg関数と同じ結果が得られていることが分かります。

testdb=# EXPLAIN (verbose,analyze) SELECT pyavg(c1) FROM t1;
QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------------------
Finalize Aggregate (cost=114800.96..114800.97 rows=1 width=8) (actual time=4469.722..4469.723 rows=1 loops=1)
Output: pyavg((c1)::double precision)
-> Gather (cost=114800.00..114800.21 rows=2 width=32) (actual time=4468.969..4469.632 rows=3 loops=1)
Output: (PARTIAL pyavg((c1)::double precision))
Workers Planned: 2
Workers Launched: 2
-> Partial Aggregate (cost=113800.00..113800.01 rows=1 width=32) (actual time=4453.186..4453.195 rows=1 loops=3)
Output: PARTIAL pyavg((c1)::double precision)
Worker 0: actual time=4453.345..4453.369 rows=1 loops=1
Worker 1: actual time=4437.589..4437.591 rows=1 loops=1
-> Parallel Seq Scan on public.t1 (cost=0.00..8591.67 rows=416667 width=4) (actual time=0.032..923.497 rows=333333 loops=3)
Output: c1
Worker 0: actual time=0.041..876.075 rows=350250 loops=1
Worker 1: actual time=0.041..986.962 rows=340808 loops=1
Planning time: 0.218 ms
Execution time: 4471.432 ms
(16 rows)

testdb=# SELECT pyavg(c1) FROM t1;
pyavg
----------
500000.5
(1 row)

testdb=# SELECT avg(c1) FROM t1;
avg
---------------------
500000.500000000000
(1 row)

testdb=#

■まとめ


今回はパラレルクエリで並列実行可能な集約関数をPL/Pythonで作成する方法をご紹介しました。

データ分析の処理をデータベースで実装しようとすると、独自の集約関数で実装したくなることが多々発生します。

また、9.6ではせっかくパラレルクエリが使えるようになったわけですから、自分の実装する処理も極力パラレル処理可能にすることで、CPUを効果的に使ってデータ分析に必要となる処理時間を短縮したくなるというのは、ある意味で当たり前と言えます。

データ分析と親和性の高いPythonで独自の集約関数をパラレル処理できることが確認できましたので、ぜひいろいろな分析処理を並列化して、新しいPostgreSQLを使い倒してみていただければと思います。

では、また。

TF-IDFでデータベース内の類似テキストを検索する Part 1 (基本機能編)

$
0
0
最近、「TF-IDF」と呼ばれる手法を使ってPostgreSQL内に保存されたテキストの類似度を計算して、似ているテキストを検索する方法を試していました。

一通り目途が立った気がしてきましたので、今回から3回に渡ってその方法をご紹介します。
  • Part 1: 基本機能編
  • Part 2: 実践編
  • Part 3: 性能改善編
Part 1 は基本機能編ということで、TF-IDF に基づく類似性検索を PostgreSQL 内部で実装する方法をご紹介します。

Part 2 は実践編として、大量のドキュメントをPostgreSQLに格納して、TF-IDF を計算して検索するまでを解説します。

Part 3 は性能改善編ということで、検索性能を改善する方法を検討します。

■「TF-IDF」とは


TF-IDFは、自然言語処理で使われるアルゴリズムで、文書やコーパス(文書群)の中における単語の出現頻度を用いて、文書の単語に重み付けをする手法です。
TF-IDFでは、「Term Frequency」と呼ばれる「単一の文書中における単語の出現頻度」と、「Inverse Document Frequency」と呼ばれる「コーパス全体における単語の出現頻度(の逆数)」を組み合わせて、単語に重み付けをします。
  • TF: 特定の単語の出現回数 / 文書全体の単語数
  • IDF: log(全文書数 / 単語を含んでいる文書数) + 1
として定義され、これらを組み合わせて「TF-IDF」が「TF * IDF」として計算されます。

このTF-IDFを用いて、文書中に出現する個々の単語に対して重み付けがされることになります。

詳細は、Wikipediaなど関連資料を参照ください。

TF-IDFを用いることによって、単語、文書、コーパスに含まれる情報を定量化することができ、文書の類似度の計算などができるようになります。

■処理の概要


今回の目的は、PostgreSQL内に蓄積された文書群(コーパス)に対して、特定の文書をクエリとして与えて、似ているドキュメントを取得できるようにする、というものです。

以下は、今回の処理と設計の概要です。
  • 日本語のテキストを形態素解析を使って分かち書きをする。(テキストを入力、トークンを出力とする関数として実装)
  • 分かち書きの結果を使って、各単語のTFを計算する(トークンを入力、TFを出力とする関数として実装)
  • 計算したTFの結果を使って、IDFを計算する。(TFを入力、IDFを出力とする集約関数として実装)
  • 計算したTFとIDFを使って、各文書のTF-IDFを計算する。(TFとIDFを入力、TF-IDFを出力とする関数として実装)
  • 2つの文書のTF-IDFを入力として類似度を計算する。(2つのTF-IDFを入力、ユークリッド距離を出力とする関数として実装)

■動作環境


今回の動作確認環境は以下の通りです。
  • CentOS 6
  • Python 2.7
  • PostgreSQL 9.5 (w/ PL/Python)
  • scikit-learn 0.17.1
  • mecab-python 0.996
PL/Pythonは、Python 2.7に対応したものを用意しています。CentOS/RHEL 6系のPostgreSQLは、デフォルトではOSバンドル版のPython 2.6系のPL/Pythonとなりますのでご注意ください。詳細は以下のエントリをご参照ください。

■日本語を形態素解析して分かち書きする


まず、入力された日本語のテキストを分かち書き(形態素解析)してトークンに分解する関数を作成します。形態素解析には Mecab を使います。

Mecab および mecab-python については、以下のエントリを参照してください。
以下のように、分かち書きの関数 mecab_tokenize() を作成し、TEXT で与えた文章を品詞に分解して JSONB (文字列の配列)として返却するように実装します。

snaga=> SELECT mecab_tokenize('今日の天気は晴れです。');
mecab_tokenize
----------------------------------------------------
["今日", "の", "天気", "は", "晴れ", "です", "。"]
(1 row)

snaga=>
mecab_tokenize 関数の定義は以下のようになります。

CREATE OR REPLACE FUNCTION mecab_tokenize(string text)
RETURNS jsonb
AS $$
import MeCab
import json
import plpy
import sys

tokens = []
m = MeCab.Tagger("-Ochasen")

"""
Mecabに渡すためにはunicodeではなくutf-8である必要がある。
Mecabから戻ってきたらunicodeに戻す。

また、Mecabはエンコード済みのutf-8文字列へのポインタを返すので、
on-the-flyでutf-8に変換するのではなく、変数として保持しておく
必要がある。(でないとメモリ領域がGCで回収されてデータが壊れる)

参照:
http://shogo82148.github.io/blog/2012/12/15/mecab-python/
"""
enc_string = string
node = m.parseToNode(enc_string)
while node:
n = {}
n['surface'] = node.surface.decode('utf-8')
n['feature'] = node.feature.decode('utf-8').split(",")
t = n['feature'][6]
if t == '*':
t = n['surface']
if len(t) > 0:
tokens.append(t)
node = node.next
return json.dumps(tokens)
$$
LANGUAGE 'plpython2u';

■TFを計算する


次に、トークンの配列を受け取って、TF(文書内での単語の出現率)を計算する関数を作成します。

以下のように、JSONBの配列を入力として受け取ると、各単語ごとのTFを計算してJSONB型で返却します。

snaga=> SELECT tf('["今日", "の", "天気", "は", "晴れ", "です", "。"]'::jsonb);

tf

-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
{"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "です": 0.14285714285714285, "今日": 0.14285714285714285, "天気": 0.14285714285714285, "晴れ": 0.14285714285714285}
(1 row)

snaga=>
この例の場合は、ドキュメントが7つのトークンから構成され、各品詞がすべて1回しか出現していないため、各単語のTFはすべて 1/7 、つまり 0.14 となります。

TF を計算する関数は以下の通りです。先ほど分かち書きした結果を JSONB で受け取り、渡されたすべてのトークン(つまり文書)の中での出現頻度を計算して、トークンをキーとして、出現頻度をバリューとして持つ、Key-Value構造の JSONB で返却します。

CREATE OR REPLACE FUNCTION tf(tokens jsonb)
RETURNS jsonb
AS $$
import json
import plpy
import re
import sys

tf = {}
_tokens = json.loads(tokens)
_count = len(_tokens)
for t in _tokens:
if t in tf:
tf[t] += 1.0
else:
tf[t] = 1.0
for t in tf:
tf[t] = tf[t]/_count
return json.dumps(tf)
$$
LANGUAGE 'plpython2u';

■IDFを計算する


TF を計算したら、次は IDF を計算します。 IDF はコーパス(文書群)全体におけるある単語の出現する文書数(の逆数の対数)ですので、先に集計した TF を使って計算します。

snaga=> \d t1
Table "public.t1"
Column | Type | Modifiers
--------+-------+-----------
tf | jsonb |

snaga=> SELECT * FROM t1;
tf

-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
{"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.14285714285714285, "です": 0.14285714285714285, "晴れ": 0.14285714285714285, "気分": 0.14285714285714285}
{"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "です": 0.14285714285714285, "今日": 0.14285714285714285, "天気":0.14285714285714285, "晴れ": 0.14285714285714285}
{"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "雨": 0.14285714285714285, "です": 0.14285714285714285, "天気": 0.14285714285714285, "明日": 0.14285714285714285}
(3 rows)

snaga=>
上記の例のように複数のドキュメントの TF があった場合に、これらの TF を集約処理する集約関数 idf() として実装します。この関数では、各単語がいくつの文書に出てくるかを計算し、その逆数の対数を計算して返却します。

snaga=> SELECT idf(tf) FROM t1;
idf

--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
{"。": 1.0, "の": 1.0, "は": 1.0, "僕": 2.09861228866811, "雨": 2.09861228866811, "です": 1.0, "今日": 2.09861228866811, "天気": 1.4054651081081644, "明日": 2.09861228866811, "晴れ": 1.4054651081081644, "気分": 2.09861228866811}
(1 row)

snaga=>
例えば、上記の例の「天気」という単語は全3文書中2つの文書で出てくるので、その IDF は log(3/2) + 1 = 1.405 となります。

PL/Pythonで集約関数を実装する方法については、以下のエントリを参照してください。
今回の idf() 集約関数のコードは以下の通りです。

CREATE OR REPLACE FUNCTION _idf(s jsonb, n jsonb)
RETURNS jsonb
AS $$
import json
import plpy
global s

if s is None:
s = "{\"count\": 0, \"df\": {}}"
agg = json.loads(s)
doc_freq = agg['df']
doc = json.loads(n)
for w in doc:
if w in doc_freq:
doc_freq[w] += 1.0
else:
doc_freq[w] = 1.0
agg['count'] += 1.0
return json.dumps(agg)
$$
LANGUAGE 'plpython2u';

CREATE OR REPLACE FUNCTION _idf_final(s jsonb)
RETURNS jsonb
AS $$
import json
import math
import plpy
global s

a = json.loads(s)
dc = a['count']
df = a['df']
for t in df:
df[t] = math.log(dc/df[t]) + 1.0
return json.dumps(df)
$$
LANGUAGE 'plpython2u';

CREATE AGGREGATE idf(jsonb)
(
sfunc = _idf,
stype = jsonb,
finalfunc = _idf_final
);

■TF-IDFを計算する


最後に、ここまで計算してきたTFとIDFを使ってTF-IDFを計算する関数を作成します。

以下のように TF があり、

snaga=> SELECT docid,tf FROM t1;
docid | tf
-------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
1 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "です": 0.14285714285714285, "今日": 0.14285714285714285, "天気": 0.14285714285714285, "晴れ": 0.14285714285714285}
2 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "雨": 0.14285714285714285, "です": 0.14285714285714285, "天気": 0.14285714285714285, "明日": 0.14285714285714285}
3 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.14285714285714285, "です": 0.14285714285714285, "晴れ": 0.14285714285714285, "気分": 0.14285714285714285}
4 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "です": 0.14285714285714285, "天気": 0.14285714285714285, "昨日": 0.14285714285714285, "晴れ": 0.14285714285714285}
(4 rows)

snaga=>
また、以下のように IDF があった場合に、

snaga=> SELECT idf(tf) FROM t1;
idf
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
{"。": 1.0, "の": 1.0, "は": 1.0, "僕": 2.386294361119891, "雨": 2.386294361119891, "です": 1.0, "今日": 2.386294361119891, "天気": 1.2876820724517808, "明日": 2.386294361119891, "昨日": 2.386294361119891, "晴れ": 1.2876820724517808, "気分": 2.386294361119891}
(1 row)

snaga=>
これらを JSONB 型の入力として受け取って、TF-IDF を JSONB 型で返却します。

snaga=> SELECT docid,tf_idf(tf, (SELECT idf(tf) FROM t1)) FROM t1;
docid | tf_idf
-------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
1 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.0, "雨": 0.0, "です": 0.14285714285714285, "今日": 0.34089919444569866, "天気": 0.18395458177882582, "明日": 0.0, "昨日": 0.0, "晴れ": 0.18395458177882582, "気分": 0.0}
2 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.0, "雨": 0.34089919444569866, "です": 0.14285714285714285, "今日": 0.0, "天気": 0.18395458177882582, "明日": 0.34089919444569866, "昨日": 0.0, "晴れ": 0.0, "気分": 0.0}
3 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.34089919444569866, "雨": 0.0, "です": 0.14285714285714285, "今日": 0.0, "天気": 0.0, "明日": 0.0, "昨日": 0.0, "晴れ": 0.18395458177882582, "気分": 0.34089919444569866}
4 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.0, "雨": 0.0, "です": 0.14285714285714285, "今日": 0.0, "天気": 0.18395458177882582, "明日": 0.0, "昨日": 0.34089919444569866, "晴れ": 0.18395458177882582, "気分": 0.0}
(4 rows)

snaga=>
計算方法は、単に同じ品詞の TF と IDF を掛け合わせるだけです。

tf_idf 関数の実装は以下の通りです。

CREATE OR REPLACE FUNCTION tf_idf(tf jsonb, idf jsonb)
RETURNS jsonb
AS $$
import json
import math
import plpy

_tf = json.loads(tf)
_idf = json.loads(idf)
terms = list(set(_tf.keys() + _idf.keys()))

tf_idf = {}
for t in terms:
if t in _tf:
tf1 = float(_tf[t])
else:
tf1 = float(0)
if t in _idf:
idf1 = float(_idf[t])
else:
idf1 = float(0)
tfidf = tf1 * idf1
tf_idf[unicode(t)] = tfidf

return json.dumps(tf_idf)
$$
LANGUAGE 'plpython2u';

■TF-IDFを用いて2つの文書の類似度を算出する


最後に、計算したTF-IDFを2つ受け取って、それらの類似度を計算する関数を作成します。

例えば、「今日の天気は晴れです。」という文章と「明日の天気は雨です。」という2つの文章のTF-IDFがJSONBで与えられたとき、

snaga=> SELECT docid,t,tfidf FROM t1;
docid | t | tfidf
-------+------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
1 | 今日の天気は晴れです。 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.0, "雨": 0.0, "です": 0.14285714285714285, "今日": 0.34089919444569866, "天気": 0.18395458177882582, "明日": 0.0, "昨日": 0.0, "晴れ": 0.18395458177882582, "気分": 0.0}
2 | 明日の天気は雨です。 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.0, "雨": 0.34089919444569866, "です": 0.14285714285714285, "今日": 0.0, "天気": 0.18395458177882582, "明日": 0.34089919444569866, "昨日": 0.0, "晴れ": 0.0, "気分": 0.0}
(2 rows)

snaga=>
以下のようにユークリッド距離を返します。

snaga=> SELECT euclidean_distance('{"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.0, "雨": 0.0, "です": 0.14285714285714285, "今日": 0.34089919444569866, "天気": 0.18395458177882582, "明日": 0.0, "昨日": 0.0, "晴れ": 0.18395458177882582, "気分": 0.0}'::jsonb, '{"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.0, "雨": 0.34089919444569866, "です": 0.14285714285714285, "今日": 0.0, "天気": 0.18395458177882582, "明日": 0.34089919444569866, "昨日": 0.0, "晴れ": 0.0, "気分": 0.0}'::jsonb);
euclidean_distance
--------------------
0.618446497668635
(1 row)

snaga=>
ユークリッド距離を計算する関数 euclidean_distance 関数は以下のように実装します。

2つの文書の TF-IDF のセットを受け取って各品詞ごとに並べ替えて、品詞ごとの TF-IDF の値を並べたベクトルを2つ作成し、その2つのベクトルのユークリッド距離を計算します。ユークリッド距離の計算は scikit-learn に用意されている euclidean_distances 関数を使います。

CREATE OR REPLACE FUNCTION euclidean_distance(tfidf_a jsonb, tfidf_b jsonb)
RETURNS float8
AS $$
from sklearn.metrics.pairwise import euclidean_distances
import json

aa = json.loads(tfidf_a)
bb = json.loads(tfidf_b)
w = list(set(aa.keys()).union(set(bb.keys())))
vec_a = []
vec_b = []
for t in w:
if t in aa:
vec_a.append(aa[t])
else:
vec_a.append(0)
if t in bb:
vec_b.append(bb[t])
else:
vec_b.append(0)
distance = euclidean_distances([vec_a], [vec_b])
return distance[0][0]
$$
LANGUAGE 'plpython2u';

■動作確認


それでは全体を通して簡単に動作確認をしてみます。

まずは、テキストを格納するテーブルを作成し、サンプルとなるテキストを INSERT します。

snaga=> CREATE TABLE t1 (
snaga(> docid serial primary key,
snaga(> t text,
snaga(> tf jsonb,
snaga(> tfidf jsonb
snaga(> );
CREATE TABLE
snaga=> \d t1
テーブル "public.t1"
列 | 型 | 修飾語
-------+---------+----------------------------------------------------
docid | integer | not null default nextval('t1_docid_seq'::regclass)
t | text |
tf | jsonb |
tfidf | jsonb |
インデックス:
"t1_pkey" PRIMARY KEY, btree (docid)

snaga=> INSERT INTO t1 (t) VALUES ('今日の天気は晴れです。'),
snaga-> ('明日の天気は雨です。'),
snaga-> ('僕の気分は晴れです。'),
snaga-> ('昨日の天気は晴れです。');
INSERT 0 4
snaga=> SELECT * FROM t1;
docid | t | tf | tfidf
-------+------------------------+----+-------
1 | 今日の天気は晴れです。 | |
2 | 明日の天気は雨です。 | |
3 | 僕の気分は晴れです。 | |
4 | 昨日の天気は晴れです。 | |
(4 行)

snaga=>
次に、各テキストごとの TF を計算して、 tf カラムに JSONB 形式で保存します。

snaga=> UPDATE t1 SET tf = tf(mecab_tokenize(t));
UPDATE 4
snaga=> SELECT t,tf FROM t1;
t | tf

------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
今日の天気は晴れです。 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "です": 0.14285714285714285, "今日": 0.14285714285714285, "天気": 0.14285714285714285, "晴れ": 0.14285714285714285}
明日の天気は雨です。 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "雨": 0.14285714285714285, "です": 0.14285714285714285, "天気": 0.14285714285714285, "明日": 0.14285714285714285}
僕の気分は晴れです。 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.14285714285714285, "です": 0.14285714285714285, "晴れ": 0.14285714285714285, "気分": 0.14285714285714285}
昨日の天気は晴れです。 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "です": 0.14285714285714285, "天気": 0.14285714285714285, "昨日": 0.14285714285714285, "晴れ": 0.14285714285714285}
(4 行)

snaga=>
次に、今計算した TF を使って IDF を計算し、TF と IDF を使って TF-IDF を計算、 tfidf カラムに保存します。(ここでは、IDF の計算をサブクエリで行い、それを使って一気に TF-IDF を計算しています。)

snaga=> UPDATE t1 SET tfidf = tf_idf(tf, (SELECT idf(tf) FROM t1));
UPDATE 4
snaga=> SELECT t,tfidf FROM t1;
t | tfidf

------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
今日の天気は晴れです。 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.0, "雨": 0.0, "です": 0.14285714285714285, "今日": 0.34089919444569866, "天気": 0.18395458177882582, "明日": 0.0, "昨日": 0.0, "晴れ": 0.18395458177882582, "気分": 0.0}
明日の天気は雨です。 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.0, "雨": 0.34089919444569866, "です": 0.14285714285714285, "今日": 0.0, "天気": 0.18395458177882582, "明日": 0.34089919444569866, "昨日": 0.0, "晴れ": 0.0, "気分": 0.0}
僕の気分は晴れです。 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.34089919444569866, "雨": 0.0, "です": 0.14285714285714285, "今日": 0.0, "天気": 0.0, "明日": 0.0, "昨日": 0.0, "晴れ": 0.18395458177882582, "気分": 0.34089919444569866}
昨日の天気は晴れです。 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.0, "雨": 0.0, "です": 0.14285714285714285, "今日": 0.0, "天気": 0.18395458177882582, "明日": 0.0, "昨日": 0.34089919444569866, "晴れ": 0.18395458177882582, "気分": 0.0}
(4 行)

snaga=>
最後に、テキスト「今日の天気は晴れです。」をクエリのテキストとして、その他のテキストとのユークリッド距離を計算します。

snaga=> SELECT docid,t,euclidean_distance(tfidf, (SELECT tfidf FROM t1 WHERE docid = 1)) FROM t1;
docid | t | euclidean_distance
-------+------------------------+----------------------
1 | 今日の天気は晴れです。 | 1.05367121277235e-08
2 | 明日の天気は雨です。 | 0.618446497668635
3 | 僕の気分は晴れです。 | 0.618446497668635
4 | 昨日の天気は晴れです。 | 0.48210426418717
(4 行)

snaga=>
ここまでで、PostgreSQL のテキストの TF-IDF を計算して、クエリのテキストとのユークリッド距離を計算する動作確認は完了になります。

■まとめ


今回は、TF-IDF を使ってテキストの類似度検索をするために必要な関数群を実装してみました。

このような関数群を UDF として PostgreSQL 内部に実装することで、PostgreSQL 内に保存されたデータを分析のために PostgreSQL から取り出す必要がありませんし、PL/Python を使うことによって scikit-learn などの Python のエコシステムを活用できるようになります。

次回は、今回作成した関数を使って、より大きなドキュメントを PostgreSQL に保存して、類似度に基づいて検索してみます。

では。

TF-IDFでデータベース内の類似テキストを検索する Part 2 (実践編)

$
0
0
前回の TF-IDF Part 1 の続きです。
今回は、現実的なドキュメントをPostgreSQLに格納して TF-IDF の類似度に基づく検索をしてみます。

■検索対象とするドキュメント


今回題材として使うドキュメント(コーパス)はPostgreSQLの日本語マニュアルです。
PostgreSQLのマニュアルは、かなりしっかりした内容が書かれている反面、ボリュームが多い、どこに書いてあるのか分からない、そもそも分かっていない人には分かりづらい、などの指摘がされることがあります。

そういう文書を読むときに、「関連するページ」を自動的にピックアップすることができれば、より深い理解や周辺の理解につながるのではないか、というのがもともとのモチベーションです。

■PostgreSQL内にコーパスを作成する


まず、PostgreSQLのマニュアルを保存するテーブルを作成します。スキーマは以下の通りです。

CREATE TABLE pgsql_doc (
docid SERIAL PRIMARY KEY,
filename TEXT NOT NULL,
html TEXT NOT NULL,
plain TEXT,
tf JSONB,
tfidf JSONB
);
ファイル名、HTMLとしてのドキュメント、プレーンテキストに直したドキュメントを保存するカラムを準備します(TF-IDFの計算はプレーンテキストに変換してから行います)。また、TF および TF-IDF を保存するカラムも作成します。

次に、使用するPostgreSQLマニュアルをHTMLファイルで取得します。今回は wget を使ってクローリングします。

[snaga@localhost pgsql-doc]$ wget -r -np http://www.postgresql.jp/document/9.5/html/index.html
--2016-07-12 11:24:10-- http://www.postgresql.jp/document/9.5/html/index.html
www.postgresql.jp をDNSに問いあわせています... 157.7.153.71
www.postgresql.jp|157.7.153.71|:80 に接続しています... 接続しました。
HTTP による接続要求を送信しました、応答を待っています... 200 OK
長さ: 特定できません [text/html]
`www.postgresql.jp/document/9.5/html/index.html'に保存中

[ <=> ] 15,839 --.-K/s 時間 0.01s

2016-07-12 11:24:11 (1.50 MB/s) - `www.postgresql.jp/document/9.5/html/index.html'へ保存終了 [15839]

(...略...)
`www.postgresql.jp/document/9.5/html/pgstandby.html'に保存中

[ <=> ] 31,352 --.-K/s 時間 0.01s

2016-07-12 11:30:46 (2.15 MB/s) - `www.postgresql.jp/document/9.5/html/pgstandby.html'へ保存終了 [31352]

終了しました --2016-07-12 11:30:46--
ダウンロード完了: 1306 ファイル、28M バイトを 1m 22s で取得 (349 KB/s)
[snaga@localhost pgsql-doc]$ ls -l
合計 4
drwxrwxr-x. 3 snaga snaga 4096 7月 12 11:24 2016 www.postgresql.jp
[snaga@localhost pgsql-doc]$
取得したファイルは www.postgresql.jp というディレクトリに保存されますので、取得したファイルの中から、HTML ファイルだけを抜き出してリストを作成します。

[snaga@localhost pgsql-doc]$ find www.postgresql.jp -type f -name '*.html'> file.txt
[snaga@localhost pgsql-doc]$ head file.txt
www.postgresql.jp/document/9.5/html/typeconv-union-case.html
www.postgresql.jp/document/9.5/html/release-9-1-20.html
www.postgresql.jp/document/9.5/html/recovery-config.html
www.postgresql.jp/document/9.5/html/tutorial-agg.html
www.postgresql.jp/document/9.5/html/ecpg-sql-deallocate-descriptor.html
www.postgresql.jp/document/9.5/html/postgres-fdw.html
www.postgresql.jp/document/9.5/html/tutorial.html
www.postgresql.jp/document/9.5/html/runtime-config-error-handling.html
www.postgresql.jp/document/9.5/html/release-9-3-5.html
www.postgresql.jp/document/9.5/html/release-9-1-16.html
[snaga@localhost pgsql-doc]$ wc file.txt
1304 1304 74949 file.txt
[snaga@localhost pgsql-doc]$
リストができたら、以下のスクリプトを使って、先ほど定義したテーブルの filename カラムにファイル名を、html カラムにHTMLテキストを INSERT する SQL ファイルを生成します。

#!/usr/bin/env python2.7

print "BEGIN;"

for f in open("file.txt"):
f = f.rstrip()
print "-- " + f
html = ""
for h in open(f):
html = html + h
filename = f.replace('www.postgresql.jp/document/9.5/html/', '')
html = html.replace("'", "''")
print("INSERT INTO pgsql_doc (filename, html) VALUES ('{filename}', '{html}');".format(filename=filename, html=html))

print "COMMIT;"
以下のように、load.sql ファイルを作成します。

[snaga@localhost pgsql-doc]$ ./load_html.py > load.sql
[snaga@localhost pgsql-doc]$ head load.sql
BEGIN;
-- www.postgresql.jp/document/9.5/html/typeconv-union-case.html
INSERT INTO pgsql_doc (filename, html) VALUES ('typeconv-union-case.html', '<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN""http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /><title>10.5. UNION、CASEおよび関連する構文</title><link rel="stylesheet" type="text/css" href="stylesheet.css" /><link rev="made" href="pgsql-docs@postgresql.org" /><meta name="generator" content="DocBook XSL Stylesheets V1.78.1" /><link rel="home" href="index.html" title="PostgreSQL 9.5.3文書" /><link rel="up" href="typeconv.html" title="第10章 型変換" /><link rel="prev" href="typeconv-query.html" title="10.4. 値の格納" /><link rel="next" href="indexes.html" title="第11章 インデックス" /><link rel="copyright" href="legalnotice.html" title="法的告知" /><meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0" /><style type="text/css"><!--
body{margin:0;}
#lay_body{margin:8px;}
#lay_header{margin:0 0 4px 0 !important;border:0;width:100%;padding:0;}
#lay_header .b1{border-bottom:#cef solid 1px;}
#lay_header .b2{border-bottom:#8cf solid 1px;}
[snaga@localhost pgsql-doc]$
ここまでできたら、先ほどのCREATE TABLE文と、このload.sqlをPostgreSQLデータベースに対して実行して、コーパスを作成します。

[snaga@localhost pgsql-doc]$ psql testdb
psql (9.5.2)
"help"でヘルプを表示します.

testdb=> CREATE TABLE pgsql_doc (
testdb(> docid SERIAL PRIMARY KEY,
testdb(> filename TEXT NOT NULL,
testdb(> html TEXT NOT NULL,
testdb(> plain TEXT,
testdb(> tf JSONB,
testdb(> tfidf JSONB
testdb(> );
CREATE TABLE
testdb=> \i load.sql
BEGIN
INSERT 0 1
INSERT 0 1
INSERT 0 1
(...略...)
INSERT 0 1
INSERT 0 1
COMMIT
testdb=> \d+
リレーションの一覧
スキーマ | 名前 | 型 | 所有者 | サイズ | 説明
----------+---------------------+------------+--------+------------+------
public | pgsql_doc | テーブル | snaga | 13 MB |
public | pgsql_doc_docid_seq | シーケンス | snaga | 8192 bytes |
(2 行)

testdb=> select count(*) from pgsql_doc;
count
-------
1304
(1 行)

testdb=>
全部で 1304 のHTMLページが読み込まれていることが分かります。

最後に、テーブルの html カラムに取り込んだ HTML テキストを、プレーンテキストに変換して plain カラムに格納します。

HTML をプレーンテキストに変換するには、Python の html2text モジュールを呼び出す UDF を使います。

CREATE OR REPLACE FUNCTION html2text(html text)
RETURNS text
AS $$
import html2text

h = html2text.HTML2Text()
h.ignore_links = True

return h.handle(html.decode('utf-8')).encode('utf-8')
$$
LANGUAGE 'plpython2u';
UPDATE 文を発行して、plain カラムにプレーンテキストを保存します。

testdb=> UPDATE pgsql_doc SET plain = html2text(html);
UPDATE 1304
testdb=> \d+
リレーションの一覧
スキーマ | 名前 | 型 | 所有者 | サイズ | 説明
----------+---------------------+------------+--------+------------+------
public | pgsql_doc | テーブル | snaga | 18 MB |
public | pgsql_doc_docid_seq | シーケンス | snaga | 8192 bytes |
(2 行)

testdb=>
以上でコーパスの準備は完了です。

■TF-IDFを計算する


まず、各ドキュメントの TF を計算して tf カラムに保存します。

なお、今回は前回と異なり、分かち書きの際に2文字以上のトークンのみを拾うように mecab_tokenize 関数を修正しています。

*** pg_tfidf.sql.orig 2016-07-12 16:11:36.122012659 +0900
--- pg_tfidf.sql 2016-07-12 16:10:32.886012704 +0900
***************
*** 45,51 ****
t = n['feature'][6]
if t == '*':
t = n['surface']
! if len(t) > 0:
tokens.append(t)
node = node.next
return json.dumps(tokens)
--- 45,51 ----
t = n['feature'][6]
if t == '*':
t = n['surface']
! if len(t) > 1:
tokens.append(t)
node = node.next
return json.dumps(tokens)
それでは TF を計算します。

testdb=> SELECT COUNT(tf) FROM pgsql_doc;
count
-------
0
(1 row)
testdb=> UPDATE pgsql_doc SET tf = tf(mecab_tokenize(plain));
UPDATE 1304
testdb=> SELECT COUNT(tf) FROM pgsql_doc;
count
-------
1304
(1 row)

testdb=>
上記の例では、最初 NULL だった tf カラムに値が設定されていることが分かります。

次に、今作成した TF を使って TF-IDF を計算して tfidf カラムに保存します。

testdb=> SELECT COUNT(tfidf) FROM pgsql_doc;
count
-------
0
(1 row)
testdb=> UPDATE pgsql_doc SET tfidf = tf_idf(tf, (SELECT idf(tf) FROM pgsql_doc));
UPDATE 1304
testdb=> SELECT COUNT(tfidf) FROM pgsql_doc;
count
-------
1304
(1 row)

testdb=>
tfidf カラムについても、最初すべて NULL だったカラムが、すべて TF-IDF の値で埋まっていることが分かります。

■TF-IDFを基準に類似度の高いテキストを取得する


それでは、実際に特定のページに対して、内容が近いページを検索してみます。

ここでは wal.html というページを取り出し、それに(ユークリッド距離が)近い順番に他のページを並べてみています。

testdb=> SELECT
testdb-> filename,
testdb-> euclidean_distance(tfidf, (SELECT tfidf FROM pgsql_doc WHERE filename = 'wal.html') )
testdb-> FROM
testdb-> pgsql_doc
testdb-> ORDER BY
testdb-> 2;
filename | euclidean_distance
---------------------------------------------------+----------------------
wal.html | 1.49011611938477e-08
wal-intro.html | 0.670408645135645
wal-internals.html | 0.696786282911372
wal-reliability.html | 0.721978311656912
admin.html | 0.723694675276873
wal-async-commit.html | 0.725251929411429
runtime-config-wal.html | 0.72553385845936
wal-configuration.html | 0.729987981895102
release-7-4-29.html | 0.732088992934256
disk-full.html | 0.733543368352212
(...略...)
brin.html | 1.39337005247978
nls.html | 1.41008067598852
gin.html | 1.51901488292384
event-trigger-matrix.html | 1.91842947389792
sql-keywords-appendix.html | 2.23230386856231
(1304 rows)

testdb=>
距離の近いドキュメントを見てみると、いずれも WAL やディスク関連の内容が記述されたページが出てきました。TF-IDF による類似度検索が機能していることが分かります。

もう一つの例として、gin.html に近いページを類似度検索してみます。

testdb=> SELECT
testdb-> filename,
testdb-> euclidean_distance(tfidf, (SELECT tfidf FROM pgsql_doc WHERE filename = 'gin.html') )
testdb-> FROM
testdb-> pgsql_doc
testdb-> ORDER BY
testdb-> 2;
filename | euclidean_distance
---------------------------------------------------+---------------------
gin.html | 2.1073424255447e-08
gin-limit.html | 1.1318263488524
gin-examples.html | 1.19411267385758
gin-intro.html | 1.26178461661249
gin-implementation.html | 1.28122198499309
gin-tips.html | 1.28399693208161
spgist-examples.html | 1.30248544760188
gin-extensibility.html | 1.32677302605941
indexes-types.html | 1.32835949747107
release-9-1-7.html | 1.34013416302523
sql-createindex.html | 1.3407384158454
textsearch-indexes.html | 1.34299614984885
xindex.html | 1.34457129207184
(...略...)
gist.html | 1.68315092630428
brin-builtin-opclasses.html | 1.68897216768224
nls.html | 1.79686563589178
event-trigger-matrix.html | 2.22159433303442
sql-keywords-appendix.html | 2.49797882729631
(1304 rows)

testdb=>
こちらも、GINや関連するインデックスに関するページが上位に出てきました。TF-IDF の類似度検索が機能していると考えて良さそうです。

■パフォーマンスを再考する


さて、実際に実行してみると分かりますが、この検索クエリはかなり時間がかかります。

私の手元の環境(Think Pad X1 Carbon 上の Vagrant)で100秒近くかかっています。

testdb=> EXPLAIN ANALYZE VERBOSE SELECT
testdb-> filename,
testdb-> euclidean_distance(tfidf, (SELECT tfidf FROM pgsql_doc WHERE filename = 'wal.html') )
testdb-> FROM
testdb-> pgsql_doc
testdb-> ORDER BY
testdb-> 2;
QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------
Sort (cost=988.81..992.07 rows=1304 width=39) (actual time=98609.860..98611.538 rows=1304 loops=1)
Output: pgsql_doc.filename, (euclidean_distance(pgsql_doc.tfidf, $0))
Sort Key: (euclidean_distance(pgsql_doc.tfidf, $0))
Sort Method: quicksort Memory: 151kB
InitPlan 1 (returns $0)
-> Seq Scan on public.pgsql_doc pgsql_doc_1 (cost=0.00..299.30 rows=1 width=18) (actual time=0.111..0.287 rows=1 loops=1)
Output: pgsql_doc_1.tfidf
Filter: (pgsql_doc_1.filename = 'wal.html'::text)
Rows Removed by Filter: 1303
-> Seq Scan on public.pgsql_doc (cost=0.00..622.04 rows=1304 width=39) (actual time=73.871..98546.860 rows=1304 loops=1)
Output: pgsql_doc.filename, euclidean_distance(pgsql_doc.tfidf, $0)
Planning time: 0.062 ms
Execution time: 98613.087 ms
(13 rows)

Time: 98613.888 ms
testdb=>
euclidean_distance 関数を呼びながらのシーケンシャルスキャンで 98秒(actual time 98546.860)もかかっていますが、シーケンシャルスキャンだけでこんなにかかるわけはないので、当然ながらボトルネックは euclidean_distance 関数の内部にあるのでしょう。

類似度検索はいくら便利だとは言え、もう少しパフォーマンスをどうにかしたいところです。

というわけで、次回はこの euclidean_distance 関数のボトルネックを調査し、パフォーマンス改善をしてみます。

■まとめ


今回は、PostgreSQLに実装した TF-IDF の関数を使って、実際の PostgreSQL のマニュアルの類似度検索を行い、現実の問題に対して TF-IDF の類似度検索が一通り動作することを確認しました。

一方で、現在の実装では検索にかなりの時間を要していることも分かりました。

最終回である次回は、何がボトルネックになっているのか、そしてパフォーマンスを改善するために PostgreSQL 内部で何ができるのかを考えてみます。

では。


TF-IDFでデータベース内の類似テキストを検索する Part 3 (性能改善編)

$
0
0
PostgreSQL 感動巨編 TF-IDF 3部作の最終回、「性能改善編」です。 前回の最後で、今回作成した UDF である euclidean_distance の処理に時間がかかってそうだ、ということが分かってきました。

そのため、本エントリではこの UDF をもう少し詳細に見ながら、パフォーマンスを改善する方法を探ります。

■euclidean_distanceの性能分析


処理時間がかかっていることが分かった euclidean_distance 関数ですが、改めて処理を詳細に見ていきます。

CREATE OR REPLACE FUNCTION euclidean_distance(tfidf_a jsonb, tfidf_b jsonb)
RETURNS float8
AS $$
from sklearn.metrics.pairwise import euclidean_distances
import json

aa = json.loads(tfidf_a)
bb = json.loads(tfidf_b)
w = list(set(aa.keys()).union(set(bb.keys())))
vec_a = []
vec_b = []
for t in w:
if t in aa:
vec_a.append(aa[t])
else:
vec_a.append(0)
if t in bb:
vec_b.append(bb[t])
else:
vec_b.append(0)
distance = euclidean_distances([vec_a], [vec_b])
return distance[0][0]
$$
LANGUAGE 'plpython2u';
まず、ボトルネックの確認のため、少しずつこの関数のコードをコメントアウトして実行時間を比較してみると、


aa = json.loads(tfidf_a)
bb = json.loads(tfidf_b)
の処理をコメントアウトすると実行時間が大幅に短縮されることが分かります。つまり、JSONB から Python のオブジェクト、今回の場合は dict 型ですが、ここへの変換に時間がかかっているわけです。

ちなみに、今回の TF-IDF の JSONB 型のデータのサイズは、平均 130kB 以上もある大きな JSON ドキュメントです。そのため、これをロードするにはそれなりに時間がかかっているというのは、それなりに理解できる状況ではあります。

testdb=> SELECT avg(pg_column_size(tfidf)) FROM pgsql_doc;
avg
---------------------
137897.235429447853
(1 行)

testdb=>
つまり、この JSON のロード処理を無くすことができれば、かなり処理時間を短縮できると思われます。

というわけで、どうやったらここで JSON のロードをせずに済ませられるか、ということを考えてみます。

■euclidean_distanceの処理詳細


ここで、改めて euclidean_distance 関数の処理の詳細を見てみます。

CREATE OR REPLACE FUNCTION euclidean_distance(tfidf_a jsonb, tfidf_b jsonb)
まず、引数として受け取る2つの JSONB は TF-IDF の情報であり、キーがトークン、値は TF-IDF の値のペアを持つ Key-Value 構造になっています。

testdb=> SELECT substring(tfidf::text from 300500 for 100) FROM pgsql_doc LIMIT 1;
substring
----------------------------------------------------------------------------------------------------------------------------------
ン": 0.0, "ミレニアム": 0.0, "メカニズム": 0.0, "メガバイト": 0.0025927042824036564, "メタデータ": 0.0, "メッセージ": 0.0, "モジ
(1 行)

testdb=>
このように、「すべてのドキュメントに出てくるトークン」をキーとして持つ、非常に大きな Key-Value 構造になっているわけです。

そして、このような大きな2つの JSONB ドキュメントを受け取った後、

w = list(set(aa.keys()).union(set(bb.keys())))
で、2つの JSONB ドキュメントのキーをマージします。(実際には、ここでマージしなくても、2つの JSONB ドキュメントですでにキーはすべて同じになっているはずなのですが、念のための処理です)

そして、キー(トークン)をソートした後、各 dict からトークンに対応する TF-IDF 値を取り出し、リストに追加します。

vec_a = []
vec_b = []
for t in w:
if t in aa:
vec_a.append(aa[t])
else:
vec_a.append(0)
if t in bb:
vec_b.append(bb[t])
else:
vec_b.append(0)
この処理によって最後に得られるのは、ソートされたトークンの順番に並べられた TF-IDF 値のリスト、つまり float8 型のベクトルになります。

そして、このベクトルを scikit-learn の euclidean_distances 関数に与えてユークリッド距離を計算して、それを返却しています。

distance = euclidean_distances([vec_a], [vec_b])
return distance[0][0]
ここまで見てきたように、入力として渡された JSONB のデータは、最終的には float8 のベクトルに変換される必要があります。

逆に言えば、この UDF の入力として float8 のベクトルが渡されて来れば、JSONB 型でやりとりする必要性はそもそも無くなるわけです。

先のパフォーマンスの分析で、JSONB のロードに時間がかかっていることが分かっていますので、この JSONB 型からの脱却を試みてみます。

■TF-IDF のベクトル化


まず、テーブルに TF-IDF をベクトルとして保存するカラムを追加します。ここでは、float8 の配列型のカラムを使います。

testdb=> ALTER TABLE pgsql_doc ADD COLUMN tfidf_vec float8[];
ALTER TABLE
testdb=> \d pgsql_doc
テーブル "public.pgsql_doc"
列 | 型 | 修飾語
-----------+--------------------+-----------------------------------------------------------
docid | integer | not null default nextval('pgsql_doc_docid_seq'::regclass)
filename | text | not null
html | text | not null
plain | text |
tf | jsonb |
tfidf | jsonb |
tfidf_vec | double precision[] |
インデックス:
"pgsql_doc_pkey" PRIMARY KEY, btree (docid)

testdb=>
次に、TF-IDF を JSONB で受け取って、キー(トークン)でソートした後に、TF-IDF 値を float8 配列で返却する UDF を作成します。

CREATE OR REPLACE FUNCTION tfidf_jsonb2vec(tfidf jsonb)
RETURNS float8[]
AS $$
import json

a = json.loads(tfidf)
vec = []
for t in sorted(a):
vec.append(a[t])
return vec
$$
LANGUAGE 'plpython2u';
そして、この UDF を使って、 tfidf カラムの内容を float8 配列に変換して tfidf_vec カラムに保存します。

testdb=> UPDATE pgsql_doc SET tfidf_vec = tfidf_jsonb2vec(tfidf);
UPDATE 1304
testdb=> SELECT count(tfidf_vec) FROM pgsql_doc;
count
-------
1304
(1 行)

testdb=>
以上で、ベクトル化は完了です。

以下のように、数値のみの配列となり、カラムのサイズも平均 4kB まで小さくなっていることが分かります。

testdb=> SELECT substring(tfidf_vec::text from 0 for 40) FROM pgsql_doc LIMIT 1;
substring
-----------------------------------------
{0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
(1 行)

testdb=> SELECT avg(pg_column_size(tfidf_vec)) FROM pgsql_doc;
avg
-----------------------
4311.3703987730061350
(1 行)

testdb=>
最後に、ユークリッド距離を計算する UDF euclidean_distance を修正します。

データの受け渡しに JSONB を使わなくなったので、以下のようにシンプルになります。

CREATE OR REPLACE FUNCTION euclidean_distance(vec_a float8[], vec_b float8[])
RETURNS float8
AS $$
from sklearn.metrics.pairwise import euclidean_distances

distance = euclidean_distances([vec_a], [vec_b])
return distance[0][0]
$$
LANGUAGE 'plpython2u';

■TF-IDF のベクトルを使って類似性検索を実行する


では、前回と同じクエリを実行してみましょう。

変更されている点は JSONB 型の tfidf カラムではなく、 float8[] 型の tfidf_vec カラムを使うようにしたところだけです。

testdb=> SELECT
testdb-> filename,
testdb-> euclidean_distance(tfidf_vec, (SELECT tfidf_vec FROM pgsql_doc WHERE filename = 'wal.html') )
testdb-> FROM
testdb-> pgsql_doc
testdb-> ORDER BY
testdb-> 2;
filename | euclidean_distance
---------------------------------------------------+----------------------
wal.html | 1.49011611938477e-08
wal-intro.html | 0.670239012809462
wal-internals.html | 0.696524116689475
wal-reliability.html | 0.722138171836224
admin.html | 0.723666230739223
wal-async-commit.html | 0.725507228362063
runtime-config-wal.html | 0.725767186591778
wal-configuration.html | 0.730449041608858
release-7-4-29.html | 0.732007479060099
disk-full.html | 0.733419521902515
(...略...)
brin.html | 1.3933519134798
nls.html | 1.41006771577778
gin.html | 1.51897678394843
event-trigger-matrix.html | 1.91950639453063
sql-keywords-appendix.html | 2.23247656979142
(1304 行)

testdb=>
当然ながら、前回と同じ結果が返ってきています。

この時の実行時間を見ると約4秒強となり、前回の JSONB を使った実装で 100 秒近くかかったのと比べると、20倍程度の性能改善が行われたことが分かります。

testdb=> EXPLAIN ANALYZE SELECT
testdb-> filename,
testdb-> euclidean_distance(tfidf_vec, (SELECT tfidf_vec FROM pgsql_doc WHERE filename = 'wal.html') )
testdb-> FROM
testdb-> pgsql_doc
testdb-> ORDER BY
testdb-> 2;
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------
Sort (cost=1104.81..1108.07 rows=1304 width=39) (actual time=4316.830..4318.665 rows=1304 loops=1)
Sort Key: (euclidean_distance(pgsql_doc.tfidf_vec, $0))
Sort Method: quicksort Memory: 151kB
InitPlan 1 (returns $0)
-> Seq Scan on pgsql_doc pgsql_doc_1 (cost=0.00..357.30 rows=1 width=18) (actual time=0.242..0.475 rows=1 loops=1)
Filter: (filename = 'wal.html'::text)
Rows Removed by Filter: 1303
-> Seq Scan on pgsql_doc (cost=0.00..680.04 rows=1304 width=39) (actual time=4.590..4246.025 rows=1304 loops=1)
Planning time: 0.064 ms
Execution time: 4320.443 ms
(10 行)

testdb=>
これくらいまで性能が改善できれば、状況によっては使えるケースが増えてきそうです。

■その他の性能改善


今回は実施しませんでしたが、TF-IDF を事前に計算する処理も、もう少し性能改善できる可能性があります。

例えば、今回の処理では単純な Key-Value 構造しか扱わないため、 JSONB の代わりに hstore を使うことで性能が改善する可能性があります。
なお、hstore と Python の dict 型のマッピングは hstore_plpython2u という EXTENSION で実現することができます。

testdb=# CREATE EXTENSION hstore;
CREATE EXTENSION
testdb=# CREATE EXTENSION hstore_plpython2u;
CREATE EXTENSION
testdb=#
また、9.6 からはパラレルクエリが使えるようになりますので、CPU コアを増やすことで検索のレスポンスタイムを短縮させることができるようになります。

興味のある方は、こういった点にもぜひチャレンジしてみていただければと思います。

■まとめ


TF-IDF を用いた類似テキスト検索ということで、全3回に渡って PostgreSQL に保存したテキストデータに対してどのように類似検索を行うかをご紹介してきました。

PostgreSQL の UDF として実装したことによって、

-- TFの計算
UPDATE pgsql_doc SET tf = tf(mecab_tokenize(plain));
-- TF-IDFの計算
UPDATE pgsql_doc SET tfidf = tf_idf(tf, (SELECT idf(tf) FROM pgsql_doc));
-- TF-IDFのベクトル化
UPDATE pgsql_doc SET tfidf_vec = tfidf_jsonb2vec(tfidf);
という3ステップで、PostgreSQLの日本語マニュアルの類似検索を行えるようになりました。

また、最終回の今回は、データ構造を工夫することによって処理時間を大幅に短縮できることが分かりました。

PostgreSQLの強みはその拡張性であり、それはデータ分析というユースケースでも変わらないと思います。また、扱うデータが増えれば増えるほど、データベースから取り出さずに分析したい、というニーズは高まってくると思います。

ぜひ、PostgreSQLの拡張性を活かして、さまざまなデータ分析にPostgreSQLを使ってみていただければと思います。

では。

TF-IDFでデータベース内の類似テキストを検索する Part 4 (MADlib svec編)

$
0
0
TF-IDF 感動巨編3部作は前回のエントリで完結したわけですが、今回はその番外編、スピンオフとして「MADlib svec編」をお送りします。

MADlib には、sparse(疎)な配列、つまり多くの要素がゼロであるような配列を扱うデータ型として svec というデータ型があります。
本エントリでは、TF-IDF のベクトルに MADlib の svec を使って、通常の float8[] などとどのように違うのかを見てみます。

■「MADlib」とは何か


MADlib については、ガッツリと割愛します。以前のエントリで詳しくご紹介しましたので、そちらを参照してください。

■「svec」 とは何か


svec は、ゼロの多い sparse な配列を圧縮して保持するデータ型です。データ分析をしていると、頻繁に遭遇するデータの構造になります。

例えば、float8 の配列で以下のようにゼロが並ぶデータがあったとします。

'{0, 33,...40,000個のゼロ..., 12, 22 }'::float8[]
すると、この配列は 320kB 以上のディスク容量またはメモリを消費することになります。ほとんど意味のないゼロを保持するだけのために、これだけのリソースを食ってしまいます。

svec は、この配列を以下のようにランレングス圧縮(RLE圧縮)することでデータサイズを縮小します。

'{1,1,40000,1,1}:{0,33,0,12,22}'::madlib.svec
このように圧縮することによって、5つの整数型と5つの浮動小数点型に集約され、データサイズが劇的に小さくなります。

このようなデータ型を用意することで、ディスク容量とメモリの消費を抑え、大量のデータの処理を可能にします。(もちろん、演算処理時には圧縮されたデータを展開しながら行いますので、そのCPUコストは発生します)

さらなる詳細はマニュアルを参照してください。

■float8[] を svec に変換する


まず、TF-IDF 感動巨編3部作が完結した状態のテーブルから始めます。
この時、以下のようなテーブル定義になっているはずです。

snaga=> \d pgsql_doc
Table "public.pgsql_doc"
Column | Type | Modifiers
-----------+--------------------+-----------------------------------------------------------
docid | integer | not null default nextval('pgsql_doc_docid_seq'::regclass)
filename | text | not null
html | text | not null
plain | text |
tf | jsonb |
tfidf | jsonb |
tfidf_vec | double precision[] |
Indexes:
"pgsql_doc_pkey" PRIMARY KEY, btree (docid)

snaga=>
この時、tfidf_vec のカラムは、以下のようにゼロの多い float8 の配列になっています。

snaga=> SELECT substring(tfidf_vec::text from 0 for 100) FROM pgsql_doc LIMIT 1;
substring
-----------------------------------------------------------------------------------------------------
{0,0,0,0,0,0,0,0.0328645679655572,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0
(1 row)

snaga=>
この float8[] を svec に変換するには、単に madlib.svec 型へキャストすれば完了です。

snaga=> SELECT substring(tfidf_vec::madlib.svec::text from 0 for 100) FROM pgsql_doc LIMIT 1;
substring
-----------------------------------------------------------------------------------------------------
{7,1,31,1,4,1,18,1,70,1,88,1,4,1,51,1,11,1,45,1,1,1,52,1,2,1,4,1,1,7,1,1,1,36,1,1,1,36,1,106,1,14,1
(1 row)

snaga=>

■float8[] と svec のテーブルを用意する


まず、比較のために、docid, filename カラムと、float8[] の tfidf_vec カラム、もしくは svec の tfidf_svec カラムだけを保持するテーブル pgsql_doc_vec と pgsql_doc_svec を作成します。

tfidf_svec カラムは madlib.svec 型になっていることを確認します。

snaga=> CREATE TABLE pgsql_doc_vec AS SELECT docid,filename,tfidf_vec FROM pgsql_doc;
SELECT 1304
snaga=> \d pgsql_doc_vec
Table "public.pgsql_doc_vec"
Column | Type | Modifiers
-----------+--------------------+-----------
docid | integer |
filename | text |
tfidf_vec | double precision[] |

snaga=> CREATE TABLE pgsql_doc_svec AS SELECT docid,filename,tfidf_vec::madlib.svec as tfidf_svec FROM pgsql_doc;
SELECT 1304
snaga=> \d pgsql_doc_svec
Table "public.pgsql_doc_svec"
Column | Type | Modifiers
------------+-------------+-----------
docid | integer |
filename | text |
tfidf_svec | madlib.svec |

snaga=>

■float8[] と svec のデータサイズを比較する


まず、2つのテーブルのデータサイズを比較してみます。

snaga=> \d+
List of relations
Schema | Name | Type | Owner | Size | Description
--------+---------------------+----------+-------+------------+-------------
public | pgsql_doc | table | snaga | 407 MB |
public | pgsql_doc_docid_seq | sequence | snaga | 8192 bytes |
public | pgsql_doc_svec | table | snaga | 3872 kB |
public | pgsql_doc_vec | table | snaga | 6752 kB |
(4 rows)

snaga=>
上記を見て分かる通り、MADlib の svec 型を使ったテーブルの方が 3MB ほど小さくなっています。

このようにデータサイズを小さくすることによって、ディスクサイズの節約、I/O読み込みの抑制、バッファキャッシュの消費抑制などが実現され、ひいてはパフォーマンスの向上につながります。

■float8[] と svec のパフォーマンス比較


それでは、上記で作ったテーブルとカラムを使って、前回用いた「wal.html と類似のドキュメントを検索する」クエリでパフォーマンスを比較してみます。

float8[] 型を使う場合のクエリは以下の通りです。

EXPLAIN ANALYZE SELECT
filename,
euclidean_distance(tfidf_vec, (SELECT tfidf_vec FROM pgsql_doc_vec WHERE filename = 'wal.html') )
FROM
pgsql_doc_vec
ORDER BY
2;
svec 型を使う場合のクエリは以下の通りです。

EXPLAIN ANALYZE SELECT
filename,
euclidean_distance(tfidf_svec::float8[], (SELECT tfidf_svec::float8[] FROM pgsql_doc_svec WHERE filename = 'wal.html') )
FROM
pgsql_doc_svec
ORDER BY
2;
上記のクエリを5回ずつ実行した結果、
  • float8[] 使用時:平均 4757.6 ミリ秒
    • 4738.021, 4739.953, 4734.725, 4793.513, 4781.721
  • svec 使用時:平均 4379.5 ミリ秒
    • 4395.491, 4315.103, 4408.664, 4342.631, 4435.416
となり、svec を使用した方が1割ほど速い、という結果になりました。

データが圧縮されたことによって、パフォーマンス改善が実現されているようです。

■まとめ


今回は、MADlib で提供されている svec 型を使ってみました。

今回のケースでは1割程度のパフォーマンス改善になりましたが、当然ながらデータ圧縮の程度によってパフォーマンス向上の度合いは変わってくるでしょうし、データ量によっても変わってくると思われます。

興味のある方は、ぜひトライしてみていただければと思います。

では、また。

【翻訳】 On Uber’s Choice of Databases (データベースにおけるUberの選択について)

$
0
0
数日前、Uberのブログで「Why Uber Engineering Switched from Postgres to MySQL」というエントリが公開されました。
それに対して、PostgreSQLコミュニティ界隈でもいろいろなブログエントリが公開されました。
今回は、そのエントリの中でも、「Use The Index, Luke!」でおなじみのMarkus Winand氏のエントリ「On Uber’s Choice of Databases」が個人的に興味深かったので、同氏の翻訳許可をいただきまして、ここに対訳形式で公開します。

なお、当然ですが翻訳に際しての文責は翻訳者である永安にありますので、問題を見つけた場合にはコメント欄またはTwitter (@snaga)などで連絡いただけますと幸いです。

では、どうぞ。


■On Uber’s Choice of Databases (データベースにおけるUberの選択について)


On 7-29-2016
By Markus Winand

A few days ago Uber published the article “Why Uber Engineering Switched from Postgres to MySQL”. I didn’t read the article right away because my inner nerd told me to do some home improvements instead. While doing so my mailbox was filling up with questions like “Is PostgreSQL really that lousy?”. Knowing that PostgreSQL is not generally lousy, these messages made me wonder what the heck is written in this article. This post is an attempt to make sense out of Uber’s article.

数日前、Uberが「なぜUberエンジニアリングはPostgresからMySQLに切り替えたのか(Why Uber Engineering Switched from Postgres to MySQL)」という記事を公開しました。私は、この記事をすぐには読んでいませんでした。というのは、私の中のナード魂が、記事を読むのではなく自宅の改修を行うように私に促してきたからです。それをしている間、私のメールボックスは「PostgreSQLはそんなにひどいのか?(Is PostgreSQL really that lousy?)」というような質問でいっぱいになりました。一般的に言って、PostgreSQLはひどくはありません。それらのメッセージは、そもそも元記事でどれだけ大げさなことが書かれているのだろうか、という疑問を私に植えつけました。この記事は、Uberの記事にどのような理屈・道理を見出すか、というひとつ試みになります。

In my opinion Uber’s article basically says that they found MySQL to be a better fit for their environment as PostgreSQL. However, the article does a lousy job to transport this message. Instead of writing “PostgreSQL has some limitations for update-heavy use-cases” the article just says “Inefficient architecture for writes,” for example. In case you don’t have an update-heavy use-case, don’t worry about the problems described in Uber’s article.

私の見解では、Uberの記事は彼らの環境においてPostgreSQLよりMySQLの方がよくフィットしていることに気付いた、ということを基本的には述べています。が、そのメッセージを伝えるに当たって、この記事はひどいものとなっています。例えば、「PostgreSQLは更新処理の多いユースケースではいくつかの制約がある」と書くのではなく、この記事は単に「更新には非効率なアーキテクチャ」と書いています。もし、あなたが更新処理の多いユースケースでないのであれば、Uberの記事で説明されている問題を心配する必要はありません。

In this post I’ll explain why I think Uber’s article must not be taken as general advice about the choice of databases, why MySQL might still be a good fit for Uber, and why success might cause more problems than just scaling the data store.

本ポストでは、なぜUberの記事を一般的なデータベース選択のアドバイスとして受け取ってはならないのか、なぜMySQLがUberによって良い選択肢なのか、そして、成功することがなぜ単なるデータストアのスケーリング以上の問題を引き起こすのかを解説します。

■On UPDATE (UPDATEについて)


The first problem Uber’s article describes in great, yet incomplete detail is that PostgreSQL always needs to update all indexes on a table when updating rows in the table. MySQL with InnoDB, on the other hand, needs to update only those indexes that contain updated columns. The PostgreSQL approach causes more disk IOs for updates that change non-indexed columns (“Write Amplification” in the article). If this is such a big problem to Uber, these updates might be a big part of their overall workload.

Uberの記事が解説しているけれども厳密には不完全な最初の問題は、PostgreSQLはテーブル内の行を更新する時に常にすべてのインデックスを更新する必要がある、という部分です。一方で、InnoDBを使うMySQLは更新されたカラムを使っているインデックスだけを更新する必要がある。PostgreSQLのアプローチはインデックスのないカラムの更新時により多くのディスクI/Oを引き起こす(元記事では "Write Amplification"とされています)。もし、これがUberにとって大きな問題なのであれば、これらの更新が彼らのワークロードの多くを占めているはずです。

However, there is a little bit more speculation possible based upon something that is not written in Uber’s article: The article doesn’t mention PostgreSQL Heap-Only-Tuples (HOT). From the PostgreSQL source, HOT is useful for the special case “where a tuple is repeatedly updated in ways that do not change its indexed columns.” In that case, PostgreSQL is able to do the update without touching any index if the new row-version can be stored in the same page as the previous version. The latter condition can be tuned using the fillfactor setting. Assuming Uber’s Engineering is aware of this means that HOT is no solution to their problem because the updates they run at high frequency affect at least one indexed column.

しかし、Uberの記事に書かれていない事柄について考慮すると、少し思惑があるのかもしれません: 記事では PostgreSQL の Heap-Only-Tuples (HOT) について言及していないのです。PostgreSQLのソースコードには、HOTは特殊なケース、「インデックスが作成されているカラムを変更しない更新が繰り返される」時に有用である、とあります。この場合に、PostgreSQLは新しいバージョンの行が以前のバージョンの行と同じページに格納できる時には、インデックスに一切触らずに更新できるのです。後者の条件は fillfactorの設定を使うことで調整できます。Uber's Engineering の記事がこれに気付いていると仮定すると、HOTが彼らの問題のソリューションにならない理由は、つまりは彼らが高頻度で実行している更新処理が、インデックスの貼られたカラムを少なくとも一つは対象としているからなのでしょう。

This assumption is also backed by the following sentence in the article: “if we have a table with a dozen indexes defined on it, an update to a field that is only covered by a single index must be propagated into all 12 indexes to reflect the ctid for the new row”. It explicitly says “only covered by a single index” which is the edge case—just one index—otherwise PostgreSQL’s HOT would solve the problem.

この仮定は、記事中の次の文章によって裏付けられています: 「もし、1ダースのインデックスの貼られたテーブルがあったとすると、たった一つのインデックスが作成されているフィールドへの更新は、新しい行の ctid を反映させるために12個すべてのインデックスへと伝播されなければなりません」。ここでは明確に、「たった一つのインデックスが作成されている」と書かれており、ひとつのインデックスというのは境界条件(edge case)になりますが、そうでなければ(※訳注:インデックスが無いカラムの場合には)PostgreSQLのHOTがこの問題を解決します。

[Side note: I’m genuinely curious whether the number of indexes they have could be reduced—index redesign in my challenge. However, it is perfectly possible that those indexes are used sparingly, yet important when they are used.]

[備考: 私は心底、彼らの作成しているインデックスの数をどれだけ減らせるかに興味があります、インデックス再設計の挑戦として。しかし、それらのインデックスがあまり使われていないという可能性は十分にあるものの、それでもそれらが使われた時には重要ではあります。]

It seems that they are running many updates that change at least one indexed column, but still relatively few indexed columns compared to the “dozen” indexes the table has. If this is a predominate use-case, the article’s argument to use MySQL over PostgreSQL makes sense.

彼らは、多くの更新処理を、インデックスの貼られた少なくとも一つ以上のカラムを更新するものを実行しているようですが、それでも「1ダースの」インデックスと比べると、相対的に少ないです。これがユースケースの大部分なのであれば、記事の主張であるPostgreSQLの代わりにMySQLを使うという主張は納得がいくものです。

■On SELECT (SELECTについて)


There is one more statement about their use-case that caught my attention: the article explains that MySQL/InnoDB uses clustered indexes and also admits that “This design means that InnoDB is at a slight disadvantage to Postgres when doing a secondary key lookup, since two indexes must be searched with InnoDB compared to just one for Postgres.” I’ve previously written about this problem (“the clustered index penalty”) in context of SQL Server.

彼らのユースケースについて、私が注目した文章がもう一つあります: 記事では、MySQL/InnoDBはクラスター化インデックスを使っており、「この設計は、InnoDBがセカンダリインデックスを参照する時に、PostgreSQLに対してわずかな不利益があることを意味している。なぜならば、Postgresがただ一つのインデックスを使うのに対して、InnoDBでは2つのインデックスを検索しなければならないからだ」ということを認めています。私は、SQL Serverにおけるこの問題(クラスタ化インデックスのペナルティ)について以前書いたことがあります。

What caught my attention is that they describe the clustered index penalty as a “slight disadvantage”. In my opinion, it is a pretty big disadvantage if you run many queries that use secondary indexes. If it is only a slight disadvantage to them, it might suggest that those indexes are used rather seldom. That would mean, they are mostly searching by primary key (then there is no clustered index penalty to pay). Note that I wrote “searching” rather than “selecting”. The reason is that the clustered index penalty affects any statement that has a where clause—not just select. That also implies that the high frequency updates are mostly based on the primary key.

私の注意を引いたのは、彼らがクラスター化インデックスのペナルティを「わずかな不利益」としていたことです。私の見解では、もしあなたがセカンダリインデックスを使う多くのクエリを実行しているのであれば、これは非常に大きな不利益なのです。もし、これが彼らにとってわずかな不利益なのであれば、それらのインデックスがめったに使われていないことを示唆しています。つまり、ほとんどの場合には主キーによる検索(searching)である(よってクラスター化インデックスのペナルティは発生しない)ことを意味しています。私が選択(selecting)ではなく検索(searching)と書いたことに注意してください。その理由は、クラスター化インデックスのペナルティは、SELECTのみならず、すべてのWHERE句を持つクエリに影響するからです。またこのことにより、高頻度の更新処理の大部分は主キーを使っていると想定できます。

Finally there is another omission that tells me something about their queries: they don’t mention PostgreSQL’s limited ability to do index-only scans. Especially in an update-heavy database, the PostgreSQL implementation of index-only scans is pretty much useless. I’d even say this is the single issue that affects most of my clients. I’ve already blogged about this in 2011. In 2012, PostgreSQL 9.2 got limited support of index-only scans (works only for mostly static data). In 2014 I even raised one aspect of my concern at PgCon. However, Uber doesn’t complain about that. Select speed is not their problem. I guess query speed is generally solved by running the selects on the replicas (see below) and possibly limited by mostly doing primary key side.

最後に、もう一つの書かれていない点、彼らのクエリについて私に何かを教えてくれている部分があります: 彼らはPostgreSQLのIndex-Onlyスキャンの実行における制約については触れていません。特に更新の多いデータベースにおいては、PostgreSQLのIndex-Onlyスキャンはまったくと言っていいほど役に立ちません。これは、私の顧客の多くに影響を与える唯一の問題です。このことについて、2011年にはブログを書きました2012年には、PostgreSQL 9.2がIndex-Onlyスキャンの限定されたサポート(大部分が静的なデータに対してのみ機能する)を実現しました。2014年には、私の懸念のひとつの側面について PgCon で問題提起をしました。しかし、Uberはそれについて問題視していません。SELECTの速さは彼らにとって問題ではないのです。想像するに、クエリの速さは一般的に、レプリカでSELECTすることによって解決され、かつ、主キーを使って操作するということによって(※訳注:実行時間は)限定されるのです。

By now, their use-case seems to be a better fit for a key/value store. And guess what: InnoDB is a pretty solid and popular key/value store. There are even packages that bundle InnoDB with some (very limited) SQL front-ends: MySQL and MariaDB are the most popular ones, I think. Excuse the sarcasm. But seriously: if you basically need a key/value store and occasionally want to run a simple SQL query, MySQL (or MariaDB) is a reasonable choice. I guess it is at least a better choice than any random NoSQL key/value store that just started offering an even more limited SQL-ish query language. Uber, on the other hand just builds their own thing (“Schemaless”) on top of InnoDB and MySQL.

この時点で、彼らのユースケースは Key/Valueストアの方がよりフィットするように見えます。そして、想像してください: InnoDBは非常に堅牢で人気のあるKey/Valueストアなのです。InnoDBに(非常に限定された)SQLフロントエンドをバンドルしたパッケージがあります: 私が思うに、MySQLとMariaDBは非常に人気のあるものです。皮肉を許してください。でもマジメに: もしあなたが求めているものがKey/Valueストアであり、シンプルなクエリを時々実行するものであれば、MySQL(またはMariaDB)は合理的な選択肢です。少なくとも、最近になって限定されたSQLっぽいクエリ言語を提供し始めたばかりのどこかのNoSQL Key/Valueストアよりは良い選択肢だと思います。Uberは、逆に彼ら自身のもの(Schemaless)をInnoDBとMySQLの上に構築しました。

■On Index Rebalancing (インデックスの再バランスについて)


One last note about how the article describes indexing: it uses the word “rebalancing” in context of B-tree indexes. It even links to a Wikipedia article on “Rebalancing after deletion.” Unfortunately, the Wikipedia article doesn’t generally apply to database indexes because the algorithm described on Wikipedia maintains the requirement that each node has to be at least half-full. To improve concurrency, PostgreSQL uses the Lehman, Yao variation of B-trees, which lifts this requirement and thus allows sparse indexes. As a side note, PostgreSQL still removes empty pages from the index (see slide 15 of “Indexing Internals”). However, this is really just a side issue.

その記事が、インデックスについてどのように説明しているかについての最後の一点です: 記事中でB-Treeインデックスの文脈で「再バランス(rebalancing)」という言葉を使っています。また、Wikipediaの「削除後の再バランス」の記事へのリンクも貼っています。残念なことに、Wikipediaの記事の内容はデータベースのインデックスに対して適用させることはできません。なぜなら、Wikipediaで説明されているアルゴリズムでは、各ノードが少なくとも半分埋まっている状態であることを要求しているからです。PostgreSQLでは、並行性を向上させるためにこの前提を除去して疎(sparse)なインデックスを可能にするLhemanとYaoのB-treeの派生版を使っています。追記しておくと、PostgreSQLはインデックス内で空になったページを削除しますが("Indexing Internals"のスライド15枚目を見てください)、これは枝葉の問題です。

What really worries me is this sentence: “An essential aspect of B-trees are that they must be periodically rebalanced, …” Here I’d like to clarify that this is not a periodic process one that runs every day. The index balance is maintained with every single index change (even worse, hmm?). But the article continues “…and these rebalancing operations can completely change the structure of the tree as sub-trees are moved to new on-disk locations.” If you now think that the “rebalancing” involves a lot of data moving, you misunderstood it.

私が本当に心配しているのは次の文章です: 「B-treeの本質的な側面は、それらが定期的に再バランスを必要とすることです」。ここで明確にしておきたいのは、これは毎日実行されるような定期的なプロセスではない、ということです。インデックスのバランスは、いかなる小さな変更であれ、インデックスが変更される際に常にメンテナンスされるのです(もっと悪いですか? ふーむ)。しかし、この記事は続けます。「これらの再バランスの処理は、ツリーの一部(sub-trees)を新しいディスク上の位置に移動させることによって、ツリーの構造を完全に変えてしまうことになります」。ここで、もしあなたが「再バランス」が大量のデータ移動を引き起こしてしまうと考えているならば、それは間違いです。

The important operation in a B-tree is the node split. As you might guess, a node split takes place when a node cannot host a new entry that belongs into this node. To give you a ballpark figure, this might happen once for about 100 inserts. The node split allocates a new node, moves half of the entries to the new node and connects the new node to the previous, next and parent nodes. This is where Lehman, Yao save a lot of locking. In some cases, the new node cannot be added to the parent node straight away because the parent node doesn’t have enough space for the new child entry. In this case, the parent node is split and everything repeats.

B-treeにおいて重要な操作はノードの分割です。あなたが想像した通り、ノードの分割は、そのノードに保持すべき新しいエントリが入り切らなくなった時に発生します。ざっくり理解するために、100回INSERTする度に発生すると仮定しましょう。ノード分割は、新しいノードを割り当て、インデックスエントリの半分を新しいノードに移動させ、新しいノードを以前のノード、隣のノード、および親ノードと連結します。これこそが、LehmanとYaoが多くのロックを削減した部分です。場合によっては、親ノードがいっぱいで新しいノードをすぐに親ノードに追加できない場合があります。その場合、親ノードが分割され、同じようにすべてが繰り返されることになります。

In the worst case, the splitting bubbles up to the root node, which will then be split as well and a new root node will be put above it. Only in this case, a B-tree ever becomes deeper. Note that a root node split effectively shifts the whole tree down and therefore keeps the balance. However, this doesn’t involve a lot of data moving. In the worst case, it might touch three nodes on each level and the new root node. To be explicit: most real world indexes have no more than 5 levels. To be even more explicit: the worst case—root node split—might happen about five times for a billion inserts. On the other cases it will not need to go the whole tree up. After all, index maintenance is not “periodic”, not even very frequent, and is never completely changing the structure of the tree. At least not physically on disk.

最悪なケースでは、ノード分割はルートノードまで伝播していき、同じようにルートノードが分割され、新しいルートノードがその上に配置されます。この場合のみ、B-treeはその階層が深くなるのです。ルートノードの分割は実際、ツリーを下方にシフトさせることでそのバランスを保ちます。しかし、この処理は多くのデータ移動を伴うものではありません。最悪の場合には、各階層で3ノード、および新しいルートノードにアクセスします。明確にしておきたい点: 実際の世の中のほとんどのインデックスは、5階層以上にはならないということです。さらに明確にしておきたい点: 最悪なケース ―ルートノードの分割― は、10億回のINSERTに対して5回くらい起こる、ということです。その他の場合には、ツリー全体を上がっていく必要はありません。このように、インデックスのメンテナンスというのは「定期的」なのではなく、非常に頻繁に発生しているものであり、ツリー全体の構造を完全に変えてしまうようなものでもありません。少なくとも、ディスク上の物理配置については。

■On Physical Replication (物理ログを使ったレプリケーションについて)


That brings me to the next major concern the article raises about PostgreSQL: physical replication. The reason the article even touches the index “rebalancing” topic is that Uber once hit a PostgreSQL replication bug that caused data corruption on the downstream servers (the bug “only affected certain releases of Postgres 9.2 and has been fixed for a long time now”).

この記事が提起したPostgreSQLへの次の大きな懸念: 物理ログを使ったレプリケーションです。元の記事がインデックスの「再バランス」の件に触れたのは、UberがPostgreSQLのレプリケーションで、下流のレプリカにおけるデータ破壊を引き起こすバグを踏んだからです。(このバグは「Postgres 9.2の特定のバージョンだけに発生し、かなり以前に修正されているものです」)

Because PostgreSQL 9.2 only offers physical replication in core, a replication bug “can cause large parts of the tree to become completely invalid.” To elaborate: if a node split is replicated incorrectly so that it doesn’t point to the right child nodes anymore, this sub-tree is invalid. This is absolutely true—like any other “if there is a bug, bad things happen” statement. You don’t need to change a lot of data to break a tree structure: a single bad pointer is enough.

PostgreSQL 9.2では、コア(本体)では物理ログを使ったレプリケーションだけを提供していますので、レプリケーションのバグは「インデックスツリーの大部分が完全に壊れているという状況を引き起こしうる」ものです。詳細に言うと、ノード分割が間違ってレプリケーションされると、二度と正しい子ノードを指し示さなくなってしまい、ツリーの一部が壊れた状態になります。これは、その他の「もしバグがあったなら、悪いことが起こるでしょう」という話と同じで真実のように聞こえるものです。ツリー構造を壊すには、多くのデータを書き換える必要はあなく、ただひとつの間違ったポインタだけで十分なのです。

The Uber article mentions other issues with physical replication: huge replication traffic—partly due to the write amplification caused by updates—and the downtime required to update to new PostgreSQL versions. While the first one makes sense to me, I really cannot comment on the second one (but there were some statements on the PostgreSQL-hackers mailing list).

Uberの元記事は、物理ログベースのレプリケーションのその他の問題を指摘しています: 大量のレプリケーション通信―UPDATEによる書き込みの増幅(write amplification)によるものを含む―、および新しいPostgreSQLバージョンへのアップデートの際にダウンタイムを必要としていること、などです。前者については納得がいくものですが、後者については私にはコメントできません。(が、PostgreSQL-hackersメーリングリストでいくつか言及がありました)

Finally, the article also claims that “Postgres does not have true replica MVCC support.” Luckily the article links to the PostgreSQL documentation where this problem (and remediations) are explained. The problem is basically that the master doesn’t know what the replicas are doing and might thus delete data that is still required on a replica to complete a query.

最後に、元記事は「PostgreSQLはレプリカで真のMVCCをサポートしていない」と苦情を述べています。ラッキーなことに、元記事はその問題を解説した(そして改善した)PostgreSQLのドキュメントにリンクを張っています。この問題は、基本的にはマスターというのはレプリカが何をやっているか知る由もない、つまり、レプリカがクエリを完了するために必要としているデータを、(マスタが)削除するようなことがあり得る、ということです。

According to the PostgreSQL documentation, there are two ways to cope with this issue: (1) delaying the application of the replication stream for a configurable timeout so the read transaction gets a chance to complete. If a query doesn’t finish in time, kill the query and continue applying the replication stream. (2) configure the replicas to send feedback to the master about the queries they are running so that the master does not vacuum row versions still needed by any slave. Uber’s article rules the first option out and doesn’t mention the second one at all. Instead the article blames the Uber developers.

PostgreSQLのドキュメントによると、この問題に対処するためには2つの方法があります: (1) 設定したタイムアウトに達するまで、レプリケーションストリーム(ログ転送)を生成しているアプリケーションを遅延させることで、(※訳注:レプリカで)実行されている読み取りトランザクションが完了できるようにする。(※訳注:レプリカ上の読み取り)クエリが指定した時間内に完了しなければ、そのクエリをキャンセルしてレプリケーションストリームの適用を継続する。 (2) レプリカが実行しているクエリについて、マスター側にフィードバックを送るように設定する。それによって、どこかのスレーブで必要としている行のバージョンに対してマスター側でVACUUMしないようにする。Uberの元記事は1つ目の選択肢については規定していますが、2つ目の選択肢についてはまったく言及していません。Uberの開発者のせいにする代わりに。

■On Developers (開発者について)


To quote it in all its glory: “For instance, say a developer has some code that has to email a receipt to a user. Depending on how it’s written, the code may implicitly have a database transaction that’s held open until after the email finishes sending. While it’s always bad form to let your code hold open database transactions while performing unrelated blocking I/O, the reality is that most engineers are not database experts and may not always understand this problem, especially when using an ORM that obscures low-level details like open transactions.”

これを引用できることを嬉しく思います: 「例えば、開発者が領収書をユーザにメールするコードを書いたとします。どのように書かれたのか、その実装にもよりますが、そのコードでは暗黙的に、メールの送信を完了するまでデータベーストランザクションがオープンされたままになるかもしれません。データベースに関連性のないブロッキングI/Oが実行される間、データベーストランザクションをオープンしたままにするというのは、どんな場合でも悪い状況となるわけけですが、現実的にはほとんどのエンジニアはデータベースのエキスパートではなく、特にオープントランザクションのような低いレベルの問題を引き起こすORMを使うような場合には、これらの問題を常には認識していないでしょう。」

Unfortunately, I understand and even agree with this argument. Instead of “most engineers are not database experts” I’d even say that most developers have very little understanding of databases because every developer that touches SQL needs know about transactions—not just database experts.

残念なことに、私は認識しており、この主張に賛同しています。「ほとんどのエンジニアはデータベースのエキスパートではない」と言う代わりに、ほとんどの開発者はデータベースについてわずかな知識しか持っていない、なぜならば、SQLを扱うすべての開発者はトランザクションについて知っていなければならないことだからだ、と言うことができます。

Giving SQL training to developers is my main business. I do it at companies of all sizes. If there is one thing I can say for sure is that the knowledge about SQL is ridiculously low. In context of the “open transaction” problem just mentioned I can conform that hardly any developer even knows that read only transactions are a real thing. Most developers just know that transactions can be used to back out writes. I’ve encountered this misunderstanding often enough that I’ve prepared slides to explain it and I just uploaded these slides for the curious reader.

SQLのトレーニングを開発者に提供することは私のメインのビジネスです。あらゆる規模の企業においてこれを行っています。私が言えるひとつ確実なことは、SQLについての知識が尋常でなく低いということです。たった今言った「オープントランザクション」の文脈においては、開発者たちは、参照のみのトランザクションが実際重要なものである、と多少は知っていると思います。ほとんどの開発者はトランザクションは書いたものを戻すために使うことができる、ということを知っているくらいでしょう。私は、これを説明するスライドを準備している際、度々この誤解に遭遇しますし、その度にこの問題に興味のある読者に向けてスライドをアップロードしているのです。

■On Success (成功について)


This leads me to the last problem I’d like to write about: the more people a company hires, the closer their qualification will be to the average. To exaggerate, if you hire the whole planet, you’ll have the exact average. Hiring more people really just increases the sample size.

このことは、私が書きたい最後の問題に私を導きます: 企業がより多くの人を雇うようになると、彼ら彼女らの能力は平均値に近づきます。大げさに言うと、もし地球上のすべての人を雇えば、完全に平均値になります。より多くの人を雇うということは、実際にサンプルサイズを大きくする、ということなのです。

The two ways to beat the odds are: (1) Only hire the best. The difficult part with this approach is to wait if no above-average candidates are available; (2) Hire the average and train them on the job. This needs a pretty long warm-up period for the new staff and might also bind existing staff for the training. The problem with both approaches is that they take time. If you don’t have time—because your business is rapidly growing—you have to take the average, which doesn’t know a lot about databases (empirical data from 2014). In other words: for a rapidly growing company, technology is easier to change than people.

この可能性を避ける2つの方法は: (1) ベストな人材のみを雇うこと。このアプローチの難しいところは、平均値以上の候補者が見つからない時に待つ必要があることです。 (2) 平均値の人を雇って仕事をしながらトレーニングすること。この方法は新しいスタッフに対して本当に長いウォームアップ期間を必要としますし、既存のスタッフについてもトレーニングに拘束することになるでしょう。両者に共通する問題は、時間がかかる、ということです。もし、あなたに時間がない ―ビジネスが急成長しているなどの理由で― のであれば、データベースについてさほど分かっていない平均値の人を雇うしかありません(2014年に実証されています)。言い換えれば: 急激に成長している企業にとっては、人材よりもテクノロジーの方が取り換えは容易なのです。

The success factor also affects the technology stack as requirements change over time. At an early stage, start-ups need out-of-the-box technology that is immediately available and flexible enough to be used for their business. SQL is a good choice here because it is actually flexible (you can query your data in any way) and it is easy to find people knowing SQL at least a little bit. Great, let’s get started! And for many—probably most—companies, the story ends here. Even if they become moderately successful and their business grows, they might still stay well within the limits of SQL databases forever. Not so for Uber.

また成功要因は、時間の経過に伴って要求が変化するため、テクノロジースタックに影響を与えます。アーリーステージにおいては、スタートアップはすぐに利用可能なテクノロジーであり、彼らのビジネスにおいて十分に柔軟であるものを必要とします。SQLは、本当に柔軟であるからこそ(どんなやり方であれ、データに対して問い合わせすることはできます)、この局面で良い選択肢であり、SQLを多少なりとも知っている人を見つけることはたやすいことです。素晴らしい、さぁ始めましょう! そして、ほとんどの会社にとって、話はここで終わるのです。仮に緩やかな成功と、ビジネスの成長が実現できたとしても、SQLデータベースの制約の中に健やかに留まっていることになるでしょう。Uberはそうではありませんでしたが。

A few lucky start-ups eventually outgrow SQL. By the time that happens, they have access to way more (virtually unlimited?) resources and then…something wonderful happens: They realize that they can solve many problems if they replace their general purpose database by a system they develop just for their very own use-case. This is the moment a new NoSQL database is born. At Uber, they call it Schemaless.

まれにラッキーなスタートアップは最終的にSQLの限界を超えます。それが起こる時、彼らはさらなる(仮想的には無限の?)リソースを利用することができ、そして。。。何か素敵なことが起こります: 彼らは、その一般的な用途のデータベースを彼ら独自のユースケースだけのために開発したシステムでリプレースすることで、多くの問題を解決できることを認識するのです。これこそが、新しいNoSQLデータベースが生まれる瞬間です。Uberでは、それはSchemalessと呼ばれています。

■On Uber’s Choice of Databases (データベースにおけるUberの選択について)


By now, I believe Uber did not replace PostgreSQL by MySQL as their article suggests. It seems that they actually replaced PostgreSQL by their tailor-made solution, which happens to be backed by MySQL/InnoDB (at the moment).

現時点では、私はUberは彼らの記事が言っているようにPostgreSQLをMySQLでリプレースしたとは思っていません。彼らは実際には、(現時点では)MySQL/InnoDBで支えられた彼ら独自のソリューションでPostgreSQLを置き換えたように見えます。

It seems that the article just explains why MySQL/InnoDB is a better backend for Schemaless than PostgreSQL. For those of you using Schemaless, take their advice! Unfortunately, the article doesn’t make this very clear because it doesn’t mention how their requirements changed with the introduction of Schemaless compared to 2013, when they migrated from MySQL to PostgreSQL.

元記事は、MySQL/InnoDBがなぜ彼らのSchemalessのバックエンドとして、PostgreSQLよりも良かったのか、ということを説明しているように見えます。Schemalessを使っている人たちは、そのアドバイスを聞くべきでしょう! 残念ながら、元記事はそのことについて明確にしていません。というのは、Schemalessを紹介するに当たって、彼らの要件が2013年、彼らがMySQLからPostgreSQLに移行した時と比べてどのように変化してきたのかについて言及していないからです。

Sadly, the only thing that sticks in the reader’s mind is that PostgreSQL is lousy.

悲しいことに、読者の頭の中に残るのは、PostgreSQLがひどい、ということだけです。

If you like my way of explaining things, you’ll love my book.

もし、私が説明する諸々を気に入っていただけるのであれば、おそらく私の本(※訳注:日本語版)も気に入ってもらえるのではないかと思います。

9月10日(土)に第8回PostgreSQLアンカンファレンスを開催します

$
0
0
開催まであと1週間を切りましたが、9/10にPostgreSQLアンカンファレンスを開催します。多分、8回目くらいだと思います。
いつもの通り、プログラムやタイムテーブルは当日集まってから募集して調整します。

PostgreSQLに興味があって、いろんな技術レベルの人が集まっていますので、初めての方もお気軽にご参加ください。

いつもオープニングの時に聞いているのですが、参加者のうち、だいたい2/3くらいは初めて参加の方っぽいですので。

では、来週末お会いしましょう。

巡回セールスマン問題における最短経路をpgRoutingで探索する

$
0
0
先日、PostgreSQLアンカンファレンスを開催した際、「pgRoutingを使って巡回セールスマン問題を解く」という発表を国府田さんがされていました。
非常に面白そうな機能で、私も少し使ってみましたので、今回はその使い方や使用例などを含めてご紹介します。

■「巡回セールスマン問題」とは何か


「巡回セールスマン問題」というのは、以下のようなものです。
巡回セールスマン問題(じゅんかいセールスマンもんだい、英: traveling salesman problem、TSP)は、都市の集合と各2都市間の移動コスト(たとえば距離)が与えられたとき、全ての都市をちょうど一度ずつ巡り出発地に戻る巡回路の総移動コストが最小のものを求める(セールスマンが所定の複数の都市を1回だけ巡回する場合の最短経路を求める)組合せ最適化問題である。
巡回セールスマン問題 - Wikipedia
簡単に言うと、「セールスマンが何か所か回る時、回る場所が増えれば増えるほど、可能性のある経路の候補が爆発的に増えていくので、最短経路を導き出すのが困難になる」ということです。回る場所の数「n」に対して、計算量はその階乗「n!」のオーダーとなります。

そのため、データ量が増えると総当たりで解くことが計算量的に困難になる問題のひとつとして知られています。

■「pgRouting」とは何か


pgRoutingは、PostgreSQLおよびPostGISの拡張で、PostgreSQL/PostGISの地理空間データベースの機能に経路探索(routing)の機能を追加するライブラリです。
プロジェクトの紹介文によると、
  • さまざまなクライアント(ライブラリ)を通してデータや属性を加工可能。
  • データの変更はすぐに経路探索に反映される。事前の計算などは不要。
  • 「コスト」のパラメータは動的にSQLで計算され、テーブルのフィールドやレコードから取得可能。
となっています。

経路探索のアルゴリズムはいろいろあるようで、コアの機能としては以下のようなアルゴリズムを使うことができます。
  • All Pairs Shortest Path, Johnson’s Algorithm
  • All Pairs Shortest Path, Floyd-Warshall Algorithm
  • Shortest Path A*
  • Bi-directional Dijkstra Shortest Path
  • Bi-directional A* Shortest Path
  • Shortest Path Dijkstra
  • Driving Distance
  • K-Shortest Path, Multiple Alternative Paths
  • K-Dijkstra, One to Many Shortest Path
  • Traveling Sales Person
  • Turn Restriction Shortest Path (TRSP)
今回は、この中から巡回セールスマン問題(Traveling Sales Person)を解くための関数を使ってみます。

■pgRoutingのpgr_tsp関数


今回使用するpgRoutingの関数はpgr_tspです。
この関数は Simulated Annealing(焼きなまし法)という方法で、最短経路を探索します。
この関数は2つの使い方があります。ユークリッド距離を使って2点間の距離を計算して最短経路を探す方法と、各点間の距離をあらかじめ行列として定義したものを与えて最短経路を探す方法です。

まず、ユークリッド距離を使う方法ですが、引数にSQL文を渡す必要があり、このSQL文は「id, x, y」というカラムを返却するSQL文である必要があります。

pgr_costResult[] pgr_tsp(sql text, start_id integer);
pgr_costResult[] pgr_tsp(sql text, start_id integer, end_id integer);

もう一つの各点間の距離を行列で与える場合には、float型の二次元配列として与えます。

record[] pgr_tsp(matrix float[][], start integer)
record[] pgr_tsp(matrix float[][], start integer, end integer)

今回は、前者のユークリッド距離を使うバージョンを使います。

まず、以下のように位置情報をx,yとして持つテーブルを作成しておきます。

pgr=> CREATE TABLE vertex_table (
pgr(> id serial,
pgr(> x double precision,
pgr(> y double precision
pgr(> );
CREATE TABLE
pgr=>
pgr=> INSERT INTO vertex_table VALUES
pgr-> (1,2,0), (2,2,1), (3,3,1), (4,4,1), (5,0,2), (6,1,2), (7,2,2),
pgr-> (8,3,2), (9,4,2), (10,2,3), (11,3,3), (12,4,3), (13,2,4);
INSERT 0 13
pgr=> SELECT * FROM vertex_table;
id | x | y
----+---+---
1 | 2 | 0
2 | 2 | 1
3 | 3 | 1
4 | 4 | 1
5 | 0 | 2
6 | 1 | 2
7 | 2 | 2
8 | 3 | 2
9 | 4 | 2
10 | 2 | 3
11 | 3 | 3
12 | 4 | 3
13 | 2 | 4
(13 rows)

pgr=>

この位置情報のテーブルから「id,x,y」を取得するSQL文を与えて、pgr_tspを実行すると、以下のように最短経路を表示してくれます。

pgr=> SELECT seq, id1, id2, cost FROM pgr_tsp('SELECT id, x, y FROM vertex_table ORDER BY id', 6, 5);
seq | id1 | id2 | cost
-----+-----+-----+------------------
0 | 5 | 6 | 1
1 | 6 | 7 | 1
2 | 7 | 8 | 1.4142135623731
3 | 9 | 10 | 1
4 | 12 | 13 | 1.4142135623731
5 | 10 | 11 | 1
6 | 11 | 12 | 1
7 | 8 | 9 | 1
8 | 3 | 4 | 1
9 | 2 | 3 | 1.4142135623731
10 | 0 | 1 | 1
11 | 1 | 2 | 2.23606797749979
12 | 4 | 5 | 1
(13 rows)

pgr=>

出力で重要なのは「seq、id2、cost」です。seqは経路の順番、id2はノードのid(vertex_tableのidカラム)、costは次のノードに移動するためのコスト(距離)です。

■聖地巡礼の最短経路探索問題


世間では今、映画「君の名は。」が大ヒットしています。(唐突感)



アニメのヒット作には、聖地の存在が欠かせません。そしてそのファンは聖地巡礼をすることになっています(?)。

しかし、聖地といってもたくさんありますし、時間は限られていますので、効率よく回ることが求められてきます。巡るべき聖地が増えれば増えるほど計算量が爆発的に増大し、最短経路を求めることが困難になってきます。多分。

というわけで、「君の名は。」の聖地を巡礼するための最短経路を巡回セールスマン問題としてpgRoutingを使って探索してみます。(手段の目的化)

なお、本エントリはここからが本題です。

■PostGIS/pgRoutingとpykmlのインストール


まず、PostGIS/pgRoutingをインストールします。

PostgreSQLコミュニティのyumレポジトリを使っている場合には以下のコマンドでインストールできます。今回はPostgreSQL 9.5と一緒に使っています。

$ sudo yum install -y postgis2_95 pgrouting_95
(...)
$ rpm -qa | grep pgrouting
pgrouting_95-2.0.1-1.rhel6.x86_64
$ rpm -qa | grep postgis
postgis2_95-2.2.2-1.rhel6.x86_64
$

また、三次元地理空間情報のデータ形式であるKMLファイルを扱うPythonのライブラリpykmlもインストールします。

$ sudo pip install pykml
(...)
$ pip list | grep pykml
pykml (0.1.0)
$

インストールが終わったら、今回使うデータベースにPostGISとpgRoutingのEXTENSIONをインストールします。

pgr=# create extension postgis;
CREATE EXTENSION
pgr=# create extension pgrouting;
CREATE EXTENSION
pgr=# \d
List of relations
Schema | Name | Type | Owner
--------+-------------------+-------+----------
public | geography_columns | view | postgres
public | geometry_columns | view | postgres
public | raster_columns | view | postgres
public | raster_overviews | view | postgres
public | spatial_ref_sys | table | postgres
(5 rows)

pgr=#

■データの準備をする


最初、自分で聖地の位置情報のデータを作成しようかとも思っていたのですが、いろいろ探していたらGoogle Mapsに「君の名は。」の聖地マップが公開されていましたので、今回はこれを使います。
まず、この聖地マップを自分のアカウントにコピーしてきます。

コピーの方法は、マップの右上にあるプルダウンメニューから「Copy map」を選ぶだけです。




次に、聖地マップのデータをKML形式でエクスポートします。

コピーの時と同じく、プルダウンメニューから「Download KML」を選んで、マップ全体のデータをKMLとしてダウンロードして保存します。


エクスポートしたKMLファイルは以下のようなXMLファイルになっているはずです。

$ head Copy\ of\ 【君の名は。】聖地巡礼マップ【831現在】.kml
<?xml version='1.0' encoding='UTF-8'?>
<kml xmlns='http://www.opengis.net/kml/2.2'>
<Document>
<name>Copy of 【君の名は。】聖地巡礼マップ【8/31現在】</name>
<description><![CDATA[]]></description>
<Folder>
<name>無題のレイヤ</name>
<Placemark>
<name>予告映像カット
</name>
$

次に、KMLファイルの中に含まれている位置情報をPostgreSQLに投入するためのINSERT文に変換します。

今回テーブルは以下の構造にします。

CREATE TABLE seichi (
sid serial primary key,
name text not null,
descr text,
img text,
lat float8 not null,
lon float8 not null
);

sidはシーケンス番号、nameはGoogle Mapsに登録されていた名前、descrは説明文です。imgは位置の画像が設定されたいた場合のURL、lat/lonは緯度経度をfloat8の精度で持ちます。

場所の情報はKMLファイル内の「Placemark」というタグに囲まれていますので、この情報を取得します。

KMLファイルの解析はpykmlモジュールを使って行います。
kml_to_sql.pyスクリプトにKMLファイルを引数として渡すと、KMLファイル内のPlacemarkのデータをINSERT文に変換して以下のように出力します。

$ python kml_to_sql.py Copy\ of\ 【君の名は。】聖地巡礼マップ【831現在】.kml > ins.sql
$ head ins.sql
INSERT INTO seichi (name,descr,img,lat,lon) VALUES
('予告映像カット','新宿警察署裏交差点','',139.6944129,35.6925938)
, ('君の名は。第1弾キービジュアル','瀧が立っている横断歩道の背景建物','https://lh5.googleusercontent.com/proxy/Sior7uMyxlKiZMS5MQq7ytwJ_AgL8VwYhiKyTDtDcA2WxYG5WYrchQSLtUzceGHhOILYSNbJCc3X91eyJ8caYQFxwYbw7w5eiPbfTw',139.723177,35.6607657)
, ('三葉が座っていた総武線ホームのベンチ','','https://lh4.googleusercontent.com/proxy/XCtfAwJ3c_W1oI8Uvf2gGGhuv6RXwxqh52swbRHcob6GgNR5CQWInAPcqn51O1v0TjgEtUZrmdPvg7M_AyoKPjH77ZGeWmK-bVUKhQ',139.7020626,35.6840715)
, ('瀧と奥寺先輩がお茶していたスタバ','看板TSUTAYAのTSUの部分にあたるカウンター席','https://lh5.googleusercontent.com/proxy/qgTU7IClAh-I-byP2gBdIim_Ybqea1WJlEmBmpxMyc7ohPk-nV8BQ7Vsx-pkFv8nEpW5__sRVe3a5wZ3FimzMlXC9b0PsCMRmGLing',139.7003701,35.6598526)
, ('君の名は。第1弾キービジュアル','背景の六本木ヒルズ','',139.7293139,35.6603647)
, ('君の名は。第1弾キービジュアル','背景の東京ミッドタウンビル','',139.7312933,35.6662877)
, ('君の名は。第1弾キービジュアル','瀧が立っている横断道','',139.7228122,35.6613584)
, ('道路標識案内板','瀧と奥寺先輩が別れる歩道橋についている道路標識案内板','https://lh6.googleusercontent.com/proxy/F8rCZSs_2tcCqGBBIxsECaYSr0FmEeM9FfG2evxuaoqSl-RoHnq1OThqd-ZIu-PtZV1S7oM5bOF8viSIoRxp8T3-Hi98C3IcF5y33Q',139.7233808,35.6743456)
, ('歩道橋背景1','瀧と奥寺先輩が別れる歩道橋の背景','https://lh6.googleusercontent.com/proxy/WaP7vdtpJOHiIeZHIXPCa5zk5SizpjBWms3u01T_oQVKT_L1_EuYGoggRZzQONo1d2Bi0OOjBEDRfY8FHtd02OcCKNJeAUCRiVr95a09',139.7238582,35.6723802)
$

そして、作成したテーブルにINSERT文でデータを流し込みます。

$ psql -f ins.sql pgr
INSERT 0 38
$

データができたことを確認したら、準備は完了です。

pgr=> \x
Expanded display is on.
pgr=> select * from seichi limit 3;
-[ RECORD 1 ]-----------------------------------------------------------------------------------------------------------------------------------------
sid | 1
name | 予告映像カット
descr | 新宿警察署裏交差点
img |
lat | 139.6944129
lon | 35.6925938
-[ RECORD 2 ]-----------------------------------------------------------------------------------------------------------------------------------------
sid | 2
name | 君の名は。第1弾キービジュアル
descr | 瀧が立っている横断歩道の背景建物
img | https://lh5.googleusercontent.com/proxy/Sior7uMyxlKiZMS5MQq7ytwJ_AgL8VwYhiKyTDtDcA2WxYG5WYrchQSLtUzceGHhOILYSNbJCc3X91eyJ8caYQFxwYbw7w5eiPbfTw
lat | 139.723177
lon | 35.6607657
-[ RECORD 3 ]-----------------------------------------------------------------------------------------------------------------------------------------
sid | 3
name | 三葉が座っていた総武線ホームのベンチ
descr |
img | https://lh4.googleusercontent.com/proxy/XCtfAwJ3c_W1oI8Uvf2gGGhuv6RXwxqh52swbRHcob6GgNR5CQWInAPcqn51O1v0TjgEtUZrmdPvg7M_AyoKPjH77ZGeWmK-bVUKhQ
lat | 139.7020626
lon | 35.6840715

pgr=>

■聖地巡礼の最短経路を求める


それでは、聖地巡礼の最短経路を巡回セールスマン問題として探索してみます。

前述したように、pgr_tsp関数にユークリッド距離を使って計算させます。

pgr_tspに与えるクエリは

SELECT sid id, lat x, lon y FROM seichi ORDER BY sid

となります(エイリアスでカラム名を指定)。

pgr_tsp関数に与える2つ目の引数は、スタートの点のidを示しています。(今回は1番目から出発)

さて、クエリを実行してみましょう。

pgr=> SELECT * FROM pgr_tsp('SELECT sid id, lat x, lon y FROM seichi ORDER BY sid', 1);
seq | id1 | id2 | cost
-----+-----+-----+----------------------
0 | 0 | 1 | 0.00276786017347617
1 | 16 | 17 | 0.00336861318052751
2 | 21 | 22 | 0.00418150821235386
3 | 26 | 27 | 0.00070285384682649
4 | 18 | 19 | 0.00103679575616323
5 | 17 | 18 | 0.000724005262411046
6 | 14 | 15 | 0.00040594088239891
7 | 25 | 26 | 0.00420010183685812
8 | 2 | 3 | 0.00965949435530557
9 | 19 | 20 | 0.00786443285746516
10 | 24 | 25 | 0.00638462619110202
11 | 30 | 31 | 0.000613460422526542
12 | 31 | 32 | 0.0020755731593899
13 | 36 | 37 | 0.00521034970899676
14 | 34 | 35 | 0.00453278438158901
15 | 23 | 24 | 0.00214997988828621
16 | 22 | 23 | 0.00338756302081414
17 | 20 | 21 | 0.00901689128802769
18 | 13 | 14 | 4.29288947063113e-05
19 | 33 | 34 | 0.00758742562995817
20 | 35 | 36 | 0.00655833544277088
21 | 37 | 38 | 0.00662867080567097
22 | 32 | 33 | 0.0076538252201047
23 | 29 | 30 | 0.000494603275347511
24 | 28 | 29 | 0.00116522954392147
25 | 11 | 12 | 0.00340343115400377
26 | 12 | 13 | 0.00387475855377472
27 | 7 | 8 | 0.00202254985600024
28 | 8 | 9 | 0.000924678733398354
29 | 9 | 10 | 0.00108906486492197
30 | 10 | 11 | 0.00636705982066888
31 | 15 | 16 | 0.00573513230013025
32 | 4 | 5 | 0.00657719868789178
33 | 6 | 7 | 0.0222159042861008
34 | 27 | 28 | 0.00284098090455204
35 | 3 | 4 | 0.0228251711761441
36 | 1 | 2 | 0.00981665980311944
37 | 5 | 6 | 0.0453009359877922
(38 rows)

pgr=>

最短経路が出ました。id2カラムの順番に巡礼していけば、最短経路で聖地巡礼ができることになります。

なお、これだけではちょっと分かりづらいので、元の地理のデータをJOINしてnameとdescrを表示してみましょう。(不要なid1とcostカラムも省きます)

pgr=> SELECT
pgr-> t.seq,
pgr-> t.id2,
pgr-> s.name,
pgr-> s.descr
pgr-> FROM
pgr-> pgr_tsp('SELECT sid id, lat x, lon y FROM seichi ORDER BY sid', 1) t
pgr-> LEFT OUTER JOIN seichi s ON t.id2 = s.sid;
seq | id2 | name | descr
-----+-----+--------------------------------------------------+--------------------------------------------------------------------------------------------------------------------------
0 | 1 | 予告映像カット | 新宿警察署裏交差点
1 | 17 | オープニング | ・作中のオープニングの背景・大人になった瀧と三葉がすれ違う歩道橋
2 | 22 | ユニカビジョン |
3 | 27 | 大人になった瀧が走り出てくる改札 | 三葉を探しに飛び出す南口改札
4 | 19 | 瀧の通学路&大人になった瀧が走っていく場所 | ・はじめて瀧と入れ替わった三葉が学校へ向かう通ったルミネのショップウィンドウ沿い・大人になった瀧が三葉を探しに走っていく
5 | 18 | 作中背景 | 連絡橋からバルト9方面
6 | 15 | 風景カット | サザンテラス口店 スタバの入口階段と手前の花壇
7 | 26 | 大人になった瀧が来ていたスタバ | ここで式の話をしている大人になったテッシーとさやちんが登場
8 | 3 | 三葉が座っていた総武線ホームのベンチ |
9 | 20 | 大人の三葉 | ラスト、 瀧を見つけた大人の三葉が駆け出してくる千駄ヶ谷駅の改札
10 | 25 | 走る三葉 | 大人になった三葉が瀧を見つけに走って通る場所
11 | 31 | 君の名は。 第2弾キービジュアル&作中ラストシーン | 瀧と三葉がすれ違う階段・物語の最後のシーン
12 | 32 | 十字路 | みつはが走ってくるシーン
13 | 37 | 風景カット | 道路標識
14 | 35 | 瀧が息を整える場所 |
15 | 24 | 瀧と奥寺先輩が四ッ谷駅方面を眺める場所 | 就活中の瀧と奥寺先輩
16 | 23 | 瀧(大人)と奥寺先輩が話しながら歩いている道 |
17 | 21 | 瀧と奥寺先輩のデート | 物語終盤、就活中の瀧と奥寺先輩のデートで渡っていた弁慶橋
18 | 14 | 瀧と奥寺先輩の待ち合わせ1 | 四ッ谷駅赤坂方面改札
19 | 34 | 瀧と奥寺先輩の待ち合わせ2 | アトレの方角
20 | 36 | 風景カット | 郵便ポスト
21 | 38 | 風景カット | 四谷三丁目の交差点
22 | 33 | 分かれ道 | 瀧が走っていくシーン
23 | 30 | 太陽の光とドコモタワー |
24 | 29 | 瀧が立っていた歩道橋 | JR総武線信濃町駅前の歩道橋
25 | 12 | 歩道橋背景4 | 瀧と奥寺先輩が別れる歩道橋の下に見切れている道路標識板
26 | 13 | 瀧の通学路 | 瀧と司と真太の下校シーン
27 | 8 | 道路標識案内板 | 瀧と奥寺先輩が別れる歩道橋についている道路標識案内板
28 | 9 | 歩道橋背景1 | 瀧と奥寺先輩が別れる歩道橋の背景
29 | 10 | 歩道橋背景2 | 瀧と奥寺先輩が別れる歩道橋
30 | 11 | 歩道橋背景3 | 瀧と奥寺先輩が別れる歩道橋
31 | 16 | 国立新美術館 |
32 | 5 | 君の名は。第1弾キービジュアル | 背景の六本木ヒルズ
33 | 7 | 君の名は。第1弾キービジュアル | 瀧が立っている横断道
34 | 28 | あおい書店前歩道橋 | 作中、瀧が数回通っていた歩道橋
35 | 4 | 瀧と奥寺先輩がお茶していたスタバ | 看板TSUTAYAのTSUの部分にあたるカウンター席
36 | 2 | 君の名は。第1弾キービジュアル | 瀧が立っている横断歩道の背景建物
37 | 6 | 君の名は。第1弾キービジュアル | 背景の東京ミッドタウンビル
(38 rows)

pgr=>

「新宿警察署裏交差点」を出発点として、聖地巡礼のルートがより具体的に見えてきました。

■求めた最短経路を可視化する


最後に、この巡礼の経路をGoogle Mapsに取り込んで可視化してみます。

経路を可視化するには、2点間に線を引かなければなりません。Google Maps上で線を引かせるには、出発点の緯度経度と、到着点の緯度経度情報が必要です。ウィンドウ関数の匂いがします。

まず、先ほどのクエリを少し修正して、場所の名前ではなく緯度経度を表示させます。

pgr=> SELECT
pgr-> t.seq,
pgr-> t.id2,
pgr-> s.lat,
pgr-> s.lon
pgr-> FROM
pgr-> pgr_tsp('SELECT sid id, lat x, lon y FROM seichi ORDER BY sid', 1) t
pgr-> LEFT OUTER JOIN seichi s ON t.id2 = s.sid;
seq | id2 | lat | lon
-----+-----+-------------+------------
0 | 1 | 139.6944129 | 35.6925938
1 | 17 | 139.6971166 | 35.6931863
2 | 22 | 139.7004586 | 35.6936089
3 | 27 | 139.7002923 | 35.6894307
4 | 19 | 139.7009951 | 35.689422
(...)
33 | 7 | 139.7228122 | 35.6613584
34 | 28 | 139.7010112 | 35.6570849
35 | 4 | 139.7003701 | 35.6598526
36 | 2 | 139.723177 | 35.6607657
37 | 6 | 139.7312933 | 35.6662877
(38 rows)

pgr=>

次に、ウィンドウ関数を使って「前の地点の緯度経度」を表示させます。

pgr=> SELECT
pgr-> t.seq,
pgr-> t.id2,
pgr-> s.lat,
pgr-> s.lon,
pgr-> lag(s.lat) OVER (ORDER BY seq) prev_lat,
pgr-> lag(s.lon) OVER (ORDER BY seq) prev_lon
pgr-> FROM
pgr-> pgr_tsp('SELECT sid id, lat x, lon y FROM seichi ORDER BY sid', 1) t
pgr-> LEFT OUTER JOIN seichi s ON t.id2 = s.sid;
seq | id2 | lat | lon | prev_lat | prev_lon
-----+-----+-------------+------------+-------------+------------
0 | 1 | 139.6944129 | 35.6925938 | |
1 | 17 | 139.6971166 | 35.6931863 | 139.6944129 | 35.6925938
2 | 22 | 139.7004586 | 35.6936089 | 139.6971166 | 35.6931863
3 | 27 | 139.7002923 | 35.6894307 | 139.7004586 | 35.6936089
4 | 19 | 139.7009951 | 35.689422 | 139.7002923 | 35.6894307
(...)
33 | 7 | 139.7228122 | 35.6613584 | 139.7293139 | 35.6603647
34 | 28 | 139.7010112 | 35.6570849 | 139.7228122 | 35.6613584
35 | 4 | 139.7003701 | 35.6598526 | 139.7010112 | 35.6570849
36 | 2 | 139.723177 | 35.6607657 | 139.7003701 | 35.6598526
37 | 6 | 139.7312933 | 35.6662877 | 139.723177 | 35.6607657
(38 rows)

pgr=>

このデータをPythonスクリプトでKMLファイルに変換します。

tsp_to_kml.pyスクリプトでは、psycopg2を使ってPostgreSQLに接続してデータを取り出し、XMLファイルをベタに書き出します。

このスクリプトの出力を保存すると、以下のようなKMLファイルを得られます。

$ python tsp_to_kml.py > route.kml
$ cat route.kml

<?xml version='1.0' encoding='UTF-8'?>
<kml xmlns='http://www.opengis.net/kml/2.2'>
<Document>
<Style id="line-DB4436-5-nodesc">
<LineStyle>
<color>ff0000ff</color>
<width>5</width>
</LineStyle>
</Style>
<name>Copy of 【君の名は。】聖地巡礼マップ【8/31現在】</name>
<description><![CDATA[]]></description>
<Folder>
<name>巡礼最短経路</name>

<Placemark>
<name>Path 1</name>
<styleUrl>#line-DB4436-5-nodesc</styleUrl>
<LineString>
<coordinates>139.6971166,35.6931863,0 139.6944129,35.6925938,0</coordinates>
</LineString>
</Placemark>

<Placemark>
<name>Path 2</name>
<styleUrl>#line-DB4436-5-nodesc</styleUrl>
<LineString>
<coordinates>139.7004586,35.6936089,0 139.6971166,35.6931863,0</coordinates>
</LineString>
</Placemark>
(...)
</Folder>
</Document>
</kml>

$

なお、KMLファイルのFolder要素がGoogle Mapsで言うところのレイヤーに当たります。今回は、巡礼の経路情報はすべて「巡礼最短経路」という一つのレイヤーにまとめてあります。

最後に、作成したroute.kmlというKMLファイルを、Google Mapsにインポートします。

まず、地図に「Add layer」でレイヤーを追加します。


次に、作成した新しいレイヤーに「Import」というメニューがありますので、そこをクリックしてKMLファイルをインポートします。



インポートが無事に完了すれば、レイヤーの名前が「巡礼最短経路」となり、経路が地図上に表示されます。


やったぜ可視化!

■まとめ


今回は、複数の緯度経度の情報から、それらを結ぶ最短経路を求める演算をpgRoutingを使って実現しました。

また、KMLファイルをエクスポート・インポートすることで、その演算結果をGoogle Mapsのデータを活用して、可視化できることを示しました。

なお、今回は実現できなかったこととして、以下のようなことがあります。
  • KMLのエクスポート、インポートをWebAPIでやりたい。(誰か教えてください)
  • 地理的な距離ではなく、所用時間などでコスト計算やってみたい。
  • 総距離や総時間に制限を設けた上で、「制限時間内にできるだけ多く回る」みたいな探索をしてみたい。
  • など。
演算の部分については、もしかしたらpgRoutingの他の関数などで実現できるかもしれないので、少しずつ調べてみたいと思います。

ぜひ、みなさんも地理情報とPostGISやpgRoutingを使って、何か面白いことにチャレンジしてみていただければと思います。

Enjoy, 聖地巡礼ライフ!!

では、また。

MADlib 1.9.1 Release (GA)がリリースされました

$
0
0
このブログでも何度か紹介しているPostgreSQLのデータベース内で機械学習の処理を行えるApache MADlibですが、1.9.1 GAがリリースされました。
前のリリース1.9からの変更点は、以下のようになっています。
  • New function: One class SVM
  • SVM: Added functionality to assign weights to each class, simplying classification of unbalanced data.
  • New function: Prediction metrics
  • New function: Sessionization
  • New function: Pivot
  • Path: Major performance improvement
  • Path: Add support for overlapping patterns
  • Build: Add support for PG 9.5 and 9.6
  • PGXN: Update PostgreSQL Extension Network to latest release
私の送ったパッチも取り込まれて、無事に最新のPostgreSQL 9.5と、パラレルクエリを実装した次期バージョンである9.6でも動作するようになりました。

興味のある方は、ぜひ試してみていただければと思います。

以下のエントリあたりが参考になればと思います。
では、また。

PostgreSQL 9.5日本語マニュアルの検索システムをリリースしました

$
0
0
PostgreSQL 9.5の日本語マニュアルの検索システムをリリースしたので、ご紹介します。
少し前からPostgreSQLのマニュアルを細かく調べる必要性が出てきたのですが、ご存じの通り、PostgreSQLのオンラインのマニュアルはGoogleと相性が良くありません。

本当はgrep -cでもいいくらいの機能なのですが、公開されているフォーマットがHTML、マニュアルのソースファイルはSGMLファイルなので、実際にそのままgrepしても、見栄え的にあまり嬉しくありません。

そのため、自分の開発の練習もかねてWebアプリとして作ってみました。

■マニュアル検索システムの機能


検索システムのURLは以下です。
検索対象となるのは、日本PostgreSQLユーザ会が翻訳して以下で公開しているPostgreSQL 9.5の日本語マニュアルで、リリースノートと索引を除いたページです。
マニュアルのページ自体は、日本PostgreSQLユーザ会のマニュアルをオンラインで参照する形になっています。

検索キーワードを入力すると、キーワードが合致した回数が多いページから順に表示します。検索結果のページタイトルの右側に表示されている数字は、キーワードが合致した回数です。1回でも合致したページはすべて表示します。

また、複数のキーワードをスペースで区切って入力することで、OR検索またはAND検索ができます。OR検索では、「いずれかのキーワードを含むページ」を表示します。AND検索では「すべてのキーワードを含むページ」を表示します。

OR検索の場合は「単語 単語」のように単にスペースで区切って入力してください。AND検索の場合には「単語 +単語」のように、いずれかの単語の最初にプラス記号を付加してください。プラス記号を付加するとAND検索に切り替わります。

「性能」や「パフォーマンス」などのように言い換えた言葉で記載されている個所をすべて確認したい場合にはOR検索を使うといいでしょう。一方で、「ログ」、「アーカイブ」のように異なる単語で絞り込む場合には、AND検索を使うといいでしょう。

単語の出現回数をもとにランキングする仕組みであるため、ページの長さでnormalize(単語の出現回数を文書の長さで割る)しようかとも思ったのですが、「長いページである」というのもひとつの情報であり、そのままにした方がより価値があると考えたためnormalizeはしていません。

■検索の仕組み


この検索システムは、Python用のWebフレームワークであるFlaskを使ったWebアプリケーションとして作成されています。

以前のエントリで紹介したように、PostgreSQLのマニュアルをwgetで取得、データベースに格納して、HTMLからプレーンテキストに変換し、それを検索対象としています。
今回のテーブルは以下の通りです。

testdb=> \d pgdoc
Table "public.pgdoc"
Column | Type | Modifiers
----------+---------+-------------------------------------------------------
docid | integer | not null default nextval('pgdoc_docid_seq'::regclass)
filename | text | not null
html | text | not null
plain | text |
title | text |
Indexes:
"pgdoc_pkey" PRIMARY KEY, btree (docid)

ページごとのスコア(キーワード出現回数)を取得するために、ドキュメントとキーワードのtext配列を渡すと、合致した回数をスコアとして返却するPL/PythonのSQL関数を作成します。


CREATE OR REPLACE FUNCTION pgdoc_score(doc text, q text[])
RETURNS float8
AS $$
import re

score = 0
for t in q:
f = re.findall(t, doc, flags=re.IGNORECASE)
score += len(f)

return score
$$
LANGUAGE 'plpython2u';

この関数は、以下のように大文字小文字を違いを無視して、キーワードが合致した回数を返却します。

testdb=> SELECT pgdoc_score('foo foo bar bar bar', '{"foo", "BAR"}');
pgdoc_score
-------------
5
(1 row)

testdb=>

このSQL関数を使って、指定したキーワードに対するスコアを取得し、それをスコア順に並べ替えて表示しています。

testdb=> SELECT docid,filename,title,pgdoc_score(plain, '{WAL}') FROM pgdoc ORDER BY 4 DESC LIMIT 5;
docid | filename | title | pgdoc_score
-------+---------------------------------+------------------------------------------------------------+-------------
1181 | continuous-archiving.html | 24.3. 継続的アーカイブとポイントインタイムリカバリ(PITR) | 85
178 | runtime-config-wal.html | 18.5. ログ先行書き込み(WAL) | 77
69 | warm-standby.html | 25.2. ログシッピングスタンバイサーバ | 70
907 | wal-configuration.html | 29.4. WALの設定 | 56
1011 | runtime-config-replication.html | 18.6. レプリケーション | 40
(5 rows)

■pg_bigmを試してみる


なお、全文検索ということでpg_bigmを使ってみたのですが、これくらいの文章量だと少なすぎてインデックスをうまく使ってくれませんでした。実行プランを見ても、シーケンシャルスキャンの方がコストが低いと判断されているようです。


testdb=> CREATE INDEX pgdoc_plain_idx ON pgdoc USING GIN (plain gin_bigm_ops);
CREATE INDEX
testdb=> EXPLAIN ANALYZE SELECT
testdb-> docid,
testdb-> title,
testdb-> filename,
testdb-> pgdoc_score(plain,'{ログ,アーカイブ}')
testdb-> FROM
testdb-> pgdoc
testdb-> WHERE
testdb-> docid in (SELECT docid FROM pgdoc WHERE (plain ILIKE '%ログ%' AND plain ILIKE '%アーカイブ%') AND filename NOT LIKE 'release-%' AND filename <>'bookindex.html')
testdb-> ORDER BY
testdb-> 4 DESC;
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------
Sort (cost=170.75..170.75 rows=1 width=755) (actual time=201.327..201.362 rows=38 loops=1)
Sort Key: (pgdoc_score(pgdoc.plain, '{ログ,アーカイブ}'::text[]))
Sort Method: quicksort Memory: 30kB
-> Nested Loop (cost=0.28..170.74 rows=1 width=755) (actual time=2.166..201.201 rows=38 loops=1)
-> Seq Scan on pgdoc pgdoc_1 (cost=0.00..162.18 rows=1 width=4) (actual time=1.387..169.877 rows=38 loops=1)
Filter: ((plain ~~* '%ログ%'::text) AND (plain ~~* '%アーカイブ%'::text) AND (filename !~~ 'release-%'::text) AND (filename <>'bookindex.html'::text))
Rows Removed by Filter: 1271
-> Index Scan using pgdoc_pkey on pgdoc (cost=0.28..8.30 rows=1 width=755) (actual time=0.004..0.006 rows=1 loops=38)
Index Cond: (docid = pgdoc_1.docid)
Planning time: 1.654 ms
Execution time: 201.443 ms
(11 行)

enable_seqscanパラメータをoffにして強制的にインデックスを使うようにしてみましたが、推定コストはこちらの方が高く、実際の実行時間もほとんど変わりませんでした。

testdb=> set enable_seqscan TO off;
SET
testdb=> EXPLAIN ANALYZE SELECT
testdb-> docid,
testdb-> title,
testdb-> filename,
testdb-> pgdoc_score(plain,'{ログ,アーカイブ}')
testdb-> FROM
testdb-> pgdoc
testdb-> WHERE
testdb-> docid in (SELECT docid FROM pgdoc WHERE (plain ILIKE '%ログ%' AND plain ILIKE '%アーカイブ%') AND filename NOT LIKE 'release-%' AND filename <>'bookindex.html')
testdb-> ORDER BY
testdb-> 4 DESC;
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------
Sort (cost=381.05..381.05 rows=1 width=755) (actual time=200.820..200.854 rows=38 loops=1)
Sort Key: (pgdoc_score(pgdoc.plain, '{ログ,アーカイブ}'::text[]))
Sort Method: quicksort Memory: 30kB
-> Nested Loop (cost=0.56..381.04 rows=1 width=755) (actual time=3.918..200.702 rows=38 loops=1)
-> Index Scan using pgdoc_pkey on pgdoc pgdoc_1 (cost=0.28..372.48 rows=1 width=4) (actual time=3.136..169.352 rows=38 loops=1)
Filter: ((plain ~~* '%ログ%'::text) AND (plain ~~* '%アーカイブ%'::text) AND (filename !~~ 'release-%'::text) AND (filename <>'bookindex.html'::text))
Rows Removed by Filter: 1271
-> Index Scan using pgdoc_pkey on pgdoc (cost=0.28..8.30 rows=1 width=755) (actual time=0.004..0.005 rows=1 loops=38)
Index Cond: (docid = pgdoc_1.docid)
Planning time: 1.672 ms
Execution time: 200.934 ms
(11 行)

というわけで、パフォーマンス的にも特に問題がないので、今のところはpg_bigmは使っていません。今後ドキュメント量が増えたら使うかもしれません。

■まとめ


今回は、PostgreSQLで構築したPostgreSQLの日本語マニュアルの検索システムをご紹介しました。

PostgreSQLを使いこんでくると、パラメータ名などでマニュアルを隅々まで検索したい、といったニーズが出てくることがあります。

Googleなどの検索エンジンでざっくりと検索するだけでは検索の精度が足りないケースが出てくると思いますので、そのような場合に活用していただければと思います。

では、また。

Jupyter NotebookからPostgreSQLに接続してデータを可視化する

$
0
0
最近、なんだかんだとデータに触る機会が増えてきております。

Unix系エンジニア兼DBAとしては、CLI(コマンドラインインターフェース)が生産性が高くて好きだけど、一方で可視化もお手軽にやりたい、というケースが多々あります。

Jupyter Notebookでデータベースに接続して可視化できる、という話は以前から聞いたことがあったのですが、実際に試してみたことがありませんでした。

今回、軽くPostgreSQLで試してみたのでその手順を簡単にご紹介します。

■セットアップ


以下の3つのモジュールをpipでインストールします。
  • jupyter
  • psycopg2
  • ipython-sql

[snaga@localhost]$ ipython notebook --ip=\* --port=8080
[W 16:01:11.273 NotebookApp] WARNING: The notebook server is listening on all IP addresses and not using encryption. This is not recommended.
[W 16:01:11.273 NotebookApp] WARNING: The notebook server is listening on all IP addresses and not using authentication. This is highly insecure and not recommended.
[I 16:01:11.276 NotebookApp] Serving notebooks from local directory: /disk/disk1/snaga
[I 16:01:11.276 NotebookApp] 0 active kernels
[I 16:01:11.276 NotebookApp] The IPython Notebook is running at: http://[all ip addresses on your system]:8080/
[I 16:01:11.276 NotebookApp] Use Control-C to stop this server and shut down all kernels (twice to skip confirmation).

と起動してブラウザから接続できるようにします。

■PostgreSQLデータベースへの接続


まず、sql拡張をロードして、PostgreSQLへ接続します。


%load_ext sql
%sql postgresql://snaga@localhost/testdb

「Connected」と表示されたら成功です。

■クエリの実行


クエリを実行するには、「%sql」に続けてクエリを入力します。


%sql select table_catalog,table_schema,table_name,table_type from information_schema.tables where table_schema = 'public'
%sql select count(*) from orders

SQLの実行だけを行うと、その結果が表形式で表示されます。

■問い合わせ結果の利用


問い合わせ結果は「_」という変数に格納されていますので、これを取り出します。

ここでは、顧客ごとの売り上げを集計するクエリを実行し、その後で変数 res に結果を取り出します。


# https://pypi.python.org/pypi/ipython-sql
%config SqlMagic.displaylimit=5

%sql select c_name,sum(o_totalprice) from customer left outer join orders on c_custkey = o_custkey group by c_name order by 2 desc

res = _

■問い合わせ結果の可視化


最後にmatplotlibを使って可視化します。

取り出した問い合わせ結果は普通にfor文で1レコードずつ取り出すことができますし、そのレコードはカラムのリストになっていますので、通常のSQLアクセスと同じようにデータを取り出します。

そして matplotlib に渡してグラフを描画します。

import matplotlib.pyplot as plt
%matplotlib inline

x = [rr[0] for rr in res]
y = [rr[1] for rr in res]

plt.bar(range(len(x)), y)


matplotlibの詳細については、長くなるのでここでは割愛します。(というか、私もまだ詳しくないので・・・)

■まとめ


今回は、Jupyter Notebookから直接SQLを発行して取得したデータを可視化する方法を試してみました。

Jupyter Notebookは探索的にデータを分析したり、作業の過程を記録に残したりするのに非常に便利です。また、matplotlibはさまざまなチャートを描くことができます。

ぜひ、このようなツールを活用しつつ、データでいろいろ遊んでみていただければと思います。

私ももう少しmatplotlibを活用できるように勉強をしてみようと思います。

では。

■参考文献


Logical Decodingを使ったCDC(Change Data Capture)の実現方法を考えてみる

$
0
0
今年も風物詩である PostgreSQL Advent Calendar の時期がやって参りました。Day1担当のデータマエショリスト @snagaです。
去年もDay1を担当した気がしますが、それはさておき。

余談ですが、今年のAdvent Calendarは
にも参加しております。また、
というのにも(個人的に)チャレンジしていますので、この辺に興味のある方はよろしければどうぞ。

■Logical Decoding?


閑話休題。

皆さんご存知の通り、「Logical Decoding」と呼ばれる機能がPostgreSQL 9.4で導入されました。

PostgreSQLでは「新しい機能入ったらしいが一体何にどう使えばいいんだ?」というような機能が稀によくあります。そのため、2年前にリリースされた機能にも関わらず誰かが使っているという話を聞いたことがない、といったことが起こります。

Logical Decodingにもその空気を感じます。

個人的には Logical Decoding みたいなものはインフラストラクチャーであって機能ではないと思うのですが、リリースノートにガッツリ「新機能!」とか書かれたりする関係上、いろいろなところでプレゼンなどを通して話に聞くようになるものの、具体的に何に使えるかサッパリ分からんというような事態に遭遇します。分かります。私もです。

不憫な機能はそのままそっとしておいてもいいのですが、今回は歳末助け合いの精神を発揮して使いどころを考えてみたいと思います。Advent Calendarですし。

なお、以降のLogical Decodingの話に特に興味のないという方には、とりあえず以下の動画をお楽しみいただければと思います。



ありがとうございました。

■CDC(Change Data Capture)とは何か


CDC(Change Data Capture)とは、その名の通り「変更を検知・検出する処理」です。データウェアハウスなどの情報系(分析系)システムで出てくる用語で、具体的にはETLの一貫として行われることの多い処理になります。

なぜ情報系のシステムで出てくるのかというと、その瞬間の取引の記録を残すのが要件のオンライン系のシステムと異なり、情報系では「一定の期間に渡るデータの変化」を時系列で分析したいという局面が多々あるからです。

例えば、以下のように会員情報のマスタがあった場合、オンライン系のシステムでは「その取引が発生した瞬間に会員が(住所とか各種ステータスとかが)どういう状態だったのか」が分かっていれば用は足ります。


一方で、情報系のシステムで分析をする場合には「どこに住んでいる会員がどれくらいいるのか、それはどれくらい変化しているのか」といった「変化」を把握する必要が出てきます(もちろん分析内容次第ですが)。

そのため、会員情報などのマスタについてもその変化を追える必要がある、具体的に言うと以下のような形式でデータが欲しくなるわけです。


というわけで、データ分析に適した形に変換するためにオンライン系のテーブルからその変更を検出する処理をCDC(Change Data Capture)と言います。

ETL処理についてガッツリ知りたい方は以下の書籍などを参照してください。

■Logical Decodingとは何か


次にLogical Decodingの復習です。

Logical DecodingはPostgreSQL 9.4で実装された機能で、トランザクションログの内容を論理的なレコードとして取り出せる、という機能です。

PostgreSQLはRDBMSですので、もともとのトランザクションログは物理的なログ、つまり「どのブロックのどのオフセットにどのようなバイト列を書き込む」というような形式でした。

が、物理的なログでは活用(再利用)できる範囲が限られるため、論理的なログ(いわゆるレコード)として取得できるようになった、というのがLogical Decodingの意味するところです。

Logical Decodingの基礎は以下の記事を読んでいただければと思いますが、基本的には「テーブルへの変更が論理的なレコードの形式で取得できる」、というものです。

■CDCに必要な要件


というわけで、本エントリでは「Logical DecodingをCDCに使えるのか」という点について、もう少し詳細に見ていこうと思います。

それトリガでやればいいじゃん、とか、夜間バッチで、といった方式もありますが(実際私もやっておりますが)、「ですよねー」と言った瞬間に話が終わるので、今回はLogical Decodingで検討してみることにします。(某社さんのGoldenGateとかってのもこの方式らしいですしおすし)

CDCを実現するに当たって本質的に必要な要件が3つほどあります。(snaga調べ)

まずは「レコードを特定することができ、新規作成か更新かを判別することができる」ということです。つまり、「このレコードは新規レコードなのか、それとも既存のレコードの変更なのか」を判別する必要があるのです。

これは一般的には主キーまたはユニークインデックスの作成されたカラムを使って実現されます。

主キーが「A」というレコードがテーブルに存在している場合、新たに入ってきたログが「A」という主キーを持っていれば既存レコードの更新(UPDATE)、「B」という主キーを持っていたら新規レコード(INSERT)と判別します。

2つ目の要件は「変更を検出したい対象のカラムを絞ることができる」ということです。

つまり、「変更されたら検出したいカラム」と「変更されても検出したくないカラム」を設定できることが重要です。

例えば、会員のマスターとなるテーブルに「住所」と「最終ログイン時刻」のような情報を保持している場合もあるかと思いますが、住所の変更は検出したくても、最終ログイン時刻は検出したくない、といったケースが考えられます。


このような時に「住所」や「ステータス」の変更だけを検出できるというのもCDCに必要な要件になります。

そして、最後は「データが(論理的に)変更されていない時にはログを吐かない」ということです。

これが物理ログと論理ログの違いであり、「論理ログの量」を必要最小限に抑えるために重要な要件となります。

■Logical Decodingのセットアップ


さて、というわけで、そろそろLogical Decodingの出力を詳細に見てみます。

Logical Decodingでは出力プラグインを自由に設定できますが、今回は test_decoding を使います。
test_decodingの詳細はマニュアルを参照してください、と言いたいところなのですが、マニュアルにはまったく詳細が書かれていないので、それは言うだけ野暮というものです。

まず、以下の設定をpostgresql.confにします。

wal_level = logical
max_replication_slot = 3

次に、レプリケーションスロットを作成します。

レプリケーションスロットは、Logical Decodingでデータを取得する対象のデータベース上で作成する必要があるので注意してください。(今回はtestdb)

testdb=# SELECT * FROM pg_replication_slots;
slot_name | plugin | slot_type | datoid | database | active | active_pid | xmin | catalog_xmin | restart_lsn | confirmed_flush_lsn
-----------+--------+-----------+--------+----------+--------+------------+------+--------------+-------------+---------------------
(0 行)

testdb=# SELECT * FROM pg_create_logical_replication_slot('testslot0', 'test_decoding');
slot_name | xlog_position
-----------+---------------
testslot0 | 2/CFE57030
(1 row)

testdb=# SELECT * FROM pg_replication_slots;
slot_name | plugin | slot_type | datoid | database | active | active_pid | xmin | catalog_xmin | restart_lsn | confirmed_flush_lsn
-----------+---------------+-----------+--------+----------+--------+------------+------+--------------+-------------+---------------------
testslot0 | test_decoding | logical | 83604 | testdb | f | | | 4283 | 1/FF467510 | 1/FF467548
(1 行)

testdb=#

まず、テーブルを作成します。

testdb=# create table k1 (
testdb(# uid integer primary key,
testdb(# uname text not null,
testdb(# gname text not null
testdb(# );
CREATE TABLE
testdb=#

この時、レプリケーションスロットからは以下のデータを取得できます。

testdb=# SELECT * FROM pg_logical_slot_get_changes('testslot0', NULL, NULL, 'include-xids', '0');
location | xid | data
------------+------+--------
1/FF4675B0 | 4283 | BEGIN
1/FF4861C0 | 4283 | COMMIT
(2 行)

testdb=#

どうやらDDLのデータは取得できないようです。

■Logical Decodingでは何が出力され、何を取得できるのか


それでは、まずレコードをINSERTしてみます。


testdb=# insert into k1 values (1, 'Park Gyu-ri', 'KARA');
INSERT 0 1
testdb=#

この時、以下のようなログを取得することができます。

testdb=# SELECT * FROM pg_logical_slot_get_changes('testslot0', NULL, NULL, 'include-xids', '0');
location | xid | data
------------+------+--------------------------------------------------------------------------------------
1/FF4861F8 | 4284 | BEGIN
1/FF4861F8 | 4284 | table public.k1: INSERT: uid[integer]:1 uname[text]:'Park Gyu-ri' gname[text]:'KARA'
1/FF486308 | 4284 | COMMIT
(3 行)

testdb=#
主キーの値と、名前が出力されています。

複数レコードを一括してINSERTすると、

testdb=# insert into k1 values (2, 'Nicole Jung', 'KARA'),
testdb-# (3, 'Goo Ha-ra', 'KARA'),
testdb-# (4, 'Han Seung-yeon', 'KARA'),
testdb-# (5, 'Kang Ji-young', 'KARA');
INSERT 0 4
testdb=#

以下のようなログになります。

testdb=# SELECT * FROM pg_logical_slot_get_changes('testslot0', NULL, NULL, 'include-xids', '0');
location | xid | data
------------+------+-----------------------------------------------------------------------------------------
1/FF486340 | 4285 | BEGIN
1/FF486340 | 4285 | table public.k1: INSERT: uid[integer]:2 uname[text]:'Nicole Jung' gname[text]:'KARA'
1/FF4863D0 | 4285 | table public.k1: INSERT: uid[integer]:3 uname[text]:'Goo Ha-ra' gname[text]:'KARA'
1/FF486460 | 4285 | table public.k1: INSERT: uid[integer]:4 uname[text]:'Han Seung-yeon' gname[text]:'KARA'
1/FF4864F0 | 4285 | table public.k1: INSERT: uid[integer]:5 uname[text]:'Kang Ji-young' gname[text]:'KARA'
1/FF4865B0 | 4285 | COMMIT
(6 行)

testdb=#

次に、主キーを指定してレコードを更新してみます。

testdb=# update k1 set uname = 'Nicole' where uid = 2;
UPDATE 1
testdb=#

この時のログは以下のようになります。主キーおよび更新された属性(今回は名前)が出力されています。

testdb=# SELECT * FROM pg_logical_slot_get_changes('testslot0', NULL, NULL, 'include-xids', '0');
location | xid | data
------------+------+---------------------------------------------------------------------------------
1/FF4865E8 | 4286 | BEGIN
1/FF4865E8 | 4286 | table public.k1: UPDATE: uid[integer]:2 uname[text]:'Nicole' gname[text]:'KARA'
1/FF486670 | 4286 | COMMIT
(3 行)

testdb=#

では次に、主キーを指定しないで更新してみます。

testdb=# update k1 set uname = 'Nicole Jung' where uname = 'Nicole';
UPDATE 1
testdb=#

この時、以下のログを取得できます。主キーを指定しない更新でしたが、ログには主キーの情報も出力されています。

testdb=# SELECT * FROM pg_logical_slot_get_changes('testslot0', NULL, NULL, 'include-xids', '0');
location | xid | data
------------+------+--------------------------------------------------------------------------------------
1/FF4866A8 | 4287 | BEGIN
1/FF4866A8 | 4287 | table public.k1: UPDATE: uid[integer]:2 uname[text]:'Nicole Jung' gname[text]:'KARA'
1/FF486730 | 4287 | COMMIT
(3 行)

testdb=#

なお、値が変わらない更新をしてみると、

testdb=# update k1 set uname = uname;
UPDATE 5
testdb=#

律儀に全レコードの更新ログが出力されます。

testdb=# SELECT * FROM pg_logical_slot_get_changes('testslot0', NULL, NULL, 'include-xids', '0');
location | xid | data
------------+------+-----------------------------------------------------------------------------------------
1/FF4869C0 | 4288 | BEGIN
1/FF4869C0 | 4288 | table public.k1: UPDATE: uid[integer]:1 uname[text]:'Park Gyu-ri' gname[text]:'KARA'
1/FF486A18 | 4288 | table public.k1: UPDATE: uid[integer]:3 uname[text]:'Goo Ha-ra' gname[text]:'KARA'
1/FF486A70 | 4288 | table public.k1: UPDATE: uid[integer]:4 uname[text]:'Han Seung-yeon' gname[text]:'KARA'
1/FF486AD0 | 4288 | table public.k1: UPDATE: uid[integer]:5 uname[text]:'Kang Ji-young' gname[text]:'KARA'
1/FF486B30 | 4288 | table public.k1: UPDATE: uid[integer]:2 uname[text]:'Nicole Jung' gname[text]:'KARA'
1/FF486BB8 | 4288 | COMMIT
(7 行)

testdb=#

最後に主キーを指定せずに削除すると

testdb=# delete from k1;
DELETE 5
testdb=#

主キーのみを出力として含むログを取得できます。

testdb=# SELECT * FROM pg_logical_slot_get_changes('testslot0', NULL, NULL, 'include-xids', '0');
location | xid | data
------------+------+-----------------------------------------
1/FF486BF0 | 4289 | BEGIN
1/FF486BF0 | 4289 | table public.k1: DELETE: uid[integer]:1
1/FF486C30 | 4289 | table public.k1: DELETE: uid[integer]:3
1/FF486C70 | 4289 | table public.k1: DELETE: uid[integer]:4
1/FF486CB0 | 4289 | table public.k1: DELETE: uid[integer]:5
1/FF486CF0 | 4289 | table public.k1: DELETE: uid[integer]:2
1/FF486D60 | 4289 | COMMIT
(7 行)

testdb=#

■要するに、Logical DecodingはCDCに使えるのか?


ここまで見てきたように、Logical Decodingではテーブルに主キーが存在していれば、主キーを指定しない更新であってもログに主キーが出力されることが分かりました。そのため、「レコードを特定して新規か更新かを判別する」ということが可能になります。

一方で、「変更を検知したいカラムだけを対象にする」という要件については、現在のLogical Decoding(というか test_decoding プラグイン)の仕様としては、(変更されていないカラムも含めて)すべてのカラムの変更の際にログが出力されることになります。よって、変更を検知する対象としてカラムを絞りたいといった場合には別のしくみが必要になります。

通信の負荷などを考えると、Loigcal Decodingのログを受け取るアプリ側ではなく、ログを出力する Output プラグイン側でフィルターできるようにするべきでしょう。

また、「論理的に値が変わっていない時にはログを吐かない」という点についても、もう一工夫が必要なように感じます。

test_decoding のソースを見ると、テーブルの各カラムの情報である tupledesc と、更新前および更新後のタプルのデータ oldtuple と newtuple を扱えるようですので、この辺りを使えばCDCに必要な要件を実現できるように思います。(汎用的に実現するにはそれなりに手間がかかりそうですが・・・)

/*
* callback for individual changed tuples
*/
static void
pg_decode_change(LogicalDecodingContext *ctx, ReorderBufferTXN *txn,
Relation relation, ReorderBufferChange *change)
{
...
TupleDesc tupdesc;
...
tupdesc = RelationGetDescr(relation);
...
case REORDER_BUFFER_CHANGE_UPDATE:
appendStringInfoString(ctx->out, " UPDATE:");
if (change->data.tp.oldtuple != NULL)
{
appendStringInfoString(ctx->out, " old-key:");
tuple_to_stringinfo(ctx->out, tupdesc,
&change->data.tp.oldtuple->tuple,
true);
appendStringInfoString(ctx->out, " new-tuple:");
}

if (change->data.tp.newtuple == NULL)
appendStringInfoString(ctx->out, " (no-tuple-data)");
else
tuple_to_stringinfo(ctx->out, tupdesc,
&change->data.tp.newtuple->tuple,
false);
break;

■まとめ


まとめます。
  • 誰か汎用CDC用プラグイン作ってください
  • 「トリガーとかバッチでいいじゃん」って言わない
PostgreSQL Advent Calendar 2016、Day2の明日の担当は @seikoudoku2000さんです。

では、また。

みなさま、良いお年を。

オープンデータ+PostGIS+Google Maps で観光マップを作ってみた

$
0
0
本エントリは PostgreSQL Advent Calendar 2016の Day24 のエントリです。昨日は @mazudakz さんの「pg_stats_reporter をしくじった話」でした。読み応えあって面白かった。

さて、先日(と言っても結構前)、地理情報をPostgreSQLで扱う例として、巡回セールスマン問題をPostgreSQLで解きつつGoogle Mapsで可視化するエントリを書きました。
今回は、もう少し進んでPostgreSQLにおける地理情報の検索とGoogle Mapsの動的な可視化を連動させてみましたので、その内容を紹介します。

実現したいことは、
  • 観光に関連する情報をPostgreSQLに取り込んで、
  • Google Mapsで地図上にマッピングして可視化しつつ、
  • 地図上をブラウジングしながら、
  • 興味のある場所があったらそのままGoogle検索に飛ぶ
という仕組みです。

年末年始のお出かけの検討に、または雑談のお供にご活用いただければと思います。

■オープンデータ「国土数値情報 観光資源データ」とは


まず、今回使うデータですが、国土交通省が公開している「国土数値情報」の中から「観光資源」のデータを使います。
このデータは各都道府県が「観光資源」として登録しているデータで、以下のような項目が含まれています。
  • 観光資源_ID
  • 観光資源名
  • 都道府県コード
  • 行政コード
  • 種別名称
  • 所在地住所
  • 観光資源分類コード
  • 観光資源(地理情報)
そのため、これらをうまくPostgreSQLに取り込んでやる必要があります。

このデータは地理情報のデータフォーマットとして広く使われている「シェープファイル(Shape File)」と呼ばれる形式で配布されています。

PostGISには、このシェープファイルをPostgreSQLに取り込むためのコマンドラインツールが含まれており、比較的容易にPostgreSQLのデータベースに取り込むことができます。
今回はこの方法を使って、観光資源データのシェープファイルをPostgreSQLに取り込みます。

■データを準備する


データセットをダウンロードすると分かりますが、47都道府県×3種類で、100以上のシェープファイルが含まれています。

シェープファイルを取り込む shp2pgsql コマンドは1シェープファイル1テーブルとして作成しますので、普通に取り込むと100個以上のテーブルができることになります。前処理としてそれらのテーブルを統合しておいた方が良いでしょう。

というわけで、取り込むスクリプトは以下です。
各シェープファイルの取り込みと、それらを統合して「p12_14」というテーブルを作成してくれます。

実行すると以下のようになります。

[snaga@localhost postgis_test]$ ./004_P12-14_GML.sh
Archive: ./data/P12-14_GML.zip
creating: P12-14_GML/
inflating: P12-14_GML/KS-META-P12_14-01.xml
inflating: P12-14_GML/KS-META-P12_14-02.xml
inflating: P12-14_GML/KS-META-P12_14-03.xml
...
DROP TABLE
DROP TABLE
DROP TABLE
COMMIT
都道府県コード | count
----------------+-------
01 | 53
02 | 524
03 | 513
04 | 8
05 | 679
...
45 | 265
46 | 13
47 | 198
(47 rows)

[snaga@localhost postgis_test]$ psql -c 'select count(*) from p12_14' gistest
count
-------
19140
(1 row)

[snaga@localhost postgis_test]$

ついでに都道府県コードの変換テーブルも作成しておきます。

[snaga@localhost postgis_test]$ psql -f 201_PREFCODE.SQL gistest
psql:201_PREFCODE.SQL:1: ERROR: table "prefcode" does not exist
CREATE TABLE
COPY 47
[snaga@localhost postgis_test]$ psql -c 'select count(*) from prefcode' gistest
count
-------
47
(1 row)

[snaga@localhost postgis_test]$

データをロードし終わると、以下のような状態になります。


gistest=> \d
List of relations
Schema | Name | Type | Owner
--------+-------------------+-------+----------
public | geography_columns | view | postgres
public | geometry_columns | view | postgres
public | p12_14 | table | snaga
public | prefcode | table | snaga
public | raster_columns | view | postgres
public | raster_overviews | view | postgres
public | spatial_ref_sys | table | postgres
(7 rows)

gistest=> \d p12_14
Table "public.p12_14"
Column | Type | Modifiers
--------------------+----------------+-----------
観光資源ID | integer |
観光資源名 | text |
都道府県コード | character(2) |
行政コード | character(5)[] |
種別名称 | text |
所在地住所 | text |
観光資源分類コード | numeric(2,0) |
geom | geometry |
Indexes:
"p12_14_idx" btree ("都道府県コード", "観光資源ID")

gistest=> \d prefcode
Table "public.prefcode"
Column | Type | Modifiers
----------------+--------------+-----------
都道府県コード | character(2) | not null
都道府県名 | text | not null
Indexes:
"prefcode_pkey" PRIMARY KEY, btree ("都道府県コード")

gistest=>

■ポリゴンデータを点データに変換する


次に、geomカラムに含まれているポリゴンデータを点のデータに変換します。

ポリゴンデータのままだと、地理情報を使った演算に時間がかかってしまいます。今回はそこまで厳密な距離の計算などは必要ないため、計算量を減らすためにポリゴンから「点」、具体的には「ポリゴンの重心」に変換します。

重心を求めるには PostGIS の ST_Centroid() 関数を使います。


gistest=> select pg_total_relation_size('p12_14');
pg_total_relation_size
------------------------
10821632
(1 row)

gistest=> update p12_14 set geom = ST_Centroid(geom);
UPDATE 19140
gistest=> vacuum full p12_14 ;
VACUUM
gistest=> select pg_total_relation_size('p12_14');
pg_total_relation_size
------------------------
3571712
(1 row)

gistest=>

ポリゴンから点に変換したことによって、テーブルサイズが1/3程度になりました。

■観光資源データを検索する


ここまでできたら観光資源データを検索してみます。

今回の検索は基本的に「緯度経度で矩形を指定し、その中に存在している観光資源の情報を取得する」というものです。というのは、最終的にはGoogle Mapsで表示、検索できるようにしますので、Google Mapsの表示領域である矩形に切り取れる必要があるのです。

クエリとしては、まず検索したい対象の区域の緯度経度からポリゴンを作成し、そのポリゴンに含まれる観光資源を取得します。

ポリゴンを作成するには ST_MakePolygon() 関数を、作成したポリゴンに座標系を設定するには ST_SetSRID() 関数を使います。

そして、ST_Contains() 関数を使って、作成したポリゴンに観光資源が含まれるかどうかを判定します。

例えば、都庁から浜離宮恩賜庭園にかけての一帯を検索したいのであれば、それぞれの座標は
  • 都庁: 緯度 35.689634 経度 139.692101
  • 浜離宮恩賜庭園: 緯度 35.6597374 経度 139.7634925
となりますので、この座標を対角線に持つ矩形を「139.692101 35.689634, 139.692101 35.6597374, 139.7634925 35.6597374, 139.7634925 35.689634, 139.692101 35.689634」と定義して、ポリゴンを作成します。

この辺。

矩形を指定する方法ですが、「4つの頂点を指定する」のではなく、「頂点を結ぶ線を定義する」必要があります。つまり、最後には「開始点に戻ってくる」必要がある、ということです。開始点と終了点が一致して閉じられていないとエラーが出ますので注意してください。

具体的なクエリとしては以下のようになります。

gistest=> SELECT
観光資源名,
ST_Y(geom) lat,
ST_X(geom) lon,
所在地住所
FROM
p12_14
WHERE
ST_Contains(
ST_SetSRID(ST_MakePolygon('LINESTRING(139.692101 35.689634, 139.692101 35.6597374,
139.7634925 35.6597374, 139.7634925 35.689634,
139.692101 35.689634)'::geometry), 4612),
geom
);
観光資源名 | lat | lon | 所在地住所
----------------------------------+------------------+------------------+-----------------------
江戸城跡 | 35.6848450646745 | 139.753409059839 | 千代田区千代田
江戸城跡 | 35.6848545616798 | 139.753620448968 | 千代田区千代田
原宿 | 35.6677931758621 | 139.706963201124 | 渋谷区神宮前
明治神宮 | 35.6759794856927 | 139.699423688458 | 渋谷区代々木神園町1-1
新宿御苑 | 35.6852930986455 | 139.71005110951 | 新宿区
国会議事堂 | 35.6758880055466 | 139.744858001005 | 千代田区永田町1-7-1
根津美術館 | 35.6622430012588 | 139.717093001577 | 港区南青山6-6-5-1
江戸前の寿司 | 35.6896339956514 | 139.692101001444 | 新宿区
国立能楽堂で上演される能・狂言 | 35.6804039978788 | 139.708209998586 | 渋谷区千駄ヶ谷4-18-1
国立劇場で上演される歌舞伎・文楽 | 35.6815600003142 | 139.74327700092 | 千代田区隼町4-1
明治神宮 | 35.6757916030472 | 139.699511838812 | 渋谷区代々木神園町1-1
新宿御苑 | 35.6850607466651 | 139.709971187167 | 新宿区
(12 rows)

gistest=>

東京にこれしか観光資源ないのかよというツッコミはあろうかと思いますが、PostgreSQLのせいではないのでここでは不問とします。

■検索をREST API化する


矩形で検索できるようになったら、これをブラウザのJavaScriptから呼び出せるようにREST API化します。

Flaskを使ってさくっとREST API化して手元なりどこかのPaaSなりで動作させておきます。

ソースコードは以下です。

■Google Mapsと連携する


最後にGoogle Mapsと連携させます。

(力尽きたので詳細は割愛。ソース見てちょ) 特長、というか、こんな感じで動いてます。
  • Google Maps JavaScript API使ってます。
  • 表示している領域の四隅の緯度経度を取得して、その領域内にあるアイテムを検索・表示しています。
  • アイテムをクリックして情報ウィンドウを表示させ、そこからGoogle検索することができます。
  • 表示領域が変わると、新たな座標をパラメータにしてREST APIを呼び、表示すべき項目を取得します。
  • 広域表示をすると表示する項目が多くなりすぎるので、最大100件に間引いてます。
  • 雑に間引いているので、ズームアウトすると見えなくなったり見えたりします。
URLはこちら。
ぐりぐりブラウズしながら、興味のありそうなところをさくっとGoogle検索できます。

日本全域表示。最大100件に間引いて表示してます。(北海道に何も表示されていませんが、これは雑に間引いて表示している偶然の産物であり他意はございません。ズームしていくと見えてきます)
たくさん表示されるとちょっとキモい。

おきなわー。クリックすると情報ウィンドウが表示される。

情報ウィンドウから検索に飛ぶことができる。べんりー。

■まとめ


というわけで、今回はオープンデータをPostgreSQL/PostGISに投入して、クエリをREST API化することによってGoogle Mapsと連携できることを示しました。

先日の巡回セールスマン問題のエントリではMy MapsにKMLファイルをインポートする方式を取りましたので、扱っているデータはあくまでもstaticなデータでしたが、今回はREST APIを使って位置情報を動的に取得して可視化することができることを示しました。

フロントエンドと動的に連携できるようになりましたので、キーワード検索や条件検索などなど、「データベースとつながっていることの価値」が出せるようになるのではないかと思います。

また、今回は時間の都合上確認できませんでしたが、近隣にあるデータをクラスタリングして代表点を求める、みたいなこともできるのではないかと考えています(AVG() GROUP BYのようなノリで)。それができると、広域表示の時にもう少し分かりやすい表示になるように思います。

PostgreSQLの強みの一つにはGISデータの扱いであり、かつ、GISデータを扱えるというだけではなく、先のエントリで紹介したような巡回セールスマン問題のソルバーのようなライブラリが存在している、ということも非常にユニークなところだと思います。

地理情報は、まだまだいろんな使い方ができるのではないかなーと感じています。

興味のある方は、ぜひこれを機会に何かチャレンジしてみていただければと思います。

では、また。

コサイン類似度に基づくソート処理の実装方法とその性能比較

$
0
0
文書の類似度を計算する方法に「コサイン類似度」を用いる方法があります。

これは、出現する単語を出現回数などで数値化して、空間ベクトルに変換した上でベクトル同士の類似度を計算する、という手法です。
最近、このコサイン類似度を使って、似ているデータを検索するWebアプリを試しに作っていたのですが、ふと、

「このコサイン類似度を使ったソート処理をPostgreSQLでどのように実装するともっとも高速な実装になるのだろうか。また、現実的なパフォーマンスを考えた時にデータ量や次元のサイズはどこまで増やせるのだろうか」

ということが気になりました。

PostgreSQLは、その拡張性の高さがウリの一つですが、そのため「UDFを作る」ということを考えても、実装方法にもいろいろあります。

今回は、PostgreSQL内部でデータ処理を実装するに当たって、どのような実装方法があるのか、それぞれどのように性能が違うのか、そしてその時にデータサイズがどのように影響するのかを見てみます。

■前提条件


今回は以下の前提条件で実装と性能比較を行います。
  • ソート処理するデータはPostgreSQLに蓄積されているものを対象とする
  • 空間ベクトルを表すデータは、PostgreSQL の float8 の配列で1カラムに保持する
  • コサイン類似度による類似度を計算し、もっとも類似度の高いレコードをN件取得する
これらを前提条件として、コサイン類似度の計算を
  • (1) CのUDFで実装した場合
  • (2) PythonのUDFで実装した場合(PL/Python)
  • (3) scikit-learnのcosine_similarity関数を使ってPythonのUDFで実装した場合(PL/Python + scikit-learn)
  • (4) MADlibcosine_similarity関数を使った場合
  • (5) scikit-learnを使ってクライアント側に取得してクライアント側で計算する場合
に、パフォーマンスがそれぞれどう異なるかを確認してみます。

また、レコード数および空間ベクトルの次元数によって実行時間がどのように変わるかも確認してみます。

■処理対象のデータ


処理対象のデータは、 data_vec テーブルに主キーとして整数型のカラム id を持ち、空間ベクトルのデータとして float8[] 型の カラム vec を持つテーブルです。

CREATE TABLE data_vec (
id INTEGER PRIMARY KEY,
vec FLOAT8[] NOT NULL
);

このテーブルの中に、以下のようにレコードが入っています。(以下は100次元の空間ベクトルのデータが5件入っている状態)

test_cos_sim=> select * from data_vec ;
id | vec

----+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
0 | {1,1,0,0,1,0,1,0,0,1,1,1,0,1,1,0,1,1,1,1,0,1,1,1,0,0,0,1,1,1,0,1,0,1,1,0,1,0,1,1,0,0,1,0,0,1,0,1,0,1,1,0,0,0,1,1,0,1,1,1,1,1,1,1,1,0,1,0,1,0,0,0,1,1,0,0,1,1,1,1,1,1,1,0,1,0,1,1,1,1,1,1,0,1,1,1,1,0,1,0}
1 | {1,1,0,1,0,0,1,0,1,0,0,1,0,1,1,1,1,0,1,1,1,0,0,1,0,0,1,0,0,0,1,1,0,0,0,0,0,1,0,0,1,0,0,1,0,0,1,1,1,0,1,1,0,1,1,0,0,1,0,0,0,0,1,1,0,0,1,0,1,0,1,0,0,0,0,1,0,0,0,1,1,1,1,0,0,1,0,0,0,0,1,0,1,1,0,0,1,0,1,0}
2 | {0,0,0,1,0,1,1,1,0,0,0,1,1,1,0,1,1,0,1,1,0,1,0,0,1,0,1,1,0,0,1,1,0,1,0,1,0,0,0,0,1,1,0,1,1,0,1,0,0,0,1,1,1,1,1,0,1,1,1,1,1,1,0,1,0,1,0,0,0,1,1,0,1,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,0,1,1,0,0,1,0,0,1}
3 | {1,1,1,0,1,1,0,0,1,0,0,0,1,0,0,1,1,0,0,1,0,1,0,0,0,1,1,1,0,1,0,0,0,0,1,0,0,0,0,1,0,1,1,0,0,1,1,0,1,0,0,0,1,0,1,0,0,1,1,1,0,0,0,1,1,1,1,1,0,0,1,0,0,0,0,1,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,0,1,0,1,1}
4 | {1,1,0,0,1,0,0,1,1,0,0,1,0,1,0,0,1,0,0,0,0,1,1,1,0,1,0,1,0,0,0,0,0,1,1,0,1,0,0,1,0,1,1,0,1,0,0,1,1,1,0,1,1,1,1,1,1,1,1,0,1,0,1,0,1,0,0,1,0,1,1,1,1,0,0,1,1,1,1,0,0,0,0,1,1,1,0,0,1,1,0,0,1,0,1,1,1,0,0,0}
(5 rows)

test_cos_sim=>

■コサイン類似度計算UDFの実装


コサイン類似度の計算をするUDFのそれぞれの実装は以下のようになります。

CによるUDFの実装 (1) は以下のようになります。

Datum
cosine_similarity(PG_FUNCTION_ARGS)
{
ArrayType *vec_a = PG_GETARG_ARRAYTYPE_P(0);
ArrayType *vec_b = PG_GETARG_ARRAYTYPE_P(1);
float8 ab = 0;
float8 aa = 0;
float8 bb = 0;
int i;

int len_a = (ARR_SIZE(vec_a) - ARR_DATA_OFFSET(vec_a)) / sizeof(float8);
int len_b = (ARR_SIZE(vec_b) - ARR_DATA_OFFSET(vec_b)) / sizeof(float8);

float8 *a = (float8 *)ARR_DATA_PTR(vec_a);
float8 *b = (float8 *)ARR_DATA_PTR(vec_b);

for (i = 0; i < len_a; i++)
{
aa += a[i] * a[i];
bb += b[i] * b[i];
ab += a[i] * b[i];
}

PG_RETURN_FLOAT8(ab/(sqrt(aa)*sqrt(bb)));
}

PL/PythonのUDFとして実装 (2) すると以下のようになります。

CREATE OR REPLACE FUNCTION cosine_similarity_plpy(vec_a float8[], vec_b float8[])
RETURNS float8
AS $$
from math import sqrt
aa = 0
bb = 0
ab = 0
for a,b in zip(vec_a, vec_b):
aa += a * a
bb += b * b
ab += a * b
return ab/(sqrt(aa)*sqrt(bb))
$$
LANGUAGE 'plpython2u';

PL/Pythonでも scikit-learn を使った実装 (3) は以下のようになります。

CREATE OR REPLACE FUNCTION cosine_similarity_sk(vec_a float8[], vec_b float8[])
RETURNS float8
AS $$
from sklearn.metrics.pairwise import cosine_similarity
return cosine_similarity([vec_a], [vec_b])[0][0]
$$
LANGUAGE 'plpython2u';

■検証用クエリ


今回の評価では、上記のデータとUDFを使って、「id=0」の空間ベクトルを問い合わせクエリとして、「その他(id=0以外)」のレコードの中から最も似ているレコードを10件取得してみます。クエリは、それぞれ以下のようになります。

CによるUDFを使う場合 (1):

SELECT
a.id as "id1",
b.id as "id2",
cosine_similarity(a.vec, b.vec)
FROM
data_vec a,
data_vec b
WHERE
a.id = 0
AND
b.id <> 0
ORDER BY
3 DESC
LIMIT 10;

PL/PythonによるUDFを使う場合 (2):

SELECT
a.id as "id1",
b.id as "id2",
cosine_similarity_plpy(a.vec, b.vec)
FROM
data_vec a,
data_vec b
WHERE
a.id = 0
AND
b.id <> 0
ORDER BY
3 DESC
LIMIT 10;

PL/Python + scikit-learn によるUDFを使う場合 (3):

SELECT
a.id as "id1",
b.id as "id2",
cosine_similarity_sk(a.vec, b.vec)
FROM
data_vec a,
data_vec b
WHERE
a.id = 0
AND
b.id <> 0
ORDER BY
3 DESC
LIMIT 10;

MADlib の cosine_similarity 関数を使う場合 (4):

SELECT
a.id as "id1",
b.id as "id2",
madlib.cosine_similarity(a.vec, b.vec)
FROM
data_vec a,
data_vec b
WHERE
a.id = 0
AND
b.id <> 0
ORDER BY
3 DESC
LIMIT 10;

上記は関数名が違うだけで、クエリは基本的には同じです。

なお、クライアント側に取得して scikit-learn で計算する実装 (5) は以下のようになります。

import psycopg2
from sklearn.metrics.pairwise import cosine_similarity

conn = psycopg2.connect("dbname=test_cos_sim")
cur = conn.cursor()
q = """
SELECT
a.id as "id1",
a.vec as "vec1",
b.id as "id2",
b.vec as "vec2"
FROM
data_vec a,
data_vec b
WHERE
a.id = 0
AND
b.id <> 0
"""

cur.execute(q)
d = []
for r in cur.fetchall():
d.append((r[2], cosine_similarity([r[1]], [r[3]])[0][0]))

print "sk_cli"
for r in sorted(d, key=lambda s: s[1], reverse=True)[:10]:
print(r)

conn.close()

■検証環境


今回検証に用いた環境は以下の通りです。Windows上でのVirtualBoxのVM環境です。
  • Core i7-4785T 2.20GHz (Quad core)
  • CentOS 6.6 (x86_64)
  • Python 2.7.9
  • PostgreSQL 9.5.2
  • VM(VirtualBox)に4コアを100%割り当て
  • VM上で4GB RAM

■実装方式による性能比較


以下は、実装方式による実行時間の比較です。

ここでは、まずはベースラインとして500次元の空間ベクトルを10,000レコード作成して、クエリの実行時間(5回実行した平均値)を取得しています。

縦軸は実行時間(短い方が高速)、横軸はそれぞれ
  • (1) CによるUDF (func_native)
  • (2) PL/PythonによるUDF (func_plpy)
  • (3) PL/Python+scikit-learnによるUDF (func_plpy_sk)
  • (4) MADlibのcosine_similarity関数 (func_madlib)
  • (5) クライアント側に取得してscikit-learnで処理 (cli_sk)
を示しています。


この結果を見ると、
  • CによるUDFとMADlibの関数が圧倒的に高速(それぞれ102msと129ms)
  • PL/Python系の実装が一桁遅い(1906msと2854ms)
  • クライアント側にデータを持ってきてソート処理をするのがもっとも遅い(7381ms)
という結果になっています。

■次元数の違いによる性能比較


次に、空間ベクトルの次元数を500から1,000および2,000に増やして実行時間がどのように変化するかを確認します。レコード数はすべて10,000件としています。


結果を見ると、次元数に応じて実行時間は長くなっています。

CによるUDFとMADlibが圧倒的に高速なのは変わらないのですが、PL/Python系の実装について見てみると、500次元の時には(scikit-learnを使わない)素のPL/Pythonの実装(func_plpy)の方が高速であったのが、次元数が2,000になると、scikit-learnを使った実装(func_plpy_sk)の方が高速になっています。

これは、おそらく素の Python で大きな配列を扱うよりは scikit-learn の方が大量の数値データの扱いに長けているためでしょう。(今回は実施しませんでしたが、numpyを使うと素のPythonで実装するよりは高速になるかもしれません)

■レコード数の違いによる性能比較


最後に、空間ベクトルの次元数は2,000のままにして、レコード数を10,000件から20,000件、40,000件と増やしてみて、実行時間がどのように変化するかを確認します。


レコードの増加に伴って、レコード数と同じ程度に実行時間が延びていることが分かります。

ここでも、レコード数が増えると素の PL/Python による実装よりも、PL/Python の UDF 内で scikit-learn を使った方が高速になる傾向が出ています。

なお、クライアント側に取ってきて scikit-learn で処理する方式(cli_sk)は、40,000レコードの時に Out of Memory エラーで実行できなかったので、結果がありません。

以下に、計測した数値を一覧で示します。(数値はミリ秒)


なお、今回利用したコード類は以下に置いておきましたので、興味のある方は合わせてご利用ください。

■まとめ


今回は、「データベース内のデータに対してコサイン類似度を計算して、より似ているレコードを取得する」というケースを想定して、どのような実装がより高速なのかを検証してみました。

その結果として、今回の前提条件であれば、
  • Cで作成したUDFとMADlibはパフォーマンス的にほとんど変わらない。
  • それに比べると、PL/Pythonでの実装は(scikit-learnを使うかどうかに関わらず)1ケタ以上遅い。
  • 処理するデータ量(次元またはレコード数)が多くなると、scikit-learnを使う実装のメリットが出てくる。
  • オンラインシステムでの利用を想定すると、数百ミリ秒で返ってきたMADlibはそのまま利用できる可能性がある。
  • データをクライアントに転送して処理するのは時間がかかる。
ということが分かりました。

興味のある方は、自分のデータを使って、あるいは別のアルゴリズムについてもデータベース内での処理を試してみていただければと思います。

では。

In-database Analyticsの集い #1を開催します

$
0
0

3月10日(金)に「In-database Analyticsの集い #1」というMeetupを開催することになりました。

「In-Database Analytics」というのは、データベースに蓄積されたデータに対して、「データを取り出さずに」データベース内部で分析処理をする技術の総称だと思っていただければいいかと思います。

データベースに蓄積されるデータはどんどん大きくなっている昨今ですが、それに伴ってデータベースからデータを取り出してから分析処理をする、というのが難しくなりつつあります。そのため、データベースからデータを取り出さずに分析処理をする「In-Database Analytics」の重要性がより高まってくると感じています。

今回のMeetupでは、ソフトウェアによるIn-Database Analyticsの話から始めて、昨今注目されているハードウェアアクセラレーションの活用(GPGPUやFPGA)などについて情報交換する場にできればと思っています。

というわけで、この辺の話に興味がある方はぜひご参加いただければと思います。お待ちしています。

では。

Viewing all 94 articles
Browse latest View live