Используя концепции scikit-learn и NLP, мы улучшим наш опыт работы с LinkedIn, превратив описание профиля в этой социальной сети в персонажа Улицы Сезам!

Мы попросили ChatGPT создать синтетические профили LinkedIn для определенных профессий: HR, криптоэнтузиасты, гуру бизнеса и финансов и инженеры-программисты.

Мы связали эти профили с персонажем и обучили модель с результатом:

  • Лоуренс Дикерсон, ИТ-специалист, обладающий навыками работы с Microsoft Excel, Microsoft Word, Microsoft Outlook, Microsoft, PowerPoint, Бизнес-партнер по ИТ и т. д. — отмечен как трудолюбивый Гровер.
  • Ана Толли, дальновидный успешный криптовалютный трейдер web3 с опытом выявления прибыльных инвестиций, преуспевающих в техническом анализе и управлении рисками, отмечена как Оскар Ворчун в поисках потерянных цифровых активов.
  • Мэтью Бёрд, финансовый вдохновитель, раскройте мощь своих финансов и возьмите под контроль свое богатство под руководством финансового вдохновителя, отмеченного как великий лидер Большая птица.
  • Олета Рис, информационный бюллетень по созданию богатства, менеджер по персоналу, менеджер по подбору персонала, тренер по бизнес-тренировкам — отмечена как суетливый граф.
  • Гэри Отис, отвечающий за управление всем циклом рекрутинга, включая поиск поставщиков, установление контактов, планирование собеседований, выпуск письма с предложением и размещение квалифицированных ресурсов. Помечен как забавный дуэт Эрни и Берт.

Заинтересованы? Тогда читайте дальше.

Начните с расширения вашего браузера

Нашей точкой расширения будет расширение для хрома.

Мы хотим, чтобы расширение очищало все элементы фида, которые мы видим, извлекало информацию о найденных профилях, отправляло их в классификатор и сопоставляло вывод с персонажем.

Создание расширения для очистки и демонстрации

Либо используйте ChatGPT, чтобы получить шаблон расширения для Chrome, либо извлеките его из краткого руководства для разработчиков, которое предлагает Google на своем сайте (см. Ссылки).

Каким бы ни был метод, расширение будет состоять из:

  • manifest.json
  • фон.js
  • контент.js

Манифест сообщает Chrome, какие права и функции есть у расширения, в нашем случае:

{
    "manifest_version": 3,
    "name": "AugmentedLinkedInFun",
    "version": "1.0",
    "description": "Replaces LinkedIn user names with 'NPC'.",
    "permissions": [
        "tabs",
        "scripting",
        "activeTab",
        "notifications",
        "storage"
    ],
    "host_permissions": [
        "http://www.linkedin.com/*",
        "https://www.linkedin.com/*"
    ],
    "optional_host_permissions": [
        "https://*/*",
        "http://*/*"
    ],
    "web_accessible_resources": [{
        "resources": [ "assets/bigbird.png", "assets/count.png","assets/erniebert.png", "assets/grouch.png", "assets/grover.png" ],
        "matches": [  "*://www.linkedin.com/*" ],
        "use_dynamic_url": true
    }],
    "content_scripts": [
        {
            "matches": [
                "*://www.linkedin.com/*"
            ],
            "js": [
                "scripts/jquery-3.6.4.slim.min.js",
                "scripts/content.js"
            ]
        }
    ],
    "background": {
        "service_worker": "scripts/background.js",
        "type": "module"
    },
  • Разрешения сообщают браузеру, что мы будем вставлять скрипт на активную вкладку, в данном случае на конкурс.js.
  • Host_permissions сигнализирует, где запускать.
  • Content_script — это все сценарии и ресурсы, которые мы будем использовать и где.
  • background определяет, какой фоновый скрипт будет иметь доступ к большинству API Chrome. Мы также включили утонченную версию библиотеки jQuery для ее выбора элементов и функциональности манипулирования ими.
  • web_accessible_resources предназначен для всех изображений, которые мы будем использовать.

Внедрение скрипта

В сценарии content.js мы хотим захватить все ссылки, которые ведут нас к профилю, используя функцию ниже:

/**
 * scrape all profile links.
 * @return array of links.
 */
getAllLinks() {
  const re = new RegExp("^(http|https)://", "i");;
  var links = [];
  $('a[href*="/in/"]').each((index, element) => {
      let link = $(element).attr('href');
      if (/^(http|https).*/i.test(link) === false) {
      // Linkedin may use relative links, we need to convert to absolutes.
      link = `https://www.linkedin.com${link}`
      }
      
      links.push(link);
  })
  return links;
}

Функция вернет массив всех ссылок, ведущих на профиль. Такая ссылка идентифицируется регулярным выражением ссылки: ^((http|https). )?/in/.*..

Мы очистим каждую ссылку в нашем фиде, которая соответствует этому регулярному выражению.

Затем мы вызываем функцию для извлечения всего содержимого профиля.

/**
 * Given a collection of profile links, we collect relevant information.
 * @param {*} links Absolute links to profiles. If link is not for a profile (we use xpath), it will be ignored.
 * @returns Array of Profile objects, made up of
 *            {
 *                user
 *                titles
 *                link
 *            }
 */
async getProfilesDetailsFromLinks(links, cachedProfiles) {

  /**
   * Scraping internal function.
   */
  function _scrape(profile){
    let lnName = $("main h1", profile).text()
    let profileObj = null;
    if (lnName !== null && lnName.length > 0) {
      lnName = lnName.trim();
      console.debug(`found name: ${lnName}`);
    }

    let titles = $("main div.text-body-medium", profile).text()
    if (titles !== null && titles.length > 0) {
      titles = titles.trim();
    }

    if ((lnName !== null && lnName !== "") && (titles !== null && titles !== "")) {
      profileObj = {
        user: lnName,
        titles: titles,
        link: ''
      }
    }
    return profileObj;
  }
  
  let profiles = []
  let calls = []
  let MAX_ITERS = 5
  for (const link of links) {
      let profile = null;
      // Avoid rate limit and allow dynamic content to load
      // Randomly wait for up to 2sec.
      await new Promise(r => setTimeout(r, Math.floor(Math.random() * 2000)));

      let _link = link;
      let call = chrome.runtime.sendMessage({ link: link }).then(response => {
          let profile = response?.profile;
          let profileObj = null;
          if (profile) {
          profileObj = _scrape(profile);
          if (profileObj){
              profileObj.link = _link;
          }
          }
          return Promise.resolve(profileObj);
      });
      calls.push(call)
      if (MAX_ITERS <= 0){
          break; // Just to make things faster.
      }
      MAX_ITERS -= 1;
  
  }
 
  profiles = profiles.concat(await Promise.all(calls));
  
  return profiles;
}

В основном теле функции мы используем jQuery для очистки соответствующей информации о профиле.

Селектор $(“main h1”, profile) будет использоваться для захвата имени пользователя, а $(“main div.text-body-medium”, profile) будет использоваться для захвата заголовков пользователя.

Обратите внимание, что очистить профиль по ссылке не так просто, поскольку linkedIn генерирует динамический контент,

Нам нужно вызвать chrome.runtime.sendMessage для отправки команды, которая будет получена нашим глобальным скриптом background.js (имеющим наибольший доступ к API), и он откроет новую вкладку для загрузки всего динамического содержимого профиля. .

Фоновый скрипт для оркестрации профилей

Как мы уже выяснили, LinkedIn генерирует динамический контент «на лету», что означает, что когда страница загружается — не вся информация доступна.

Поэтому мы откроем профиль в новой вкладке, вытащим содержимое и закроем вкладку, отправив обратно HTML-содержимое вызывающей стороне.

Делается это в глобальном скрипте background.js:

chrome.runtime.onMessage.addListener(function (message, sender, sendResponse) {
  console.log(`Message from ${sender}: ${JSON.stringify(message)}`)
  if (message?.link) {
      chrome.tabs
          .create({ url: message.link, active: false }) // Create a inactive TAB
          .then(tab => {
              // Inject a script to pull in all DOM
              let tabID = tab.id
              chrome.scripting.executeScript({
                  func: getProfile,
                  target : {
                  abId: tabID
                  },
                  injectImmediately: false
              })
              .then(injectionResults => {
                  // Send the DOM back to the calling content.js
                  let profile = injectionResults[0]?.result;
                  return sendResponse({ profile: profile});
              })
              .then(() => chrome.tabs.remove(tabID)) // Remove the tab
              .catch(error => console.error(error.message))
          })
  } 
return true;
});

chrome.runtime.onMessage.addListener зарегистрирует функцию, которая будет выбирать сообщение, отправленное из нашего content.js.

Он откроет вкладку и внедрит приведенный ниже скрипт с помощью chrome.scripting.executeScript:

async function getProfile() {
  // Open a profile, and wait for all content to be loaded.
  const el = document.querySelector(".pvs-list");
  if (el) {
      console.log("Has activity!")
  }
  else {
      // Delay the tab close.
      await new Promise(r => setTimeout(r, 400));
  }
return document.body.innerHTML;
}

Обратите внимание на время ожидания, в этом расширении их будет много.

Тайм-аут ждет, пока страница загрузится с нужными нам данными, поскольку этот динамический контент, в частности, не имеет функции обратного вызова, которая может сигнализировать нам, что весь контент был загружен.

Как только весь HTML будет доступен, мы получим DOM и закроем вкладку.

Важное замечание о тайм-аутах и ​​парсинге: это хорошо работает с LinkedIn, потому что, если он обнаружит неправомерную автоматизацию, он забанит нас. Если вы хотите автоматизировать, подайте заявку на создание аккаунта разработчика LinkedIn.

Имея в руках HTML-код профиля, мы вернем его в скрипт content.js из предыдущего раздела.

Что за персонаж

У нас есть данные профилей, сохраненные в структуру, теперь давайте свяжем их с персонажем Улицы Сезам, используя функцию ниже:

/**
 * Augment the linkedin experience by adding info or visual cues.
 * All will happen async as they  call our classification server.
 * @param {*} profiles 
 */
async augmentLinkedInExperience(profiles) {
    async function _augmentText(element,profile,data){
      if ($(element).data( "scanned" )){
        return;
      }
let search = `^${profile.user}$`;
      let re = new RegExp(search, "g");
      $(element).data( "scanned", true );
      let text = $(element).text().trim();
      if (text.match(re)){
        if (data['proba'] && data['proba'] >= 0){
          $(element).text(`${text} [${data['proba']}% as ${data['label']}]`);
        }
      }
    }
    async function _augmentImage(element,profile,data){
      // TODO: Inefficient, will load all images for each profile.
      // Use tokens to discern if this image is related to the profile.
      let name = $(element).attr('alt')
      if (!name){
        return;
      }
      name = name.trim().toLocaleLowerCase();
      if (!name.includes("photo") && !name.includes("profile") && !name.includes(profile.user.toLocaleLowerCase())){
        return;
      }
      const tokens = profile.user.toLocaleLowerCase().split(" ");
      for (const tok of tokens){
        if (name.includes(tok)){
          const imgUrl = await chrome.runtime.getURL(`assets/${data['label']}.png`)
          $(element).attr('href', imgUrl);
          $(element).attr('src', imgUrl);
          break;
        }
      }
    }
    let promises = [];
    profiles.forEach(function( profile) {
      if (!profile)
        return
      const data = {
        'descriptions': (profile.posts?.join(' ') ?? ' ') + profile.titles,
      };
      promises.push(
        fetch('http://127.0.0.1:800/profile', {
          method: "POST", 
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify(data), 
        })
        .then(response=>response.json())
        .then((data) => {
          $(`div.visually-hidden`).each(async (index, element) => {
            try{
              const wrapper = $(element).parent().parent().parent(); 
              if (!wrapper){
                return;
              }
              wrapper.empty();
              const url = await chrome.runtime.getURL(`assets/${data['label']}.png`)
              const img = $(`<img src="${url}" alt="${profile.user}'s photo" id="ember23" class="EntityPhoto-circle-3 evi-image ember-view" href="${url}">`);
              wrapper.append(img);
            }
            catch (e){
              console.error(e);
            }
          })
          $(`img`).each(async (index, element) => {
            _augmentImage(element,profile,data)
          })
          $(`h1.text-heading-xlarge:contains("${profile.user}")`).each((index, element) => {
            _augmentText(element,profile,data);
          })
          $(`div:contains("${profile.user}")`).each((index, element) => {
            _augmentText(element,profile,data);
          })
          $(`span:contains("${profile.user}")`).each((index, element) => {
            _augmentText(element,profile,data);
          })
          $(`a:contains("${profile.user}")`).each((index, element) => {
            _augmentText(element,profile,data);
          })            
          
        }).catch ((error) => {
          console.log('Error: ', error);
        })
      );
  }); 
}

Мы вызываем наш сервер flask, обращаясь к обученной модели, с помощью fetch(‘http://127.0.0.1:800/profile’).

После классификации профиля мы собираем элементы, которые ссылаются на пользователей, используя селектор $('h1.text-heading-xlarge:contains("${profile.user}")'') или $('div:contains("${profile.user}")') и измените их содержимое, чтобы оно отражало нужный нам символ.

Обучение нашей модели — урок «Улицы Сезам»

Давайте загрузим все размеченные данные из anonLinkedInProfiles.csv и очистим их:

DATA = "./data/anonLinkedInProfiles.csv"
data = pd.concat([chunk for chunk in tqdm(pd.read_csv(DATA, chunksize=1000), desc=f'Loadin {DATA}')])
print(f'Shape: {data.shape}, does it have NAs:\n{data.isna().any()}')

data = data.dropna()
data = data.drop(data[(data['descriptions'] == '') | (data['titles'] == '')].index)
print(f'Post fill NAs:\n{data.isna().any()}')
data['class'] = data['class'].apply(lambda x: x.lower())
# For this exercise, keep it small.
data = data.sample(800)
data = data.reset_index() # Reset index, since we will do operations on it!
print(f'Resampled Shape: {data.shape}')
data.head()

Затем мы создаем токенизатор, который удаляет стоп-слова, пробелы, символы и сводит слова к их леммам:

import string
import pandas as pd
import nltk
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.base import TransformerMixin
from nltk.corpus import stopwords
from sklearn.base import TransformerMixin
from nltk.tokenize import sent_tokenize
from nltk.tokenize import ToktokTokenizer
from nltk.stem import WordNetLemmatizer
from nltk.corpus import wordnet 
from nltk import pos_tag

nltk.download('all')
NGRAMS = (2,2) # BGrams only
STOP_WORDS = stopwords.words('english')
SYMBOLS = " ".join(string.punctuation).split(" ") + ["-", "...", """, """, "|", "#"]
COMMON_WORDS = [] # to be populated later in our analysis
toktok = ToktokTokenizer()
wnl = WordNetLemmatizer()
def _get_wordnet_pos(word):
  tag = pos_tag([word])[0][1][0].upper()
  tag_dict = {"J": wordnet.ADJ,
              "N": wordnet.NOUN,
              "V": wordnet.VERB,
              "R": wordnet.ADV}
  return tag_dict.get(tag, wordnet.NOUN)
# Creating our tokenizer function. Can also use a TFIDF
def custom_tokenizer(sentence):
  # Let's use some speed here.
  tokens = [toktok.tokenize(sent) for sent in sent_tokenize(sentence)]
  tokens = [wnl.lemmatize(word, _get_wordnet_pos(word)) for word in tokens[0]]
  tokens = [word.lower().strip() for word in tokens]
  tokens = [tok for tok in tokens if (tok not in STOP_WORDS and tok not in SYMBOLS and tok not in COMMON_WORDS)]
  return tokens
class predictors(TransformerMixin):
  def transform(self, X, **transform_params):
      return [clean_text(text) for text in X]
  def fit(self, X, y=None, **fit_params):
      return self
  def get_params(self, deep=True):
      return {}

def clean_text(text):
  if (type(text) == str):
      text = text.strip().replace("\n", " ").replace("\r", " ")
      text = text.lower()
  else:
      text = "NA"
  return text
bow_vector = CountVectorizer(
  tokenizer=custom_tokenizer, ngram_range=NGRAMS)

Мы делаем стандартное разделение обучающих и тестовых данных:

from sklearn.model_selection import train_test_split
from sklearn import preprocessing

le = preprocessing.LabelEncoder()
# Combine features for NLP.
X = data['titles'].astype(str) +  ' ' + data['descriptions'].astype(str)
ylabels = le.fit_transform(data['class'])
train_ratio = 0.75
validation_ratio = 0.15
test_ratio = 0.10
# train is now 75% of the entire data set
X_train, X_test, y_train, y_test = train_test_split(X, ylabels, test_size=1 - train_ratio)
X_val, X_test, y_val, y_test = train_test_split(X_test, y_test, test_size=test_ratio/(test_ratio + validation_ratio))

И мы должны быть готовы идти.

Но прежде давайте проведем некоторый анализ, чтобы понять, какие описания будет вводить наша модель, являясь ngrams, которые составляют наши выбранные профили:

import seaborn as sns
from sklearn.feature_selection import chi2

def get_top_n_dependant_ngrams(corpus, corpus_labels, ngram=1, n=3):
  # use a private vectorizer.
  _vect = CountVectorizer(tokenizer=custom_tokenizer,
                          ngram_range=(ngram, ngram))
  vect = _vect.fit(tqdm(corpus, "fn:fit"))
  bow_vect = vect.transform(tqdm(corpus, "fn:transform"))
  features = bow_vect.toarray()
  labels = np.unique(corpus_labels)
  ngrams_dict = {}
  for label in tqdm(labels, "fn:labels"):
      corpus_label_filtered = corpus_labels == label
      features_chi2 = chi2(features, corpus_label_filtered)
      feature_names = np.array(_vect.get_feature_names_out())
      feature_rev_indices = np.argsort(features_chi2[0])[::-1]
      feature_rev_indices = feature_rev_indices[:n]
      ngrams = [(feature_names[idx], features_chi2[0][idx]) for idx in feature_rev_indices]
      ngrams_dict[label] = ngrams
  # while we are at it, let's return top N counts also
  sum_words = bow_vect.sum(axis=0)
  bottom_words_counts = [(word, sum_words[0, idx])
                for word, idx in tqdm(_vect.vocabulary_.items())]
  top_words_counts = sorted(
      bottom_words_counts, key=lambda x: x[1], reverse=True)
  top_words_counts = top_words_counts[:n]
  bottom_words_counts= bottom_words_counts[:n]
      
  return {'labels_freq': ngrams_dict,
          'top_corpus_freq': top_words_counts,
          'bottom_corpus_freq': bottom_words_counts}

TOP_N_WORDS = 10
common_bigrams_label_dict = get_top_n_dependant_ngrams(X, ylabels, ngram=1, n=TOP_N_WORDS)
fig, axes = plt.subplots(2, 3, figsize=(26, 12), sharey=False)
fig.suptitle('NGrams per Class')
fig.subplots_adjust(hspace=0.25, wspace=0.50)
x_plot = 0
y_plot = 0
labels = np.sort(np.unique(ylabels), axis=None)
for idx, label in tqdm(enumerate(labels), "Plot labels"):
  common_ngrams_df = pd.DataFrame(
      common_bigrams_label_dict['labels_freq'][label], columns=['ngram', 'chi2'])
  x1, y1 = common_ngrams_df['chi2'], common_ngrams_df['ngram']
  # Reverse it from the ordinal label we transformed it.
  axes[y_plot][x_plot].set_title(
      f'{le.inverse_transform([label])} ngram dependence', fontsize=6)
  axes[y_plot][x_plot].set_yticklabels(y1, rotation=0)
  sns.barplot(ax=axes[y_plot][x_plot], x=x1, y=y1)
  # Go to next plot.
  if idx > 0 and idx % 2 == 0:
      x_plot = 0
      y_plot += 1
  else:
      x_plot += 1
plt.show()

На приведенной выше диаграмме мы можем увидеть некоторые общие ngrams для каждой метки. Мы должны отфильтровать самые часто используемые и самые редкие:

print(common_bigrams_label_dict['top_corpus_freq'])
print(common_bigrams_label_dict['bottom_corpus_freq'])

common_label_freq = [word for label in labels for word, count in common_bigrams_label_dict['labels_freq'][label]]
print(f'Highest frequency of ngrames in labels: {common_label_freq}')
COMMON_WORDS = np.append([word for word,count in common_bigrams_label_dict['top_corpus_freq'] if word not in common_label_freq], 
                         [word for word,count in common_bigrams_label_dict['bottom_corpus_freq'] if word not in common_label_freq ])
COMMON_WORDS
plt.figure(figsize=(4,2))
sns.countplot(x=y_train)
plt.show

На нашей диаграмме подсчета мы замечаем, что наши обучающие данные несбалансированы, поэтому давайте понизим дискретизацию до наименьшей частоты:

# Either use class weights
from sklearn.utils.class_weight import compute_class_weight

keys = np.unique(y_train)
values = compute_class_weight(class_weight='balanced', classes=keys, y=y_train)
class_weights = dict(zip(keys, values))
print(f'Use these wieghts: {class_weights}')
# Or undersmaple.
min_size = np.array([len(data[data['class'] == 's']), len(data[data['class'] == 'o']), len(data[data['class'] == 'c']), len(data[data['class'] == 'f']), len(data[data['class'] == 'w'])]).min()
print(f'Least sampled class of size {min_size}')
data4 = data[data['class'] == 's'].sample(n=min_size, random_state=101)
data3 = data[data['class'] == 'o'].sample(n=min_size, random_state=101)
data2 = data[data['class']=='c'].sample(n=min_size, random_state=101)
data1 = data[data['class']=='f'].sample(n=min_size, random_state=101)
data0 = data[data['class']=='w'].sample(n=min_size, random_state=101)
data_under = pd.concat([data0,data1,data2,data3,data4],axis=0)
print(f'Undersampled shapes: {data0.shape}, {data1.shape}, {data2.shape}, {data3.shape}, {data4.shape}')

Наконец, мы обучаем модель:

from sklearn import metrics
from sklearn.svm import LinearSVC
from sklearn.pipeline import Pipeline
from sklearn.feature_extraction.text import TfidfTransformer
from sklearn.model_selection import GridSearchCV
from sklearn.calibration import CalibratedClassifierCV

text_clf = Pipeline([
      ("cleaner", predictors()),
      ('vect', bow_vector),
      ('tfidf', TfidfTransformer()),
      ('clf', LinearSVC()),
  ],
  verbose=False) # Add verbose to see progress, note that we run x2 for each param combination.
parameters = {
  'vect__ngram_range': [(1, 2)],
  'tfidf__use_idf': [True],
  'tfidf__sublinear_tf': [True],
  'clf__penalty': ['l2'],
  'clf__loss':  ['squared_hinge'],
  'clf__C': [1],
  'clf__class_weight': ['balanced']
}
model_clf = GridSearchCV(text_clf,
                      param_grid=parameters,
                      refit=True,
                      cv=2,
                      error_score='raise')
model = model_clf.fit(X_train, y_train)
# see: model.cv_results_ for more reuslts
print(f'The best estimator: {model.best_estimator_}\n')
print(f'The best score: {model.best_score_}\n')
print(f'The best parameters: {model.best_params_}\n')
model = model.best_estimator_
model = CalibratedClassifierCV(model).fit(X_val, y_val)
predicted = model.predict(X_test)

Ранее мы использовали перекрестные проверки и ансамбль, чтобы найти лучшие классификаторы и гиперпараметры; приведенный выше код является результатом этого.

Давайте проверим точность нашей модели с помощью следующего кода:

# Model Accuracy
print("F1:", metrics.f1_score(y_test, predicted, average='weighted'))
print("Accuracy:", metrics.accuracy_score(y_test, predicted))
print("Precision:", metrics.precision_score(
  y_test, predicted, average='weighted'))
print("Recall:", metrics.recall_score(y_test, predicted, average='weighted'))

И постройте матрицу путаницы, чтобы увидеть точность нашей модели:

plt.figure(figsize=(2, 2))
cm = metrics.confusion_matrix(y_test, predicted)
disp = metrics.ConfusionMatrixDisplay(confusion_matrix=cm,
                                      display_labels=model.classes_)
disp.plot()

Более 90% с небольшой потерей точности, это более чем идеально для нас, Граф будет гордиться!

Сохраните нашу модель для сервера Flask:

from joblib import dump, load
import sys
print(sys.executable)
print(sys.version)
print(sys.version_info)

pickled_le = dump(le, './models/labelencoder.joblib')
validate_pickled_le = load('./models/labelencoder.joblib')
pickled_model = dump(model, './models/model.joblib')
validate_pickled_model = load('./models/model.joblib')
xx_test = ["IT Consultant at Sesame Street, lord of Java Code, who likes to learn new stuff and tries some machine learning in my free engineering time."]
yy_result = validate_pickled_model.predict(xx_test)
yy_result_label = validate_pickled_le.inverse_transform(yy_result)
yy_result_proba = validate_pickled_model.predict_proba(xx_test)
print(f'Predicted: {yy_result_label} at confidece {yy_result_proba[0][yy_result]}\n \
  for features: {validate_pickled_le.inverse_transform(validate_pickled_model.classes_)}\n \
  and their probability: {yy_result_proba}')

Фляга забавных моделей

Мы будем использовать фляжный сервер для размещения модели и обслуживания ее через REST API.

Сначала мы импортируем или пересоздаем все соответствующие интерфейсы для замаринованной модели (обратите внимание, что это тот же код, что и в нашем блокноте):

NGRAMS = (2,2) # BGrams only
STOP_WORDS = stopwords.words('english')
SYMBOLS = " ".join(string.punctuation).split(" ") + ["-", "...", "”", "”", "|", "#"]
COMMON_WORDS = [] # to be populated later in our analysis
toktok = ToktokTokenizer()
wnl = WordNetLemmatizer()

app = Flask(__name__)
CORS(app)
# These functions will be referred to by the unpickled object.
def _get_wordnet_pos(word):
  tag = pos_tag([word])[0][1][0].upper()
  tag_dict = {"J": wordnet.ADJ,
              "N": wordnet.NOUN,
              "V": wordnet.VERB,
              "R": wordnet.ADV}
  return tag_dict.get(tag, wordnet.NOUN)
# Creating our tokenizer function. Can also use a TFIDF
def custom_tokenizer(sentence):
  # Let's use some speed here.
  tokens = [toktok.tokenize(sent) for sent in sent_tokenize(sentence)]
  tokens = [wnl.lemmatize(word, _get_wordnet_pos(word)) for word in tokens[0]]
  tokens = [word.lower().strip() for word in tokens]
  tokens = [tok for tok in tokens if (tok not in STOP_WORDS and tok not in SYMBOLS and tok not in COMMON_WORDS)]
  return tokens
def clean_text(text):
  if (type(text) == str):
      text = text.strip().replace("\n", " ").replace("\r", " ")
      text = text.lower()
  else:
      text = "NA"
  return text
class predictors(TransformerMixin):
  def transform(self, X, **transform_params):
      return [clean_text(text) for text in X]
  def fit(self, X, y=None, **fit_params):
      return self
  def get_params(self, deep=True):
      return {}
  
CORS_HEADERS = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "*",
  "Access-Control-Allow-Headers": "*",
  "Access-Control-Max-Age": "3600",
}

Затем подготовьте наш API прогнозирования:

def predict_profile(profile_dict):
  try:
      prediction = MODEL.predict([profile_dict["descriptions"]])
      label = ENCODER.inverse_transform(prediction)
      pp = MODEL.predict_proba([profile_dict["descriptions"]])
position = ''
      if label[0] == "o":
          position = "Engineering"
      elif label[0] == "c":
          position =  "CFA"
      elif label[0] == "s":
          position =  "HR"
      elif label[0] == "f":
          position =  "Product Management"
      elif label[0] == "w":
          position =  "Managing Director"
      else:
          position = "Analyst"
      proba = round(pp[0][prediction][0]*100, 2)
      return {
          "label": position,
          "proba": proba
      }
  except Exception as e:
     app.logger.error(f'We got this error: {e}')
     return None

и обслуживать его на конечной точке REST:

@app.route("/profile", methods=["POST"])
def profile():
  prop = request.get_json()
if MODEL is None:
      raise RuntimeError("RE MODEL cannot be None!")
  if (
      hasattr(request, "headers")
      and "content-type" in request.headers
      and request.headers["content-type"] != "application/json"
  ):
      ct = request.headers["content-type"]
      return (
          json.dumps({"error": f"Unknown content type: {ct}!"}),
          400,
          CORS_HEADERS,
      )
  if prop is None:
      return (json.dumps({"error": "No features passed!"}), 400, CORS_HEADERS)
  titles = {
      "descriptions": prop["descriptions"] if "descriptions" in prop else -1,
  }
  prediction = predict_profile(titles)
  return (json.dumps(prediction), 200, CORS_HEADERS) if (prediction != None) else (
      json.dumps({"error": f"Unknown error in prediction!"}),
      503,
      CORS_HEADERS,
  )

Вызовите фляжный сервер и попробуйте его с помощью завитка или почтальона:

if __name__ == "__main__":
  app.logger.info("Running from the command line")
  app.run(host="0.0.0.0", port=800)
  mock_profile()

Выглядит неплохо! Время реализовать все для расширенного опыта.

Установите и получайте удовольствие

Мы почти на месте!

Чтобы запустить расширение, откройте браузер Chrome и введите chrome://extensions. Отсюда нажмите Загрузить распакованное и выберите папку chromeExtension:

Перейдите в свой LinkedIn и обновите страницу. Если вы откроете консоль браузера, вы увидите, что наше расширение просматривает ленту и собирает всю информацию.

Он дополнит нашу ленту, заменив имена людей их помеченными персонажами «Улицы Сезам», показывая, что расширение может создать расширенный опыт:

Так весело!

Заключение

Мы узнали, как создать расширение и как использовать языковую модель, чтобы сделать наш Linkedin более интересным!

Расширение, которое мы построили, связано с ссылками, но максимум до 5 профилей. Linkedin заблокирует вашу учетную запись, если вы будете злоупотреблять сайтом, так что будьте добропорядочными, когда делаете парсинг.

Хотя это упражнение было сделано для обучения и развлечения, в использовании машинного обучения для курирования ваших сетей есть много скрытых преимуществ.

Рекомендации

Гитхаб

Статья здесь также доступна на Github

Блокнот Kaggle доступен здесь

СМИ

Все используемые медиафайлы (в виде кода или изображений) либо принадлежат мне единолично, либо приобретены по лицензии, либо являются частью общественного достояния и предоставлены для использования по лицензии Creative Commons.

Улица Сезам® и связанные с ней персонажи, товарные знаки и элементы дизайна принадлежат и лицензируются Sesame Workshop. © 2020 Мастерская кунжута. Все права защищены.

Лицензирование и использование CC

Эта работа находится под лицензией Creative Commons Attribution-NonCommercial 4.0 International License.

Сделано с 💗 Адамом