langchianlogo

LangChain入門 – 10)アプリ例

LangChain入門の締めくくりです。ここまで学習したことを踏まえてLangChainを使ったアプリケーションをいくつか作成してみましょう。

本記事はFuture Coders独自教材からの抜粋です。

内容理解クイズ

ドキュメントを読み込んで分割し、QAGenerationChainクラスを使って、クイズと答えを自動的に作成するアプリです。

トロッコの小説の内容理解を問う質問と回答が自動で生成されます。質問をクリックするとその下に回答が表示されます。

vector12
LangChain入門 – 10)アプリ例 4
import streamlit as st
from langchain.chains import QAGenerationChain
from langchain_openai import ChatOpenAI

from langchain.text_splitter import CharacterTextSplitter
from langchain_community.document_loaders import TextLoader
from langchain.chains.qa_generation.prompt import templ1, templ2

from langchain.prompts.chat import (
    ChatPromptTemplate,
    HumanMessagePromptTemplate,
    SystemMessagePromptTemplate,
)

prompt = ChatPromptTemplate.from_messages([
    SystemMessagePromptTemplate.from_template(templ1+" in Japanese."),
    HumanMessagePromptTemplate.from_template(templ2),
])

@st.cache_resource
def get_QA():
    docs = TextLoader('./data/trokko.txt', encoding="utf-8").load()
    text_splitter = CharacterTextSplitter(chunk_size=400,
        chunk_overlap=0, separator=" "
    )
    chunks = text_splitter.split_documents(docs)

    chain = QAGenerationChain.from_llm(ChatOpenAI(), prompt=prompt)
    QAlist = [chain.invoke(c.page_content) for c in chunks]
    return QAlist

st.title("トロッコ 理解度テスト")
for qa in get_QA():
    q = qa["questions"][0]["question"]
    a = qa["questions"][0]["answer"]
    with st.expander(q):
        st.write(a)

今回はQAGenerationChainクラスを使用しています。一般的な使い方であれば、以下のように単にQAGenerationChainクラスを使用するだけで質問と回答が生成されます。

from langchain.chains import QAGenerationChain

from langchain_openai import ChatOpenAI
from langchain_community.document_loaders import TextLoader
from langchain.text_splitter import CharacterTextSplitter

docs = TextLoader('./data/trokko.txt', encoding="utf-8").load()
text_splitter = CharacterTextSplitter(chunk_size=400,
    chunk_overlap=0, separator=" "
)
chunks = text_splitter.split_documents(docs)

chain = QAGenerationChain.from_llm(ChatOpenAI(temperature = 0))

print(chain.invoke(chunks[0].page_content))

上記コードでは、まずtrokko.txtをTextLoaderで読み込んでいます。このままではサイズが大きいのでCharacterTextSplitterで分割します。QAGenerationChainクラスを使って、分割した最初のドキュメント(chunks[0].page_content)のQ&Aを作成しています。出力は以下のようになりました。

{'text': '小田原熱海あたみ間に、軽便鉄道敷設ふせつの工事が始まったのは、良平りょうへいの八つの年だった。良平は毎日村外
はずれへ、その工事を見物に行った。工事を――といったところが、唯ただトロッコで土を運搬する――それが面白さに見に行ったので
ある。', 'questions': [{'question': 'Why did Ryohei go to watch the construction every day?', 'answer': 'He found it interesting to see the construction workers transporting soil using a trolley.'}]}

確かに、Q&Aの形になっていますが、英語になってしまいました。これはQAGenerationChainのデフォルトのテンプレートが英語のためです。デフォルトのプロンプトはlangchain.chains.qa_generation.promptというモジュールに含まれており、そのtempl1, templ2という文字列でアクセスできることがわかりました。

そこで、デフォルトのテンプレート(langchain.chains.qa_generation.prompt.temp1)に” in Japanese.”という文字列を連結し、自分でテンプレートを用意しました。そのようにすることで日本語での質問・回答を得ています。

アプリはstreamlitのst.expanderを使用しています。デフォルトの状態では質問だけが表示されていおり、クリックすると広がった領域に回答が表示されるようにしています。

就業規則チャットボット

一般的に就業規則は読みづらいものです。そこで、質問するとわかりやすく説明してくれる、そんなチャットボットを作ってみます。元データは厚生労働省の「モデル就業規則」をダウンロードして使用しました。

from langchain_community.document_loaders import Docx2txtLoader
from langchain.text_splitter import CharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import FAISS
docs = Docx2txtLoader("./data/labour_standard.docx").load()

text_splitter = CharacterTextSplitter(chunk_size=400, chunk_overlap=0, separator="\n")
chunks = text_splitter.split_documents(docs)

embeddings = OpenAIEmbeddings()
faiss = FAISS.from_documents(chunks, embedding=embeddings)
faiss.save_local("labour-db")

Docx2txtLoaderを使ってWord文書を読み込み、CharacterTextSplitterで分割しています。分割後はFAISSのfrom_documentsメソッドでベクトル化して、save_localでデータを保存しています。

次にアプリです。

import streamlit as st
from langchain_openai import OpenAI
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import FAISS
from langchain.prompts import PromptTemplate

template = PromptTemplate.from_template(
    """あなたは就業規則に詳しい社労士です。小学生にもわかるよう短くシンプルに答えます。
    文書:{document}
    質問:{question}
    回答:
    """
)
embeddings = OpenAIEmbeddings()

@st.cache_resource
def get_index():
    db = FAISS.load_local("labour-db", embeddings)
    return db

@st.cache_resource
def get_llm():
    return OpenAI(max_tokens=200)

if "msg" not in st.session_state:
    st.session_state["msg"] = []

st.title("就業規則チャットボット")

for msg in st.session_state["msg"]:
    with st.chat_message(msg["role"]):
        st.markdown(msg["content"])

if question := st.chat_input(""):
    with st.chat_message("user"):
        st.markdown(question)

    db = get_index()
    r = db.similarity_search(question, k=1)
    similar = r[0].page_content

    llm = get_llm()
    prompt = template.format(document = similar, question=question)
    response = llm.invoke(prompt)
    with st.chat_message("assistant"):
        st.markdown(response)
        st.caption(similar)

    st.session_state["msg"].append({"role":"user", "content":question})
    st.session_state["msg"].append({"role":"assistant", "content":response})

データベースを返すget_index、 生成AIを返すget_llmという2つの関数を定義しました。ともに @st.cache_resource で関数を修飾することで、関数が返す内容をキャッシュしています。

自分の入力と、AIの返答はオブジェクトのリストという形式でセッションに保存しています。保存される内容は以下のようなイメージです。

[
    {"role": "user", "content": ユーザーの入力内容1},
    {"role": "assistant", "content": 生成AIの応答1},
    {"role": "user", "content": ユーザーの入力内容2},
    {"role": "assistant", "content": 生成AIの応答2},
    ...
]

以下のコードでは、セッションに保存されているリストの内容を順番に取り出して、自分、AIと役割を区別しながら過去のメッセージを表示しています。

for msg in st.session_state["msg"]:
    with st.chat_message(msg["role"]):
        st.markdown(msg["content"])

以下の行以降が、ユーザの入力を処理する箇所です。

if question := st.chat_input(""):

“:=”演算子は見慣れないかもしれません。ウォルラス演算子と呼ばれ、Python3.8で導入されました。変数に値を代入して、その結果をすぐに使用できます。つまり、st.chat_inputの戻り値をquestionに代入し、その変数に何か値が入っていればif文が実行されます。

ウォルラス演算子を使った別の例をみてみましょう。以下は改行が押されるまで入力した内容を出力するサンプルです。

while True:
    n = input(">")
    if not n:
        break
    print(n)

ウォルラス演算子を使えば以下のようにシンプルに記述できます。

while n := input(">"):
    print(n)

input関数の戻り値が変数nに代入され、その値をwhile文で評価してループを続けるか判断します。input関数は入力がないとき空文字列を返すので、whileループが終了します。

アプリに戻りましょう。st.chat_inputでユーザーの入力を受け付けて、変数questionに代入します。そしてst.chat_message("user")でユーザの発言領域を作って、そこにst.markdownで質問内容を表示します。

次に db = get_index() でデータベースを取得し、入力した内容に近い文書をsimilarity_searchで探しています。その文書と、ユーザー入力からPromptを作成して、OpenAIのLLMを使って回答を得ています。最後に、自分の入力と生成AIの応答をセッションに保存しています。

このような処理を行うことで、就業規則のQ&Aボットを実装することができました。今回のサンプルはシンプルなものですが、MemoryモジュールやChain、ツールキットなどを使うと、さらに高度な対応が可能にあります。

ちなみに、VectorDBQAクラスを使用すると、質問に近い文書を探すところ(similarity_searchを処理するあたり)もクラスに任せることができます。

https://github.com/hwchase17/chroma-langchain/blob/master/persistent-qa.ipynb

import streamlit as st
from langchain_openai import OpenAI
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import FAISS
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate
from langchain_core.vectorstores import VectorStoreRetriever
from langchain_community.vectorstores import FAISS

template = PromptTemplate.from_template(
    """あなたは就業規則に詳しい社労士です。小学生にもわかるよう短くシンプルに答えます。
    質問:{question}
    回答:
    """
)

@st.cache_resource
def get_dbqa():
    embeddings = OpenAIEmbeddings()
    db = FAISS.load_local("labour-db", embeddings)
    retriever = VectorStoreRetriever(vectorstore=db)
    return RetrievalQA.from_chain_type(llm=OpenAI(), retriever=retriever)

if "msg" not in st.session_state:
    st.session_state["msg"] = []

st.title("就業規則チャットボット2")

for msg in st.session_state["msg"]:
    with st.chat_message(msg["role"]):
        st.markdown(msg["content"])

if question := st.chat_input(""):
    with st.chat_message("user"):
        st.markdown(question)

    qa = get_dbqa()
    response = qa.invoke(template.format(question=question))
    with st.chat_message("assistant"):
        st.markdown(response["result"])

    st.session_state["msg"].append({"role":"user", "content":question})
    st.session_state["msg"].append({"role":"assistant", "content":response})

質問をしたときの処理が若干シンプルになっていることがわかります。

レビューの要約と評価

商品レビューには数多くのコメントが寄せられます。長いものから短いもの、批判的なものから肯定的なもの、内容はさまざまです。フリーテキストの場合は個々のコメントに目を通すのも大変です。その内容を要約し、かつスコアにすることで、緊急の内容によりスムーズに対応できるようになります。そんな処理を行ってみましょう。

今回はHuggingfaceで公開されているAmazonのレビューをサンプルデータとして使用しました。

https://huggingface.co/datasets/amazon_reviews_multi

日本語でフィルタリングした内容をdata/amazon_dataset_ja_dev.jsonとしてファイルに保存しました。

以下はファイルの先頭部分です。

{"review_id":"ja_0442576","product_id":"product_ja_0142340","reviewer_id":"reviewer_ja_0067777","stars":1,"review_body":"味自体及び吸い心地は良いのだが、不良品が多過ぎる。私の場合5本のうち2本が蒸気も出ず、吸い込み も出来なかった。腹が立ってごみ箱行きでした。こんなものは2度と購入する気はない。 返品するのも交渉するのも、金額も金額だからと面倒くさがってしない方が多いのではないか? 最初から不良品多しとでも表記しておいたら如何?","review_title":"不良品","language":"ja","product_category":"drugstore"}
{"review_id":"ja_0944897","product_id":"product_ja_0821731","reviewer_id":"reviewer_ja_0192786","stars":1,"review_body":"ホームボタン周りの気泡が全く抜けません。 返金をお願いしましたが、断られた。","review_title":"欠陥品","language":"ja","product_category":"wireless"}
...

このファイルは1行が1つのレビューになっています。それぞれの行はjson形式ですが、ファイル全体としては正しいjson形式ではありません。それぞれの行を”,”でつなげ、全体を”[]”で囲むことで正式なjson形式にしてから、Pandasのread_json関数でデータフレームに変換しています。以下はデータのファイルを読み込んでデータフレームを作成するまでのサンプルです。

import pandas as pd

with open("data/amazon_dataset_ja_dev.json", encoding="utf-8") as f:
    content = f"[{','.join(f.readlines())}]"
df = pd.read_json(content)
print(df.head(3))

いろいろなジャンルのレビューが含まれています。これをグラフで確認してみましょう。

import pandas as pd
import matplotlib.pyplot as plt
with open("data/amazon_dataset_ja_dev.json", encoding="utf-8") as f:
    content = f"[{','.join(f.readlines())}]"
df = pd.read_json(content)
df.groupby("product_category").size().sort_values(ascending=False).plot.bar()
plt.show()

データフレームのgroupbyメソッドでカテゴリごとにグループ分けを行い、それぞれのグループの個数をsize()メソッドで取得し、sort_valuesメソッドで降順に並べ、棒グラフを描画しています。以下のような結果となりました。

全てのデータを処理すると時間とコストがかかるので、今回はwatchのジャンルのみ抽出して、要約とスコア算出します。以下サンプルコードです。

import pandas as pd
import re
from langchain_openai import OpenAI

llm = OpenAI()

def summarize(review):
    response = llm.invoke(
        f"以下のテキストを20文字程度で短くまとめてください:\n{review}\n",
        max_tokens=100,
    )
    return response

def score(review):
    response = llm.invoke(
        f"以下のテキストを5段階(1:不満~5:満足)の数値(int型)で評価してください:\n{review}\n",
    )
    str = re.sub("\D","",response)
    return int(str) 

with open("data/amazon_dataset_ja_dev.json", encoding="utf-8") as f:
    content = f"[{','.join(f.readlines())}]"
df = pd.read_json(content)
print(df)
df_watch = df[df["product_category"] == "watch"].copy()
df_watch.loc[:,"score"] = df_watch.review_body.map(lambda x:score(x))
df_watch.loc[:,"summary"] = df_watch.review_body.map(lambda x:summarize(x))
df_watch[["stars","score","summary","review_body"]].to_excel("data/amazon_dataset_ja_dev.xlsx")
print(df_watch)

summarizeとscoreという関数を定義しています。summarizeは要約を、scoreは1~5までの評価を行います。

以下の行で、watchのジャンルだけ抽出して、df_watchという変数に代入しています。

df_watch = df[df["product_category"] == "watch"].copy()

summarizeとscore、それぞれの関数を適用しているのが以下の行です。

df_watch.loc[:,"score"] = df_watch.review_body.map(lambda x:score(x))
df_watch.loc[:,"summary"] = df_watch.review_body.map(lambda x:summarize(x))

df_watch.review_bodyという記述でreview_body列を取り出します。その列(Series型)に対してmapメソッドを適用します。mapメソッドは引数に関数を取ります。その関数の引数には元のデータ(review_bodyのテキスト)が渡されます。その値をscore/summarize関数に渡し、その結果が戻ってきます。その戻り値をloc[:,列名]という形で、データフレームに新規の列として追加します。

score関数に関しては、生成AIの結果が”5: 満足”のように数値だけでなくテキストも含むケースがときどきあることがわかりました。そこで、その応答から数値のみを取り出すために正規表現(reモジュール)を使用しています。sub関数は置換を行います。”\D”は数値以外、という意味です。数値以外のテキストは空文字””で置換することで数値のみにし、その結果をint関数に渡して整数を返すようにしています。

最後に必要な列だけを抽出して、to_excel関数でExcelのファイルに保存しています。結果は以下のようになりました。

app2

score列はreview_bodyから求めたスコア、summaryはreview_body列の要約です。stars列はレビューした人がつけた点数ですが、score列の値とそれなりに近い値になっていることが確認できます。またsummaryとreview_bodyを見比べてみると要約が行われていることが確認できます。

人材募集案件のクラスタリング

大量のデータを分析するとき、その特徴をつかむために分類(クラスタリング)をすることは少なくありません。今回は人材募集のデータをクラスタリングして、どのような傾向があるか調べてみましょう。

データの取得

元データはYahoo! Developers Networkにある求人情報から取得しました。

https://developer.yahoo.co.jp/webapi/job/

WebAPIを使ってデータを取得するため、アカウントを作成してログインし、アプリケーションIDを取得してください。

以下は100件分のデータをファイルに保存するプログラムです。appidの値はご自身で取得したアプリケーションIDの値に置き換えてください。

import requests
import json
url = "https://job.yahooapis.jp/v1/furusato/jobinfo/"
params = {
    "appid":"dj00aiZpPTRyc05kWTM0N0E3dCZzPWNvbnN1bWVyc2VjcmV0Jng9NGY-",
    "results":100}
response = requests.get(url, params=params)
with open("data/yahoo-jobs.json", mode="w", encoding="utf-8") as f:
    f.write(json.dumps(response.json(), ensure_ascii=False, indent=4))

requestsモジュールのget関数で指定されたurlから情報を取得し、json形式にしてファイルに保存しています。

取得したデータは以下のようなフォーマットです。resultsの値として各求人の情報が格納されています。

{
    "total": 13714,
    "start": 1,
    "count": 100,
    "results": [
        {
            "jobId": "350001-0000000003001",
            "corporationId": 4250001002966,
            "startDate": "2019-08-21T11:03:36",
            "updatedDate": "2022-11-11T10:28:33",
            "endDate": null,
            "status": 1,
            "title": "【求人】経験を活かしてご活躍いただける環境です。建築技術者の募集情報【移住支援金対象】",
            "postalCode": "7560885",
            "localGovernmentCode": "352161",
<<中略>>
            "occupationName": null,
            "description": "当社工場内での鉄工製品の製作・その据付(宇部・山陽小野田)  \n・一部出張もあります。(日当+(食事・宿泊費)実費。)  \n・現場には社有車で向かいます。  \n・将来的には、工事用図面の製作、見積もり作業も行います。  \n・将来的には、管理業務もあります。",
            "imgUrlPc": null,
<<中略>>

いろいろな情報がふくまれていますが、今回はjobId, title, descriptionのフィールドのみ参照することにしました。
以下は、jsonファイルからデータフレームを作成するサンプルです。

import pandas as pd
import json
jobs = []
with open("data/yahoo-jobs.json", encoding="utf-8") as f:
    data = json.load(f)

for r in data["results"]:
    jobs.append({
        "jobId": r["jobId"],
        "text": r["title"]+" : " + " ".join(r["description"].splitlines())
    })
df = pd.DataFrame(jobs)
print(df.head(3))

出力は以下のようになりました。

                  jobId                                               text
0  350001-0000000003001  【求人】経験を活かしてご活躍いただける環境です。建築技術者の募集情報【移住支援金対象】 : ...
1  350001-0000000003002  【求人】経験を活かしてご活躍いただける環境です。建築技術者の募集情報【移住支援金対象】 : ...
2  350001-0000000003080  リラックスできる環境と1人1人にあったオーダーメイドの医療を提供します【移住支援金対象】 :...

ファイルを開いてjsonモジュールをつかって、オブジェクトに変換しています。resultsに格納されているリストから個々の求人を取り出し、jobIdとtextをキーとするオブジェクトを作成し、jobsというリストに追加しています。descriptionの内容は改行コードが含まれているケースが多かったので、splitlinesメソッドで文字列を分割し、joinメソッドを使って、それらを結合しました。

ベクトル表現と保存

データフレームができたら、それぞれの求人テキストのベクトル表現を求めます。今回Embeddingのモデルには”text-embedding-ada-002″を、トークン化するモデルには”cl100k_base”を使用しました。この辺りのパラメタは高い頻度で更新されているので、以下のURLなどから最新のモデルを参照するのがよいでしょう。

https://platform.openai.com/docs/guides/embeddings/what-are-embeddings

import pandas as pd
import tiktoken
import json
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings()

jobs = []
with open("data/yahoo-jobs.json", encoding="utf-8") as f:
    data = json.load(f)

for r in data["results"]:
    jobs.append({
        "jobId": r["jobId"],
        "text": r["title"]+" : " + " ".join(r["description"].splitlines())
    })
df = pd.DataFrame(jobs)

embedding_model = "text-embedding-ada-002"
embedding_encoding = "cl100k_base"  
max_tokens = 8000  
encoding = tiktoken.get_encoding(embedding_encoding)

df["n_tokens"] = df.text.map(lambda x: len(encoding.encode(x)))
df["embedding"] = df.text.map(lambda x: embeddings.embed_query(x))
df.to_csv("data/jobs_embeddings.csv")

dfがデータフレームです。df.textでtext列を取り出します。その列に対してmapメソッドを使って、トークン数とembedding(テキストのベクトル表現)を求めて、それぞれn_tokens, embeddingという列に保存して、最後にファイルjobs_embeddings.csvとして保存しています。

以下はファイルの先頭部分です。

,jobId,text,n_tokens,embedding
0,350001-0000000003001,【求人】経験を活かしてご活躍いただける環境です。建築技術者の募集情報【移住支援金対象】 : 当社工場内での鉄工製品の製作・その据付(宇部・山陽小野田)   ・一部出張もあります。(日当+(食事・宿泊費)実費。)   ・現場には社有車で向かいます。   ・将来的には、工事用図面の製作、見積もり作業も行います。   ・将来的には、管理業務もあります。,189,"[-0.01960092969238758, -0.008341695182025433, 0.016861455515027046, -0.01032781321555376, -0.02055974490940571, 0.0194228645414114, -0.02662767842411995, -0.029860256239771843,
<<略>>

文書であるtext、トークン数が189、そのあとにベクトル表現embeddingと続いています。embeddingの先頭が”ダブルクォーテーションとなっていることからembeddingが文字列の形式になっていることがわかります。

この段階で、jobs_embeddings.csvのembedding列には各行のベクトル表現が文字列として保存されています。

ベクトルの分類と要約

ここから、保存されたベクトル表現の文字列から、ベクトル表現のリスト(ベクトル表現自身がリストなので、リストのリストとなります)を求め、機械学習を使って分類してゆきます。

まず、csvファイルからベクトル表現のリストを求めます。

import numpy as np
import pandas as pd
from ast import literal_eval

df = pd.read_csv("data/jobs_embeddings.csv")
df["embedding"] = df.embedding.map(literal_eval).map(np.array)
matrix = np.vstack(df.embedding.values)
print(matrix.shape)

出力は(100, 1536)となります。このコードを1行ずつみてゆきましょう。

Pandasのread_csvでCSVファイルからデータフレームを作成します。embeddingという列(文字列)にたいして以下の処理を行っています。

  1. mapメソッドでliteral_evalという関数を適用し、
  2. さらにnp.arrayという関数を適用して、numpyモジュールで扱うリストの形式に変換

このままではイメージがつきにくいと思います。簡単な例を使って説明します。以下のサンプルをみてください。

import numpy as np
from ast import literal_eval

text = "[1,2,3]"

a = literal_eval(text)
print(f"a={a} type(a)={type(a)}")

b = np.array(a)
print(f"b={b} type(b)={type(b)}")

出力は以下の通りです。

a=[1, 2, 3] type(a)=<class 'list'>
b=[1 2 3] type(b)=<class 'numpy.ndarray'>

textという変数には文字列が格納されています。literal_eval関数は引数の文字列をデータに変換します。戻り値の変数aはlistになっています。さらに、np.array関数を使って、通常のリストをnumpyモジュールで扱うリストの形式に変換しています。numpyは各種数値演算を高速に計算することができます。

つまり、以下の行は、データフレームからembedding列を取り出し、1)その文字列をデータに変換し、2)さらにnumpyのリストに変換するという処理を行っていました。

df["embedding"] = df.embedding.map(literal_eval).map(np.array)

以下の行は、上で求めたデータフレームのembedding列の値から、ベクトルのベクトル(=リストのリスト)を作成しています。ベクトルの次元(行と列の数)はshapeプロパティで求められます。

matrix = np.vstack(df.embedding.values)

(100, 1536)と出力されます。つまり、matrix変数には100行, 1536列の形でデータが格納されています。各行が各求人のベクトル表現となります。

クラスタリング

グループに分割することをクラスタリングと呼びます。ここではScikit-LearnモジュールのKMeansクラスを使って分割します。ここまでのコードで100行分のベクトル表現がmatrixという変数に格納するところまで処理をおこないました。以下のコードではそのmatrixを4つのクラスタに分類しています。

import numpy as np
import pandas as pd
from ast import literal_eval
from sklearn.cluster import KMeans

df = pd.read_csv("data/jobs_embeddings.csv")
df["embedding"] = df.embedding.map(literal_eval).map(np.array)
matrix = np.vstack(df.embedding.values)

kmeans = KMeans(n_clusters=4, n_init="auto")
kmeans.fit(matrix)
df["Cluster"] = kmeans.labels_
print(df[["text", "Cluster"]].head(3))

出力は以下のようになりました。

                            text         Cluster
0  【求人】経験を活かしてご活躍いた ...        1
1  【求人】経験を活かしてご活躍いた ...        1
2  リラックスできる環境と1人1人にあ ...        3

それぞれの行にClusterという列が追加され、値としてグループの番号が格納されています。

コードを見てみましょう。sklearnのclusterモジュールからKMeansクラスをimportしています。
KMeansオブジェクトの作成時にn_clusters引数でクラスタの個数を、n_init引数にはautoとし、試行回数を適切に設定するように指示しています。fitモジュールで学習することで分類が行われます。どの行がどのグループに属するかはkmeans.labels_に格納されます。それをデータフレームのCluster列に設定しています。

それでは、それぞれのクラスタの概要も出力してみましょう。

from langchain_openai import OpenAI
from langchain.prompts import PromptTemplate

import numpy as np
import pandas as pd
from ast import literal_eval
from sklearn.cluster import KMeans

df = pd.read_csv("data/jobs_embeddings.csv")
df["embedding"] = df.embedding.map(literal_eval).map(np.array)
matrix = np.vstack(df.embedding.values)

kmeans = KMeans(n_clusters=4, n_init="auto")
kmeans.fit(matrix)
df["Cluster"] = kmeans.labels_

rev_per_cluster = 3
llm = OpenAI()
template = PromptTemplate.from_template(
    "これらの説明の共通点は何ですか?日本語で答えてください。\n職務内容:\n{description}\n概要:"
)
for i in range(4):
    print(f"クラスタ {i} 概要:", end=" ")
    texts =df[df.Cluster == i].text.sample(rev_per_cluster, random_state=42).values
    description = "\n".join(texts)
    prompt = template.format(description=description)
    print(llm.invoke(prompt))

    for j, text in enumerate(texts):
        s = text[:50].replace('\n',' ')
        print(f"[{j}]: {s}")

    print("-" * 100)

rev_per_clusterは各クラスタでいくつのサンプルを抽出するかを表す変数です。以下のコードが分かりづらいかもしれません。

texts =df[df.Cluster == i].text.sample(rev_per_cluster, random_state=42).values

df[df.Cluster == i]で、Clusterがi番目の行だけを抽出します。その結果のデータフレームからtext列のみを取り出し、sampleでサンプリング(いくつか例を抽出)します。今回はrev_per_clusterで、3つのサンプルを抽出し、valuesでその値を取り出しています。その結果を”\n”.joinで一つの文字列に結合したものをdescriptionという変数に格納しています。あとは、その内容を生成AIにわたして共通点を抽出しています。最後の

出力は以下のようになりました。4つのクラスタに分類され、それぞれの概要が表示されています。全てに目を通す前に、このように全体像が把握できると見通しが立てやすくなると思います。

クラスタ 0 概要: 

これらの説明の共通点は、高齢者の生活のサポートを行う介護関係の職務内容であることです。
[0]: 訪問看護職員募集!短期就労も可 : 在宅で生活されている高齢者の健康管理及び医療処置。
[1]: 介護職員パート! : 高齢者の生活全般のお世話をします。
[2]: 介護補助職員 : 特別養護老人ホーム、グループホームやデイサービスセンターでの介護補助業務 ※掃除、
----------------------------------------------------------------------------------------------------
クラスタ 1 概要:

共通点は移住支援金の対象であることです。
[0]: 正社員 DTPデザイナー・DTP制作業務【移住支援金対象】 : お客様が求めている製品のイメージから
[1]: 業務全般【移住支援金対象】 : 梅干、梅酒の製造販売に関する業務全般を担当していただきます。
[2]: 製造職募集【移住支援金対象】 : 製造(生産管理もしくは技術職)
----------------------------------------------------------------------------------------------------
クラスタ 2 概要:
これらの説明の共通点は、受付・接客・事務業務全般、施工管理の補助、販売・アフターフォロー、および事務作業を行う職務を行うことです。
[0]: ドコモショップスタッフ募集!【移住支援金対象】 : ドコモショップでの受付・接客・事務業務全般
[1]: プロジェクト運営アシスタント募集!【移住支援金対象】 : 工事部において、工事の施工管理などの補助見
[2]: カーライフアドバイザー 営業職 募集【移住支援金対象】 : 日産自動車の新車販売・アフターフォロー
----------------------------------------------------------------------------------------------------
クラスタ 3 概要:
これらの説明の共通点は、「移住支援金の対象」と「職務内容」です。
[0]: リラックスできる環境と1人1人にあったオーダーメイドの医療を提供します【移住支援金対象】 : 医師の
[1]: 株式会社河北食品【移住支援金対象】 : 栄養士、管理栄養士、調理士
[2]: 看護師募集!!【移住支援金対象】 : 施設内における看護業務及び、身の回りのお世話、バイタルチェック
----------------------------------------------------------------------------------------------------