GPT护理机器人 - 让护士的工作变简单

2023-07-29,,

引子   

  书接上文《GPT接入企微应用 - 让工作快乐起来》,我把GPT接入了企微应用,不少同事都开始尝试起来了。有的浅尝辄止,有的刨根问底,五花八门,无所不有。这里摘抄几份:

  “帮我写一份表白信,我们是大学同学,暗恋十年”

  ”顺产后多久可以用收腹带?生完宝宝用收腹带好还是不用好“ (背景:公司主营月子中心,护理相关的领域知识是公司对于护士培训的重点内容

  ”我的工资是多少“ (这个有点强机器人所难了,不过如果机器人有了公司的人事语料数据,应该是可以回答的)

   ......

   总的来说,除了一些尝鲜,猎奇之外,有相当一部分还是咨询公司的内部的相关信息,比如HR方面的育儿假等,再就是母婴护理方面的问题了(公司有近60%的是护理人员,日常工作就是与宝宝,宝妈一起,这就不奇怪了)。

  看到这些问题后,我就开始尝试通过Fine-tune训练公司内部的护理机器人,希望他可以为护士们的工作带来一些便利。诸多尝试失败后,索性就放了一些时间。

  恰逢五一假,回了媳妇娘家,掐指一算,已经3年6个月没有回来过了,二娃子都快3岁了,还没见过外婆。想不到娃子亲舅舅,我到清闲了,又捡起护理机器人捣鼓起来了。于是有了这篇文章。

Fine-tune可能真的不合适

  刚看到Fine-tune的介绍时就想,如果通过fine-tune构建个性化的模型,导入公司的母婴护理知识,并且在未来了问答中进化,变成企业内部专家。所以一开始就是向这样的路子摸索着。毕竟介绍里也说了通过少量样本即可完成训练,分类这样的任务可能只需要200个左右的样本即可。(其实问答模型的样本要求至少要有几千个可能会有点效果)

  

  当然,文档中也有一些关于Fine-tune的一些指南和准则。一来是全是英文文档,理解不太深入;二来就是无知无畏,不尝试下就是不死心。这是文档原文,大概的意思Fine-tune可以用来解决一些类似分类(判断对错,情绪判断(乐观,悲观),邮件分类),以及扩写总结之类的场景。 文档也有提到案例”Customer support chatbot“,这可能也是大家这样尝试的原因之一吧。 在其demo推荐使用 emebedding 来实现,也是本文的重点内容。这是后

  虽然通过Fine-tune的方式最终也没有好的效果,一来可能是样本太少,或者样本质量不好;也或者过程中有疏漏的地方。在这里也和大家一起探讨下。毕竟fine-tune的方式还是让人非常神往的。实现代码基本是参考了 openai-cookbook 中的 fine-tuned_qa Demo。大致流程如入:

环境配置就不多说了(版本 python 3.10.4 整个过程基本还是流畅的。除了v-p-n自身原因折腾好久(原来用的是mono),换个客户端居然好了)
收集文本数据并根据token的限制,合理分段落。(我自己则是找到内部了母婴护理培训的电子版本。)
用模型text-davinci-003 为每个段落自动生成若干问题,并根据段落及问题自动生成答案。
使用所有生成问题及答案组织成fine-tuen所需要的数据集。
创建新模型并使用。

  1,文本分段 - 因为拿到的资料是word,并且有标题,就直接根据标题他分段了,超过2048的再分一次,代码如下(现学现用,比较粗漏)

  


import docx
import pandas as pd


def getText(fileName):
doc = docx.Document(fileName)
TextList = []


data = {"title":"","content":""}
for paragraph in doc.paragraphs:
if paragraph.style.name == 'Heading 1':
print("title %s " % paragraph.text)
if (len(data['content']) > 0):
datax = {}
datax['title'] = data['title']
datax['content'] = data['content']


TextList.append(datax)
data['title'] = paragraph.text
data['content'] = ''
else:
data['content'] += paragraph.text+"\n"
TextList.append(data)
return TextList


## 根据doc 转 csv
if __name__ == '__main__':
fileName = '/Users/jijunjian/openai/test2.docx'


articList = getText(fileName)
count = 0
for article in articList:
if len(article['content']) > 800:
print("%s,%s,\n%s" % (article['title'], len(article['content']),article['content']))
count += 1


header = ['title', 'content']
print("总共 %s 篇文章" % count)
pd.DataFrame(articList, columns=header).to_csv('data_oring.csv', index=False, encoding='utf-8')


 

  

  2,生成问题与答案 - 这样生成的质量可能不是太高,可能实际使用时还是要对生成的问题和答案,让领域专家进行修正比较好。

  据官方文档介绍,建议生成的数据集中,prompt与completion都要有固定的结尾,且尽量保证其他地方不会出现这个,所以我们这里使用了”\n\n###\n\n“作为结束标志。

 1 import pandas as pd
2 import openai
3 import sys
4 sys.path.append("..")
5 from tools.OpenaiInit import openai_config
6 from transformers import GPT2TokenizerFast
7
8
9 tokenizer = GPT2TokenizerFast.from_pretrained("gpt2")
10
11 def count_tokens(text: str) -> int:
12 """count the number of tokens in a string"""
13 return len(tokenizer.encode(text))
14
15
16 COMPLETION_MODEL = "text-davinci-003"
17 FILE_TUNE_FILE = "search_data.jsonl"
18
19
20 # 获取训练数据
21 def get_training_data():
22 file_name = "data_oring.csv"
23 df = pd.read_csv(file_name)
24 df['context'] = df.title + "\n\n" + df.content
25 print(f"{len(df)} rows in the data.")
26 return df
27
28
29 # 根据内容,生成问题
30 def get_questions(context):
31 print("正在生成问题")
32 try:
33 response = openai.Completion.create(
34 engine=COMPLETION_MODEL,
35 prompt=f"基于下面的文本生成问题\n\n文本: {context}\n\n问题集:\n1.",
36 temperature=0,
37 max_tokens=500,
38 top_p=1,
39 frequency_penalty=0,
40 presence_penalty=0,
41 stop=["\n\n"]
42 )
43 return response['choices'][0]['text']
44 except Exception as e:
45 print("创建问题错误 %s" % e)
46 return ""
47
48
49 # 根据问题,生成答案
50 def get_answers(row):
51 print("正在生成答案")
52 try:
53 response = openai.Completion.create(
54 engine=COMPLETION_MODEL,
55 prompt=f"基于下面的文本生成答案\n\n文本: {row.context}\n\n问题集:\n{row.questions}\n\n答案集:\n1.",
56 temperature=0,
57 max_tokens=500,
58 top_p=1,
59 frequency_penalty=0,
60 presence_penalty=0
61 )
62 return response['choices'][0]['text']
63 except Exception as e:
64 print (e)
65 return ""
66
67
68 # 获取训练数据 /Users/jijunjian/tuningdata.xlsx
69 if __name__ == '__main__':
70 openai_config()
71 df = get_training_data()
72 df['tokens'] = df.context.apply(count_tokens)
73 # questions 根据返回生成
74 df['questions']= df.context.apply(get_questions)
75 df['questions'] = "1." + df.questions
76
77 df['answers']= df.apply(get_answers, axis=1)
78 df['answers'] = "1." + df.answers
79 df = df.dropna().reset_index().drop('index',axis=1)
80
81 print("正在保存数据")
82 df.to_csv('nursing_qa.csv', index=False)
83
84
85
86 df['prompt'] = df.context + "\n\n###\n\n"
87 df['completion'] = " yes\n\n###\n\n"
88
89 df[['prompt', 'completion']].to_json(FILE_TUNE_FILE, orient='records', lines=True)
90
91 search_file = openai.File.create(
92 file=open(FILE_TUNE_FILE),
93 purpose='fine-tune'
94 )
95 qa_search_fileid = search_file['id']
96 print("上传文件成功,文件ID为:%s" % qa_search_fileid)
97
98 # file_id = file-Bv5gP2lAmxLL9rRtdaQXixHF

  3,根据生成数据集,创建新的模型。

  官方的demo,还有生成验证集,测试集,生成相识的文本,同样的问题与答案来增加一些对抗性,因为最终效果不太好,再是文档中有使用search 模块,但是这已经下线了,我用prompt-completion的数据结构模拟了下,也不知道有没有效果, 因为使用openai tools 创建模型可以有一些交互动作,也方便看一些执行结果,花费数据,这里就使用这这工具作了演示,执行一段时间后,可以通过”openai.Model.list()“查看我们创建的模型。当时大概有1000来个问题与答案,花费了0.78刀。(这是4月13尝试的,因为效果不好,结果一放就是半月有余了。时间真是如白驹过隙一般)  

 1 openai api fine_tunes.create -t "discriminator_train.jsonl" -v "discriminator_test.jsonl" --batch_size 16  --compute_classification_metrics --classification_positive_class yes --model ada --suffix 'discriminator'
2
3 Uploaded file from discriminator_train.jsonl: file-5OeHx3bMDqk******
4 Uploaded file from discriminator_test.jsonl: file-AnOiDwG1Oqv3Jh******
5 Created fine-tune: ft-cQBMLPzqVNml1ZWqkGYQKUdO
6 Streaming events until fine-tuning is complete...
7
8 (Ctrl-C will interrupt the stream, but not cancel the fine-tune)
9 [2023-04-13 23:17:05] Created fine-tune: ft-cQBMLPz********
10 [2023-04-13 23:17:22] Fine-tune costs $0.78
11 [2023-04-13 23:17:23] Fine-tune enqueued. Queue number: 3

  最后,效果不太理想,一番尝试后,看到文档中的提示信息:

  ”Note: To answer questions based on text documents, we recommend the procedure in Question Answering using Embeddings. Some of the code below may rely on deprecated API endpoints.“  于是借着五一的空闲,开始尝试emebedding 方式 了

emebedding可能是当下最好的选择

  GPT擅长回答训练数据中存在的问题,对于一些不常见的话题,或者企业内部的语料信息,则可以通过把相关信息放在上下文中,传给GPT,根据上下问进行回答。因为不同模型对于token的限制,以及Token本身的成本因素。

  具体实现时,我们需要把文本信息Chunk(分块)并Embed(不知道如何翻译)得到一个值,收到问题时,同样进行Embed,找到最相近的Chunk,做为上下文传给GPT。官方文档如下: 

  Specifically, this notebook demonstrates the following procedure:

    Prepare search data (once)

      Collect: We'll download a few hundred Wikipedia articles about the 2022 Olympics
      Chunk: Documents are split into short, mostly self-contained sections to be embedded
      Embed: Each section is embedded with the OpenAI API
      Store: Embeddings are saved (for large datasets, use a vector database)

    Search (once per query)

      Given a user question, generate an embedding for the query from the OpenAI API
      Using the embeddings, rank the text sections by relevance to the query

    Ask (once per query)

      Insert the question and the most relevant sections into a message to GPT
      Return GPT's answer

  一开始本想参考这个demo Question_answering_using_embeddings.ipynb 编写代码,后来有意无意看到使用llama_index的实现,并且对于语料信息格式无要求,就摘抄过来了,感谢代码的贡献者,节省了大家好些时间。


#!/usr/bin/env python
# coding=utf-8

from langchain import OpenAI
from llama_index import SimpleDirectoryReader, LangchainEmbedding, GPTListIndex,GPTSimpleVectorIndex, PromptHelper
from llama_index import LLMPredictor, ServiceContext
import gradio as gr
import sys
import os
os.environ["OPENAI_API_KEY"] = 'sk-fHstI********************'

#MODEL_NAME = "text-davinci-003"
MODEL_NAME = "ada:ft-primecare:*************"

def construct_index(directory_path):
max_input_size = 2048
num_outputs = 512
max_chunk_overlap = 20
chunk_size_limit = 600
prompt_helper = PromptHelper(max_input_size, num_outputs, max_chunk_overlap, chunk_size_limit=chunk_size_limit)
llm_predictor = LLMPredictor(llm=OpenAI(temperature=0.7, model_name=MODEL_NAME, max_tokens=num_outputs))
documents = SimpleDirectoryReader(directory_path).load_data()
#index = GPTSimpleVectorIndex(documents, llm_predictor=llm_predictor, prompt_helper=prompt_helper)

service_context = ServiceContext.from_defaults(llm_predictor=llm_predictor, prompt_helper=prompt_helper)
index = GPTSimpleVectorIndex.from_documents(documents, service_context=service_context)

index.save_to_disk('index.json')
return index
def chatbot(input_text):
index = GPTSimpleVectorIndex.load_from_disk('data/index.json')
response = index.query(input_text, response_mode="compact")
return response.response

if __name__ == '__main__':

iface = gr.Interface(fn=chatbot,inputs=gr.inputs.Textbox(lines=7, label="输入你的问题"),outputs="text",title="护理智能机器人")
## 用于生成数据, 放在docs文件夹下
##index = construct_index("docs")
iface.launch(share=True, server_name='0.0.0.0', server_port=8012)

  使用了gradio 作为演示,效果如下,基本可以根据我们的内部培训资料中回复,美中不足的就是通过要10几秒才可以完成回复,至少比之前fine-tune有了很大的进步了。至此,总算可以安抚下这半月的苦恼了。(下图中的output 如果变成自定义的文本,尝试多次一起没有成功,也是有点遗憾)

曲折的部署之路

  孟子有云:独乐乐不如众乐乐。如何让同事们一起体验,又是一个艰巨任务开始了。再则也需要让护理专家们看看回复的质量,以及如何优化文本内容。原本以为部署是一件简答的事儿,但是对于python菜-鸡的我,每一步都是坎坷。

  一开始以为直接用pyinstaller 打包就可以直接放在服务器上执行,结果 pyinstaller -F, -D 尝试很久都无法打包依赖,  --hidden-import 也用了, .spec也用了,都不好使。索性放弃了。

  到了晚上12点半时,毫无进展,索性直接放原在码放上去。结果又提示无法安装指定版本的langchain。然后开始捣腾pip版本升级到最新,python版本升级到3.10.4(和本地一样)。

  python升级后,又是提示ModuleNotFoundError: No module named '_bz2',总算是错误信息变了。这个错误大概就是原来自带中的版本中有_bz2模块,重安装的3.10中没有,解决版本都是复制这个文件到新的版本中。

mv _bz2.cpython-36m-x86_64-linux-gnu.so /usr/local/python/lib/python3.10/lib-dynload/_bz2.cpython-310-x86_64-linux-gnu.so

  再次运行终于启动了,太不容易了。配置好防火墙,腾讯云的安全组, 输入外网ip:8012,潇洒的一回车 - 还是无法访问。 借用毛爷爷的一句话描述下当下的心情:它是站在海岸遥望海中已经看得见桅杆尖头了的一只航船,它是立于高山之巅远看东方已见光芒四射喷薄欲出的一轮朝日,它是躁动于母腹中的快要成熟了的一个婴儿。加之夜确实太深了,才踏实的睡下了。

1 /usr/local/python/lib/python3.10/site-packages/gradio/inputs.py:27: UserWarning: Usage of gradio.inputs is deprecated, and will not be supported in the future, please import your component from gradio.components
2 warnings.warn(
3 /usr/local/python/lib/python3.10/site-packages/gradio/deprecation.py:40: UserWarning: `optional` parameter is deprecated, and it has no effect
4 warnings.warn(value)
5 /usr/local/python/lib/python3.10/site-packages/gradio/deprecation.py:40: UserWarning: `numeric` parameter is deprecated, and it has no effect
6 warnings.warn(value)
7 Running on local URL: http://127.0.0.1:8012
8 Running on public URL: https://11d5*****.gradio.live

  第二天,找到gradio 中Interface.launch 的参数有个 server_name 设置成 通过设置server_name=‘0.0.0.0’ 即可通过IP访问。 通过ss -tnlp | grep ":8012" 也可以看到端口的监听从   ”127.0.0.1:8012“ 就成了 ”0.0.0.0:8012 “。

LISTEN 0      128          0.0.0.0:8012      0.0.0.0:*    users:(("python",pid=2801254,fd=7))

  

 展望一下

  从目前测试的情况来,每问一个问题成本在10美分左右(成本还是比较高),优化的方向可能Chunk的大小,太小无法包含住够的上下问,太大成本又比较高。再回头看Fine-tune的方式,应该是前期训练话费的成本会比较高,后期回答的成本会比较少,只是目前训练效果不太好,看其他文章也是同样的问题。从目前的情况来可能 emebedding的是一个较为合适的落地方式。

接下看看使用的情况,如果效果不错,考虑增加语音转文字,用GPT回复答案,甚至可以再文字转语音播报出来,这样护士们的工作可能会更加便利与快乐了。

   成为一名优秀的程序员!

GPT护理机器人 - 让护士的工作变简单的相关教程结束。

《GPT护理机器人 - 让护士的工作变简单.doc》

下载本文的Word格式文档,以方便收藏与打印。