[おあそび]とても簡単な英単語当てクイズを公開データから作る[Python][Word2Vec]


# 概要



<!-- えいごで遊ぼう -->

* 公開されている英単語データと和訳データを用いて、簡単な英単語当てクイズを作る。
* 英単語の語彙レベルを指定した出題がされるようにする。
* Word2Vecモデルのsimilarity（埋め込み表現・分散表現の類似度）を用いる。
    * 4択問題の候補表示に取り入れる。
    * クイズモードとして、「similarityの高い/低い単語らから元の単語を当てる」モードを用意する。

息抜きお遊びに。


## 作る機能

ゲームモードとして、以下の3つを用意します。
(0)と(2)は正解が英単語で、(1)は正解が和文説明です。

* (0) 仲間当て：「正解の英単語とSimilarityの高い単語/低い単語を2つずつ提示する。正解の単語を含む4つの単語リストを表示する（当ててね）」
* (1) 意味当て：「英単語をはじめに提示する。その単語の説明候補（和文）を4つ表示し、正解の説明を当てる」
 * ※候補としてダミーの和文を表示するが、それらは、別の単語の和文説明にあたる。
* (2) 和文説明から単語当て：「正解の英単語に対する和文説明を表示する。正解の単語を含む4つの単語リストを表示する（当ててね）」

ユーザは、次のことが行えるようにします。

* 単語の語彙レベル（決め打ち/最大値）を指定できる。

また、クイズで使うダミーの単語（やその和文説明）について、次の特徴を持たせるようにします。

* Similarityの範囲を制限する。

なお、正解の選択肢を実際に入力してもらう機能は、ここでは実装しません。
<!-- // Google ColaboratoryのNotebook上で作って動かしていたので、プレイヤー側の入力が増えるの面倒だったからです。 -->
質問文を見たら頭の中で正解を考えて、数行後の答えを見る。それでいいのだ。


<!-- ## この記事の注意 -->

<!-- また今回はWord2Vecを使っているのですが、「分散表現」「埋め込み表現」もまちまちです。-->


また、この記事ではいろいろな用語に揺れがあり、計算の速さもあまり考慮していません。お遊びということでご容赦ください。


# データ


使うデータは、次の2点です。

1. 英単語とその意味が載っているもの（辞書）
2. 英単語に登場頻度がついているデータ（頻度表）


せっかくなので、語彙が1万語以上あるものを使います。

## (1) 辞書

ここではオンラインで公開されている英和辞典を用います。
「ANC単語頻度準拠_英和辞典/ANC Frequency Dictionary」を使用します。
https://jamsystem.com/ancdic/index.html

約3万語あります。

全言語分あるファイルをダウンロードし、日本語の分を抽出し、UTF-8形式のCSVとして保存しておきます。

### その他の辞書（英英辞書）

公開されているパブリックドメインの英英辞書で語数が多いものに、
「The Online Plain Text English Dictionary (OPTED)」
https://www.mso.anu.edu.au/~ralph/OPTED/
があります。


KaggleのDatasetsにCSVファイルをダウンロードできるページがあります。

https://www.kaggle.com/datasets/dfydata/the-online-plain-text-english-dictionary-opted

【仕様などについての注意点】
* 同じ見出し語（Word）・同じ品詞(POS)であっても、行は1つとは限らない。Definitionが異なる複数の行に分かれることが多い。
* 形容詞では、比較級/最上級がmore/most系でないものについて、品詞が「superl.」と記されていることも多くある。
* 単語名（Word）が「#NAME?」となっている箇所もある（-で始まるものがそうなっているのを複数見つけた）。


### その他のデータ（類義語）

類義語データを使いたい場合は「WordNet」があります。

https://wordnet.princeton.edu/download/standoff-files

## (2) 単語データ（登場頻度つき・意味説明なし）

<!-- 特定のコーパス中の単語の登場頻度をまとめた表、というのは何種類かあります。 -->

英単語にどう優先順位をつけて覚えていくか？
なかには、特定の分野で使われる用語をまず覚えたいのだ、というケースもあるでしょうが、全体的な語彙力をつけていきたいのなら、単語の登場する「頻度」が一つの指標になるでしょう。

書籍・新聞・ラジオなどで、知らない単語を減らしていく。
そのためには、高頻度な単語から攻略していくことが有用だと思われます。
ただし、0スタートではない学習なら、既知語ばかりのゾーンはスキップして、「知らない単語が多くなってきたゾーン」をターゲットにすると効率がよさそうです。


さて、幸いなことに、世の中では、現代英語の書き言葉・話し言葉のデータを（広範なジャンルから）集める営みが行われています。
そして、高頻度の英単語を抜き出したリストや、頻度レベルごとに単語を分類したリストが公表されています。


ここでは、「EAPFoundation.com」が提供する、応用言語学者のPaul Nationが構築した「BNC/COCA lists」を使います。



https://www.eapfoundation.com/vocab/general/bnccoca/

（「BNC/COCA lists」は、こちらのウェブページからxlsx形式のファイルをダウンロードできます。）


リストでは、各単語に対して、関係する形（活用形など）・登場頻度が示されています。さらには1000語ごとに区切られた「frequency level（頻度レベル）」を表す列があります。

データの数は、Full Listで約2万5千行です。高頻度・中頻度・低頻度の3つに分かれたものをダウンロードすることもできるようです。

ダウンロードしたファイルをCSVに変換しておきます。



【補足】「BNC」は「British National Corpus」の略で、現代イギリス英語のコーパスです。「COCA」は「Corpus of Contemporary American English」の略で、現代アメリカ英語のコーパスです。

### 語彙のレベル

このリストは、頻度以外に「List」という項目があります。
1000語ごとに区分が与えられ、全25段階が、「1k」「2k」「25k」というふうにラベルづけられています。
これが「frequency level（頻度レベル）」を表すようです（完全にTotal Frequencyの順と一致しているわけではなさそうでしたが）。
このラベルが学習用によさそうなので、ここではこのリストを使います。

以後、これを「語彙レベル」とみなします。

Listと、どのような単語があるかの例の表です。

| List | 単語の例                         | 例のTotal Frequency      |
| ---- | ---------------------------- | ---------------------- |
| 1k   | 「a」「able」「about」             | 2525253, 47760, 192168 |
| 2k   | 「accent」「access」「accident」   | 略                      |
| 6k   | 「abduct」「abide」「abolition」   | 略                      |
| 11k  | 「abrasion」「abridge」「abscess」 | 略                      |
| 21k  | 「abaft」「abbacy」「ablative」    | 8, 20, 3               |


また、このリストの特徴として、
見出し語（Headword）「able」に対して「able (29930), abilities (1334), ability (9113)……」のような関連する形も載っている、ということが挙げられます。

### 他の頻度表データ


https://user.keio.ac.jp/~rhotta/hellog/2010-03-01-1.html

こちらでは、さまざまな頻度表が紹介されています。



# 実装

ここでは、PythonとJupyter Notebookでやります。
簡単なお遊びなので、Google Colaboratoryを使いました。

Word2Vecのモデルを使うためにライブラリを導入しておきます。

```
! pip install gensim
```

軽めのモデル（66.0MB）を使います。

```
import gensim.downloader as api
model = api.load("glove-wiki-gigaword-50")
```



## 結合


### 読み込み・前処理



まず、単純に`pd.read_csv()`（オプションなし）で読み込むと、「null」という単語や「nan」という単語がNaNとして読み込まれてしまう、という問題がありました。

このため、`read_csv`では、
`keep_default_na=False`＆`na_values=''`とオプションをつけることにします。


```
# 辞書
df_eap = pd.read_csv('eapfoundation_com_duo30__ver.csv', keep_default_na=False, na_values='')
# レベル・頻度付き単語リスト
df_anc = pd.read_csv('ANC30000_1014_Dic_EnJaのみ_UTF8.csv', keep_default_na=False, na_values='')
```





ただし、これでも辞書ファイルである`df_eap`のほうは、列名に妙な空白がありました。
また、'null'という単語がnullとして扱われていました。
さらに、手元のファイルはCSVへの変換の時点で'true'と'false'が'TRUE'と'FALSE'になってしまってました。
このため、前処理をします。


```
# 前処理
df_eap.columns = ['List', 'Headword', 'Related forms', 'Total frequency']
df_eap['Headword'].fillna('null', inplace=True)
df_eap['Headword'] = df_eap['Headword'].str.replace('TRUE', 'true')
df_eap['Headword'] = df_eap['Headword'].str.replace('FALSE', 'false')
```

ついでに保存。

```
# ついでに保存
df_eap.to_csv('eapfoundation_com_duo30__ver_preprocessed.csv', quoting=2)
```

全体はこのような感じです。2万5千語ほどありますね。

```
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 25002 entries, 0 to 25001
Data columns (total 4 columns):
 #   Column           Non-Null Count  Dtype 
---  ------           --------------  ----- 
 0   List             25002 non-null  object
 1   Headword         25002 non-null  object
 2   Related forms    25002 non-null  object
 3   Total frequency  25002 non-null  int64 
dtypes: int64(1), object(3)
memory usage: 781.4+ KB
None
```

次に、ANCのほうです。

```
print(df_anc.columns)
print(df_anc.info())
```

で見てみると、次のようになっています。

```
Index(['Lemma\n（見出し語）', 'Rank\n(順位)', 'Frequency\n(頻度)', 'Japanese\n(Short)',
       'Commentary\n(解説)', 'cf', 'Ctgory'],
      dtype='object')
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 30640 entries, 0 to 30639
Data columns (total 7 columns):
 #   Column            Non-Null Count  Dtype  
---  ------            --------------  -----  
 0   Lemma
（見出し語）      30638 non-null  object 
 1   Rank
(順位)         30640 non-null  int64  
 2   Frequency
(頻度)    30640 non-null  int64  
 3   Japanese
(Short)  30640 non-null  object 
 4   Commentary
(解説)   30640 non-null  object 
 5   cf                6301 non-null   float64
 6   Ctgory            13527 non-null  float64
dtypes: float64(2), int64(2), object(3)
memory usage: 1.6+ MB
None
```

列名を変えます。また、見出し語に大文字小文字があるので、結合時の便宜上これを小文字に揃えた列を用意します。

```
df_anc.columns = ['Headword', 'Rank', 'Frequency', 'Japanese(Short)', 'Commentary', 'cf', 'Category']
df_anc['Headword_Lower'] = df_anc['Headword'].str.lower()
```


語彙レベルがあるほうのdf_eapを左として、左外部結合します。
結合して使わなくなった列を除きます。

```

merged_df = pd.merge(df_eap, df_anc, left_on='Headword', right_on='Headword_Lower', how='left')
merged_df.drop('Headword_Lower', axis=1, inplace=True)
merged_df.drop('Headword_y', axis=1, inplace=True)
merged_df.rename(columns={'Headword_x': 'Headword'}, inplace=True)

merged_df.info()
```

データはこんな感じです。


```
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 25002 entries, 0 to 25001
Data columns (total 10 columns):
 #   Column           Non-Null Count  Dtype  
---  ------           --------------  -----  
 0   List             25002 non-null  object 
 1   Headword         25002 non-null  object 
 2   Related forms    25002 non-null  object 
 3   Total frequency  25002 non-null  int64  
 4   Rank             14608 non-null  float64
 5   Frequency        14608 non-null  float64
 6   Japanese(Short)  14608 non-null  object 
 7   Commentary       14608 non-null  object 
 8   cf               3626 non-null   float64
 9   Category         6387 non-null   float64
dtypes: float64(4), int64(1), object(5)
memory usage: 1.9+ MB
```

「語彙レベル」に関わる「List」列から数字の部分だけ取り出します。

```
merged_df['Level'] = merged_df['List'].apply(lambda x: int(x.split('k')[0]))
merged_df.drop('List', axis=1, inplace=True)
```

ここで、和訳のない単語がどれだけあるかを見ておきます。


```
# 説明descriptonを半端に略したせいで別の意味がありそうに見える。
non_desc_df.groupby('Level').count()
print(non_desc_df.groupby('Level')['Headword'].count())
```


結果。レベル10（10000語）までは、各1000件中で和訳のないのが100件未満ですが、どんどん増えていき、レベル15では過半数になっていることがうかがえます。
（見出し語が「Related forms」の何をとるか、というあたりが関係するかもしれません）


```
Level
1       6
2       8
3      11
4      21
5      17
6      26
7      44
8      53
9      66
10     93
11    126
12    227
13    296
14    395
15    507
16    606
17    714
18    757
19    820
20    859
21    900
22    937
23    957
24    973
25    975
Name: Headword, dtype: int64
```


### Related formsを利用した和訳の補完

和訳を補完する手として、「Related forms」列の使用が考えられます。

Related formsに入っているものには和訳がある場合、それを和訳に入れるのです。

ここでは【関連】を付加して入れるようにします。

```
import re

df = merged_df.copy()

# `Japanese(Short)`がNaNの行を抽出
df_nan = df[df['Japanese(Short)'].isna()]

def extract_strings(related_forms):
    """
    'Related forms'の文字列から、括弧前の単語を抽出してリスト化する。
    例: "able (29930), abilities (1334), ability (9113)" -> ['able', 'abilities', 'ability']
    """
    # 正規表現を使用して、括弧前の単語を抽出
    return re.findall(r'(\w+)\s*\(\d+\)', related_forms)


# `df_anc`を辞書に変換
headword_to_japanese = pd.Series(df_anc['Japanese(Short)'].values, index=df_anc['Headword_Lower']).to_dict()

def update_japanese_short(row):
    related_forms = row['Related forms']
    words = extract_strings(related_forms)
    # Headwordにマッチする日本語の短縮形をリスト化
    matched_japanese = [headword_to_japanese[word] for word in words if word in headword_to_japanese]
    if matched_japanese:
        # 重複を避けるためにSetに変換し、Listに戻す
        matched_japanese = list(set(matched_japanese))
        # "関連: " を付加して結合
        return "【関連】" + ", ".join(matched_japanese)
    else:
        return row['Japanese(Short)']  # 変更がない場合は元の値を保持

# `df`の`Japanese(Short)`を更新
df.loc[df['Japanese(Short)'].isna(), 'Japanese(Short)'] = df_nan.apply(update_japanese_short, axis=1)

```


| Level | ない個数（補完前） | ない個数（補完後） |
| - | - | - |
| 1 | 6 | 1 |
| 2 | 8 | 1 |
| 3 | 11 | 5 |
| 4 | 21 | 6 |
| 5 | 17 | 6 |
| 6 | 26 | 8 |
| 7 | 43 | 12 |
| 8 | 53 | 20 |
| 9 | 66 | 34 |
| 10 | 93 | 64 |
| 11 | 126 | 80 |
| 12 | 227 | 184 |
| 13 | 296 | 246 |
| 14 | 395 | 361 |
| 15 | 507 | 480 |
| 16 | 606 | 581 |
| 17 | 714 | 693 |
| 18 | 757 | 744 |
| 19 | 820 | 810 |
| 20 | 859 | 854 |
| 21 | 900 | 898 |
| 22 | 937 | 937 |
| 23 | 957 | 957 |
| 24 | 973 | 973 |
| 25 | 975 | 971 |

### 補完（英英での補完）


英訳でもいいのなら、英英辞書のOPTED（上述）を使用した結合も考えられます。
<!-- Related formsを使わずOPTEDを使ったとき、説明が与えられなかった単語の数 -->




## ゲーム部分 - 1 分散表現類似度関係


上述の操作で作ったような列を含むデータフレームをdfとします。


まず、分散表現が近い/遠い単語を提示する関数を作ります。

※ここでは別々に作りましたが、どうせ一緒に動かすというのなら、共通する部分（分散表現の取得など）はある程度まとめてしまったほうがパフォーマンスはいいでしょう。


```
# 分散表現が最も近い単語を2つ提示する関数
def find_similar_words(word, model, df, max_level=10):

    if word not in model:
        print(f"'{word}'の分散表現が見つかりません。")
        return

    word_vector = model[word]

    # word_level = df.loc[df['Headword'] == word, 'Level'].values[0]

    # DataFrame内の各Headwordの類似度を取得
    df['similarity'] = df['Headword'].apply(lambda w: model.similarity(word, w) if w in model else None)

    df_koho = df[df['Level'] <= max_level]

    # 分散表現類似度（印象的に「『連れ』度」？）が高い単語2つを取得
    similar_words = df_koho[df_koho['Headword'] != word].nlargest(2, 'similarity')[['Headword', 'similarity']]

    # print(f"単語 '{word}' に最も近い単語:")
    for idx, row in similar_words.iterrows():
        print(f"{row['Headword']} (分散表現類似度: {row['similarity']:.2f})")

    return similar_words


# 分散表現が最も遠い単語を2つ提示する関数
def find_far_words(word, model, df, max_level=10):

    if word not in model:
        print(f"'{word}'の分散表現が見つかりません。")
        return

    word_vector = model[word]
    df['similarity'] = df['Headword'].apply(lambda w: model.similarity(word, w) if w in model else None)
    df_koho = df[df['Level'] <= max_level]
    far_words = df_koho[df_koho['Headword'] != word].nsmallest(2, 'similarity')[['Headword', 'similarity']]
    # print(f"単語 '{word}' から最も遠い単語:")
    for idx, row in far_words.iterrows():
        print(f"{row['Headword']} (分散表現類似度: {row['similarity']:.2f})")

    return far_words
```


たとえば、`find_similar_words('avoid', model, df)`のようにして実行すると、


```
prevent (分散表現類似度: 0.89)
trouble (分散表現類似度: 0.84)
```

のようなものが表示されます。


trueについては、`find_similar_words('true', model, df)`が

```
indeed (分散表現類似度: 0.88)
fact (分散表現類似度: 0.86)
```

で、`find_far_words('true', model, df)`が

```
shamble (分散表現類似度: -0.50)
congest (分散表現類似度: -0.44)
```

とのこと。

## ゲーム部分 - 2 程よい近さの候補選定

```
import random

# Levelが同じ単語の中で分散表現類似度が指定範囲に入るものをランダムに提示する
# 範囲のデフォルトは0.2〜0.5
def find_words_in_same_level_with_particular_similarity_range(word, model, df, similarity_lb = 0.2, similarity_ub = 0.5, required_column_name = None):
    if word not in model:
        print(f"'{word}'の分散表現が見つかりません。")
        return

    # 対象の単語のLevelを取得
    word_level = df.loc[df['Headword'] == word, 'Level'].values[0]

    # 同じLevelの単語をフィルタリング
    same_level_df = df[df['Level'] == word_level]

    # DataFrame内の単語の分散表現類似度を計算
    same_level_df.loc[:,'similarity'] = same_level_df['Headword'].apply(lambda w: model.similarity(word, w) if w in model else -1)

    # 分散表現類似度が2割〜5割に入るものを抽出
    filtered_df = same_level_df[(same_level_df['similarity'] >= similarity_lb) & (same_level_df['similarity'] <= similarity_ub)]

    # ランダムに3つ選択
    random_words = filtered_df.sample(n=3, replace=False)[['Headword', 'similarity', 'Japanese(Short)']]

    if required_column_name is not None:
      random_words = random_words[random_words[required_column_name].isna() == False].reset_index()

    return random_words
```

ここで次のように実行すると、


```
# 関数の実行例
find_words_in_same_level_with_particular_similarity_range('get', model, df)
```

|index|Headword|similarity|Japanese\(Short\)|
|---|---|---|---|
|346|glance|0\.31558337807655334|一目|
|835|sweet|0\.4234450161457062|甘い|
|784|song|0\.445430189371109|歌|

のように結果が返ってきます。



## クイズ部分


* 語彙レベルの指定可（1つだけ/最大語彙レベルの2パターン対応。1つだけ指定を行う場合はそちらを優先する）
* クイズモードを0,1,2から指定。異常値のことは無視。


```

def quiz_q(model, df, max_level=10, level=None, quiz_mode=0):

  if quiz_mode == 0:
    print("埋め込み表現の類似度が高い単語・低い単語を出します。候補の中から正解の単語を当ててください。")
  elif quiz_mode == 1:
    print("単語の和訳表現を選んでください。")
  elif quiz_mode == 2:
    print("和訳表現から、正解の単語を選んでください。")

  df_HW_koho = None

  if level is not None:
    level = int(level)
    df_HW_koho = df[df['Level'] == level][['Headword', 'Japanese(Short)']].reset_index()
  elif max_level is not None:
    df_HW_koho = df[df['Level'] <= max_level][['Headword', 'Japanese(Short)']].reset_index()
  else:
    df_HW_koho = df[['Headword', 'Japanese(Short)']].reset_index()

  if (quiz_mode == 1 or quiz_mode == 2):
    df_HW_koho = df_HW_koho[df_HW_koho['Japanese(Short)'].isna() == False].reset_index()

  len_koho = len(df_HW_koho)

  hidden_word_index = random.randrange(len_koho)

  hidden_word = df_HW_koho[hidden_word_index: hidden_word_index+1]['Headword'].tolist()[0]
  hidden_word_JA = df_HW_koho[hidden_word_index: hidden_word_index+1]['Japanese(Short)'].tolist()[0]

  other_words_df = None

  if quiz_mode == 0:
    other_words_df = find_words_in_same_level_with_particular_similarity_range('get', model, df)
  elif quiz_mode in [1,2]:
    other_words_df = find_words_in_same_level_with_particular_similarity_range('get', model, df, required_column_name='Japanese(Short)')

  koho_words_HW_list = other_words_df['Headword'].to_list()

  insert_index = random.randrange(len(koho_words_HW_list)+1)
  koho_words_HW_list.insert(insert_index, hidden_word)

  koho_words_JA_list = other_words_df['Japanese(Short)'].to_list()
  koho_words_JA_list.insert(insert_index, hidden_word_JA)

  if quiz_mode == 0:

    print("正解の単語と近いらしい単語たちです！")
    find_similar_words(word=hidden_word, model=model, df=df)

    print("遠いらしい単語たちです！")
    find_far_words(word=hidden_word, model=model, df=df)

    print("正解の単語を考えてね")
    print(koho_words_HW_list)


  elif quiz_mode == 1:

    print(f"単語：{hidden_word}")
    print("和訳表現たちです")
    print(koho_words_JA_list)
    print("どれが正しいでしょう？")

  elif quiz_mode == 2:

    print("和訳はこれだよ")
    print(hidden_word_JA)
    print("この中に答えがあるよ")
    print(koho_words_HW_list)

  print("\n\n\n")

  print(f"正解の英単語： {hidden_word}, 和訳： {hidden_word_JA}")

  return insert_index

```


## サンプル

### 近い・遠い単語


```
quiz_q(model=model,df=df,quiz_mode=0,max_level=5)
```

に対して

```
埋め込み表現の類似度が高い単語・低い単語を出します。候補の中から正解の単語を当ててください。
正解の単語と近いらしい単語たちです！
micro (分散表現類似度: 0.67)
amorphous (分散表現類似度: 0.65)
遠いらしい単語たちです！
waddle (分散表現類似度: -0.44)
dote (分散表現類似度: -0.42)
正解の単語を考えてね
['silicon', 'sudden', 'chair', 'police']




正解の英単語： silicon, 和訳： シリコン
0
```

### 和訳当て


```
quiz_q(model=model,df=df,quiz_mode=1)
```

に対して

```
単語の和訳表現を選んでください。
単語：physician
和訳表現たちです
['医師', '訪問', '秋', '海']
どれが正しいでしょう？




正解の英単語： physician, 和訳： 医師
0
```


### 和文説明から英単語当て


```
quiz_q(model=model,df=df,quiz_mode=2,level=6)
```

に対して

```
和訳表現から、正解の単語を選んでください。
和訳はこれだよ
高原
この中に答えがあるよ
['race', 'god', 'plateau', 'horse']




正解の英単語： plateau, 和訳： 高原
2
```