ktsn-ud commited on
Commit
20bc01d
·
1 Parent(s): d150b0f

同義語キャッシュの生成スクリプトを作成

Browse files
config/files.json CHANGED
@@ -2,7 +2,10 @@
2
  "sudachi": {
3
  "sudachi_config": "scripts/sudachi.json",
4
  "user_dict": "resources/user_dict.csv",
5
- "user_dict_generated": "data/generated/user.dic"
 
 
 
6
  },
7
  "projects": {
8
  "original_csv": "resources/test_data.csv",
 
2
  "sudachi": {
3
  "sudachi_config": "scripts/sudachi.json",
4
  "user_dict": "resources/user_dict.csv",
5
+ "user_dict_generated": "data/generated/user.dic",
6
+ "synonyms": "resources/synonyms_DO_NOT_EDIT.txt",
7
+ "synonyms_cache": "data/generated/synonyms_cache.json",
8
+ "stopwords": "resources/stopwords.json"
9
  },
10
  "projects": {
11
  "original_csv": "resources/test_data.csv",
config/search_model.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "target_pos_l1": [
3
+ "名詞", "動詞"
4
+ ],
5
+ "target_fields": [
6
+ "title",
7
+ "organization",
8
+ "description",
9
+ "prComment",
10
+ "prCommentLong",
11
+ "reading"
12
+ ],
13
+ "synonyms": {
14
+ "enable": true,
15
+ "sources": {
16
+ "sudachi": true,
17
+ "custom_json": "resources/synonyms_custom.json"
18
+ },
19
+ "limits": {
20
+ "max_expansions_per_term": 4,
21
+ "max_query_variants": 5,
22
+ "min_char_len": 2
23
+ },
24
+ "banlist": [
25
+ "部",
26
+ "会",
27
+ "サークル"
28
+ ]
29
+ }
30
+ }
scripts/3_build_synonyms_from_sudachi.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import json
4
+ from collections import defaultdict
5
+ from tqdm import tqdm
6
+ import pandas as pd
7
+
8
+ from sudachipy import dictionary, tokenizer
9
+
10
+ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
11
+
12
+ from utils.logger import setup_logger
13
+ from utils.json import get_file_path_from_config, field_getter, json_dumps
14
+ from utils.io import LineIteratorIO, comment_filtered_lines
15
+ import schemas.projects as projects_schema
16
+
17
+ # --- ロギングの設定 ---
18
+ log = setup_logger(__name__)
19
+
20
+ # --- 設定 ---
21
+ input_file = get_file_path_from_config("projects.projects_json")
22
+ output_file = get_file_path_from_config("sudachi.synonyms_cache")
23
+ sudachi_config_file = get_file_path_from_config("sudachi.sudachi_config")
24
+
25
+ try:
26
+ search_model = field_getter("config/search_model.json")
27
+ target_pos_l1 = search_model("target_pos_l1")
28
+ target_fields = search_model("target_fields")
29
+ ban_list = search_model("synonyms.banlist")
30
+ stopwords_file = get_file_path_from_config("sudachi.stopwords")
31
+ with open(stopwords_file, encoding="utf-8") as f:
32
+ stopwords = set(json.load(f))
33
+ synonyms_file = get_file_path_from_config("sudachi.synonyms")
34
+ except FileNotFoundError as e:
35
+ log.error(f"設定ファイルが見つかりません: {e}")
36
+ sys.exit(1)
37
+ except KeyError as e:
38
+ log.error(f"設定ファイルに必要なキーが見つかりません: {e}")
39
+ sys.exit(1)
40
+ except Exception as e:
41
+ log.error(f"予期しないエラーが発生しました: {e}")
42
+ sys.exit(1)
43
+
44
+
45
+ # --- SudachiPy初期化 ---
46
+ tokenizer_obj = dictionary.Dictionary(config_path=sudachi_config_file).create()
47
+ mode = tokenizer.Tokenizer.SplitMode.A
48
+
49
+
50
+ def tokenize(text: str) -> list[str]:
51
+ """テキストをトークン化し、ストップワードを除去する。"""
52
+ if not text:
53
+ return []
54
+ tokens = []
55
+ for m in tokenizer_obj.tokenize(text, mode):
56
+ if m.normalized_form().strip() == "":
57
+ continue
58
+ pos = m.part_of_speech()
59
+ if pos[0] not in target_pos_l1:
60
+ continue
61
+ base = m.normalized_form().lower()
62
+ if base in stopwords or base in ban_list:
63
+ continue
64
+ tokens.append(base)
65
+ return tokens
66
+
67
+
68
+ def get_corpus_vocab(projects: list[projects_schema.Project]) -> set[str]:
69
+ """プロジェクト全体から語彙セットを構築する"""
70
+ vocab = set()
71
+ log.info("語彙セットを構築中...")
72
+ for project in tqdm(projects):
73
+ for field in target_fields:
74
+ text = getattr(project, field, "")
75
+ if not text:
76
+ continue
77
+ tokens = tokenize(str(text))
78
+ vocab.update(tokens)
79
+ return vocab
80
+
81
+
82
+ def find_and_parse_synonyms_text() -> dict[str, list[str]]:
83
+ """同義語辞書をpandasを使ってパースする"""
84
+ log.info("同義語辞書をパース中...")
85
+ log.info(f"同義語辞書のパス: {synonyms_file}")
86
+
87
+ try:
88
+ # ヘッダーなしのCSVとして読み込む
89
+ # 必要なのはグループID(0)と見出し語(8)
90
+ GROUP_ID_COL_IDX = 0
91
+ TERM_COL_IDX = 8
92
+
93
+ stream = LineIteratorIO(comment_filtered_lines(synonyms_file))
94
+ df = pd.read_csv(
95
+ stream,
96
+ header=None,
97
+ usecols=[GROUP_ID_COL_IDX, TERM_COL_IDX],
98
+ names=["group_id", "term"],
99
+ on_bad_lines="skip",
100
+ encoding="utf-8",
101
+ engine="c",
102
+ sep=",",
103
+ )
104
+
105
+ except Exception as e:
106
+ log.error(f"同義語辞書のパース中にエラーが発生しました: {e}")
107
+ raise e
108
+
109
+ synonym_groups = defaultdict(list)
110
+ # group_idでグループ化し、各グループ内の単語リストを作成
111
+ for group_id, group_df in df.groupby("group_id"):
112
+ terms = group_df["term"].tolist()
113
+ if len(terms) < 2:
114
+ continue
115
+ for i, term in enumerate(terms):
116
+ synonyms = [t for j, t in enumerate(terms) if i != j]
117
+ synonym_groups[term].extend(synonyms)
118
+
119
+ return synonym_groups
120
+
121
+
122
+ def main():
123
+ log.info("同義語キャッシュの生成を開始します")
124
+
125
+ with open(input_file, encoding="utf-8") as f:
126
+ try:
127
+ project_dicts = json.load(f)
128
+ projects = [projects_schema.Project(**item) for item in project_dicts]
129
+ except json.JSONDecodeError as e:
130
+ log.error(f"JSONデコードエラー: {e}")
131
+ sys.exit(1)
132
+ except FileNotFoundError:
133
+ log.error(f"ファイルが見つかりません: {input_file}")
134
+ sys.exit(1)
135
+ except Exception as e:
136
+ log.error(f"予期しないエラーが発生しました: {e}")
137
+ sys.exit(1)
138
+
139
+ # 1. コーパスの語彙を構築
140
+ vocab = get_corpus_vocab(projects)
141
+ log.info(f"コーパスの語彙数: {len(vocab)}")
142
+
143
+ # 2. Sudachiの同義語辞書をパース
144
+ try:
145
+ synonym_groups = find_and_parse_synonyms_text()
146
+ except Exception as e:
147
+ log.error(e)
148
+ sys.exit(1)
149
+
150
+ # 3. コーパスに存在する単語に絞ってキャッシュを作成
151
+ synonym_cache = {}
152
+ log.info("同義語キャッシュを構築中...")
153
+ for term in tqdm(vocab):
154
+ if term in ban_list:
155
+ continue
156
+ if term in synonym_groups:
157
+ valid_synonyms = [
158
+ s for s in synonym_groups[term] if s in vocab and s not in ban_list
159
+ ]
160
+ if valid_synonyms:
161
+ synonym_cache[term] = list(set(valid_synonyms)) # 重複除去
162
+
163
+ # 4. キャッシュを保存
164
+ json_dumps(synonym_cache, output_file)
165
+
166
+ log.info(f"同義語キャッシュの生成が完了しました: {output_file}")
167
+ log.info(f"キャッシュされた単語数: {len(synonym_cache)}")
168
+
169
+
170
+ if __name__ == "__main__":
171
+ main()
utils/io.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+
3
+
4
+ def comment_filtered_lines(path: str):
5
+ with open(path, encoding="utf-8") as f:
6
+ for line in f:
7
+ if not line.lstrip().startswith("#"):
8
+ yield line
9
+
10
+
11
+ class LineIteratorIO(io.TextIOBase):
12
+ def __init__(self, iterator):
13
+ self._it = iter(iterator)
14
+ self._buf = ""
15
+
16
+ def readable(self):
17
+ return True
18
+
19
+ def read(self, size=-1):
20
+ # size<0 のときはEOFまで貯めて返す
21
+ if size is None or size < 0:
22
+ try:
23
+ for chunk in self._it:
24
+ self._buf += chunk
25
+ except StopIteration:
26
+ pass
27
+ out, self._buf = self._buf, ""
28
+ return out
29
+
30
+ # size 指定ありのときは、必要分だけバッファを満たす
31
+ while len(self._buf) < size:
32
+ try:
33
+ self._buf += next(self._it)
34
+ except StopIteration:
35
+ break
36
+
37
+ out, self._buf = self._buf[:size], self._buf[size:]
38
+ return out
utils/json.py CHANGED
@@ -14,6 +14,35 @@ def _get_nested(d: dict[str, Any], keys: list[str]) -> Any:
14
  return cur
15
 
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  def get_file_path_from_config(
18
  key: str, default: str | None = None, sep: str = "."
19
  ) -> str:
@@ -43,6 +72,13 @@ def get_file_path_from_config(
43
  raise TypeError(f"Value at '{key}' is not a string: {type(value).__name__}")
44
 
45
 
 
 
 
 
 
 
 
46
  def _write_atomic(path: Path, data: str) -> None:
47
  temp = path.with_suffix(path.suffix + ".tmp")
48
  temp.write_bytes(data)
 
14
  return cur
15
 
16
 
17
+ def _get_field_from_json(
18
+ path: str | Path, key: str, default: str | None = None, sep: str = "."
19
+ ) -> Any:
20
+ """
21
+ JSONファイルからフィールドを取得する。
22
+ 階層構造がある場合は`sep`で区切って指定する。
23
+
24
+ example: `get_file_path_from_config("projects.original_csv")`
25
+ """
26
+ with open(path, "r", encoding="utf-8") as f:
27
+ try:
28
+ config: dict[str, Any] = json.load(f)
29
+ except json.JSONDecodeError as e:
30
+ raise ValueError(f"JSONデコードエラー: {e}")
31
+ except FileNotFoundError:
32
+ raise ValueError(f"ファイルが見つかりません: {path}")
33
+
34
+ keys = key.split(sep) if sep in key else [key]
35
+
36
+ try:
37
+ value = _get_nested(config, keys)
38
+ except KeyError:
39
+ if default is not None:
40
+ return default
41
+ raise KeyError(f"Key '{key}' not found in {path}")
42
+
43
+ return value
44
+
45
+
46
  def get_file_path_from_config(
47
  key: str, default: str | None = None, sep: str = "."
48
  ) -> str:
 
72
  raise TypeError(f"Value at '{key}' is not a string: {type(value).__name__}")
73
 
74
 
75
+ def field_getter(path: str | Path):
76
+ def getter(key: str, default: str | None = None, sep: str = ".") -> Any:
77
+ return _get_field_from_json(path, key, default, sep)
78
+
79
+ return getter
80
+
81
+
82
  def _write_atomic(path: Path, data: str) -> None:
83
  temp = path.with_suffix(path.suffix + ".tmp")
84
  temp.write_bytes(data)