بناء Recurrent Neural Network من الصفر بالبايثون

1٬061

مقدمة 

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

هل تستطيع الخوارزمية محاكاة ذلك ؟

أول طريقة تخطر ببالنا غالبا هي الشبكات العصبية Neural Network، ولكن للأسف لا تستطيع الشبكات العصبية التقليدية فعل هذا. لشرح هذا دعنا نستخدم مثالا : إذا أردت توقع ما هو المشهد القادم في الفيديو؟ في الحقيقة ستعاني الشبكة العصبية العادية لتوليد نتائج دقيقة بهذا الخصوص.

هنا ظهر مصطلح الشبكات العصبية المتكررة Recurrent Neural Network واختصارا RNN، المصطلح الذي أصبح شهيرا جدا في مجال التعلم العميق Deep Learning مما يجعل تعلمهم أمر حتمي بشكل ما. وبإمكانك فهم أهميتها من خلال التطبيقات عليها والتي تتمثل في :

  • تمييز الكلام  speech recognition.
  • الترجمة باستخدام الآلة machine translation.
  • تأليف الموسيقى Music composition.
  • تمييز خط اليد hand written recognition.
  • تعلم قواعد اللغة Grammar Learning.

في هذه المقالة سنقوم أولا بمعالجة المكونات الرئيسية لنموذج الRNN، ثم سنقوم بإعداد مشكلة لحلها في النهاية بكتابة كود بايثون لبناء نموذج RNN من البداية وبتوضيح التفاصيل.

في الحقيقة، بإمكانك دائما الاستفادة من مكتبات الpython المتميزة لبناء نموذج RNN، فلماذا نكتبه من البداية؟

أظن أن أفضل طريقة للتعلم هي تعلم الشيء من الألف للياء، وهذا ما سنستعرضه معا هنا.

هذا المقال يفترض معرفتك المسبقة ببعض الأساسيات عن الRNN، إذا كنت تحتاج لقليل من المراجعة بإمكانك رؤية هذين.

المحتويات

  • مراجعة : خلاصة مفاهيم الRNN.
  • تسلسل التنبؤ باستخدام RNN.
  • بناء نموذج RNN باستخدام لغة البايثون.

مراجعة : خلاصة مفاهيم الRNN

دعنا نستعرض سريعا بعض المفاهيم الرئيسية للRNN.

وسنفعل هذا باستخدام نموذج من تسلسل البيانات sequence data، مثلا أسهم شركة معينة، قد يتعلم نموذج تعلم آلة بسيط أو شبكة عصبية صناعية التنبؤ بسعر السهم بناء على عدد من المميزات Features، مثل حجم السهم، والقيمة الافتتاحية..إلخ.

بغض النظر عن ذلك، فإن سعر السهم يعتمد أيضا على كيفية أدائه في الأيام والأسابيع السابقة.

بالنسبة للتاجر فإن هذه البيانات هي عامل حاسم لبناء التوقعات.

في Conventional feed-forward neural networks تعتبر جميع الاختبارات test cases مستقلة independant، هل تستطيع رؤية لماذا لا يناسب هذا التنبؤ بأسعار الأسهم..؟ بالضبط، لن ينظر نموذج الNN للأسعار السابقة وهذه ليست فكرة جيدة!

هناك مفهوم آخر يمكننا الاعتماد عليه عند مواجهة بيانات حساسة للوقت، الRNN!

نموذج الRNN يبدو كالتالي :

قد يبدو هذا مخيفا بعض الشيء ولكن بمجرد أن نبدأ بشرح التفاصيل ستجد أنه أصبح أكثر سهولة

الآن، أصبح الأمر أسهل في تصور كيف تفكر هذه الشبكات اتجاه أسعار الأسهم، وهذا يساعدنا في التنبؤ بأسعار اليوم. هنا يعتمد كل تنبؤ ( t (h_t على جميع التنبؤات السابقة، والمعلومات المستفادة منها. واضحة بعض الشيء أليس كذلك؟

تستطيع الRNN تحقيق هدفنا من متابعة التسلسل لحد كبير، ولكن ليس كليا.

النصوص، مثال جيد آخر على تسلسل البيانات، القدرة على التنبؤ بالكلمة أو الجملة التي يجب أن تأتي بعد هذه الجملة، قد يكون شيئا مفيدا للغاية، نريد لنموذجنا أن يكتب قصائد شكسبير!

في الحقيقة، تعتبر الRNN رائعة عندما يتعلق الأمر بشيء قصير، أو صغير في طبيعته. ولكن لكي تتمكن من كتابة قصة وتذكرها، يجب على النموذج أن يكون قادرا على فهم المحتوى الذي يخدمه التسلسل، تماما كمخ الإنسان.

التنبؤ بالتسلسل باستخدام الRNN

سوف نعمل على مشكلة التنبؤ باستخدام نموذج RNN، واحدة من أسهل الطرق لتجريب هذا هو تنبؤ يدعى تنبؤ الموجة الجيبية، وقد سمي بهذا لأنه يحتوي على تسلسل واضح ومحدد الاتجاه، وسهل الحل باستخدام الاستدلال.

هنا كيف تبدو الموجة الجيبية

سنقوم أولا بإنشاء RNN من البداية لحل هذه المشكلة، على نموذجنا أن يكون قادرا على التعميم بشكل جيد، حتى نتمكن من تطبيقه على مشاكل تسلسل أخرى.

أما شكل مشكلتنا فسيكون كالتالي، سنقوم بإعطاء النموذج تسلسل من 50 رقم ينتمون للموجة الجيبية، وعليه توقع الرقم 51 في السلسلة، حان الوقت لفتح ملف جديد في Jupyter notebook الخاصة بك، أو أي IDE أخرى تفضلها.

تكويد الRNN باستخدام الpython

الخطوة (0) : تجهيز البيانات

وهي الخطوة الأولى في أي مشروع يتعلق بالdata، والخطوة التي لا مفر منها. 

ماذا يتوقع نموذجنا أن تكون البيانات؟ من المفترض أن يقبل تسلسل واحد بطول 50 كمدخل، لذلك يكون شكل بيانات الإدخال كالتالي :

(number_of_records x length_of_sequence x types_of_sequences)

 types_of_sequences قيمته ب 1، لأنه لدينا فقط نوع واحد من التسلسل، موجة الجيب.

من الناحية الأخرى، يكون للإخراج قيمة واحدة فقط لكل record ، وتكون هذه القيمة بالتأكيد هي القيمة رقم 51 في تسلسل الإدخال. لذلك يكون شكلها :

number_of_records x types_of_sequences) #where types_of_sequences is 1)

والآن، حان وقت كتابة الكود، في البداية ربما ترغب باستدعاء المكتبات اللازمة 

%pylab inline

import math

لإنشاء موجة جيبية مثل البيانات، سنستخدم دالة الsin من مكتبة الmath في البايثون.

sin_wave = np.array([math.sin(x) for x in np.arange(200)])

ولتصور الموجة الجيبية التي صنعناها للتو 

plt.plot(sin_wave[:50])

والآن سنقوم بصناعة البيانات في الكود التالي :

X = []
Y = []

seq_len = 50
num_records = len(sin_wave) - seq_len

for i in range(num_records - 50):
    X.append(sin_wave[i:i+seq_len])
    Y.append(sin_wave[i+seq_len])
    
X = np.array(X)
X = np.expand_dims(X, axis=2)

Y = np.array(Y)
Y = np.expand_dims(Y, axis=1)

طباعة شكل البيانات 

X.shape, Y.shape
((100, 50, 1), (100, 1))

لاحظ أننا قمنا بعمل loop ل (num_records – 50) ، حيث نحتاج إلى تخصيص 50 record ك بيانات التحقق الخاصة بنا validation data، وبإمكاننا إنشاء بيانات التحقق من الصحة كالآتي :

X_val = []
Y_val = []

for i in range(num_records - 50, num_records):
    X_val.append(sin_wave[i:i+seq_len])
    Y_val.append(sin_wave[i+seq_len])
    
X_val = np.array(X_val)
X_val = np.expand_dims(X_val, axis=2)

Y_val = np.array(Y_val)
Y_val = np.expand_dims(Y_val, axis=1)

الخطوة (1) : إنشاء هيكل لنموذج الRNN

مهمتنا التالية هي تحديد جميع المتغيرات والدوال functions اللازمة التي سنستخدمها في نموذج RNN، فسيقوم نموذجنا بأخذ مدخل متسلسل، ويقوم بمعالجته من خلال طبقة مخفية hidden layers من 100 وحدة، وينتج خرج من قيمة واحدة.

learning_rate = 0.0001    
nepoch = 25               
T = 50                   # length of sequence
hidden_dim = 100         
output_dim = 1

bptt_truncate = 5
min_clip_value = -10
max_clip_value = 10

ثم نقوم بتعريف الأوزان الخاصة بالشبكة :

U = np.random.uniform(0, 1, (hidden_dim, T))
W = np.random.uniform(0, 1, (hidden_dim, hidden_dim))
V = np.random.uniform(0, 1, (output_dim, hidden_dim))

حيث : 

  • U : هي مصفوفة الوزن للأوزان بين الدخل والطبقات المخفية.
  • V : هي مصفوفة الوزن للأوزان بين الطبقات المخفية وطبقة الخرج.
  • W : هي مصفوفة الوزن للأوزان المشتركة في طبقة الRNN (الطبقة المخفية).

وفي النهاية، سنقوم بتعريف دالة التفعيل sigmoid، لاستخدامها في الطبقات المخفية.

def sigmoid(x):
    return 1 / (1 + np.exp(-x))

الخطوة (2) : تدريب النموذج

الآن وقد قمنا بتعريف النموذج، نستطيع أخيرا البدء في تدريبه على تسلسل البيانات. نستطيع تقسيم خطوات التدريب لخطوات صغيرة هي :

  • الخطوة 2.1: التحقق من الخسارة في بيانات التدريب.
  • الخطوة 2.1.1: Forward pass أو تمرير للأمام.
  • الخطوة 2.1.2: حساب الخطأ.
  • الخطوة 2.2: التحقق من الخسارة في بيانات التحقق من الصحة validation data.
  • الخطوة 2.2.1: Forward pass.
  • الخطوة 2.2.2: حساب الخطأ.
  • الخطوة 2.3: بدء التدريب الفعلي.
  • الخطوة 2.3.1: Forward pass.
  • الخطوة 2.3.2: Backpropagate Error. 
  • الخطوة 2.3.3: تحديث الأوزان.

نحتاج لتكرار هذه الخطوات حتى تتقارب النتائج تقريبا، إذا بدأ الموديل بعمل overfit فربما ينبغي عليك التوقف، أو قم بإعادة تعريف أرقام الepochs.

الخطوة 2.1: التحقق من الخسارة في بيانات التدريب

سنقوم بعمل forward pass عبر نموذج الRNN، وحساب تربيع الخطأ للتنبؤات لكل السجلات records لحساب قيمة الخسارة loss value.

for epoch in range(nepoch):
    # check loss on train
    loss = 0.0
    
    # do a forward pass to get prediction
    for i in range(Y.shape[0]):
        x, y = X[i], Y[i]                    # get input, output values of each record
        prev_s = np.zeros((hidden_dim, 1))   # here, prev-s is the value of the previous activation of hidden layer; which is initialized as all zeroes
        for t in range(T):
            new_input = np.zeros(x.shape)    # we then do a forward pass for every timestep in the sequence
            new_input[t] = x[t]              # for this, we define a single input for that timestep
            mulu = np.dot(U, new_input)
            mulw = np.dot(W, prev_s)
            add = mulw + mulu
            s = sigmoid(add)
            mulv = np.dot(V, s)
            prev_s = s

    # calculate error 
        loss_per_record = (y - mulv)**2 / 2
        loss += loss_per_record
    loss = loss / float(y.shape[0])

الخطوة 2.2: التحقق من الخسارة في بيانات التحقق من الصحة

ونقوم بعمل نفس الشيء لحساب الخسارة في الvalidation data بيانات التحقق من الصحة.

    # check loss on val
    val_loss = 0.0
    for i in range(Y_val.shape[0]):
        x, y = X_val[i], Y_val[i]
        prev_s = np.zeros((hidden_dim, 1))
        for t in range(T):
            new_input = np.zeros(x.shape)
            new_input[t] = x[t]
            mulu = np.dot(U, new_input)
            mulw = np.dot(W, prev_s)
            add = mulw + mulu
            s = sigmoid(add)
            mulv = np.dot(V, s)
            prev_s = s

        loss_per_record = (y - mulv)**2 / 2
        val_loss += loss_per_record
    val_loss = val_loss / float(y.shape[0])

    print('Epoch: ', epoch + 1, ', Loss: ', loss, ', Val Loss: ', val_loss)

الآن يجب أن يكون الخرج الذي تراه 

[[Epoch:  1 , Loss:  [[101185.61756671]] , Val Loss:  [[50591.0340148
...
...

الخطوة 2.3: بدء التدريب الفعلي

في البداية نقوم بعمل forward pass لحساب الأخطاء، ثم backward pass لحساب gradients وتحديثها، دعنا نر هذا معا بالتفصيل وخطوة بخطوة لكي تتمكن من تصوره بشكل أفضل

الخطوة 2.3.1: تمرير إلى الأمام

في الforward pass 

  • في البداية علينا ضرب الدخل في أوزان الطبقة ما بين الدخل والطبقة الخفية.
  • وجمع هذا مع حاصل ضرب الأوزان في  طبقة الRNN.
  • وتمريره عبر دالة التفعيل sigmoid.
  • وضربه في الأوزان ما بين الطبقات المخفية والخرج.
  • في طبقة الخرج، لدينا تفعيل خطي للقيم linear activation، حتى لا نقوم بتمرير القيمة صريحة خلال طبقة التنشيط.
  • احفظ الحالة المتواجدة في الطبقة الحالية، وأيضا الحالة الموجودة في الطبقة السابقة في dictionary.

وهذا هو الكود للforward pass، لاحظ أنه فقط امتداد للحلقة الموجودة أعلاه.

    # train model
    for i in range(Y.shape[0]):
        x, y = X[i], Y[i]
    
        layers = []
        prev_s = np.zeros((hidden_dim, 1))
        dU = np.zeros(U.shape)
        dV = np.zeros(V.shape)
        dW = np.zeros(W.shape)
        
        dU_t = np.zeros(U.shape)
        dV_t = np.zeros(V.shape)
        dW_t = np.zeros(W.shape)
        
        dU_i = np.zeros(U.shape)
        dW_i = np.zeros(W.shape)
        
        # forward pass
        for t in range(T):
            new_input = np.zeros(x.shape)
            new_input[t] = x[t]
            mulu = np.dot(U, new_input)
            mulw = np.dot(W, prev_s)
            add = mulw + mulu
            s = sigmoid(add)
            mulv = np.dot(V, s)
            layers.append({'s':s, 'prev_s':prev_s})
            prev_s = s

الخطوة 2.3.2: Backpropagate Error

بعد الforward propagation، نقوم بحساب الgradients في كل طبقة، وعمل back propagate للErrors، سوف نقوم باستخدام truncated back propagation  خلال الزمن (TBPTT) بدلا من vanilla backprop. ربما تبدو مقعدة بعض الشيء ولكنها في الحقيقة بسيطة للغاية.

الفرق الأساسي بين BPTT مقارنة بال  backprop، هو أن خطوة ال backpropagation يتم تطبيقها لكل الخطوات بتغير الزمن في نموذج الRNN. وبذلك إذا كان طول التسلسل 50، فسنقوم بالنسخ الاحتياطي لكل الخطوات السابقة في الزمن حتى الوقت الحالي.

إذا كنت قد فهمت ما أعنيه، فلابد أنك استوعبت أن ال BPTT يبدو مكلف للغاية من الناحية الحسابية، ولذلك بدلا من النسخ الاحتياطي لكل الخطوات السابقة في الزمن، نقوم بالنسخ حتى x time steps أو خطوة زمنية لتوفير القوة الحسابية.

اعتبر هذه الأيدلوجية مشابهة ل stochastic gradient descent ، والتي تشمل دفعة من  البيانات بدلا من البيانات كلها.

وهذا هو الكود لعمل backpropagation Error

        # derivative of pred
        dmulv = (mulv - y)
        
        # backward pass
        for t in range(T):
            dV_t = np.dot(dmulv, np.transpose(layers[t]['s']))
            dsv = np.dot(np.transpose(V), dmulv)
            
            ds = dsv
            dadd = add * (1 - add) * ds
            
            dmulw = dadd * np.ones_like(mulw)

            dprev_s = np.dot(np.transpose(W), dmulw)


            for i in range(t-1, max(-1, t-bptt_truncate-1), -1):
                ds = dsv + dprev_s
                dadd = add * (1 - add) * ds

                dmulw = dadd * np.ones_like(mulw)
                dmulu = dadd * np.ones_like(mulu)

                dW_i = np.dot(W, layers[t]['prev_s'])
                dprev_s = np.dot(np.transpose(W), dmulw)

                new_input = np.zeros(x.shape)
                new_input[t] = x[t]
                dU_i = np.dot(U, new_input)
                dx = np.dot(np.transpose(U), dmulu)

                dU_t += dU_i
                dW_t += dW_i
                
            dV += dV_t
            dU += dU_t
            dW += dW_t

الخطوة 2.3.3: تحديث الأوزان

أخيرا نقوم بتحديث الأوزان مع تدرجات gradients الأوزان المحسوبة، أما الأمر الذي يجب أخذه في الاعتبار هو أن الgradients غالبا ما تنفجر إذا لم تقم بإبقائها قيد الفحص، وهذه مشكلة أساسية في تدريب الشبكات العصبية تدعى exploding gradient problem. لذلك علينا الإبقاء عليها في مدى محدد حتى لا تنفجر. ويمكننا فعل ذلك بالطريقة التالية :

            if dU.max() > max_clip_value:
                dU[dU > max_clip_value] = max_clip_value
            if dV.max() > max_clip_value:
                dV[dV > max_clip_value] = max_clip_value
            if dW.max() > max_clip_value:
                dW[dW > max_clip_value] = max_clip_value
                
            
            if dU.min() < min_clip_value:
                dU[dU < min_clip_value] = min_clip_value
            if dV.min() < min_clip_value:
                dV[dV < min_clip_value] = min_clip_value
            if dW.min() < min_clip_value:
                dW[dW < min_clip_value] = min_clip_value
        
        # update
        U -= learning_rate * dU
        V -= learning_rate * dV
        W -= learning_rate * dW

وبتدريب النموذج أعلاه نحصل على النتائج التالية

[[Epoch:  1 , Loss:  [[101185.61756671]] , Val Loss:  [[50591.0340148
[[Epoch:  2 , Loss:  [[61205.46869629]] , Val Loss:  [[30601.34535365
[[Epoch:  3 , Loss:  [[31225.3198258]] , Val Loss:  [[15611.65669247
[[Epoch:  4 , Loss:  [[11245.17049551]] , Val Loss:  [[5621.96780111
[[Epoch:  5 , Loss:  [[1264.5157739]] , Val Loss:  [[632.02563908
[[Epoch:  6 , Loss:  [[20.15654115]] , Val Loss:  [[10.05477285
[[Epoch:  7 , Loss:  [[17.13622839]] , Val Loss:  [[8.55190426
[[Epoch:  8 , Loss:  [[17.38870495]] , Val Loss:  [[8.68196484
[[Epoch:  9 , Loss:  [[17.181681]] , Val Loss:  [[8.57837827
[[Epoch:  10 , Loss:  [[17.31275313]] , Val Loss:  [[8.64199652
[[Epoch:  11 , Loss:  [[17.12960034]] , Val Loss:  [[8.54768294
[[Epoch:  12 , Loss:  [[17.09020065]] , Val Loss:  [[8.52993502
[[Epoch:  13 , Loss:  [[17.17370113]] , Val Loss:  [[8.57517454
[[Epoch:  14 , Loss:  [[17.04906914]] , Val Loss:  [[8.50658127
[[Epoch:  15 , Loss:  [[16.96420184]] , Val Loss:  [[8.46794248
[[Epoch:  16 , Loss:  [[17.017519]] , Val Loss:  [[8.49241316
[[Epoch:  17 , Loss:  [[16.94199493]] , Val Loss:  [[8.45748739
[[Epoch:  18 , Loss:  [[16.99796892]] , Val Loss:  [[8.48242177
[[Epoch:  19 , Loss:  [[17.24817035]] , Val Loss:  [[8.6126231
[[Epoch:  20 , Loss:  [[17.00844599]] , Val Loss:  [[8.48682234
[[Epoch:  21 , Loss:  [[17.03943262]] , Val Loss:  [[8.50437328
[[Epoch:  22 , Loss:  [[17.01417255]] , Val Loss:  [[8.49409597
[[Epoch:  23 , Loss:  [[17.20918888]] , Val Loss:  [[8.5854792
[[Epoch:  24 , Loss:  [[16.92068017]] , Val Loss:  [[8.44794633
[[Epoch:  25 , Loss:  [[16.76856238]] , Val Loss:  [[8.37295808

يبدو جيدا! الآن، حان الوقت للحصول على التنبؤات، ورسمها للحصول على تصور مرئي لما قمنا به حتى الآن.

الخطوة (3) : الحصول على التنبؤات

سنقوم بعمل تمرير أمامي خلال الأوزان المدربة للحصول على التنبؤات التي نحتاجها

preds = []
for i in range(Y.shape[0]):
    x, y = X[i], Y[i]
    prev_s = np.zeros((hidden_dim, 1))
    # Forward pass
    for t in range(T):
        mulu = np.dot(U, x)
        mulw = np.dot(W, prev_s)
        add = mulw + mulu
        s = sigmoid(add)
        mulv = np.dot(V, s)
        prev_s = s

    preds.append(mulv)
    
preds = np.array(preds)

ورسم هذه التوقعات بجانب القيم الفعلية :

plt.plot(preds[:, 0, 0], 'g')
plt.plot(Y[:, 0], 'r')
plt.show()

لكن، كيف نعرف أن نموذجنا لم يحدث له overfit، هنا يأتي دور الvalidation set التي قمنا بكتابتها من قبل :

preds = []
for i in range(Y_val.shape[0]):
    x, y = X_val[i], Y_val[i]
    prev_s = np.zeros((hidden_dim, 1))
    # For each time step...
    for t in range(T):
        mulu = np.dot(U, x)
        mulw = np.dot(W, prev_s)
        add = mulw + mulu
        s = sigmoid(add)
        mulv = np.dot(V, s)
        prev_s = s

    preds.append(mulv)
    
preds = np.array(preds)

plt.plot(preds[:, 0, 0], 'g')
plt.plot(Y_val[:, 0], 'r')
plt.show()

ليس سيئا، تبدو التنبؤات مبهرة بشكل ما، ودرجة الRMSE لل validation set جيدة أيضا.

from sklearn.metrics import mean_squared_error

math.sqrt(mean_squared_error(Y_val[:, 0] * max_val, preds[:, 0, 0] * max_val))
0.127191931509431

ملاحظة أخيرة 

لا يمكنني وصف لأي مدى الRNN مفيدة في التعامل مع البيانات المتسلسلة، وأشجعكم جميعا لأخذ هذا النمؤذج  وتطبيقه على dataset، مثلا خذ مشكلة NLP وحاول إيجاد حل لها بهذه الطريقة.

في هذه المقالة ، تعلمنا كيفية إنشاء نموذج شبكة عصبية متكررة من  الصفر باستخدام مكتبة numpy فقط، بإمكانك بالتأكيد كتابتها بمكتبات أكثر تطورا مثل  Keras أو Caffe ولكن من المهم أن تدرك المفهوم الذي تقوم بكتابته.

مصدر مصدر1
تعليقات