作者 | Jackmleitch
編譯 | VK
來源 | Towards Data Science
我的想法是:給你一張配料表,我能做什麼不同的食譜?也就是說,我可以用我公寓裡的食物做什麼食譜?
首先,如果你想看到我的API(或使用它!)請按照以下步驟進行操作:
https://whats-cooking-recommendation.herokuapp.com/-如果你在美國
https://whatscooking-deployment.herokuapp.com/-如果你在歐洲
我為缺乏美觀道歉,在某個時候,當我有時間去做的時候,我會構建一個更好的應用程式。
在我關於這個項目的第一篇博客文章中,我回顧了我是如何為這個項目收集數據的。數據是烹飪食譜和相應的配料。從那以後,我添加了更多的食譜,所以我們現在總共有4647個。請隨意使用這個數據集,你可以在我的Github上找到它:https://github.com/jackmleitch/Whatscooking-
這篇文章將著重於對數據進行預處理,構建推薦系統,最後使用Flask和Heroku部署模型。
建立推薦系統的過程如下:
首先對數據集進行清理和解析,然後從數據中提取數字特徵,在此基礎上應用相似度函數來尋找已知食譜的配料與最終用戶給出的配料之間的相似度。最後根據相似度得分,得到最佳推薦食譜。
與本系列的第一篇文章不同,本文不是關於我使用的工具的教程,但它將描述我如何構建系統以及為什麼我會做出這樣的決定。雖然,代碼注釋在我看來很好地解釋了一些事情。與大多數項目一樣,我的目標是創建最簡單的模型,以使工作達到我想要的標準。
構建食譜推薦API成分的預處理與解析為了理解手頭的任務,讓我們看一個例子。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來部署模型使用Flask那麼,我如何為最終用戶提供我所構建的模型呢?我創建了一個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獲取關於義大利麵、番茄和洋蔥的食譜推薦。
將Flask API部署到Heroku如果使用Github,將flaskapi部署到Heroku非常容易!首先,我在我的項目文件夾中創建了一個沒有擴展名的Procfile文件。你只需在該文件中輸入:
web: gunicorn app:app下一步是創建一個名為requirements.txt的文件,它包含了我在這個項目中使用的所有python庫。
如果你在虛擬環境中工作(我使用conda),可以使用pip freeze > requirements.txt,確保你在正確的工作目錄中運行,否則它會將文件保存到其他地方。
現在我所要做的就是將更改提交到Github存儲庫中,然後按照上面的部署步驟進行操作https://dashboard.heroku.com/apps。如果你想試用或使用我的API,請訪問:
https://whats-cooking-recommendation.herokuapp.com/-如果你在美國
https://whatscooking-deployment.herokuapp.com/-如果你在歐洲
Docker我們現在已經到了這樣一個階段,我對我構建的模型感到滿意,所以我希望能夠將我的模型分發給其他人,以便他們也能使用它。
我已經把我的整個項目上傳到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接口。