دليلك للتعرف على الكيانات المسمّاة في اللغة العربية باستخدام النماذج مسبقة التدريب

حقق التعرف على الكيانات المسمّاة (Named Entity Recognition) تقدماً كبيراً باستخدام نماذج الTransformers، حيث برع في لغات مثل الإنجليزية. ولكن عندما يتعلق الأمر باللغة العربية، تصبح العملية أكثر تعقيداً. فغنى اللغة العربية من حيث التشكل، إلى جانب نقص مجموعات البيانات الكبيرة المرمّزة أو المسمّاة، يشكلان تحدياً كبيراً.

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

تحديات التعرف على الكيانات المسمّاة في اللغة العربية والحلول الحالية

الأساليب التقليدية مثل الأنظمة القائمة على القواعد (rule-based systems) والنماذج المخصصة (custom-trained models) غالباً ما تعجز عن استيعاب التعقيد الكامل للغة العربية. فإنّ الشكل المعقد للغة العربية (morphology)، ووجود التشكيل (diacritics)، وتحدي مواءمة الكيانات المسماة أثناء تقسيم النص إلى رموز (tokenization)، تجعل المهمة أكثر صعوبة مقارنةً بلغات مثل الإنجليزية.

تواجه هذه الأساليب التقليدية صعوبة في التعميم عبر النصوص المختلفة، وتفشل في التعامل بفعالية مع الكيانات الغامضة. والنتيجة هي أداء غير متسق، خصوصاً في التطبيقات المتخصصة مثل تحليل النصوص في مجالات الرعاية الصحية أو القانون، حيث تكون الدقة (precision) أمراً بالغ الأهمية.

في هذه المقالة، نسعى إلى معالجة هذه التحديات من خلال الاستفادة من النماذج المحولة المدربة مسبقاً (pre-trained transformer models). يتيح ضبط هذه النماذج (fine-tuning) على أنواع محددة من الكيانات حلاً أكثر دقة وقابلية للتوسع في التعرف على الكيانات المسمّاة باللغة العربية. هذا النهج لا يتصدى فقط للتحديات اللغوية بشكل مباشر، بل يُحسّن أيضاً أداء التعرف على الكيانات الأساسية مثل الأماكن، الأشخاص، والمنظمات.

مسار العمل خطوة بخطوة

سنقوم بضبط نموذج Marefa-NER، وهو نموذج عربي كبير مدرب مسبقاً، لاستهداف تسعة أنواع مختلفة من الكيانات. العملية تتضمن الخطوات التالية، وكل خطوة منها ضرورية لبناء نموذج قوي للتعرف على الكيانات المسمّاة (NER).

1. تثبيت المكتبات

لبدء العمل، يجب تثبيت جميع المكتبات اللازمة مع ضمان عدم وجود تعارضات في التبعية بينها. مكتبة fsspec لها متطلبات إصدار محددة يجب مراعاتها لتتوافق مع مكتبات أخرى مثل gcsfs و datasets.

  • fsspec: لإدارة أنظمة الملفات.
  • gcsfs: للوصول إلى التخزين السحابي من Google Cloud.
  • transformers: لتحميل النماذج المدربة مسبقاً والمحللات اللغوية (tokenizers) لاستخدامها في التعرف على الكيانات المسمّاة (NER).
  • sentencepiece: لتقسيم النص إلى رموز (tokenization)، وهي مفيدة بشكل خاص في التعامل مع النصوص العربية.
				
					!pip uninstall fsspec -y
!pip install fsspec==2023.6.0 gcsfs==2023.6.0 transformers==4.29 datasets==2.14.5 sentencepiece==0.1.99
!pip install -q seqeval==1.2.2 transformers==4.28.0 datasets==2.14.5 sentencepiece==0.1.99 accelerate==0.22.0
				
			

تعد مكتبات transformers و datasets، إلى جانب أدوات أخرى مثل sentencepiece لتجزئة النصوص (tokenization)، أساسية في التعامل مع النصوص العربية. سنقوم أيضاً باستيراد جميع المكتبات الضرورية لإعداد نموذج التعرف على الكيانات المسمّاة (NER)، بما في ذلك المحلل اللغوي (tokenizer)، والبيانات (dataset)، ومقاييس التقييم.

المكتبات الأساسية:

  • AutoTokenizer، AutoModelForTokenClassification، و AutoConfig: تتيح لنا تحميل المحلل اللغوي المدرب مسبقاً والنموذج، إلى جانب إعدادات التهيئة الخاصة بمهام التعرف على الكيانات المسمّاة.
  • Trainer و TrainingArguments: مكونات أساسية لتدريب وضبط النموذج. تقوم بتعريف حلقة التدريب وإدارة عمليات التحسين، وجدولة معدلات التعلم، وإنشاء نقاط تحقق للنموذج.
  • DataCollatorForTokenClassification: تساعد في تحضير دفعات البيانات لتصنيف الرموز (NER) أثناء التدريب، وهو أمر مهم عند التعامل مع سلاسل نصية ذات أطوال متغيرة.
  • load_dataset و load_metric: تتيح لنا تحميل مجموعات البيانات ومقاييس التقييم بسهولة من مكتبة Hugging Face الواسعة، مما يسهل عملية تدريب النموذج وتقييمه.
  • Logging: يتم تكوين نظام التسجيل لتتبع وعرض المعلومات المهمة حول تقدم النموذج، مع تقليل المخرجات غير الضرورية من مكتبة transformers عن طريق تعيين مستوى السجل إلى WARNING.
				
					from transformers import AutoTokenizer, AutoModelForTokenClassification, AutoConfig
from transformers import AutoModelForSequenceClassification, TrainingArguments, Trainer
from transformers import DataCollatorForTokenClassification
from datasets import load_dataset, load_metric, Dataset, DatasetDict

import numpy as np
import logging

logging.basicConfig(level=logging.INFO)
transformers_logger = logging.getLogger("transformers")
transformers_logger.setLevel(logging.WARNING)

				
			

2. تقسيم البيانات ومطابقة التسميات

يتم تقسيم النص المدخل ومطابقة تسميات الكيانات مع النص المجزأ (tokenized)، مما يضمن تعيين التصنيف الصحيح لكل رمز (مثل شخص، موقع، إلخ).

بالنسبة للرموز الخاصة مثل التعبئة (padding) أو الفواصل (separators)، يتم تجاهلها أثناء التدريب عن طريق تعيين التسمية -100 لها.

				
					def tokenize_and_align_labels(examples):
    tokenized_inputs = tokenizer(examples["tokens"], truncation=True, is_split_into_words=True, padding=True ,max_length=512)

    labels = [] #to store the final aligned labels
    for i, label in enumerate(examples[f"{task}_tags"]):
        #word_ids should retrieve word indices for the tokenized input so wecan map each token back to the original word
        word_ids = tokenized_inputs.word_ids(batch_index=i)

        previous_word_idx = None
        label_ids = []

        for word_idx in word_ids:
            # Special tokens have a word id that is None. We set the label to -100 so they are automatically
            # ignored in the loss function.
            if word_idx is None:
                label_ids.append(-100)
            # We set the label for the first token of each word.
            elif word_idx != previous_word_idx:
                label_ids.append(label[word_idx])
            # For the other tokens in a word, we set the label to either the current label or -100, depending on
            # the label_all_tokens flag.
            else:
                label_ids.append(label[word_idx] if label_all_tokens else -100)
            previous_word_idx = word_idx

        labels.append(label_ids)

    tokenized_inputs["labels"] = labels
    return tokenized_inputs

				
			

3. حساب مقاييس التقييم

في هذه المرحلة، سنقوم بتعريف دالتين: compute_metrics و compute_results. الدالة الأولى مخصصة لتقييم أداء النموذج أثناء عملية التدريب أو التحقق (validation)، بينما الدالة الثانية ستقيّم أداء النموذج على مجموعة البيانات بأكملها. بعد الانتهاء من عملية ضبط النموذج أو التدريب، تقوم هذه الدالة بحساب التوقعات (predictions) لمجموعة البيانات، مطابقة التسميات الحقيقية مع التسميات المتوقعة، وحساب مقاييس التقييم.

لذلك، دعونا نبدأ بالدالة الأولى

				
					def compute_metrics(p):
    global model_name, current_epoch

    predictions, labels = p
    predictions = np.argmax(predictions, axis=2)

    # Remove ignored index (special tokens)
    # custom_labels[p]: Converts the numeric prediction p into its corresponding human-readable label (e.g., "B-Person", "O").
    true_predictions = [
        [custom_labels[p] for (p, l) in zip(prediction, label) if l != -100]
        for prediction, label in zip(predictions, labels)
    ]
    true_labels = [
        [custom_labels[l] for (p, l) in zip(prediction, label) if l != -100]
        for prediction, label in zip(predictions, labels)
    ]

    results = metric.compute(predictions=true_predictions, references=true_labels)
    metric_results = {
        "precision": results["overall_precision"],
        "recall": results["overall_recall"],
        "f1": results["overall_f1"],
        "accuracy": results["overall_accuracy"],
    }

    return metric_results

				
			

بما أن دالة compute_results تقوم بتقييم أداء النموذج على مجموعة البيانات بأكملها، هل يمكنك تخمين المدخلات التي تحتاجها؟ … خذ لحظة وفكر في الأمر.

Don’t cheat!

 
  • trainer: كائن Trainer من مكتبة Hugging Face.
  • tokenized_ds: مجموعة البيانات الخاصة بالتقييم أو الاختبار، التي تم تقسيمها وجاهزة لتقييم النموذج.
  • metric: المقياس المستخدم للتقييم (مثل seqeval).
  • custom_labels: قائمة بالتسميات المخصصة للتعرف على الكيانات المسمّاة (NER)، مثل (“B-herb_name”، “O”).


إليك كيف ستبدو المدخلات في الدالة.

				
					def compute_results(trainer, tokenized_ds, metric, custom_labels):
    predictions, labels, _ = trainer.predict(tokenized_ds)
    predictions = np.argmax(predictions, axis=2) 
    # Remove ignored index (special tokens)
    true_predictions = [
        [custom_labels[p] for (p, l) in zip(prediction, label) if l != -100]
        for prediction, label in zip(predictions, labels)
    ]
    true_labels = [
        [custom_labels[l] for (p, l) in zip(prediction, label) if l != -100]
        for prediction, label in zip(predictions, labels)
    ]

    results = metric.compute(predictions=true_predictions, references=true_labels)
    return results
				
			

دالة argmax في كلتا الدالتين تُستخدم لتحويل Logits إلى توقعات، حيث إن مخرجات النموذج الأولية تكون على شكل Logits، والتي تمثل مدى ثقة النموذج في كل فئة (تسمية NER). نستخدم axis=2 لأن هذه البيانات هي Tensor ثلاثي الأبعاد، حيث يمثل العنصر الثالث تسميات NER.

والآن حان وقت البيانات!

4. تحميل البيانات ومعالجتها

يمكنك تحميل عينة من ملف ner-train.json من هنا ورفعها إلى بيئة التشغيل الخاصة بك. يحتوي هذا الملف على بيانات تدريبية مرمّزة لمهمة التعرف على الكيانات المسمّاة (NER)، والتي سيتم استخدامها لضبط النموذج المدرب مسبقاً.

				
					import json
import itertools

# Path to the uploaded file (use the full path if needed)
file_path = "/content/ner-train.json"

# Open and read the JSON file
with open(file_path) as src:
    ner_annotated_data = json.loads(src.read())
				
			

والآن، حان وقت الجد!

سيكون هذا الكود طويلاً، ولكن لا تقلق، سنقوم بشرحه بالتفصيل.

Stay Focused

 

التسميات المقابلة لـ NER على نسق BIO. الخطوات الرئيسية تشمل:

  • تقسيم النص (Tokenization): يتم تقسيم النص المدخل إلى أجزاء فردية (tokens)، وتحديد النطاقات المقابلة range (مواقع البداية والنهاية) لكل رمز.
  • تعيين تسمية NER: لكل رمز، يتم التحقق مما إذا كان يقع ضمن نطاق أي كيان مسمّى. إذا كان كذلك، يتم تعيين التسمية المناسبة (مثل “Person” أو “Location”). أما إذا لم يكن كذلك، يتم تعيين تسمية “O” (خارج الكيان).
  • تنسيق BIO: يتم تنسيق مسميات NER باستخدام نسق BIO (بداية-داخل-خارج):
    • B- (بداية): يتم تعيينها لأول جزء في الكيان.
    • I- (داخل): يتم تعيينها للأجزاء التالية لنفس الكيان.
    • O: يتم تعيينها للأجزاء التي لا تنتمي إلى أي كيان.

هذه الخطوة تضمن أن لدينا الرموز وتسمياتها بتنسيق BIO بشكل صحيح، والتي سيتم استخدامها لتدريب وتقييم نموذج NER.

				
					# create  lists to store NER tags and tokens for each sentence.
all_ner_tags, all_ner_tokens = [], []
#o_tag: This is the "Outside" tag (O), used to mark tokens that are not part of any entity.
o_tag = "O"

#Processing Each Record in the Annotated Data
for rec in ner_annotated_data:
    ner_tags, ner_tokens = [], []

    #Extract the sentence (text) from the record.
    text = rec["data"]["text"]
    #Skipping Records Without Valid Annotations:
    #This checks if there are no annotations in the current record or if the annotation result is missing or empty. If any of these conditions are true, the code skips this record and moves to the next one.
    if len(rec["annotations"]) == 0 or "result" not in rec["annotations"][0] or len(rec["annotations"][0]["result"]) == 0:
        continue

    # sort rec["annotations"][0]["result"] based on start index
    rec["annotations"][0]["result"].sort(key=lambda x: x["value"]["start"])
    # x["value"]["start"]: Refers to the starting position of the entity in the text.

    # collect all ranges with their labels
    ranges = []
    for r in rec["annotations"][0]["result"]:
        ranges.append((range(r["value"]["start"], r["value"]["end"]+1), r["value"]["labels"][0]))

    # split text into tokens
    tokens = text.split()
    token_ranges = []
    c = 0 #keeps track of the character position of each token
    for i, token in enumerate(tokens):
        token_ranges.append( (range(c, c+len(token)), token) )
        c += len(token) + 1

    # find all tokens that are in the ranges
    for token_range in token_ranges:
        #Before checking if a token matches an entity, the flag is_found is set to False. This means that, by default, we assume the token does not belong to any entity.
        is_found = False

        for sub_range in ranges:
            if all(e in sub_range[0] for e in token_range[0]):
                ner_tags.append(sub_range[1])
                ner_tokens.append(token_range[1])
                is_found = True
                break

        # If a token is not found in any entity range after the inner loop,
        #this means that the token does not correspond to any named entity.
        if not is_found:
            ner_tags.append(o_tag)
            ner_tokens.append(token_range[1])

    # format BI prefix
    for i, tag in enumerate(ner_tags):
        #processing the first token
        if i == 0 and ner_tags[i] != o_tag:
            ner_tags[i] = f"B-{ner_tags[i]}"
            continue

        # skipping tokens labeled as O
        if i == 0 or ner_tags[i] == o_tag:
            continue

        #handling sub sequent tokens
        if ner_tags[i-1].replace("B-","").replace("I-","") == ner_tags[i]:
            ner_tags[i] = f"I-{ner_tags[i]}"
        else:
            ner_tags[i] = f"B-{ner_tags[i]}"


    all_ner_tags.append(ner_tags)
    all_ner_tokens.append(ner_tokens)
				
			

ما زلنا بحاجة إلى القيام بالآتي:

  • تقسيم البيانات
  • استخراج التسميات الفريدة لـ NER
  • تحويل البيانات المعالجة مسبقاً إلى تنسيق Hugging Face Dataset
  • ضبط النموذج (Fine-tuning)
				
					# Split the data into training and development sets

train_texts = all_ner_tokens[:8]
train_tags = all_ner_tags[:8]

dev_texts = all_ner_tokens[8:]
dev_tags = all_ner_tags[8:]

# Extract unique NER tags from the dataset
set(itertools.chain.from_iterable(all_ner_tags))

# marefa-ner base checkpoint
base_checkpoint = "marefa-nlp/marefa-ner"
task = "ner"
#all tokens, including subword tokens, should be labeled
label_all_tokens = True
seed = 101

# where to save the new model and its logs
new_model_path = f"./finetuned-ner"
logs_path = f"./logs"

# seqeval metric
metric = load_metric("seqeval")

## all of the tags in your dataset
custom_labels = ["O", "B-herb_name", "I-herb_name", "B-case", "I-case", "B-side_effect", "I-side_effect"]

device = "cuda:0"

# Convert preprocessed data into Hugging Face Dataset format
datasets = DatasetDict({
    "train": Dataset.from_dict({
        "tokens": train_texts,
        "ner_tags": [ [ custom_labels.index(r) for r in rec ] for rec in train_tags ]
    }),
    "dev": Dataset.from_dict({
        "tokens": dev_texts,
        "ner_tags": [ [ custom_labels.index(r) for r in rec ] for rec in dev_tags ]
    }),
})

				
			

5. ضبط النموذج (Fine-Tuning)

بعد تجهيز البيانات وتجزئتها، ننتقل إلى ضبط نموذج Marefa-NER لمهمة التعرف على الكيانات المسمّاة (NER).

إليك كيفية إعداد تدريب النموذج:

				
					from transformers import set_seed

set_seed(seed)
# Load the tokenizer and pre-trained model for token classification

tokenizer = AutoTokenizer.from_pretrained(base_checkpoint)
model = AutoModelForTokenClassification.from_pretrained(base_checkpoint,
                                                        num_labels=len(custom_labels),
                                                        ignore_mismatched_sizes=True).to(device)

# Tokenize and align the labels for the dataset
tokenized_datasets = datasets.map(tokenize_and_align_labels, batched=True)

# configure your fine-tuning process

args = TrainingArguments(
    new_model_path,
    logging_dir=logs_path,
    evaluation_strategy = "epoch",
    logging_strategy= "epoch",
    save_strategy= "no",
    learning_rate= 1e-4,
    load_best_model_at_end= False,
    per_device_train_batch_size= 4,
    per_device_eval_batch_size= 4,
    num_train_epochs= 10,
    weight_decay= 0.01,
    push_to_hub= False,
)
# Data collator for dynamic padding during training

data_collator = DataCollatorForTokenClassification(tokenizer)

				
			

أخيراً، نحتاج إلى إعداد Hugging Face Trainer لتدريب النموذج وتقييمه، ثم البدء في تدريب النموذج.

				
					trainer = Trainer(
    model,
    args,
    train_dataset=tokenized_datasets["train"],
    eval_dataset=tokenized_datasets["dev"],
    data_collator=data_collator,
    tokenizer=tokenizer,
    compute_metrics=compute_metrics
)


train_result = trainer.train()
dev_results = compute_results(trainer, tokenized_datasets["dev"], metric, custom_labels)

				
			

يجب أن تبدو النتائج كالتالي:

يمكنك حفظ النموذج الخاص بك في مساحة التخزين المؤقتة أو Drive لتحميله مرة أخرى عند الحاجة.

				
					from google.colab import drive
drive.mount('/gdrive')

!mkdir -p /gdrive/MyDrive/finetuned-ner-model-herbs

new_model_path = "/gdrive/MyDrive/finetuned-ner-model-herbs"

trainer.save_model(f"{new_model_path}/best")
tokenizer.add_tokens(custom_labels)
tokenizer.save_pretrained(f"{new_model_path}/best")

				
			
 

اقتربنا من النهاية! حان وقت الاختبار 🥳

اختبار النموذج

لاختبار النموذج، سنقوم باتباع هذه الخطوات:

  • تعريف دالة استخراج الكيانات المسمّاة (NER Extraction).
  • تحميل النموذج الذي تم ضبطه.
  • تشغيل التنبؤات (inferences).

تعريف دالة استخراج الكيانات المسمّاة

				
					from transformers import AutoTokenizer, AutoModelForTokenClassification
import torch

import numpy as np
import nltk
nltk.download('punkt')
from nltk.tokenize import word_tokenize

# NER extraction function


def _extract_ner(text: str, model: AutoModelForTokenClassification,
                 tokenizer: AutoTokenizer, start_token: str="▁"):
    # Tokenize the input text
    tokenized_sentence = tokenizer([text], padding=True, truncation=True, return_tensors="pt")
    tokenized_sentences = tokenized_sentence['input_ids'].numpy()

    # Get model predictions
    with torch.no_grad():
        output = model(**tokenized_sentence.to("cuda:0"))

    last_hidden_states = output[0].cpu().numpy()
    label_indices = np.argmax(last_hidden_states[0], axis=1)

    # Convert token IDs to actual tokens
    tokens = tokenizer.convert_ids_to_tokens(tokenized_sentences[0])
    special_tags = set(tokenizer.special_tokens_map.values())

    grouped_tokens = []
    for token, label_idx in zip(tokens, label_indices):
        if token not in special_tags:
            if not token.startswith(start_token) and len(token.replace(start_token,"").strip()) > 0:
                grouped_tokens[-1]["token"] += token
            else:
                grouped_tokens.append({"token": token, "label": custom_labels[label_idx]})

    # Extract entities and group tokens belonging to the same entity
    ents = []
    prev_label = "O"
    for token in grouped_tokens:
        label = token["label"].replace("I-","").replace("B-","")
        if token["label"] != "O":

            if label != prev_label:
                ents.append({"token": [token["token"]], "label": label})
            else:
                ents[-1]["token"].append(token["token"])

        prev_label = label

    ## Combine multi-token entities
    ents = [{"token": "".join(rec["token"]).replace(start_token," ").strip(), "label": rec["label"]}  for rec in ents ]

    return ents

				
			

تحميل النموذج الذي تم ضبطه (Fine-tuned)

				
					# Define the path to the fine-tuned model

new_model_path = f"./finetuned-ner"
# new_model_path = "/gdrive/MyDrive/finetuned-ner-model-herbs"

device = "cuda:0"

# Custom labels used in the model
custom_labels = ["O", "B-herb_name", "I-herb_name", "B-case", "I-case", "B-side_effect", "I-side_effect"]

# Load the tokenizer and model
model_cp = f"{new_model_path}/best"

tokenizer = AutoTokenizer.from_pretrained(model_cp)
model = AutoModelForTokenClassification.from_pretrained(model_cp, num_labels=len(custom_labels)).to(device)


				
			

حان وقت التنبّؤ (inferences).

				
					sample = "تعتبر نبته المرمرية من النباتات المفيدة لتجنب آلام البطن و حصوات الكلية"

ents = _extract_ner(text=sample, model=model, tokenizer=tokenizer, start_token="▁")

print(ents)

				
			

ستكون المخرجات على الشكل التالي:

				
					[{'token': 'المرمرية', 'label': 'herb_name'}, {'token': 'آلام البطن', 'label': 'case'}, {'token': 'حصوات الكلية', 'label': 'case'}]

				
			
 

مستقبل التعرف على الكيانات المسمّاة باللغة العربية: النقاط الرئيسية والخطوات القادمة

يقدم ضبط النماذج المحولة المدربة مسبقاً للتعرف على الكيانات المسمّاة باللغة العربية قفزة نوعية مقارنة بالأساليب التقليدية، من خلال تحسين الدقة والتعامل مع تعقيدات اللغة. نهجنا أثبت نجاحه في التعرف على الكيانات عبر مجالات متعددة مثل الرعاية الصحية والتحليل القانوني.

في المستقبل، يمكن توسيع مجموعة البيانات لتشمل كيانات أكثر تنوعاً ودمج التعرف على الكيانات المسمّاة مع تقنيات أخرى في معالجة اللغة الطبيعية، مثل تحليل المشاعر، مما سيعزز قدرات النموذج بشكل أكبر. ومع تزايد حجم البيانات وتطور نماذج المحولات، سيزداد دور التعرف على الكيانات المسمّاة باللغة العربية في أتمتة تحليل النصوص عبر مختلف الصناعات.

من قال إن العربية ليست جزءاً من ثورة الذكاء الاصطناعي؟

من قال إن العربية ليست جزءاً من ثورة الذكاء الاصطناعي؟