PR:このページには広告(アフィリエイトリンク)が含まれます。リンク経由の応募で当サイトに紹介料が入ることがありますが、掲載順は各社から受け取る金額では決めていません。掲載基準について
ショウジョウバエの嗅覚回路を使って、派遣会社をまたいだ求人の時給差を出す
リゾートソートには、掲載中の求人から「同じエリア・同じ職種なのに、派遣会社によって時給が違う組み合わせ」を自動で拾い出す仕組みがあります。ショウジョウバエの脳にある嗅覚回路の構造をそのまま計算に使っていて、実装は229行です。この記事では、その中身と、この方法で何が言えて何が言えないのかを書きます。
先に結論を書きます。
- 求人12,497件から、時給差のある組み合わせを85,377ペア抽出しています
- 使っているのは FlyHash という、ショウジョウバエのキノコ体(Mushroom Body)を模した局所性鋭敏型ハッシュです
- ただし、これが同定しているのは「同じエリア・同じ職種」であって「同じホテル」ではありません。ここは誤解されやすいので、記事の後半で正直に書きます
- 厳密な「同じ施設」の比較は、別の方法(郵便番号+番地の一致)で15か所だけ確認できています。桁が4つ違います
なぜ普通の文字列比較では足りなかったか
派遣会社は、勤務先の施設名をほとんど公開していません。求人票に出てくるのは「北海道/ニセコ/調理補助」「長野県/白馬/ホール」といった粒度です。掲載している12,497件のうち、施設名(workplace_name)が入っているものはごく一部です。
そのため、A社の求人とB社の求人が同じ現場なのかを、文字列の一致で判定することができません。表記も揃っていません。「賀茂郡河津町」と書く会社もあれば「河津」と書く会社もあります。
さらに、総当たりで比較すると 12,497 の2乗で約7,800万通りになります。素朴に回すと時間がかかります。
キノコ体がやっていること
ショウジョウバエは、数千種類の匂いを嗅ぎ分けます。その処理をしているのがキノコ体という部位で、構造がかなり変わっています。
入力側の射影ニューロン(PN)は数十次元しかないのに、その先のケニオン細胞(KC)は2,000個以上あります。情報を圧縮するのではなく、いったん高次元に広げます。 そして各KCは、全PNの中からランダムに数本だけ枝を伸ばして結合しています。最後にAPLという抑制性ニューロンが全体を見張っていて、強く反応した上位数%のKCだけを残し、残りを黙らせます。
結果として、1つの匂いは「2,000個のうち100個くらいだけが光っているパターン」になります。似た匂いは似たパターンになり、違う匂いは重ならないパターンになります。
これを「似ているものを似たハッシュ値にする」アルゴリズムとして定式化したのが FlyHash です(Dasgupta, Stevens, Navlakha, *Science*, 2017)。普通のハッシュ関数は、入力が少し違えば全く違う値を返すように設計されています。FlyHash はその逆で、似た入力を意図的に似た値に落とします。
実装
求人1件を、こういう素性の集合に変換します。
def extract_features(job):
tokens = []
if job.get("prefecture"): tokens.append(f"pref:{job['prefecture']}")
if job.get("city"): tokens.append(f"city:{job['city']}")
if job.get("job_category"): tokens.append(f"cat:{job['job_category']}")
text = f"{job.get('workplace_name') or ''} {job.get('workplace_key') or ''} {job.get('city') or ''}"
clean = re.sub(r"[【】\[\]\s★♪◆◇◎※・\-_/0-9]", "", text)
for i in range(len(clean) - 2):
tokens.append(f"ng3:{clean[i:i+3]}") # 3文字ずつの部分文字列
return tokens
全求人を通すと、素性の語彙は 7,588種類 になりました。これが入力側(PN層)の次元数です。
ここから先が回路の再現です。
class FlyConnectomeEngine:
def __init__(self, n_kc=2048, syn_per_kc=6, top_k_ratio=0.05):
...
n_kc=2048:KC層を2,048個用意します。7,588次元の入力を、いったん2,048個の細胞に投げますsyn_per_kc=6:各KCは、7,588個の入力からランダムに6本だけ結合します。結合の重みは対数正規分布から引きます。生体でも結合はまばらで、重みは一様ではありませんtop_k_ratio=0.05:反応が強かった上位5%(102個)だけを1、残りを0にします。APLの抑制にあたります
こうして各求人が「2,048ビットのうち102ビットが立ったパターン」になります。あとは2つのパターンのXORを取って立っているビットを数えれば、似ている度合いが出ます(ハミング距離)。
dist = int(np.sum(hashes[idx1] ^ hashes[idx2]))
if dist <= max_hamming: # max_hamming = 18
...
総当たりを避けるため、比較は(都道府県, 職種)で区切ったブロックの中だけで行っています。
何が出てくるか
実際に抽出されたものを、そのまま出します。
距離5 北海道定山渓/洗い場 ワクトリ 1,300円 vs ヒューマニック 1,120円
距離8 北海道ニセコ/調理補助 ワクトリ 1,500円 vs ヒューマニック 1,200円
距離6 長野県白馬/ホール ヒューマニック 1,400円 vs ワクトリ 1,100円
距離5 静岡県賀茂郡河津町/調理補助 グッドマン 1,400円 vs アルファ 1,280円
時給差が30円以上あるものだけを残して、85,377ペアになりました。時給以外(月給・日給)は混ぜていません。極端な値を除くため、800円〜3,500円の範囲外も外しています。
ここからが正直な話
この記事を書くにあたって、自分の実装を読み直しました。説明できないことが2つ見つかったので、先に書いておきます。
これは「同じホテル」を特定していません
素性を見てもらうと分かる通り、使っているのは都道府県・市区町村・職種と、市区町村名の3-gramです。施設名はほとんどのデータに入っていないので、実質的に効いているのは地名と職種だけです。
つまり距離5で一致した「北海道定山渓/洗い場」の2件は、定山渓にある別々の旅館かもしれません。 定山渓には宿が何十軒もあります。
なので、この数字を「同じ職場なのに時給が違う」と読むのは正しくありません。正しくは「同じ温泉地の同じ職種で、会社によって出している時給が違う」です。求職者にとっては十分に意味のある情報ですが、言葉は変えるべきだと考えています。
厳密な「同じ施設」の比較は、別の分析で行っています。そちらは各社のJSON-LDに入っている郵便番号と番地が一致するものだけを同じ勤務地とみなす方法で、確認できたのは15か所です。85,377と15。この差が、そのまま「手法の緩さ」の差です。
精度を測っていません
ハミング距離の閾値18は、出力を眺めて決めた値です。正解データを用意して精度を測る作業を、まだやっていません。「信頼度98%」のような数字は、出せません。
閾値を8まで厳しくすると36,192ペア(42.4%)に減ります。どちらが妥当かを判断する根拠が、今は手元にありません。
想定される指摘
「それ、FlyHashである必要ある?」
正直、この用途ではありません。素性が地名と職種しかないので、(都道府県, 市区町村, 職種)で辞書を引けば同じことができます。FlyHash が効いてくるのは、施設名や求人本文のような表記揺れのあるテキストを素性に入れたときです。そこまでデータが揃っていないので、現状は回路の性能を使い切れていません。 施設名の取得率を上げるのが次の課題です。
「ランダムな投影で意味のある比較ができるの?」
できます。Johnson–Lindenstrauss の補題が保証しているのは「ランダム射影は距離をおおむね保つ」ことで、FlyHash はその疎・二値版にあたります。元論文でも、局所性鋭敏型ハッシュの標準手法と比べて同等以上の近傍検索性能が示されています。ただし乱数シードを固定しないと結果が変わるので、np.random.seed(42) で固定しています。
「毎回2,048×7,588の行列を作るの?」
疎行列(scipy.sparse.csr_matrix)です。結合は 2,048 × 6 = 12,288 本だけなので、密行列の 0.08% です。ここは生体の構造がそのまま計算量の節約になっています。
まだできていないこと
施設名が取れていないのが、すべての制約の根っこです。ここが埋まれば、FlyHash は本来の使い方になり、「同じ施設」の判定も15か所から大きく増やせます。精度の検証も、それができてから手作業で正解を作るのが順当だと考えています。
現時点の出力は時給差の一覧で公開しています。上に書いた制約を踏まえて見ていただければと思います。
この条件の求人
| 勤務地・職種 | 給与 | 寮 | 会社 | |
|---|---|---|---|---|
| 長野県下高井郡 | ¥1,900 | 完全個室 | グッドマンサービス | 詳細(公式) |
| 長野県下高井郡 | ¥1,850 | 完全個室 | グッドマンサービス | 詳細(公式) |
| 長野県下高井郡 | ¥1,800 | 完全個室 | グッドマンサービス | 詳細(公式) |
| 長野県下高井郡山ノ内町 | ¥1,800 | 完全個室 | 株式会社ダイブ | 詳細(公式) |
| 長野県下高井郡山ノ内町 | ¥1,800 | 完全個室 | 株式会社ダイブ | 詳細(公式) |
| 神奈川県柄下郡 | ¥1,800 | 完全個室 | グッドマンサービス | 詳細(公式) |
| 静岡県伊東市 | ¥1,700 | 完全個室 | グッドマンサービス | 詳細(公式) |
| 長野県下高井郡野沢温泉村 | ¥1,900 | 完全個室 | 株式会社アルファスタッフ | 詳細(公式) |
| 長野県下高井郡 | ¥1,600 | 完全個室 | グッドマンサービス | 詳細(公式) |
| 長野県下高井郡 | ¥1,600 | 完全個室 | グッドマンサービス | 詳細(公式) |
| 群馬県吾妻郡 | ¥1,700 | 完全個室 | グッドマンサービス | 詳細(公式) |
| 神奈川県足柄下郡箱根町 | ¥1,650 | 完全個室 | グッドマンサービス | 詳細(公式) |