UNPKG

ts-content-based-recommender

Version:

A TypeScript-based content-based recommender with multilingual support (Japanese & English). Forked from content-based-recommender.

345 lines 14.1 kB
import * as _ from 'underscore'; import Vector from 'vector-object'; import { ProcessingPipelineFactory } from './factories/ProcessingPipelineFactory.js'; import natural from 'natural'; const { TfIdf } = natural; /** * デフォルト設定オプション */ const defaultOptions = { maxVectorSize: 100, maxSimilarDocuments: Number.MAX_SAFE_INTEGER, minScore: 0, debug: false, language: 'en', tokenFilterOptions: { removeDuplicates: true, removeStopwords: true, customStopWords: [], minTokenLength: 1, allowedPos: ['名詞', '動詞', '形容詞'] } }; /** * コンテンツベース推薦システムのメインクラス * TF-IDFとコサイン類似度を使用して文書間の類似性を計算し、類似したアイテムを推薦します */ class ContentBasedRecommender { /** * ContentBasedRecommenderのコンストラクタ * @param options 推薦システムの設定オプション */ constructor(options = {}) { this.setOptions(options); this.data = {}; // 処理パイプラインの初期化 this.pipeline = ProcessingPipelineFactory.createPipeline(this.options.language, this.options.tokenFilterOptions); } /** * 設定オプションを設定・検証する * @param options 設定オプション * @throws {Error} 無効なオプションが指定された場合 */ setOptions(options = {}) { // バリデーション if ((options.maxVectorSize !== undefined) && (!Number.isInteger(options.maxVectorSize) || options.maxVectorSize <= 0)) { throw new Error('The option maxVectorSize should be integer and greater than 0'); } if ((options.maxSimilarDocuments !== undefined) && (!Number.isInteger(options.maxSimilarDocuments) || options.maxSimilarDocuments <= 0)) { throw new Error('The option maxSimilarDocuments should be integer and greater than 0'); } if ((options.minScore !== undefined) && (!_.isNumber(options.minScore) || options.minScore < 0 || options.minScore > 1)) { throw new Error('The option minScore should be a number between 0 and 1'); } if ((options.language !== undefined) && (!_.isString(options.language) || !['en', 'ja'].includes(options.language))) { throw new Error('The option language should be either "en" or "ja"'); } const prevLanguage = this.options?.language; this.options = Object.assign({}, defaultOptions, options); // 言語が変更された場合、処理パイプラインを再初期化 if (this.pipeline && prevLanguage !== this.options.language) { this.pipeline = ProcessingPipelineFactory.createPipeline(this.options.language, this.options.tokenFilterOptions); } } /** * 単一コレクションの文書を学習する * @param documents 学習対象の文書配列 */ async train(documents) { this.validateDocuments(documents); if (this.options.debug) { console.log(`Total documents: ${documents.length}`); } // ステップ1 - 文書の前処理 const preprocessDocs = await this._preprocessDocuments(documents, this.options); // ステップ2 - 文書ベクトルの作成 const docVectors = this._produceWordVectors(preprocessDocs, this.options); // ステップ3 - 類似度の計算 this.data = this._calculateSimilarities(docVectors, this.options); } /** * 双方向学習(異なるコレクション間の類似度計算) * @param documents メインの文書配列 * @param targetDocuments ターゲット文書配列 */ async trainBidirectional(documents, targetDocuments) { this.validateDocuments(documents); this.validateDocuments(targetDocuments); if (this.options.debug) { console.log(`Total documents: ${documents.length}`); } // ステップ1 - 文書の前処理 const preprocessDocs = await this._preprocessDocuments(documents, this.options); const preprocessTargetDocs = await this._preprocessDocuments(targetDocuments, this.options); // ステップ2 - 文書ベクトルの作成 const docVectors = this._produceWordVectors(preprocessDocs, this.options); const targetDocVectors = this._produceWordVectors(preprocessTargetDocs, this.options); // ステップ3 - 類似度の計算 this.data = this._calculateSimilaritiesBetweenTwoVectors(docVectors, targetDocVectors, this.options); } /** * 文書配列のバリデーション * @param documents 検証対象の文書配列 * @throws {Error} 無効な文書配列が指定された場合 */ validateDocuments(documents) { if (!_.isArray(documents)) { throw new Error('Documents should be an array of objects'); } for (let i = 0; i < documents.length; i += 1) { const document = documents[i]; if (!_.has(document, 'id') || !_.has(document, 'content')) { throw new Error('Documents should be have fields id and content'); } if (_.has(document, 'tokens') || _.has(document, 'vector')) { throw new Error('"tokens" and "vector" properties are reserved and cannot be used as document properties'); } } } /** * 指定IDの類似文書を取得する * @param id 文書ID * @param start 開始インデックス(デフォルト: 0) * @param size 取得サイズ(未指定の場合は全て) * @returns 類似文書の配列 */ getSimilarDocuments(id, start = 0, size) { let similarDocuments = this.data[id]; if (similarDocuments === undefined) { return []; } const end = (size !== undefined) ? start + size : undefined; similarDocuments = similarDocuments.slice(start, end); return similarDocuments; } /** * 学習済みモデルをエクスポートする * @returns エクスポートデータ */ export() { return { options: this.options, data: this.data, }; } /** * エクスポートされたモデルをインポートする * @param object インポートするモデルデータ */ import(object) { const { options, data } = object; if (options) { this.setOptions(options); } if (data) { this.data = data; } } // プライベートメソッド /** * 文書の前処理(トークン化、ステミング等) * @param documents 対象文書配列 * @param options 設定オプション * @returns 前処理済み文書配列 */ async _preprocessDocuments(documents, options) { if (options.debug) { console.log('Preprocessing documents'); } const processedDocuments = await Promise.all(documents.map(async (item) => { const tokens = await this._getTokensFromString(item.content); return { id: item.id, tokens, originalDocument: item, }; })); return processedDocuments; } /** * 文字列からトークンを抽出する * @param string 対象文字列 * @returns トークン配列のPromise */ async _getTokensFromString(string) { // トークン化 const rawTokens = await this.pipeline.tokenizer.tokenize(string); // フィルタリング if (this.options.language === 'ja') { // 日本語の場合、品詞情報を含む詳細トークンを取得してフィルタリング const japaneseTokenizer = this.pipeline.tokenizer; const japaneseFilter = this.pipeline.filter; const detailedTokens = await japaneseTokenizer.getDetailedJapaneseTokens(string); return japaneseFilter.filterWithPos(detailedTokens); } else if (this.options.language === 'en') { // 英語の場合、N-gram対応フィルタリング const englishFilter = this.pipeline.filter; return englishFilter.filterWithNgrams(rawTokens); } else { // その他の場合、通常のフィルタリング return this.pipeline.filter.filter(rawTokens); } } /** * 類似文書を降順でソートし、最大数を制限する * @param data 類似度データ * @param options 設定オプション */ orderDocuments(data, options) { // 類似文書を降順でソート Object.keys(data) .forEach((id) => { data[id].sort((a, b) => b.score - a.score); if (data[id].length > options.maxSimilarDocuments) { data[id] = data[id].slice(0, options.maxSimilarDocuments); } }); } /** * 文書ベクトルを生成する * @param processedDocuments 前処理済み文書配列 * @param options 設定オプション * @returns 文書ベクトル配列 */ _produceWordVectors(processedDocuments, options) { // TF-IDFの処理 const tfidf = new TfIdf(); processedDocuments.forEach((processedDocument) => { tfidf.addDocument(processedDocument.tokens); }); // ワードベクトルの作成 const documentVectors = []; for (let i = 0; i < processedDocuments.length; i += 1) { if (options.debug) { console.log(`Creating word vector for document ${i}`); } const processedDocument = processedDocuments[i]; const hash = {}; const items = tfidf.listTerms(i); const maxSize = Math.min(options.maxVectorSize, items.length); for (let j = 0; j < maxSize; j += 1) { const item = items[j]; hash[item.term] = item.tfidf; } const documentVector = { id: processedDocument.id, vector: new Vector(hash), }; documentVectors.push(documentVector); } return documentVectors; } /** * 2つのベクトルセット間の類似度を計算する(双方向学習用) * @param documentVectors メイン文書のベクトル配列 * @param targetDocumentVectors ターゲット文書のベクトル配列 * @param options 設定オプション * @returns 類似度データ */ _calculateSimilaritiesBetweenTwoVectors(documentVectors, targetDocumentVectors, options) { const data = { ...this.initializeDataHash(documentVectors), ...this.initializeDataHash(targetDocumentVectors) }; // 類似度スコアの計算 for (let i = 0; i < documentVectors.length; i += 1) { if (options.debug) console.log(`Calculating similarity score for document ${i}`); for (let j = 0; j < targetDocumentVectors.length; j += 1) { const documentVectorA = documentVectors[i]; const targetDocumentVectorB = targetDocumentVectors[j]; const idi = documentVectorA.id; const vi = documentVectorA.vector; const idj = targetDocumentVectorB.id; const vj = targetDocumentVectorB.vector; const similarity = vi.getCosineSimilarity(vj); if (similarity > options.minScore) { data[idi].push({ id: targetDocumentVectorB.id, score: similarity }); data[idj].push({ id: documentVectorA.id, score: similarity }); } } } this.orderDocuments(data, options); return data; } /** * データハッシュを初期化する * @param documentVectors 文書ベクトル配列 * @returns 初期化されたデータハッシュ */ initializeDataHash(documentVectors) { return documentVectors.reduce((acc, item) => { acc[item.id] = []; return acc; }, {}); } /** * 同一コレクション内の類似度を計算する * @param documentVectors 文書ベクトル配列 * @param options 設定オプション * @returns 類似度データ */ _calculateSimilarities(documentVectors, options) { const data = { ...this.initializeDataHash(documentVectors) }; // 類似度スコアの計算 for (let i = 0; i < documentVectors.length; i += 1) { if (options.debug) console.log(`Calculating similarity score for document ${i}`); for (let j = 0; j < i; j += 1) { const documentVectorA = documentVectors[i]; const idi = documentVectorA.id; const vi = documentVectorA.vector; const documentVectorB = documentVectors[j]; const idj = documentVectorB.id; const vj = documentVectorB.vector; const similarity = vi.getCosineSimilarity(vj); if (similarity > options.minScore) { data[idi].push({ id: documentVectorB.id, score: similarity }); data[idj].push({ id: documentVectorA.id, score: similarity }); } } } this.orderDocuments(data, options); return data; } } export default ContentBasedRecommender; //# sourceMappingURL=ContentBasedRecommender.js.map