

中高端软件定制开发服务商

13245491521 13245491521
OpenAI快速上手 在之前的很多分享中,了解了一些机器学习的基础方法理论以及gpt模型的演变,但是对究竟如何将gpt真正应用起来,以及具体的api如何使用我自己还有很多疑问,所以通过官方的文档和外部的课程/讲座,在这里对openai的接口做一些具体的使用向的简单探索、尝试、学习和总结。 先来简单回顾下不同的模型对比: 模型不同点Bert- Mask language model - 在一句话中随机选择一个token mask掉,如我来自__国__,预训练的目标就是填上这个字GPT- 只能单向的,从左到右,从上到下的预测,只能通过上文预测下文,不能够提前看到下文 - 使得gpt更适合做生成模型,符合写作的顺序(即不停的去预测下一个单词) - Auto-regressive modeling 自回归的去预测下一个单词T5- 将bert和gpt做了结合 - bert的encoder的上/下文做编码,通过Auto-regressive modeling自回归的生成去做解码ChatGPT- 引入了基于用户/人类偏好的强化学习 Reinforcement Learning from Human Feedback (RLHF) - 手动标注,人工为希望模型作出的结果排序, reward model - 强化学习 - 防止hallucination - 让模型更好的align - 让模型和设计者的目标更好的贴合InstructGpt- 通过指令微调gpt,完成人类交给的任务 - Instruction:指令,任务 - response:实现该prompt所完成的一个结果 - focus于单轮指令,只能交互一次零、从一些日常工具开始很多工具之前都只是看了介绍,但是没有自己真正使用过,这里逐一进行尝试,可以对各类封装好的ai生产力工具有个更好地了解,作为一个小热身: Copilot :代码生成 https://github.com/settings/copilot 即每次使用生成时,copilot会检测前后的150个character,看是否与github的公共代码重合,如果有重合,则不进行/进行推荐github提供免费试用,绑定银行卡即可(不用了记得cancel,否则会被收费) 在ide中下载即可使用,vscode和jetbrains都支持(只有vscode支持copilot labs) 以jetbrains为例,在ide中安装后,需要login到github账户,打开vpn,并完成必要的一项设置才能使用,右下角可以随时关闭/打开自动生成 使用效果:可以呼出全部代码生成方案: 快捷键 SciSpace:文章学习/总结https://typeset.io/papers/exploring-the-limits-of-transfer-learning-with-a-unified-5f0j0o6sin 上传pdf后,可以帮助进行总结/摘要等,以下是一些列出的常用功能: Glarity:音视频总结https://chrome.google.com/webstore/detail/chatgpt-glarity-summarize/cmnlolelipjlhfkhpohphpedmkfbobjc/related 以YouTube为例,右边会给出视频内容的summary以及字幕: NotionAI:文章润色/改写https://www.notion.so/ce9385b6230f4b01a0465002ec79038c 可以帮助润色文章,包括段落的扩写/精简,以及替换为更好的表达等。选取了本文的一些段落进行尝试,表现的还算不错,如一些上下文因果关系能够正确识别,例如??之类的符号能够智能替换为“美元” 一、准备工作1. openai账号账号注册 你能访问 Google 邮箱 国外手机号 虚拟手机号可以尝试使用https://sms-activate.org/,泰国亲测可用,3??起充注册openai网页版及获取接口调用条件需要: 注册成功后即可免费使用网页版,但是可能会有不能特别频繁的访问或者偶尔崩掉的情况,可以自行考虑升级plus(20??/月) 接口使用 所以调试的时候可以使用网页版本,prompt调的差不多了再用接口调用需要注意这里生成的key只会展示一次,需要自行保存(没保存也可以再重新生成,问题不大)在个人页面生成自己的api secret key https://platform.openai.com/account/api-keys,后续的接口使用均采用`openai.api_key=xxx`的方式进行鉴权 注册成功后即可享有三个月内免费的5??额度,可以在个人账号中看到额度的使用情况https://platform.openai.com/account/usage 具体模型对应的收费标准见https://openai.com/pricing 2. python环境因为日常开发多数使用go,所以这里也列出了python所需的一些环境: Pythonide(pycharm等)包管理&解释器(anaconda等)也可以直接使用jupyter notebook可以选择性安装cuda,加快运行速度(例如第四部分里如果想用图片生成图片最好还是安装一下)二、api简介1. Promptprompt的编写在整个openai的应用中当属至关重要的一环,官方guidance中也提到了一次文本生成的成功与否一般就取决于complexity of the task以及quality of your prompt,prompt engineer也成为了专门的职业,因此如何编写更高质量的prompt是最先需要明确的。 官方给出了这样的tips: think about how you would write a word problem for a middle-schooler to solveA well-written prompt provides enough information for the model to know what you want and how it should respondShowing, not just telling总结起来就是: 首先要提供清晰的指令。可以是一个命令,也可以是一个例子,或者是两者的结合 提供充足的、高质量的参考数据。 Eg. 我们想要针对人名对应的出生地(而非居住地或其他的关联地名)进行分类 人名a:地名a 人名b:地名b 人名c: Eg. 给定一种问答格式 我们的问题一般用这样的格式回答: 问题:blablabla 回答: 1. 文档地址 blabla 2. 操作步骤 blabla === 以下是上下文 {context_str} 问题:{question_str} 回答: 例如针对classification的例子,我们需要保证提供的数据已经经过人工或其他途径的校准,因为稍有错误的数据都有可能被ai判断为“故意为之” 可以通过提供给模型少量上下文数据达成一种in context learning/few-shot learning,如以下的两种方式 进行“角色扮演” 众多资料都推荐给ai指定一个人设,更好的在特定语境下回答问题。例如官方文档中给出的指定ai角色为“尖酸刻薄的、挖苦的”,其给出的resp会受到明显影响 Marv is a chatbot that reluctantly answers questions with sarcastic responses: You: How many pounds are in a kilogram? Marv: This again? There are 2.2 pounds in a kilogram. Please make a note of this. You: What does HTML stand for? Marv: Was Google too busy? Hypertext Markup Language. The T is for try to ask better questions in the future. You: When did the first airplane fly? Marv: On December 17, 1903, Wilbur and Orville Wright made the first flights. I wish they’d come and take me away. You: What is the meaning of life? Marv: I’m not sure. I’ll ask my friend Google. 在ChatCompletion接口调用中也是直接给出了system的角色,可以利用这个参数进行角色的指定通过进行接口参数的调整,达到更好的效果。如temperature和top_p参数,后面也会具体提到 2. 基础接口具体的接口reference见(参数说明等):https://platform.openai.com/docs/api-reference/completions 很详细的guidance见(注意事项等):https://platform.openai.com/docs/guides/completion/introduction 可以自定义一些参数的playgroud(这个跟网页版的区别是,可以将接口中的自定义参数显式指定) Text completion:https://platform.openai.com/playground/p/8P6JA6XEx74NTvcRUngWKEYWChat completion:https://platform.openai.com/playground?mode=chat一些代码示例cookbook:https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb(这里的例子是如何通过tiktoken库计算token数量) 2.1 TextCompletion即指Completion这个接口,官方给出的介绍是: You input some text as a prompt, and the model will generate a text completion that attempts to match whatever context or pattern you gave it. 即根据给出的prompt指令进行文本生成,或者根据给出的上下文及示例完成期望其完成的工作。 常见的应用有: Classification 进行大类的分类进行更具体的打分(如1-10)Generation 例如一些idea、slogan的创造Conversation 这里的指令诀窍类似于prompt的编写,重点是: 1. 给出角色定位; 2. 给出一些希望ai如何表现的例子The key is to tell the API how it should behave(an identity) and then provide a few examples.Transformation 翻译改写总结Completion本身 文章的续写代码的续写两个还在内测/公测中的功能 插入文本 文本改写 常用的参数简介: (针对python openai包) engine:即使用openai的哪一个model,这里处理一些text completion可以选择使用text-davinci-003(其实用gpt-3.5-turbo也行而且会更便宜),两者的区别如下,简单来说text-davinci-003拥有更好一些的语言表达和细节,在需要处理长文本,对生成质量有较高要求时可以选用,但考虑到性价比(chat模型大概是text十分之一的价钱,但是他不接受fine tune)及模型表现,gpt-3.5-turbo一般就够用了 我们可以通过openai.Engine.list()指令获取当前available的模型,具体的模型介绍见https://platform.openai.com/docs/modelsprompt:即我们输入的提示语 max_tokens:即调用生成的内容所允许的最大的token数量 token 即为分词之后的一个字符序列里的一个单元上述两个模型规定的最大token限制为4096需要注意的是,模型规定的最大数量计算的是prompt提示语token数量+ai产出的回答token数量,而这里的max_tokens仅对生成的回答而言,因此如果prompt已经占到1000个token,这里的max_tokens最多只能有3096了n:表示希望ai生成几条内容供我们选择 需要注意的是生成较多的内容会加速消耗token,可以和stop配合,谨慎使用stop:表示希望模型输出的内容在遇到什么内容时停下来 例如我们设定“stop”为该参数的值,那么ai生成的内容在遇到stop这个单词后就会停下来temperature:表示输出内容的随机性/多样性,是一个0-2之间的浮点数 官方给出的参考中,0.8被认为是more random的,0.2是更加focused的如果是在比较严肃的一些场景下,我们希望ai生成的内容固定,则可以设置temperature=0,n=1presence_penalty:是在-2~2之间的浮点数,当值为正时,会使得ai倾向于聊新的话题和内容。即当一个token在前面已经出现过了,那么在后面生成的时候给他的概率一定的惩罚 frequency_penalty:是在-2~2之间的浮点数,当值为正时,会根据目前已生成的内容中,某个token出现的概率对未来的生成行为中该token的出现一定概率的惩罚 两者的区别如图,一个是根据是否出现做惩罚每一个是根据已经出现的概率做惩罚logit_bias:是一个map,key为一个token_id,value为-100~100的一个浮点数。通过设置某个token的value为正/负来控制该token是有更大概率出现,还是更小概率出现 Token id可以通过这个网页直接获取https://platform.openai.com/tokenizer?view=bpe,也可以利用encoding包获取如何尽量避免瞎说的情况(hallucination) 尽量给出可靠的消息源 可以是我们上面提到的few-shot learing,同时python也有包可以提供直接对某个网页链接进行搜索的能力(LLMRequestsChain)Use a low probability 如何理解:指让ai有更少的可能去给出一些模糊的、没有把握的答案,转而更倾向于给出确定的、把握大的答案如何做到:设置temperature的参数为0,可以使得ai放弃选择一些不太确定的答案以达成随机性 教会模型说“不知道” 可以在prompt指令中,或在给出的例子中告知ai对于不确定/不知道的问题直接回答不知道,而非编造结果2.2 ChatCompletion即指ChatCompletion这个接口,官方给出的介绍是: take a series of messages as input, and return a model-generated message as output.is designed to make multi-turn conversations easyAlso useful for single-turn tasks without any conversations(like text-davinci-003 above)更多的被用于多轮会话的场景,同时在单轮的任务处理中也有出色的表现。 首先关注输入的三种角色: ChatCompletion的参数和Completion类似,model即为上面提到的engine,标志选用哪个模型,messages即为我们的对话内容,我们来重点关注messages这个字段: response = openai.ChatCompletion.create( model="gpt-3.5-turbo", messages=messages, temperature=0.5, max_tokens=2048, n=3, presence_penalty=0, frequency_penalty=2, logit_bias = bias_map, ) messages是一个消息list,以一个一个的key/value均为string的map组成,分为三组,每一组都包含role和content两部分内容,role即为: system:当role为system时,对应的content即指定了ai的行为。具体来说,content代表我们给 AI 的一个指令,也就是告诉 AI 应该怎么回答用户的问题。比如我们希望 AI 都通过中文回答,我们就可以在 content 里面写“你是一个只会用中文回答问题的助理”,这样即使用户问的问题都是英文的,AI 的回复也都会是中文的。 user:当role为user时,content即为我们对ai发出的指令,是我们希望ai来做什么 assistant:当role为assistant时,content即为历史上ai给出的回答 这里注意,在多轮对话中,ai本身是不会记住之前的对话内容的,所呈现出的“记忆”的效果其实是我们每次都将历史生成的答案放入assistant的content中,不断累加作为输入传给ai的messages = [] messages.append( {"role": "system", "content": "你是一个用来将文本改写得短的AI助手,用户输入一段文本,你给出一段意思相同,但是短小精悍的结果"}) messages.append( {"role": "user", "content": text}) message = response["choices"][0]["message"]["content"] messages.append({"role": "assistant", "content": message}) 具体了解一下返回的结构: { 'id': 'chatcmpl-6p9XYPYSTTRi0xEviKjjilqrWU2Ve', 'object': 'chat.completion', 'created': 1677649420, 'model': 'gpt-3.5-turbo', 'usage': {'prompt_tokens': 56, 'completion_tokens': 31, 'total_tokens': 87}, 'choices': [ { 'message': { 'role': 'assistant', 'content': 'The 2020 World Series was played in Arlington, Texas at the Globe Life Field, which was the new home stadium for the Texas Rangers.'}, 'finish_reason': 'stop', 'index': 0 } ] } Message content:在python中我们可以通过response['choices'][0]['message']['content']提取出ai生成的最终内容,也是我们通常最需要关注的内容,在下一轮请求发起时,需要在assistant content中append进这一部分内容 Finish reason:每一次请求都会有一个finish reason标志请求终止的原因: stop:表示模型正常生成结束length:受到最大token限制而结束(可能是max_tokens的设置)content_filter:由于在参数中设置了filter,遇到该词的时候会停止生成。官方接口说明中没有找到这个参数,咨询chatgpt给出的示例如下:usage:共有三个数值,prompt_tokens表示prompt占用掉的token数量,completion_tokens表示ai生成的返回值所占用的token数量,total_tokens则表示总token数量,这个返回值可以帮助我们对已经消耗的token做到心中有数,一方面方便计算成本,另一方面方便计算还剩余多少token 那么在多轮对话中token达到上限了怎么办? 可以借助LangChain对现有的记忆进行总结/取舍,在下面会介绍到 模型表现的不尽如人意如何进行优化? 官方给出了很详尽的阐述https://github.com/openai/openai-cookbook/blob/main/techniques_to_improve_reliability.md,总结起来常用的方法有: 给出更清晰明确的指令 可以将复杂的问题进行拆分可以通过selection-inference prompting的方式,给定可能的选项范围,以及推理提示,引导模型根据推理提示中给的线索进行思考和回答对希望ai给出什么答案进行描述和限制 类似few-shot的场景,我们可以先给ai一些例子,或限定一些Q/A的格式让ai逐步进行思考,而非一次性得出结论 一个比较tricky的方法是,在给出问题后,加上一条指令“Let's think step by step”,有数据表明,在面对一些数学类问题时,这种方式可以使答案的可靠性大幅提升(18%-79%)在zero-shot的情况下,我们可以引导ai先思考,给出原因,然后再确定结论进行模型微调 在fine tune中会介绍上传数据的隐私情况是怎样的? 目前官方给出的回答是,上传的数据会保存30天,但不会被用于模型的训练 2.3 Embedding即指embeddings这个接口,官方给出的介绍是: measure the relatedness of text stringsAn embedding is a vector (list) of floating point numbers. The distance between two vectors measures their relatedness. Small distances suggest high relatedness and large distances suggest low relatedness.即可以计算出文本对应的向量,并根据向量之间的距离计算文本的相关性。官方推荐使用的模型是text-embedding-ada-002 常见的应用有: 搜索场景:根据结果的相关性进行排序 聚类场景:根据文本相似性进行聚类 推荐场景:对拥有更高相似性属性的item进行推荐 离群检测:识别出相似性极小的离群值 多样性检测:根据相似性分布评估多样性,如衡量聚类结果的多样性 分类场景:根据文本与给定标签的相似性进行分类 与聚类的区别:聚类是将一些数据根据内部的相似性分成不同的几堆;而分类是给定几个标签,将一些数据与这些标签进行相似性的比对,从而归类到某个标签中(如情感分析)如何进行请求: 只需要指定好模型和输入即可,可以通过curl直接请求,也可以通过openai.embeddings_utils包中的get_embedding接口请求 curlcurl https://api.openai.com/v1/embeddings \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $OPENAI_API_KEY" \ -d '{ "input": "Your text string goes here", "model": "text-embedding-ada-002" }' 接口get_embedding("Your text string goes here", engine="text-embedding-ada-002") 返回值中会给出对应input的向量,类似于: { "data": [ { "embedding": [ -0.006929283495992422, -0.005336422007530928, ... -4.547132266452536e-05, -0.024047505110502243 ], "index": 0, "object": "embedding" } ], "model": "text-embedding-ada-002", "object": "list", "usage": { "prompt_tokens": 5, "total_tokens": 5 } } 相似性的计算 官方更推荐的是使用余弦距离计算相似性,有如下理由: 余弦距离计算起来更快 由于余弦相似度的计算通过内积(dot product)实现,具体是通过将两个向量的对应元素相乘并求和来计算。而内积的计算效率很高,并且在现代计算机硬件中有高度优化的实现由于余弦相似度对向量长度归一化的处理,减少了计算的复杂性,不必考虑向量的绝对大小,提升了效率余弦距离可以保证结果更加可靠和稳定 本质是通过计算两个向量的夹角余弦值来衡量它们之间的相似性,夹角的余弦值仅依赖于向量的方向,而与它们的绝对长度无关 由于这种向量的长度归一化,使得排序结果不会受到向量的缩放影响 目前模型的一些局限性 当前模型的训练的数据集截止8/2020(现在网页版的chatgpt是到9/2021,不知道是不是官方的文档没更新),因此如果需要依赖更具有实效性的数据,我们就需要进行一些自助微调 官方提出模型可能存在一定的social biases,即可能对特定的群体存在一些刻板印象/负面评价,在一些场合使用模型时需要考虑到这一点 官方通过SEAT (May et al, 2019)以及Winogender (Rudinger et al, 2018)这两个数据集进行偏见检测,我们也可以根据这个数据集对自己的模型进行测试2.4 Moderation把moderation列在基础接口里是因为这个接口可以免费使用,并且感觉可用性/实用性也比较高。 官方给出的介绍是: is a tool you can use to check whether content complies with OpenAI's usage policies.即这个接口是一个“合规”检测的接口,他不仅会给出是否合规,还会给出如果不合规的话,是属于哪种违规类别,具体的policy见上面的链接,类别简介为: 使用说明 直接使用openai提供的Moderation.create接口即可 threaten="拿刀砍死你" defmoderation(text): response=openai.Moderation.create( input=text ) output=response["results"][0] returnoutput print(moderation(threaten)) 返回值如下: { "id":"modr-XXXXX", "model":"text-moderation-001", "results":[ { "categories":{ "hate":false, "hate/threatening":false, "self-harm":false, "sexual":false, "sexual/minors":false, "violence":true, "violence/graphic":false }, "category_scores":{ "hate":0.030033664777874947, "hate/threatening":0.0002820899826474488, "self-harm":0.004850226454436779, "sexual":2.2907377569936216e-05, "sexual/minors":6.477687275463495e-09, "violence":0.9996402263641357, "violence/graphic":4.35576839663554e-05 }, "flagged":true } ] } flagged:如果返回true的话,则说明我们的input是不合规的 categories:返回了具体属于哪种类型的违规 category_scores:表示我们的input在每种类型下违规的置信值,从0到1(注意这里不是概率) 3. FineTune前面提到了从zero-shot到few-shot的小调优,而FineTune则是帮助我进一步对模型做定制化和优化,以达到以下目的: 比prompt design优化更加高质量的答案生成 更定制化的语料库,更多的自定义训练资料 优化后达成更短的prompt指令,节省token 更少的request latency,更快速的响应 使用步骤 调优的步骤也很简单,只有以下三步: 准备和上传训练集 prompt结束后通过 \n\n###\n\n标志结束,告知模型completion开始completion以空格开始,方便做tokenizationcompletion一般以e\n, ###结束Traning data至少要几百(In general, we've found that each doubling of the dataset size leads to a linear increase in model quality.)格式必须是jsonl https://jsonlines.org/ 官方给出的一些最佳实践参考:(prompt + completion) 进行模型调优 目前支持调优的模型只有davinci, curie, babbage, 和 ada,这几种模型按照字母顺序效果越来越好,价格也越来越高已经经过调优的模型也可以再一次再再一次进行调优使用训练好的模型 可以使用fine_tunes.list指令列出所有微调模型的名称训练参数 在调优时,也提供了一些参数可以指定 model:即进行调优的base model,也是上面提到的四种模型中的一种 n_epochs:即训练模型时进行的epoch数量,我们可以将一个epoch理解为针对整个训练集完整的一轮训练周期。默认值是4 batch_size:是指一次训练时使用的训练数据大小(在一个epoch中会训练多次),默认为训练集大小的0.2%,通常针对size较大的训练集时会制定更大比例的batch_size learning_rate_multiplier:举例说明,更低的learning_rate会让我们的模型在进行参数调整时更加“谨慎”,一般在首轮训练中,会采用较高的learning_rate;在后续的调整时,我们可能面向某一个更有针对性的领域,采用数据量较小的数据集,这是可以采用较低的learning_rate进行微调。官方的默认值在0.05、0.1和0.2(根据batch_size决定),并且推荐我们在0.02-0.2之间进行尝试,看哪一个值能够提供更好的表现 compute_classification_metrics:一个bool值,为true时,模型会在每一个epoch结束的时候输出相应的训练结果指标(accuracy,F-1 score等)(针对validation set) 结果衡量 可以上传validation set,并结合上面的compute_classification_metrics参数,对模型进行验证,并输出每个epoch的验证结果 需要指定multiple classification和binary classification3.1 LlamaIndex“第二大脑”通常来说,大语言模型包含了两种能力: 在前置的海量语料中,本身已经包含了很多知识信息。我们的一些问题可以从这些语料中直接得到,我们称之为“世界知识”根据用户所输入的内容,模型具备一定的理解和推理的能力。即指不需要语料中有一样的内容,模型可以通过“思维能力”进行阅读理解,此时的知识并非是前置语料提供的,而是我们所提供的上下文中分析得到的这种先搜索、后提示的方式,又被叫做ai的“第二大脑”模式,即指我们提前将希望ai能够回答的知识建立一个外部的索引,而这个索引就好像是ai的第二个大脑。前面提到的一些通过论文回答问题的应用例子就是通过这种模式实现的。 由于这种模式很常见,所以python已经提供了封装好的开源包,叫做llama-index(源码见https://github.com/jerryjliu/llama_index),我们来看下具体的使用方法: 首先是文档的前置输入及索引的构建import openai, os from llama_index import GPTVectorStoreIndex, SimpleDirectoryReader openai.api_key = os.environ.get("OPENAI_API_KEY") // 这里的每一个文件都会被当成是一篇文档 documents = SimpleDirectoryReader('导入路径directory').load_data() // 这里会给这些文档构建索引,即会把文档分段转换成一个个向量,并存储成索引 index = GPTSimpleVectorIndex.from_documents(documents) // 最后我们将索引存下来,存储结果是一个json文件 index.save_to_disk('导出路径.json') 接下来即可使用创建的索引进行查询// 先将索引加载到内存中 index = GPTVectorStoreIndex.load_from_disk('导出路径.json') response = index.query("问题") print(response) 利用 llama-index 做文章总结 当我们希望对一篇论文或一本书进行总结时,api4096个token的限制往往就不太够用,这是我们也可以借助llama- index完成分段小结。 具体来说,我们可以先将一篇文章分割成几部分,然后对这些部分分别进行摘要,再将得到的摘要进一步总结,类似树状结构一样得到我们最终想要的结果。而python也提供了用于文本分割的方法,即SpacyTextSplitter,下面来看一下具体的使用: from langchain.chat_models import ChatOpenAI from langchain.text_splitter import SpacyTextSplitter from llama_index import GPTListIndex, LLMPredictor, ServiceContext from llama_index.node_parser import SimpleNodeParser // 先来定义一个解析模型,由于文章的长度较长,因此可以使用比较便宜的模型,默认的text模型比较贵 llm_predictor = LLMPredictor(llm=ChatOpenAI(temperature=0, model_name="gpt-3.5-turbo", max_tokens=1024)) // 对文本进行拆分,可以使用方便中文模型的分割方式,同时我们可以指定chunk size来限制文本段的长度 // (如果太长的话,可能会将一些语意没有那么连贯和相关的内容放在一起) text_splitter = SpacyTextSplitter(pipeline="zh_core_web_sm", chunk_size = 2048) parser = SimpleNodeParser(text_splitter=text_splitter) documents = SimpleDirectoryReader('导入路径').load_data() // 在这里对文档进行一个拆分 nodes = parser.get_nodes_from_documents(documents) service_context = ServiceContext.from_defaults(llm_predictor=llm_predictor) // 在这里构建索引,我们需要:1. 拆分好的文本段; 2. 我们要使用的处理模型 list_index = GPTListIndex(nodes=nodes, service_context=service_context) 在GPTListIndex 构建索引的时候,并不会创建 Embedding,所以索引创建的时候很快,也不消耗 Token 数量。它只是根据你设置的索引结构和分割方式,建立了一个 List 的索引,接下来就可以通过query来让ai进行文本总结了: response = list_index.query("请你用中文总结一下文本内容:", response_mode="tree_summarize") print(response) 3.2 真正意义上的FineTune上面提到的llama-index其实本质上还是一种先搜索,再提示的方式,而不是真正的fine tuning,在FineTune这一节的最开始已经对一些相关的基础知识作了介绍,这里来看一下具体的调用方式: 我们需要借助python的subprocess包来调用命令行里的openai工具。前面提到了,fine tuning直接受jsonl格式的训练数据,而我们可以利用现成的指令对训练数据进行格式转换: import subprocess subprocess.run('openai tools fine_tunes.prepare_data --file data/prepared_data.csv --quiet'.split()) 该指令运行结束后,我们可以得到一个处理好的训练集,data/prepared_data_prepared.jsonl,下面将这个文件喂进去即可: subprocess.run('openai api fine_tunes.create --training_file data/prepared_data_prepared.jsonl --model curie --suffix "example"'.split()) 在这个指令里我们指定了想要用来调优的base model,同时我们可以为自己的模型指定一个名称前缀,如这里我们用example,那么最终得到的模型名称就会类似于curie:xx-xxx-ai:example-2023-xx-xx-xx-xx-xx,这个名称我们可以用前面的指令来得到。 接下来我们既可以在这个模型上继续进行调优,也可以进行使用,使用时只需要将model换成我们微调后的model名称即可,需要注意的是,官方提到模型训练完成后需要几分钟才能生效,如果发出的请求timeout了,那可能是因为模型还在load。如果后续我们想要删除该模型,只要确认自己是onwer角色即可。 4. LangChainLangChain顾名思义是一种链式调用,事实上在实际应用中有很多需要链式调用的场景。比如我们期望用英文的prompt来使ai生成更高质量更准确的回答,就可以先将中文的问题翻译成英文,再用英文提问,然后再将回答翻译回中文,这其实就是一个链式调用。 4.1 LLMChain通常写一个链式调用的步骤是,先定义一个prompt模版,然后利用LLMChain将一次调用定义为一个chain,再通过SimpleSequentialChain将几个chain串联起来,下面我们来详细看这个步骤: 定义prompt模版 importopenai,os fromlangchain.promptsimportPromptTemplate fromlangchain.llmsimportOpenAI fromlangchain.chainsimportLLMChain openai.api_key=os.environ.get("OPENAI_API_KEY") en_to_zh_prompt=PromptTemplate( template="请把下面这句话翻译成英文:\n\n{question}?",input_variables=["question"] ) question_prompt=PromptTemplate( template="{english_question}",input_variables=["english_question"] ) zh_to_cn_prompt=PromptTemplate( input_variables=["english_answer"], template="请把下面这一段翻译成中文:\n\n{english_answer}?", ) 定义chain这里定义一个LLMChain所需要的参数仍然是包括所需要使用的模型,以及一个prompt,模型参数依然如同前面讲到的,不同的是这里还定义了output key,这个key就代表了这一条prompt执行之后的结果 llm = OpenAI(model_name="text-davinci-003", max_tokens=2048, temperature=0.5) question_translate_chain = LLMChain(llm=llm, prompt=en_to_zh_prompt, output_key="english_question") qa_chain = LLMChain(llm=llm, prompt=question_prompt, output_key="english_answer") answer_translate_chain = LLMChain(llm=llm, prompt=zh_to_cn_prompt) 3. 串联chain 串联中前后chain的输入输出就是通过output_key和input_variables对应起来的,串联的时候要注意按照调用次序。 这里的verbose置为true后,chain执行时会将每一次调用的结果展现给我们 from langchain.chains import SimpleSequentialChain chinese_qa_chain = SimpleSequentialChain(chains=[question_translate_chain, qa_chain, answer_translate_chain], input_key="question", verbose=True) 4. 使用chain 使用的时候我们只需要将最初的input传入即可,通过定义好的chinese_qa_chain就可以完成一整个链式调用 answer = chinese_qa_chain.run(question="问题")print(answer) 4.2 ConversationChain智能记忆前面我们提到了,对于多轮对话,我们需要将上下文也一起输入给ai,但最大token数有限制,因此可能会出现上下文大小超过限制的情况,这时我们就可以利用LangChain的一些内置方法来实现更智能的“记忆”。主要的思路有两点: 通过一个滑动窗口,每次只记住最近几轮的对话上下文 对于更久远的对话,我们不再记忆完整的对话内容,而是对内容进行总结并进行简单记忆 记忆的滑动窗口可以通过BufferWindow实现 前面在ChatCompletion的介绍中,我们每次需要执行append的操作,将ai的返回都作为assistant角色的content再次输入给ai。在LangChain下,我们可以定义一个template,将对话历史作为一个变量进行传入: fromlangchain.memoryimportConversationBufferWindowMemory fromlangchain.llmsimportOpenAI template="""{你的问题} {chat_history} Human:{human_input} Chatbot:""" prompt=PromptTemplate( input_variables=["chat_history","human_input"], template=template ) //这里的k=3,即表示每次只保留最近三轮的对话内容 memory=ConversationBufferWindowMemory(memory_key="chat_history",k=3) llm_chain=LLMChain( llm=OpenAI(),//即openai的defaultmodel,text-davinci-003 prompt=prompt, memory=memory, verbose=True ) //多轮对话时,只需要不断调用llm_chain.predict即可 llm_chain.predict(human_input="xxx") 同时我们也可以通过load_memory_variables去看到memory中实际记忆了哪些内容: // 这个指令会返回chat_history的内容 memory.load_memory_variables({}) 对话的记忆可以通过SummaryMemory实现 BufferWindow有一个弊端就是,最初几轮的对话无法被保存下来,这时我们可以对这些对话进行总结,将总结后精简的内容记住。 fromlangchain.chainsimportConversationChain fromlangchain.memoryimportConversationSummaryMemory llm=OpenAI(temperature=0) memory=ConversationSummaryMemory(llm=OpenAI()) prompt_template="""{你的问题} {history} Human:{input} AI:""" prompt=PromptTemplate( input_variables=["history","input"],template=prompt_template ) conversation_with_summary=ConversationChain( llm=llm, memory=memory, prompt=prompt, verbose=True ) conversation_with_summary.predict(input="xxx") 整体的调用其实跟前面的ConversationBufferWindowMemory差不多,这里额外关注两点: 前面ConversationBufferWindowMemory的时候我们其实没有传入llm的参数,这里需要llm参数是因为这里对memory处理时,还需要调用一次api去做总结,而这里总结采用的模型跟下面对话使用的模型可以是不同的除了使用LLMChain之外,也可以使用LLMChain已经封装好的ConversationChain,这样可以不用自定义prompt_template(但是想通过中文进行一些有前置条件的对话,也可以自己的写一个template)对话时的调用方法以及对历史记忆内容的查看跟上面都是一样的。 将两者结合 理所当然的,langchain也提供了将上述两个功能结合起来的接口,即ConversationSummaryBufferMemory,调用方法也是类似的,只要将memory对应的对象替换一下就行了。 三、应用举例1. 情感分析(以embedding为主)我们都知道通过传统的机器学习(如逻辑回归、随机森林)等 可以实现情感分析,但是需要较多的前置知识。在embedding的介绍中已经提到了向量距离的计算,这里具体来看如何通过openai很简单的实现零样本的情感分析: import openai import os from openai.embeddings_utils import cosine_similarity, get_embedding openai.api_key = os.getenv("OPENAI_API_KEY") EMBEDDING_MODEL = "text-embedding-ada-002" // 先计算出“好评”和“差评”的向量 positive_review = get_embedding("好评") negative_review = get_embedding("差评") // 再计算出待评估分类的文本的向量 positive_example = get_embedding("买的银色版真的很好看,一天就到了,晚上就开始拿起来完系统很丝滑流畅,做工扎实,手感细腻,很精致哦苹果一如既往的好品质") negative_example = get_embedding("降价厉害,保价不合理,不推荐") // 分别计算文本到“好评”/“差评”的余弦距离,看到哪一个更近 def get_score(sample_embedding): return cosine_similarity(sample_embedding, positive_review) - cosine_similarity(sample_embedding, negative_review) positive_score = get_score(positive_example) negative_score = get_score(negative_example) print("好评例子的评分 : %f" % (positive_score)) print("差评例子的评分 : %f" % (negative_score)) 整体实现非常简单,openai帮我们实现了最复杂的获取文本向量的步骤,并且帮助我们避免去考虑一些传统情感分析时需要考虑的内容,如应当根据双字节还是三字节去看,停用词的选择,以及标点符号的处理等 由于词语位置的不同很可能导致语意的不同(如“太好吃了,不糟糕”和“太糟糕了,不好吃”),因此在正常的情感分析中需要考虑的问题很多除了通过embedding接口实现零样本的情感分析外,我们也可以通过直接给chatgpt一些已知例子,来获取一些位置文本的分类 2. 客服机器人(以ChatCompletion为主)chatgpt一个最直接的应用就是做客服机器人,其实就是考虑将我们上述介绍的各种功能组合起来,实现较为完备的机器人对话。 实现:多种能力分治 + 历史对话记录输入 + 自定义语料库检索 + 网页链接检索 + 文本审核 + 智能记忆 + (专业知识微调) 部署:gradio轻量部署 多种能力分治 客服机器人可能需要回答很多场景的问题,而不同的场景可能对应着不同的语料库,这时候我们可以定义不同的处理函数,让ai进行判断,应该走哪一个处理流程,这里利用到langchains提供的agents包: from langchain.agents import initialize_agent, Tool from langchain.llms import OpenAI llm = OpenAI(temperature=0) def search_order(input: str) - str: // 根据input进行处理,得到返回 def recommend_product(input: str) - str: // 根据input进行处理,得到返回 def faq(intput: str) - str: // 根据input进行处理,得到返回 // 这里定义每个func应该在什么时候执行,具体是通过description来让ai进行判断 tools = [ Tool( name = "Search Order",func=search_order, description="useful for when you need to answer questions about customers orders" ), Tool(name="Recommend Product", func=recommend_product, description="useful for when you need to answer questions about product recommendations" ), Tool(name="FAQ", func=faq, description="useful for when you need to answer questions about shopping policies, like return policy, shipping policy, etc." ) ] // 这里来初始化这个agent,将verbose置为true时,可以详细的看到ai判断执行那个func的过程 agent = initialize_agent(tools, llm, agent="zero-shot-react-description", verbose=True) 使用agent时,只需要: answer = agent.run("问题") 自定义 语料库 检索 即指先检索再回复的能力,其实本质就是前面提到的lama-index的使用场景,我们可以通过一个已经封装好的LLMChain来实现: from langchain.embeddings.openai import OpenAIEmbeddings from langchain.vectorstores import FAISS from langchain.text_splitter import SpacyTextSplitter from langchain import OpenAI, VectorDBQA from langchain.document_loaders import TextLoader // 定义好要用的模型 llm = OpenAI(temperature=0) // 加载需要的语料数据 loader = TextLoader('语料库路径') documents = loader.load() // 使用中文模型进行文本分割 text_splitter = SpacyTextSplitter(chunk_size=256, pipeline="zh_core_web_sm") texts = text_splitter.split_documents(documents) // 为分割好的文档创建embedding embeddings = OpenAIEmbeddings() // 利用FAISS(facebook ai similarity search)来将其存储成一个VecterStore docsearch = FAISS.from_documents(texts, embeddings) // 最后通过VectorDBQA帮我们封装好这个先检索再回答的模型 faq_chain = VectorDBQA.from_chain_type(llm=llm, vectorstore=docsearch, verbose=True) 封装好检索的模型后,在前面分治的func中我们就可以直接使用faq_chain.run(input)做return了 网页链接检索 对于语料库没有的问题,或者是一些时效性较高的问题,我们也可以通过网页检索的方式进行搜索后回答,同样的,langchain也提供了这样的包: from langchain.chains import LLMRequestsChain // 类似上面的LLMChain,先定义一个prompt,说明我们需要从网页检索到的结果里做一次与问题的匹配 // requests_result在这里就是网页检索得到的结果 template = """在 和 直接是来自Google的原始搜索结果. 请把对于问题 '{query}' 的答案从里面提取出来,如果里面没有相关信息的话就说 "找不到" 请使用如下格式: Extracted:answer or "找不到" {requests_result} Extracted:""" PROMPT = PromptTemplate( input_variables=["query", "requests_result"], template=template, ) // 定义关键的搜索chain requests_chain = LLMRequestsChain(llm_chain = LLMChain(llm=OpenAI(temperature=0), prompt=PROMPT)) question = "今天上海的天气怎么样?" // 搜索的inputs包含两项,一个是询问的问题,一个是用于检索的网址 inputs = { "query": question, "url": "https://www.google.com/search?q=" + question.replace(" ", "+") } // 执行询问 result=requests_chain(inputs) print(result) // 会返回详细的请求数据,包括query,url,以及返回的结果 print(result['output']) // 只打印返回的结果 这种搜索的调用也可以作为我们上述分治func中的一种 历史对话载入 有时候我们手中可能已经有了一系列的历史对话,这时候我们也可以通过现成的接口将已有的对话喂给ai,让ai拥有“初始的记忆”并在此基础上和用户对话: from langchain.memory import ConversationSummaryBufferMemory // 这里我们希望的是不要逐字逐句对历史记录进行记忆,而是进行总结后记忆 memory = ConversationSummaryBufferMemory(llm=OpenAI(), prompt=SUMMARY_PROMPT, max_token_limit=40) memory.save_context( {"input": "你好"}, {"ouput": "你好,我是客服李四,有什么我可以帮助您的么"} ) memory.save_context( {"input": "我叫张三,在你们这里下了一张订单,订单号是 2023ABCD,我的邮箱地址是 customer@abc.com,但是这个订单十几天了还没有收到货"}, {"ouput": "好的,您稍等,我先为您查询一下您的订单"} ) memory.load_memory_variables({}) gradio 轻量部署 不做详细的介绍,但是他可以通过很简单的代码实现一个可视化的chatbot页面,代码类似于: 引入gradio的包,Interface即为gradio提供的ui,fn为执行的函数,input、output为输入输出 运行后会给我们一个ip和端口,可以直接看到我们的界面,上述代码结果: 四、多模态拓展应用前面的介绍都是针对文本进行一些处理,除此之外,openai当然也支持多模态内容的处理,多模态即指多种媒体形式的内容,如音视频、图像等。 1. 音频处理1.1 语音转文字openai提供了一个名为whisper的api,通过这个api我们可以很简单的将音频转为文字: import openai, os openai.api_key = os.getenv("OPENAI_API_KEY") // 这里使用python的open函数,rb标识了以二进制只读模式打开 audio_file= open("路径", "rb") // transcribe即是我们主要使用的函数,这里指定了使用"whisper-1"的模型,但其实也可以不写,因为 // 目前只有这一种模型是可用的,默认也会采用这种模型 transcript = openai.Audio.transcribe("whisper-1", audio_file) print(transcript['text']) 其实transcribe也有类似于前面文本处理的参数: Temperature:与上文的相同prompt:这里的prompt可以是一些guidance来对模型的语音转文字行为进行一些辅助/说明/要求(这里官方要求prompt使用的语言和音频中的语言相同,当然我们也可以前置的通过chatgpt来翻译得到所需要的指令),如我们可以将一段音频的大概摘要告诉ai,以帮助他更准确的转义。同时,transcribe也有一些新增的参数: language:可以指定语言,来加快转化的速度 response_format:可以选择转化后返回的文件格式,如默认的json,或text纯文本,或srt等 转文字后顺便做个翻译 除了transcribe接口外,还有一个可选择的接口是translate,使用的方式和transcribe相同,区别是这个接口会将音频中的内容翻译成英文再输出,目前还不支持翻译成其他的语音,相应的,这个接口的prompt也只能接受英文。 大音频的拆分 whisper目前每次只能进行25MB大小音频的转化,如果我们的音频文件较大,也就需要先进行分割,然后再调用,python本身就有这样音频处理的包,即pydub: 其实本质就是一个分批的函数,得到拆分的数组之后,再分别使用whisper转化即可 frompydubimportAudioSegment podcast=AudioSegment.from_mp3("路径") //处理的时候是以ms为单位 ten_minutes=15*60*1000 total_length=len(podcast) start=0 index=0 whilestarttotal_length: end=start+ten_minutes ifendtotal_length: chunk=podcast[start:end] else: chunk=podcast[start:] //这里路径前面的f表示format,即将index的值传入 //wb与前面rb类似,表示二进制写入 withopen(f"路径_{index}.mp3","wb")asf: chunk.export(f,format="mp3") start=end index+=1 1.2 文字转语音对于语音合成,目前也有很多成熟的平台提供相应的能力,这里通过微软Azure做一个尝试。需要前置完成的操作是:1. 注册Microsoft Azure账号; 2. 开通Congitive Services服务; 3. 获取自己的api key 完成后在Resource Management界面可以看到自己的key,同时我们需要在python中安装对应的包azure.cognitiveservices.speech 文字转语音 简单尝试一下这个包: import os import azure.cognitiveservices.speech as speechsdk // 这里的speech key就是前面我们在网页获得的api key,region就是上面的region speech_config = speechsdk.SpeechConfig(subscription=os.environ.get('AZURE_SPEECH_KEY'), region=os.environ.get('AZURE_SPEECH_REGION')) // 这里use_default_speaker置为true时,运行代码后会直接朗读 audio_config = speechsdk.audio.AudioOutputConfig(use_default_speaker=True) // speech_config规定了输出语音的语言以及声音类型,比如这里我们规定用粤语朗读 speech_config.speech_synthesis_language = 'yue-CN' speech_config.speech_synthesis_voice_name = 'yue-CN-YunSongNeural' // 这里初始化我们的生成器 speech_synthesizer = speechsdk.SpeechSynthesizer(speech_config=speech_config, audio_config=audio_config) text = "拦路雨偏似雪花,饮泣的你冻吗" // 进行输出 speech_synthesizer.speak_text_async(text) 注意以下几点: 语音输出需要时间,往往是还没开始读程序就结束了,所以听不到的话可以尝试在后面加个sleep Azure提供了很多语种/声音,感兴趣可以查看:https://learn.microsoft.com/en-us/azure/cognitive-services/speech-service/language-support?tabs=tts#prebuilt-neural-voices,对于中文也提供了个别地区的方言 上文的例子用粤语和辽宁话分别跑了一次: 暂时无法在飞书文档外展示此内容 暂时无法在飞书文档外展示此内容 导出语音 将上述代码中audioConfig里表示直接朗读的部分替换成导出的路径即可,这里列出的语音即是用这种方式导出: audio_config = speechsdk.audio.AudioOutputConfig(use_default_speaker=True) (filename="路径") 更多的风格和角色 其实除了固定的语种以及声音外,每种声音还可以变换不同的语气和角色,随便找一个例子: 但是语气和角色不能直接在接口中指定,需要通过规定的xml格式来做指令,例如: ssml="""speakversion="1.0"xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="https://www.w3.org/2001/mstts"xml:lang="zh-CN" voicename="zh-CN-YunyeNeural"//这里规定了使用哪种声音 儿子看见母亲走了过来,说到: mstts:express-asrole="Boy"style="cheerful"//这里指定了声音角色是小男孩,语气是激动的 “妈妈,我想要买个新玩具” /mstts:express-as /voice voicename="zh-CN-XiaomoNeural" 母亲放下包,说: mstts:express-asrole="SeniorFemale"style="angry" “我看你长得像个玩具。” /mstts:express-as /voice /speak""" speech_synthesis_result=speech_synthesizer.speak_ssml_async(ssml).get() 在这个xml中,我们可以将不同的句子用不同的语气/声音区分开,达到一种对话的效果,导出效果如下: 暂时无法在飞书文档外展示此内容 有了上面的语音/文字相互转化,其实就可以结合前面的内容,尝试做可以接受语音输入,以及提供语音输出能力的chatbot了。 2. 图像处理与文本类似的,openai也通过对大量图片以及图片对应的文字标签(html标签的alt或title字段)训练的方式的到了一个可用于处理图片的多模态模型,即CLIP模型。 2.1 图片分类其实我们只需要知道最重要的一点:无论是图片的分类,还是图片与文字的匹配,本质上还是向量的距离计算。如在做图片分类时,其实就是: (image encoder)将图片先做预处理,变成一系列的数值特征表示的向量。(即先将图片变成像素的RGB值,然后统一图片尺寸,做数值的归一化),然后再将这个向量转化为一个表达图片含义的Tensor,我们也理解为一个多维数组;(text encoder)将分类标签的文本转换成token,然后再转化为表示文本含义的tensor计算两个tensor之间的余弦相似度,进而判断图片的分类对于如何将图片/文字转换为tensor,其实CLIP提供的模型已经为我们封装好了,一个很简短的代码如下: from PIL import Image from transformers import CLIPProcessor, CLIPModel // model是将文本/图片转换为tensor并投影在同一空间的模型 model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32") // processor是对文本/图片进行预处理和后处理的处理工具,如做文本分词、图片归一化等 processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32") image_file = "./cat.jpg" image = Image.open(image_file) categories = ["one cat", "two cats", "three cats", "one cat with green eyes and another with orange eyes"] categories_text = list(map(lambda x: f"a photo of {x}", categories)) inputs = processor(text=categories_text, images=image, return_tensors="pt", padding=True) outputs = model(**inputs) // Logits本质上是对网络的原始非标准化预测 logits_per_image = outputs.logits_per_image // 这里是将每一个标签都与图片算一下向量表示之间的乘积,并通过softmax算法做概率的判别 probs = logits_per_image.softmax(dim=1) for i in range(len(categories)): print(f"{categories[i]}\t{probs[0][i].item():.2%}") 图片是随便在ttq找的可爱小??,描述词写的都比较相近且加了一些具体特征,结果也比较好的区分出来了,而且速度很快 2.2 图片生成除了图片分类外,我们也可以通过文字描述或根据一张图片来生成另一张图片,这里来看开源包Stable Diffusion提供的相关能力: 先看代码实现:from diffusers import DiffusionPipeline from PIL import Image pipeline = DiffusionPipeline.from_pretrained("runwayml/stable-diffusion-v1-5") // 可以下载一个cuda用gpu跑,会快一些,不是必须 #pipeline.to("cuda") // 在这里给出我们的文字指令,同时可以指定训练的轮数,默认是50 image = pipeline("A photo of Cinderella and the dwarfs eating cake together.", num_inference_steps=100).images[0] image.save("./output.jpg") 生成的掉san图片大赏: 辛德瑞拉和小矮人吃蛋糕 - 50轮 辛德瑞拉和小矮人吃蛋糕 - 100轮 辛德瑞拉和小矮人吃蛋糕 - 500轮 塞尔达和小矮人吃蛋糕 - 50轮 塞尔达和小矮人吃蛋糕 - 50轮 原理这里只是简单的描述一下Stable Diffusion大概的一个运作流程。首先,他由三个模块组成: Text encoder:即将我们输入的文本变成一个向量(如果是图生图,那其实就是将图片变成向量),其实也就是使用前面提到的CLIP模型 generation:即图片生成模块。生成的操作依赖一个机器学习模型UNet和一个调度器scheduler来完成,先往前面CLIP模型得到的向量里添加一些噪声,然后再通过UNet+schedule逐渐去除噪声,最后得到我们所要生成的图片的tensor UNet作为最核心的模块,他的训练方式通过扩散模型完成,简单说,即将不同的图片加入随机的噪声后得到新的图片,并根据这个过程可以逆推出新图片减去噪声得到原始图片,通过这样反复的训练得到噪声预测器文本生成图片的过程即是将一张原始的噪声图片和文本tensor同时作为输入,反复去噪得到贴近目标的图片的过程decoder:即解码模块。同样通过机器学习的模型将generation模块得到的tensor还原成最终的图片 文章内容总结摘录于openAI官方文档(前面贴出的链接)以及极课时间《AI大模型之美》(很推荐的课程) 阅读原文
| 上一篇:2025-04-12_50句文案,裹满了春风 | 下一篇:2025-03-28_这是一只大橘……猫? |
TAG标签: |
14 |
|
我们已经准备好了,你呢?
2022我们与您携手共赢,为您的企业营销保驾护航!
|
|
不达标就退款 高性价比建站 免费网站代备案 1对1原创设计服务 7×24小时售后支持 |
|
|
