FastText: وصفة التعليمات البرمجية

يوم جيد يا اصدقاء! أقدم لكم ترجمة الهواة للمقال الأصلي: FastText: التنقل عبر التعليمات البرمجية بواسطة Maria Mestre.

تحذير صغير: قد لا تكون بعض المعلومات المقدمة صحيحة تمامًا بسبب مرور الوقت والأخطاء العشوائية للمؤلف. على أي حال ، سيكون من المرغوب فيه أي تعليق!

ربما واجهت أداة مثل FastText لتوجيه نصوصك ، ولكن هل تعلم أن FastText يمكنه أيضًا التعامل مع تصنيفها؟ أو ربما كانوا يعرفون ، ولكن هل عرف كيف يفعل ذلك؟ دعونا نلقي نظرة عليه من الداخل ... من خلال الشاشة.

تم تطوير مكتبة FastText بشكل أساسي بواسطة فريق Facebook لتصنيف النصوص ، ولكن يمكن أيضًا استخدامها لتدريب تضمين الكلمات. منذ اللحظة التي أصبح فيها FastText منتجًا في متناول الجميع (2016) ، تم استخدامه على نطاق واسع بسبب سرعة التدريب الجيدة والأداء الممتاز.

عند قراءة الوثائق الرسمية (نادرة جدًا للتفسير) ، أدركت أنها تحتوي على جزء كبير إلى حد ما من الخوارزميات التي قد لا تكون شفافة تمامًا ، لذلك تقرر معرفة كيفية عمل كل شيء وما تأكل به بشكل مستقل. لقد بدأت بقراءة المقال الرئيسي من المبدعين وإلقاء نظرة سريعة على دورة التعلم العميق ستانفورد في البرمجة اللغوية العصبية. في سياق كل هذه المتعة ، قمت بزيادة الأسئلة فقط. على سبيل المثال: في سياق محاضرات ستانفورد وفي جزء واحد من المقالة يتحدثون عن استخدام N-grams ، لكنهم لا يقولون أي ميزات في تطبيقهم. أيضًا ، تتضمن المعلمات المحددة في سطر الأوامر دلوًا معينًا هو التعليقات الوحيدة التي يبدو عليها "عدد الحاويات (الدلاء)". كيف يتم حساب N-grams؟ دلو؟ ليس من الواضح ... يبقى خيار واحد ، شاهد الرمز علىجيثب .

بعد مشاهدة كل خير ، تم العثور على ما يلي


  • يستخدم FastText بثقة N-grams من الأحرف بنفس نجاح N-grams من الكلمات.
  • يدعم FastText التصنيف متعدد الفئات ، والذي قد لا يكون واضحًا في المرة الأولى.
  • عدد معلمات النموذج

مقدمة النموذج


صورة

وفقًا لمقال كتبه المطورون ، فإن النموذج عبارة عن شبكة عصبية بسيطة ذات طبقة مخفية واحدة. يتم تمرير النص الذي يمثله كيس الكلمة (BOW) من خلال الطبقة الأولى التي يتم تحويلها إلى تضمين الكلمات. بعد ذلك ، يتم حساب عمليات التضمين الناتجة على النص بأكمله ويتم اختزالها إلى النص الوحيد الذي ينطبق على النص بأكمله. في الطبقة المخفية ، نعمل مع n_words * خافتًا حسب عدد المعلمات ، حيث يكون dim هو حجم التضمين ، و n_words هو حجم القاموس المستخدم للنص. بعد حساب المتوسط ​​، نحصل على متجه واحد يمر عبر المصنف الشائع إلى حد ما: يتم استخدام وظيفة softmaxللتخطيط الخطي (التحويل) لبيانات الإدخال للطبقة الأولى إلى الأخيرة. تعمل مصفوفة الأبعاد الخافتة * n_output كتحول خطي ، حيث n_output هو عدد الفئات الحقيقية لدينا. في المقال الأصلي ، يعتبر الاحتمال النهائي احتمالًا لوغاريتميًا :

صورة

حيث:

  • x_n هو تمثيل الكلمة في n-gram.
  • وهذه مصفوفة look_up تستخرج تضمين كلمة.
  • هذا تحويل خطي للناتج.
  • f مباشرة وظيفة softmax نفسها.

كل شيء بشكل عام ليس سيئًا للغاية ، ولكن دعنا نلقي نظرة على الرمز:

كود ترجمة ترجمة


يجب أن يتبع الملف الذي يحتوي على بيانات المصدر التي يمكننا تطبيقها على المصنف نموذجًا محددًا: __تسمية _0 (نص).

كأمثلة:
__label__cat هذا النص عن القطط.
__label__dog هذا النص عن الكلاب.

يتم استخدام الملف المصدر كوسيطة لوظيفة القطار . تبدأ وظيفة القطار بتهيئة المتغير dict_ وملئه لاحقًا .

void FastText::train(const Args args) {
  args_ = std::make_shared<Args>(args);
  dict_ = std::make_shared<Dictionary>(args_);
  if (args_->input == "-") {
    // manage expectations
    throw std::invalid_argument("Cannot use stdin for training!");
  }
  std::ifstream ifs(args_->input);
  if (!ifs.is_open()) {
    throw std::invalid_argument(
        args_->input + " cannot be opened for training!");
  }
  dict_->readFromFile(ifs);

dict_ هو مثيل لفئة القاموس.


Dictionary::Dictionary(std::shared_ptr<Args> args) : args_(args),
  word2int_(MAX_VOCAB_SIZE, -1), size_(0), nwords_(0), nlabels_(0),
  ntokens_(0), pruneidx_size_(-1) {}

بعد قراءة جمل الملف المصدر وتحليلها ، نملأ متجهين:

  • words_ تحتوي على كلمات فريدة مستخرجة من النص
  • يحتوي word2int_ على تجزئات لكل كلمة حسب موقعها في المتجه words . هذا في الواقع مهم للغاية ، لأنه يحدد ما سيتم استخدامه للبحث عن تضمين المصفوفة A

المتجه Words_ يحتوي على حالات دخول. كل منها يمكن أن يكون نوع كلمة للتسمية ولها عداد المكالمات إليها. هناك أيضًا حقل كلمات فرعية ، لكننا سننظر فيه أكثر قليلاً.

struct entry {
  std::string word;
  int64_t count;
  entry_type type;
  std::vector<int32_t> subwords;
};

بإضافة كلمات أو علامات إلى متغير word2int_ ، يتم حل التصادمات بالضرورة بطريقة لا نشير فيها أبدًا إلى كلمتين مختلفتين لهما نفس الفهرس. ببساطة لن يكون هناك أي.

int32_t Dictionary::find(const std::string& w, uint32_t h) const {
  int32_t word2intsize = word2int_.size();
  int32_t id = h % word2intsize;
  while (word2int_[id] != -1 && words_[word2int_[id]].word != w) {
    id = (id + 1) % word2intsize;
  }
  return id;
}

int32_t Dictionary::find(const std::string& w) const {
  return find(w, hash(w));
}

void Dictionary::add(const std::string& w) {
  int32_t h = find(w);
  ntokens_++;
  if (word2int_[h] == -1) {
    entry e;
    e.word = w;
    e.count = 1;
    e.type = getType(w);
    words_.push_back(e);
    word2int_[h] = size_++;
  } else {
    words_[word2int_[h]].count++;
  }
}

يتم تصفية كلا المتجهين لضمان تضمين الكلمات والعلامات التي يتم ذكرها مرة واحدة على الأقل. بعد أن ننتقل إلى الجزء حيث نستخدم readFromFile وظيفة حيث initNgrams و دعا . وصلنا تقريبًا إلى التصوف باستخدام N-grams.


void Dictionary::readFromFile(std::istream& in) {
  std::string word;
  int64_t minThreshold = 1;
  while (readWord(in, word)) {
    add(word);
    if (ntokens_ % 1000000 == 0 && args_->verbose > 1) {
      std::cerr << "\rRead " << ntokens_  / 1000000 << "M words" << std::flush;
    }
    if (size_ > 0.75 * MAX_VOCAB_SIZE) {
      minThreshold++;
      threshold(minThreshold, minThreshold);
    }
  }
  threshold(args_->minCount, args_->minCountLabel);
  initTableDiscard();
  initNgrams();

تحدد دالة initNgrams جميع مجموعات أحرف N-gram وتضيفها إلى ناقل ngram . في النهاية ، يتم حساب جميع تجزئات N-grams وإضافتها في ناقل ngram ، لتشكيل حجم الدلو . بمعنى آخر ، تتم إضافة تجزئات أحرف N-gram بعد إضافة تجزئات كلمات N-gram. نتيجة لذلك ، لا تتداخل مؤشراتهم مع مؤشرات الكلمات ، ولكن يمكن أن تتداخل مع بعضها البعض.

بشكل عام ، لكل كلمة في النص ، يمكنك توفير كلمات فرعية ... N-grams من الأحرف.
تعرض مصفوفة التضمين A ما يلي:

  • أسطر nwords_ الأولية التي تحتوي على التضمينات لكل كلمة من القاموس المتاح للنص.
  • اتبع مجموعة الخطوط التي تحتوي على التضمينات لكل N-gram من الأحرف


void Dictionary::initNgrams() {
  for (size_t i = 0; i < size_; i++) {
    std::string word = BOW + words_[i].word + EOW;
    words_[i].subwords.clear();
    words_[i].subwords.push_back(i);
    if (words_[i].word != EOS) {
      computeSubwords(word, words_[i].subwords);
    }
  }
}

void Dictionary::computeSubwords(const std::string& word,
                               std::vector<int32_t>& ngrams) const {
  for (size_t i = 0; i < word.size(); i++) {
    std::string ngram;
    if ((word[i] & 0xC0) == 0x80) continue;
    for (size_t j = i, n = 1; j < word.size() && n <= args_->maxn; n++) {
      ngram.push_back(word[j++]);
      while (j < word.size() && (word[j] & 0xC0) == 0x80) {
        ngram.push_back(word[j++]);
      }
      if (n >= args_->minn && !(n == 1 && (i == 0 || j == word.size()))) {
        int32_t h = hash(ngram) % args_->bucket;
        pushHash(ngrams, h);
      }
    }
  }
}

void Dictionary::pushHash(std::vector<int32_t>& hashes, int32_t id) const {
  if (pruneidx_size_ == 0 || id < 0) return;
  if (pruneidx_size_ > 0) {
    if (pruneidx_.count(id)) {
      id = pruneidx_.at(id);
    } else {
      return;
    }
  }
  hashes.push_back(nwords_ + id);
}

لذا خمنا شخصيات N-gram الغامضة. الآن دعونا نتعامل مع N-grams من الكلمات.

بالعودة إلى وظيفة القطار ، يتم تنفيذ التعليمات التالية:

  if (args_->pretrainedVectors.size() != 0) {
    loadVectors(args_->pretrainedVectors);
  } else {
    input_ = std::make_shared<Matrix>(dict_->nwords()+args_->bucket, args_->dim);
    input_->uniform(1.0 / args_->dim);
  }

  if (args_->model == model_name::sup) {
    output_ = std::make_shared<Matrix>(dict_->nlabels(), args_->dim);
  } else {
    output_ = std::make_shared<Matrix>(dict_->nwords(), args_->dim);
  }
  output_->zero();
  startThreads();

هذا هو المكان الذي تتم فيه تهيئة مصفوفة التضمين أ. ويجب الإشارة إلى أنه إذا عملت ناقلات مسبقة التدريب مع هذه الوظيفة ، فإن هذا المتغير يعتبر ممتلئًا. إذا لم يحدث ذلك ، فسيتم تهيئة المصفوفة بأرقام عشوائية -1 / خافت و 1 / خافت ، حيث يكون الخافت هو حجم التضمين الخاص بنا. أبعاد المصفوفة A ( n_words_ + bucket ؛ dim ) ، أي سنقوم بإعداد كل هذه التضمينات لكل كلمة. تتم تهيئة الإخراج أيضًا في هذه الخطوة.

ونتيجة لذلك ، نحصل على الجزء الذي نبدأ فيه بتدريب نموذجنا.


void FastText::trainThread(int32_t threadId) {
  std::ifstream ifs(args_->input);
  utils::seek(ifs, threadId * utils::size(ifs) / args_->thread);

  Model model(input_, output_, args_, threadId);
  if (args_->model == model_name::sup) {
    model.setTargetCounts(dict_->getCounts(entry_type::label));
  } else {
    model.setTargetCounts(dict_->getCounts(entry_type::word));
  }

  const int64_t ntokens = dict_->ntokens();
  int64_t localTokenCount = 0;
  std::vector<int32_t> line, labels;
  while (tokenCount_ < args_->epoch * ntokens) {
    real progress = real(tokenCount_) / (args_->epoch * ntokens);
    real lr = args_->lr * (1.0 - progress);
    if (args_->model == model_name::sup) {
      localTokenCount += dict_->getLine(ifs, line, labels);
      supervised(model, lr, line, labels);
    } else if (args_->model == model_name::cbow) {
      localTokenCount += dict_->getLine(ifs, line, model.rng);
      cbow(model, lr, line);
    } else if (args_->model == model_name::sg) {
      localTokenCount += dict_->getLine(ifs, line, model.rng);
      skipgram(model, lr, line);
    }
    if (localTokenCount > args_->lrUpdateRate) {
      tokenCount_ += localTokenCount;
      localTokenCount = 0;
      if (threadId == 0 && args_->verbose > 1)
        loss_ = model.getLoss();
    }
  }
  if (threadId == 0)
    loss_ = model.getLoss();
  ifs.close();
}

هناك نقطتان رئيسيتان في هذا الجزء من التعليمات البرمجية. أولاً ، تستدعي الدالة getLine المتغير dict_ . ثانيًا ، يتم استدعاء الوظيفة الخاضعة للإشراف .

int32_t Dictionary::getLine(std::istream& in,
                            std::vector<int32_t>& words,
                            std::vector<int32_t>& labels) const {
  std::vector<int32_t> word_hashes;
  std::string token;
  int32_t ntokens = 0;

  reset(in);
  words.clear();
  labels.clear();
  while (readWord(in, token)) {
    uint32_t h = hash(token);
    int32_t wid = getId(token, h);
    entry_type type = wid < 0 ? getType(token) : getType(wid);

    ntokens++;
    if (type == entry_type::word) {
      addSubwords(words, token, wid);
      word_hashes.push_back(h);
    } else if (type == entry_type::label && wid >= 0) {
      labels.push_back(wid - nwords_);
    }<source lang="cpp">
    if (token == EOS) break;
  }
  addWordNgrams(words, word_hashes, args_->wordNgrams);
  return ntokens;
}

في الوظيفة أعلاه ، نقرأ النصوص من بيانات الإدخال ، ونحدد مؤشرات كل كلمة واحدة تلو الأخرى باستخدام word2int_ . نضيف N-grams التي تشكل هذه الكلمات إلى الكلمات المتغيرة ، كما هو موضح في التعليمات البرمجية. وفي النهاية ، نضيف تسميات مباشرة إلى ناقل التسميات .

بعد القراءة الكاملة وإضافة نص (جُمل من المجموعة) مباشرةً إلى المتجهات المُنشأة لهم ، نحصل على جزء من الكود الذي يعالج N-grams. هذه هي وظيفة addWordNgrams .

void Dictionary::addWordNgrams(std::vector<int32_t>& line,
                               const std::vector<int32_t>& hashes,
                               int32_t n) const {
  for (int32_t i = 0; i < hashes.size(); i++) {
    uint64_t h = hashes[i];
    for (int32_t j = i + 1; j < hashes.size() && j < i + n; j++) {
      h = h * 116049371 + hashes[j];
      pushHash(line, h % args_->bucket);
    }
  }
}

نحن ننظر إلى أبعد من ذلك. متغير التجزئة هو مجموعة من التجزئة لكل كلمة في النص ، حيث يحتوي السطر على عدد الكلمات في الجملة وعدد N-grams المستخدمة. المعلمة n هي المعلمة wordNgrams وتشير إلى الحد الأقصى لطول الكلمات N-grams. كل N-gram من الكلمات يحصل على التجزئة الخاصة به محسوبة بواسطة صيغة عودية

ح=ح116049371+حأسحهس[ي]

. هذه الصيغة هي خوارزمية تجزئة FNV مطبقة على سلسلة: فهي تأخذ تجزئة كل كلمة في N-gram من الكلمات وتضيفها. وبالتالي ، يتم الحصول على مجموعة من التجزئة الفريدة. نتيجة لذلك ، يتم إرسال هذه القيمة (يمكن أن تتحول إلى كبيرة جدًا) يتم إرسالها إلى الوحدة النمطية.

وبالتالي ، يتم حساب N-grams من الكلمات تقريبًا بنفس الطريقة التي يتم بها حساب N-grams من الأحرف ، ولكن مع اختلاف طفيف - لا نقوم بتجزئة كلمة معينة. خطوة مذهلة.

بعد قراءة الجملة ، يتم استدعاء الوظيفة تحت الإشراف . إذا كان العرض يحتوي على عدة علامات ، فإننا نختار عشوائيًا واحدة منها.


void FastText::supervised(
    Model& model,
    real lr,
    const std::vector<int32_t>& line,
    const std::vector<int32_t>& labels) {
  if (labels.size() == 0 || line.size() == 0) return;
  std::uniform_int_distribution<> uniform(0, labels.size() - 1);
  int32_t i = uniform(model.rng);
  model.update(line, labels[i], lr);
}

وأخيرًا ، وظيفة تحديث النموذج.

void Model::computeHidden(const std::vector<int32_t>& input, Vector& hidden) const {
  assert(hidden.size() == hsz_);
  hidden.zero();
  for (auto it = input.cbegin(); it != input.cend(); ++it) {
    if(quant_) {
      hidden.addRow(*qwi_, *it);
    } else {
      hidden.addRow(*wi_, *it);
    }
  }
  hidden.mul(1.0 / input.size());
}

void Model::update(const std::vector<int32_t>& input, int32_t target, real lr) {
  assert(target >= 0);
  assert(target < osz_);
  if (input.size() == 0) return;
  computeHidden(input, hidden_);
  if (args_->loss == loss_name::ns) {
    loss_ += negativeSampling(target, lr);
  } else if (args_->loss == loss_name::hs) {
    loss_ += hierarchicalSoftmax(target, lr);
  } else {
    loss_ += softmax(target, lr);
  }
  nexamples_ += 1;

  if (args_->model == model_name::sup) {
    grad_.mul(1.0 / input.size());
  }
  for (auto it = input.cbegin(); it != input.cend(); ++it) {
    wi_->addRow(grad_, *it, 1.0);
  }
}

تحتوي متغيرات الإدخال التي تمر عبر الوظيفة الخاضعة للإشراف على قائمة بمؤشرات جميع مكوناتها (الكلمات ، N-grams من الكلمات والرموز) للجملة. الهدف هو تحديد فئة على الإخراج. تحدد الدالة computeHidden جميع عمليات التضمين لكل مكون من عناصر الإدخال من خلال البحث عنها في مصفوفة wi_ وفي المتوسط ​​( يتم جمع addRow وتقسيمها حسب حجمها). بعد تعديل المتجه المخفي ، أرسله للتفعيل عبر softmax وحدد تسمية.

يوضح هذا القسم من التعليمات البرمجية استخدام وظيفة التنشيط softmax . كما يتم حساب خسارة السجل .

void Model::computeOutputSoftmax(Vector& hidden, Vector& output) const {
  if (quant_ && args_->qout) {
    output.mul(*qwo_, hidden);
  } else {
    output.mul(*wo_, hidden);
  }
  real max = output[0], z = 0.0;
  for (int32_t i = 0; i < osz_; i++) {
    max = std::max(output[i], max);
  }
  for (int32_t i = 0; i < osz_; i++) {
    output[i] = exp(output[i] - max);
    z += output[i];
  }
  for (int32_t i = 0; i < osz_; i++) {
    output[i] /= z;
  }
}

void Model::computeOutputSoftmax() {
  computeOutputSoftmax(hidden_, output_);
}

real Model::softmax(int32_t target, real lr) {
  grad_.zero();
  computeOutputSoftmax();
  for (int32_t i = 0; i < osz_; i++) {
    real label = (i == target) ? 1.0 : 0.0;
    real alpha = lr * (label - output_[i]);
    grad_.addRow(*wo_, i, alpha);
    wo_->addRow(hidden_, i, alpha);
  }
  return -log(output_[target]);
}

باستخدام هذه الطريقة ، لن نحصل على زيادة في حجم N-grams من الكلمات والأحرف. النمو محدود بعدد الدلاء المتوفرة بالفعل .

افتراضيًا ، لا يستخدم FastText N-grams ، ولكننا نوصي بالخيارات التالية:

  • خالف = 2،000،000 ؛ wordNgrams> 1 أو maxn> 0
  • قاتمة = 100
  • n_output = 2
  • n_words = 500000

في المجموع ، نحصل على عدد كبير إلى حد ما من المعلمات للتدريب

(5،000،000+2،000،000)100+(2100)=250،000،000


كثيرًا ، أليس كذلك؟) كما يمكننا أن نرى حتى من خلال هذا النهج البسيط ، لدينا عدد كبير إلى حد ما من المعلمات ، وهو نموذجي جدًا لطرق التعلم العميق. يتم استخدام تقدير تقريبي للغاية ، على سبيل المثال ، عدد N-grams المأخوذة لـ 2_000_000 ، من أجل إظهار الترتيب. بشكل عام ، يمكن تقليل تعقيد النموذج عن طريق ضبط بعض المعلمات المفرطة ، مثل دلو أو عتبة أخذ العينات.

قد تكون بعض الروابط مفيدة:
research.fb.com/fasttext
arxiv.org/pdf/1607.01759.pdf و arxiv.org/pdf/1607.04606v1.pdf
www.youtube.com/watch؟v=isPiE-DBagM&index=5&list=PLU40WL8Ol94IJzQtileLTqGL_Xtile (من 47:39)

All Articles