作者 | Jackmleitch
编译 | VK
来源 | Towards Data Science
我的想法是:给你一张配料表,我能做什么不同的食谱?也就是说,我可以用我公寓里的食物做什么食谱?
首先,如果你想看到我的API(或使用它!)请按照以下步骤进行操作:
我为缺乏美观道歉,在某个时候,当我有时间去做的时候,我会构建一个更好的应用程序。
在我关于这个项目的第一篇博客文章中,我回顾了我是如何为这个项目收集数据的。数据是烹饪食谱和相应的配料。从那以后,我添加了更多的食谱,所以我们现在总共有4647个。请随意使用这个数据集,你可以在我的Github上找到它:https://github.com/jackmleitch/Whatscooking-
这篇文章将着重于对数据进行预处理,构建推荐系统,最后使用Flask和Heroku部署模型。
建立推荐系统的过程如下:
首先对数据集进行清理和解析,然后从数据中提取数字特征,在此基础上应用相似度函数来寻找已知食谱的配料与最终用户给出的配料之间的相似度。最后根据相似度得分,得到最佳推荐食谱。
与本系列的第一篇文章不同,本文不是关于我使用的工具的教程,但它将描述我如何构建系统以及为什么我会做出这样的决定。虽然,代码注释在我看来很好地解释了一些事情。与大多数项目一样,我的目标是创建最简单的模型,以使工作达到我想要的标准。
为了理解手头的任务,让我们看一个例子。Jamie Oliver网站上的美味“Gennaro's classic spaghetti carbonara”食谱需要以下配料:
这里有很多冗余信息;例如,重量和相关度量不会为食谱的矢量编码增加意义。如果说有什么区别的话,这将使区分食谱变得更加困难。所以我们需要把那些东西处理掉。在谷歌上快速搜索后,我找到了一个维基百科页面,里面有一个标准烹饪指标的列表,比如丁香、克(g)、茶匙等等。在我的配料分析器中删除所有这些词效果非常好。
我们还想从我们的成分中去掉停用词。在NLP中,“停止词”是指一种语言中最常见的词。例如,句子“learning about what stop words are”变成了“learning stop words”。NLTK为我们提供了一种简单的方法来删除(大部分)这些单词。
食材中还有一些对我们没用的词——这些词在食谱中很常见。例如,油在大多数食谱中都有使用,而且在食谱之间几乎没有区别。而且,大多数人家里都有油,所以每次使用API都要写油,这既麻烦又毫无意义。
简单地删除最常见的单词似乎非常有效,所以我这样做了。奥卡姆剃刀原则…为了得到最常见的词汇,我们可以执行:
import nltk
vocabulary = nltk.FreqDist()
# 我已经做好了原料的预处理
for ingredients in recipe_df['ingredients']:
ingredients = ingredients.split()
vocabulary.update(ingredients)
for word, frequency in vocabulary.most_common(200):
print(f'{word};{frequency}')
不过,我们还有最后一个障碍要克服。当我们试图从配料表中删除这些“垃圾”词时,如果同一个词有不同的变体,会发生什么情况?
如果我们想去掉“pound”这个词的每一个出现,但是食谱中的配料却写着“pounds”怎么办?幸运的是,有一个相当简单的解决方法:词形还原和词干还原。词干还原和词形还原都会产生词根变化词的词根形式,区别在于词干还原的结果可能不是一个真正的单词,而词形还原的结果是一个实际的单词。
尽管词形还原通常比较慢,但我选择使用这种技术,因为我知道实际单词对调试和可视化非常有用。当用户向API提供成分时,我们也会将这些单词词形还原
我们可以把这些都放在一个函数component_parser中,以及其他一些标准的预处理:去掉标点符号,使所有内容都小写,统一编码。
def ingredient_parser(ingredients):
# 量度和常用词(已被词形还原)
measures = ['teaspoon', 't', 'tsp.', 'tablespoon', 'T', ...]
words_to_remove = ['fresh', 'oil', 'a', 'red', 'bunch', ...]
# 将成分列表从字符串转换为列表
if isinstance(ingredients, list):
ingredients = ingredients
else:
ingredients = ast.literal_eval(ingredients)
# 我们首先去掉所有的标点符号
translator = str.maketrans('', '', string.punctuation)
# 初始化nltk的lemmatizer
lemmatizer = WordNetLemmatizer()
ingred_list = []
for i in ingredients:
i.translate(translator)
# 我们用连字符和空格分开
items = re.split(' |-', i)
# 把所有内容都改成小写
items = [word for word in items if word.isalpha()]
# 小写
items = [word.lower() for word in items]
# 统一编码
items = [unidecode.unidecode(word) for word in items]
# 词形还原,这样我们可以比较
items = [lemmatizer.lemmatize(word) for word in items]
# 删除停用词
stop_words = set(corpus.stopwords.words('english'))
items = [word for word in items if word not in stop_words]
# #避免测量单词/短语, 例如. heaped teaspoon
items = [word for word in items if word not in measures]
# 删除常见的简单词汇
items = [word for word in items if word not in words_to_remove]
if items:
ingred_list.append(' '.join(items))
ingred_list = ' '.join(ingred_list)
return ingred_list
当我们分析“Gennaro’s classic spaghetti carbonara’”的成分时,我们得到:egg yolk parmesan cheese pancetta spaghetti garlic。太好了,太棒了!
使用lambda函数,很容易解析所有成分。
recipe_df = pd.read_csv(config.RECIPES_PATH)
recipe_df['ingredients_parsed'] = recipe_df['ingredients'].apply(lambda x: ingredient_parser(x))
df = recipe_df.dropna()
df.to_csv(config.PARSED_PATH, index=False)
我们现在需要对每个文档(食谱成分)进行编码,和以前一样,简单的模型非常有效。
在进行NLP时,最基本的模型之一就是词袋。这就需要创建一个巨大的稀疏矩阵来存储我们语料库中所有单词对应的数量(所有文档,即每个食谱的所有成分)。scikitlearn的countVector有一个很好的实现。
词袋执行得不错,但TF-IDF(术语频率反向文档频率)执行得稍差,所以我们选择了这个。我不打算详细介绍tf-idf是如何工作的,因为它与博客无关。与往常一样,scikitlearn有一个很好的实现:TfidfVectorizer。然后,我用pickle保存了模型和编码,因为每次使用API时重新训练模型都会使它非常缓慢。
import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer
import pickle
import config
# 加载解析的食谱数据集
df_recipes = pd.read_csv(config.PARSED_PATH)
# Tfidf需要unicode或string类型
df_recipes['ingredients_parsed'] = df_recipes.ingredients_parsed.values.astype('U')
# TF-IDF特征提取程序
tfidf = TfidfVectorizer()
tfidf.fit(df_recipes['ingredients_parsed'])
tfidf_recipe = tfidf.transform(df_recipes['ingredients_parsed'])
# 保存tfidf模型和编码
with open(config.TFIDF_MODEL_PATH, "wb") as f:
pickle.dump(tfidf, f)
with open(config.TFIDF_ENCODING_PATH, "wb") as f:
pickle.dump(tfidf_recipe, f)
该应用程序仅由文本数据组成,并且没有可用的评分类型,因此不能使用矩阵分解方法,如基于SVD和基于相关系数的方法。
我们使用基于内容的过滤,使我们能够根据用户提供的属性(成分)向人们推荐食谱。为了度量文档之间的相似性,我使用了余弦相似性。我也尝试过使用Spacy和KNN,但是余弦相似性在性能(和易用性)方面获得了胜利。
从数学上讲,余弦相似性度量两个向量之间夹角的余弦。我选择使用这种相似性度量,即使两个相似的文档以欧几里德距离相距甚远(由于文档的大小),它们可能仍然朝向更近的方向。
例如,如果用户输入了大量的配料,而只有前半部分与食谱匹配,理论上,我们仍然应该得到一个很好的食谱匹配。在余弦相似性中,角度越小,余弦相似度越高:所以我们试图最大化这个分数。
from sklearn.metrics.pairwise import cosine_similarity
from sklearn.feature_extraction.text import TfidfVectorizer
import pickle
import config
from ingredient_parser import ingredient_parser
# 加载tdidf模型和编码
with open(config.TFIDF_ENCODING_PATH, 'rb') as f:
tfidf_encodings = pickle.load(f)
with open(config.TFIDF_MODEL_PATH, "rb") as f:
tfidf = pickle.load(f)
# 使用ingredient_parser分析配料
try:
ingredients_parsed = ingredient_parser(ingredients)
except:
ingredients_parsed = ingredient_parser([ingredients])
# 使用我们预训练的tfidf模型对输入成分进行编码
ingredients_tfidf = tfidf.transform([ingredients_parsed])
# 计算实际食谱和测试食谱之间的余弦相似性
cos_sim = map(lambda x: cosine_similarity(ingredients_tfidf, x), tfidf_encodings)
scores = list(cos_sim)
然后,我编写了一个函数get_recommendations,对这些分数进行排名,并输出一个pandas数据框,其中包含前N个菜谱的所有细节。
def get_recommendations(N, scores):
# 加载食谱数据集
df_recipes = pd.read_csv(config.PARSED_PATH)
# 对分数排序,得到N个最高的分数
top = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:N]
# 在dataframe中创建建议
recommendation = pd.DataFrame(columns = ['recipe', 'ingredients', 'score', 'url'])
count = 0
for i in top:
recommendation.at[count, 'recipe'] = title_parser(df_recipes['recipe_name'][i])
recommendation.at[count, 'ingredients'] = ingredient_parser_final(df_recipes['ingredients'][i])
recommendation.at[count, 'url'] = df_recipes['recipe_urls'][i]
recommendation.at[count, 'score'] = "{:.3f}".format(float(scores[i]))
count += 1
return recommendation
值得注意的是,没有具体的方法来评估模型的性能,所以我不得不手动评估这些建议。不过,老实说,这真的很有趣…我还发现了很多新的食谱!
到目前为止,我冰箱/橱柜里的一些东西是:碎牛肉、意大利面、番茄面酱、培根、洋葱、西葫芦和奶酪。推荐系统的建议是:
{ "ingredients" : "1 (15 ounce) can tomato sauce, 1 (8 ounce) package uncooked pasta shells, 1 large zucchini - peeled and cubed, 1 teaspoon dried basil, 1 teaspoon dried oregano, 1/2 cup white sugar, 1/2 medium onion, finely chopped, 1/4 cup grated Romano cheese, 1/4 cup olive oil, 1/8 teaspoon crushed red pepper flakes, 2 cups water, 3 cloves garlic, minced",
"recipe" : "Zucchini and Shells",
"score: "0.760",
"url":"https://www.allrecipes.com/recipe/88377/zucchini-and-shells/"
}
听起来不错-最好去做饭!
那么,我如何为最终用户提供我所构建的模型呢?我创建了一个API,可以用来输入成分,然后根据这些成分输出前5个食谱建议。为了构建这个API,我使用了Flask,它是一个微web服务框架。
# app.py
from flask import Flask, jsonify, request
import json, requests, pickle
import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from ingredient_parser import ingredient_parser
import config, rec_sys
app = Flask(__name__)
@app.route('/', methods=["GET"])
def hello():
# 这是我们API的主页
# 它可以通过http://127.0.0.1:5000/访问
return HELLO_HTML
HELLO_HTML = """
<html><body>
<h1>Welcome to my api: Whatscooking!</h1>
<p>Please add some ingredients to the url to receive recipe recommendations.
You can do this by appending "/recipe?ingredients= Pasta Tomato ..." to the current url.
<br>Click <a href="/recipe?ingredients= pasta tomato onion">here</a> for an example when using the ingredients: pasta, tomato and onion.
</body></html>
"""
@app.route('/recipe', methods=["GET"])
def recommend_recipe():
# 可以通过http://127.0.0.1:5000/recipe访问
ingredients = request.args.get('ingredients')
recipe = rec_sys.RecSys(ingredients)
# 我们需要将输出转换为JSON。
response = {}
count = 0
for index, row in recipe.iterrows():
response[count] = {
'recipe': str(row['recipe']),
'score': str(row['score']),
'ingredients': str(row['ingredients']),
'url': str(row['url'])
}
count += 1
return jsonify(response)
if __name__ == "__main__":
app.run(host="0.0.0.0", debug=True)
我们可以通过运行命令python app.py
来启动,API将在本地主机上的端口5000上启动。我们可以通过访问http://192.168.1.51:5000/recipe?ingredients=%20pasta%20tomato%20onion获取关于意大利面、番茄和洋葱的食谱推荐。
如果使用Github,将flaskapi部署到Heroku非常容易!首先,我在我的项目文件夹中创建了一个没有扩展名的Procfile文件。你只需在该文件中输入:
web: gunicorn app:app
下一步是创建一个名为requirements.txt的文件,它包含了我在这个项目中使用的所有python库。
如果你在虚拟环境中工作(我使用conda),可以使用pip freeze > requirements.txt
,确保你在正确的工作目录中运行,否则它会将文件保存到其他地方。
现在我所要做的就是将更改提交到Github存储库中,然后按照上面的部署步骤进行操作https://dashboard.heroku.com/apps。如果你想试用或使用我的API,请访问:
我们现在已经到了这样一个阶段,我对我构建的模型感到满意,所以我希望能够将我的模型分发给其他人,以便他们也能使用它。
我已经把我的整个项目上传到Github,但这还不够。仅仅因为代码在我的计算机上工作并不意味着它将在其他人的计算机上工作。
如果当我分发代码时,我复制我的计算机,这样我就知道它会工作了,那将是非常棒的。现在最流行的方法之一就是使用Docker容器。我做的第一件事是创建一个名为Dockerfile的docker文件(它没有扩展名)。简单地说,docker文件告诉我们如何构建环境,并包含用户可以在命令行中调用的所有命令来组装映像。
# 包括从何处获取映像(操作系统)
FROM ubuntu:18.04
MAINTAINER Jack Leitch 'jackmleitch@gmail.com'
# 自动按Y
RUN apt-get update && apt-get install -y \
git \
curl \
ca-certificates \
python3 \
python3-pip \
sudo \
&& rm -rf /var/lib/apt/lists/*
# 设置工作目录
WORKDIR /app
# 将currect目录中的所有内容复制到app目录中。
ADD . /app
# 安装所有要求
RUN pip3 install -r requirements.txt
# 下载wordnet作为它用来词形还原
RUN python3 -c "import nltk; nltk.download('wordnet')"
# CMD在容器启动后执行
CMD ["python3", "app.py"]
一旦我创建了docker文件,我就需要构建我的容器—这很简单。
旁注:如果你这样做,确保你所有的文件路径(我把我的放在一个config.py文件中)不是特定于你的计算机,因为docker就像一个虚拟机,包含它自己的文件系统,例如,你可以放./input/df_recipes.csv。
docker build -f Dockerfile -t whatscooking:api
在任何机器上启动API(!),我们现在要做的就是(假设你已经下载了docker容器):
docker run -p 5000:5000 -d whatscooking:api
如果你想亲自检查容器,这里有一个链接到我的Docker Bub:https://hub.docker.com/repository/docker/jackmleitch/whatscooking。你可以通过以下方式拖动图像:
docker pull jackmleitch/whatscooking:api
接下来的计划是使用Streamlit构建一个更好的API接口。