الفصل المفقود في تعليم علوم الحاسوب › معالجة وصقل البيانات 10 / 13 السابق التالي

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

هل تساءلت يومًا عن إمكانية أخذ البيانات بصيغةٍ ما وتحويلها إلى صيغةٍ مختلفة؟ بالتأكيد! هذا، بعباراتٍ عامةٍ جدًا، هو موضوع هذه المحاضرة. وتحديدًا، صقل البيانات (data wrangling)، سواءً كانت بصيغة نصيةٍ أو ثنائية، حتى تصل إلى ما أردته بالضبط.

لقد رأينا بالفعل بعض معالجة البيانات الأساسية في المحاضرات السابقة. في كل مرةٍ تستخدم فيها عامِل الأنابيب |، فأنت تقوم بنوعٍ من معالجة البيانات. تأمل أمرًا مثل journalctl | grep -i intel. إنه يجد جميع إدخالات سجل النظام التي تذكر Intel (بدون حساسيةٍ لحالة الأحرف). قد لا تعتبر ذلك معالجة بياناتٍ، لكنه انتقالٌ من صيغةٍ (سجل نظامك بالكامل) إلى صيغةٍ أكثر فائدةً لك (فقط إدخالات Intel). معظم معالجة البيانات تتعلق بمعرفة الأدوات المتاحة لديك، وكيفية دمجها.

لنبدأ من البداية. لمعالجة البيانات، نحتاج إلى شيئين: بياناتٌ لمعالجتها، وشيءٌ نفعله بها. غالبًا ما تكون السجلات (logs) حالة استخدامٍ جيدة، لأنك تريد في كثيرٍ من الأحيان التحقيق في أمورٍ تتعلق بها، وقراءة السجل بأكمله غير مجديةٍ. لنكتشف من يحاول تسجيل الدخول إلى خادمي من خلال النظر في سجل الخادم:

ssh myserver journalctl

هذا قدرٌ كبيرٌ من المحتوى. لنقصره على أمور ssh:

ssh myserver journalctl | grep sshd

لاحظ أننا نستخدم أنبوبًا لتدفق ملفٍ بعيدٍ عبر grep على حاسوبك المحلي! ssh ساحرٌ، وسنتحدث عنه أكثر في المحاضرة القادمة عن بيئة سطر الأوامر. لا يزال هذا أكثر بكثير مما أردناه. ويصعب قراءته أيضًا. لنحسّن:

ssh myserver 'journalctl | grep sshd | grep "Disconnected from"' | less

لماذا الاقتباس الإضافي؟ حسنًا، قد تكون سجلاتنا كبيرةً جدًا، ومن المهدر تدفقها كلها إلى حاسوبك ثم إجراء التصفية. بدلًا من ذلك، يمكننا إجراء التصفية على الخادم البعيد، ثم صقل البيانات محليًا. يمنحنا less "مُصفِّحًا" (pager) يسمح لنا بالتمرير لأعلى ولأسفل عبر المخرجات الطويلة. لتوفير بعض الحركة الإضافية أثناء تصفية أخطاء سطر الأوامر، يمكننا حتى لصق السجلات المصفاة الحالية في ملفٍ حتى لا نضطر إلى الوصول إلى الشبكة أثناء التطوير:

$ ssh myserver 'journalctl | grep sshd | grep "Disconnected from"' > ssh.log
$ less ssh.log

لا يزال هناك الكثير من الضجيج هنا. توجد طرقٌ كثيرة للتخلص من ذلك، لكن لننظر إلى واحدةٍ من أقوى الأدوات في أدواتك: sed.

sed هو "محرر تدفقٍ" (stream editor) مبنيٌّ على محرر ed القديم. فيه، تعطي أوامر قصيرةً أساسًا لكيفية تعديل الملف، بدلًا من التلاعب بمحتواه مباشرةً (وإن كان يمكنك فعل ذلك أيضًا). توجد أوامرٌ كثيرة، لكن أحد أكثرها شيوعًا هو s: الاستبدال (substitution). على سبيل المثال، يمكننا كتابة:

ssh myserver journalctl
 | grep sshd
 | grep "Disconnected from"
 | sed 's/.*Disconnected from //'

ما كتبناه للتو كان تعبيرًا نمطيًا بسيطًا؛ بنيةً قويةً تسمح لك بمطابقة النص مقابل أنماطٍ. أمر s يُكتب بالصيغة: s/REGEX/SUBSTITUTION/، حيث REGEX هو التعبير النمطي الذي تريد البحث عنه، وSUBSTITUTION هو النص الذي تريد استبدال النص المطابق به.

(قد تتعرف على هذه الصيغة من قسم "البحث والاستبدال" في ملاحظات محاضرة Vim! بالفعل، يستخدم Vim صيغةً للبحث والاستبدال تشبه أمر الاستبدال في sed. تعلّم أداةٍ واحدةٍ يساعدك غالبًا على أن تصبح أكثر كفاءةً مع أدواتٍ أخرى.)

التعبيرات النمطية

التعبيرات النمطية شائعةٌ ومفيدةٌ بما يكفي ليكون من الجدير قضاء بعض الوقت لفهم كيفية عملها. لنبدأ بالنظر إلى التعبير الذي استخدمناه أعلاه: /.*Disconnected from /. تُحاط التعبيرات النمطية عادةً (وإن لم يكن دائمًا) بـ/. تحمل معظم أحرف ASCII معناها الطبيعي، لكن بعض الأحرف لها سلوك "مطابقةٍ" خاص. أي الأحرف بالضبط تقوم بماذا يختلف نوعًا ما بين التنفيذات المختلفة للتعبيرات النمطية، وهو مصدر إحباطٍ كبيرٍ. الأنماط الشائعة جدًا هي:

  • . تعني "أي حرفٍ واحد" باستثناء السطر الجديد
  • * صفرٌ أو أكثر من المطابقة السابقة
  • + واحدٌ أو أكثر من المطابقة السابقة
  • [abc] أي حرفٍ واحدٍ من a وb وc
  • (RX1|RX2) إما شيءٌ يطابق RX1 أو RX2
  • ^ بداية السطر
  • $ نهاية السطر

تعبيرات sed النمطية غريبةٌ نوعًا ما، وستتطلب منك وضع \ قبل معظم هذه لإعطائها معناها الخاص. أو يمكنك تمرير -E.

إذن، بالنظر إلى الوراء إلى /.*Disconnected from /، نرى أنه يطابق أي نصٍ يبدأ بأي عددٍ من الأحرف، متبوعًا بالنص الحرفي "Disconnected from ". وهذا ما أردناه. لكن احذر، فالتعبيرات النمطية خادعة. ماذا لو حاول شخصٌ تسجيل الدخول باسم مستخدمٍ "Disconnected from"؟ سيكون لدينا:

Jan 17 03:13:00 thesquareplanet.com sshd[2631]: Disconnected from invalid user Disconnected from 46.97.239.16 port 55920 [preauth]

ماذا سنحصل في النهاية؟ حسنًا، * و+ هما، افتراضيًا، "جشعان" (greedy). سيطابقان أكبر قدرٍ ممكنٍ من النص. لذا، في المثال أعلاه، سنحصل في النهاية على فقط

46.97.239.16 port 55920 [preauth]

وهذا قد لا يكون ما أردناه. في بعض تنفيذات التعبيرات النمطية، يمكنك فقط إلحاق ? بـ* أو + لجعلها غير جشعة (non-greedy)، لكن للأسف لا يدعم sed ذلك. لكن يمكننا التبديل إلى وضع سطر الأوامر في perl، والذي يدعم تلك البنية:

perl -pe 's/.*?Disconnected from //'

سنلتزم بـsed لبقية هذه المحاضرة، لأنه بفارقٍ كبيرٍ الأداة الأكثر شيوعًا لهذه الأنواع من المهام. يمكن لـsed أيضًا القيام بأشياء مفيدةٍ أخرى مثل طباعة الأسطر التي تلي مطابقةً معينةً، وإجراء استبدالاتٍ متعددةٍ في استدعاءٍ واحدٍ، والبحث عن الأشياء، إلخ. لكننا لن نغطي ذلك كثيرًا هنا. sed أساسًا موضوعٌ كاملٌ بحد ذاته، لكن غالبًا ما توجد أدواتٌ أفضل.

حسنًا، لدينا أيضًا لاحقةٌ نود التخلص منها. كيف يمكننا فعل ذلك؟ إنه أمرٌ صعبٌ نوعًا ما لمطابقة النص الذي يتبع اسم المستخدم فقط، خاصةً إذا كان اسم المستخدم يمكن أن يحتوي على مسافاتٍ وما شابه! ما نحتاج إلى فعله هو مطابقة السطر بالكامل:

 | sed -E 's/.*Disconnected from (invalid |authenticating )?user .* [^ ]+ port [0-9]+( \[preauth\])?$//'

لننظر إلى ما يجري باستخدام مُصفِّي أخطاء تعبيراتٍ نمطية. حسنًا، البداية كما كانت سابقًا. ثم نطابق أيًّا من متغيرات "user" (يوجد بادئتان في السجلات). ثم نطابق أي سلسلة أحرفٍ حيث يكون اسم المستخدم. ثم نطابق أي كلمةٍ واحدةٍ ([^ ]+؛ أي تسلسلٌ غير فارغٍ من الأحرف غير المسافة). ثم كلمة "port" متبوعةً بتسلسلٍ من الأرقام. ثم ربما اللاحقة [preauth]، ثم نهاية السطر.

لاحظ أنه بهذه التقنية، لن يربكنا اسم المستخدم "Disconnected from" بعد الآن. هل يمكنك رؤية السبب؟

لكن هناك مشكلةٌ واحدةٌ في هذا، وهي أن السجل بأكمله يصبح فارغًا. نريد في النهاية الاحتفاظ باسم المستخدم. لهذا، يمكننا استخدام "مجموعات الالتقاط" (capture groups). أي نصٍ تطابقه تعبيرٌ نمطيٌّ محاطٌ بأقواسٍ يُخزَّن في مجموعة التقاطٍ مرقمة. وهي متاحةٌ في الاستبدال (وفي بعض المحركات، حتى في النمط نفسه!) بصفتها \1 و\2 و\3 إلخ. إذن:

 | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'

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

من المعروف أن التعبيرات النمطية يصعب إتقانها، لكن من المفيد أيضًا امتلاكها في صندوق أدواتك!

العودة إلى معالجة البيانات

حسنًا، لدينا الآن

ssh myserver journalctl
 | grep sshd
 | grep "Disconnected from"
 | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'

يمكن لـsed القيام بجميع أنواع الأشياء المثيرة الأخرى، مثل حقن النص (بأمر i)، وطباعة الأسطر صراحةً (بأمر p)، واختيار الأسطر حسب الفهرس، والكثير من الأشياء الأخرى. اطّلع على man sed!

على أي حال. ما لدينا الآن يمنحنا قائمةً بجميع أسماء المستخدمين الذين حاولوا تسجيل الدخول. لكن هذا غير مفيدٍ إلى حدٍّ كبير. لنبحث عن الأسماء الشائعة:

ssh myserver journalctl
 | grep sshd
 | grep "Disconnected from"
 | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'
 | sort | uniq -c

سيقوم sort، حسنًا، بفرز مدخلاته. وسيدمج uniq -c الأسطر المتتالية المتطابقة في سطرٍ واحدٍ، مسبوقًا بعدد التكرارات. ربما نريد فرز ذلك أيضًا والاحتفاظ فقط بأسماء المستخدمين الأكثر شيوعًا:

ssh myserver journalctl
 | grep sshd
 | grep "Disconnected from"
 | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'
 | sort | uniq -c
 | sort -nk1,1 | tail -n10

sort -n سيرتّب بترتيبٍ رقميٍّ (بدلًا من المعجمي). و-k1,1 تعني "رتّب حسب العمود الأول المفصول بمسافاتٍ فقط". الجزء ,n يقول "رتّب حتى الحقل n، حيث الافتراضي هو نهاية السطر. في هذا المثال المحدد، لن يهم الفرز حسب السطر بأكمله، لكننا هنا لنتعلم!

إذا أردنا الأسماء الأقل شيوعًا، فيمكننا استخدام head بدلًا من tail. ويوجد أيضًا sort -r، الذي يرتّب بترتيبٍ عكسي.

حسنًا، هذا رائعٌ جدًا، لكن ماذا لو أردنا استخراج أسماء المستخدمين فقط كقائمةٍ مفصولةٍ بفواصل بدلًا من واحدةٍ لكل سطر، ربما لملف إعدادات؟

ssh myserver journalctl
 | grep sshd
 | grep "Disconnected from"
 | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'
 | sort | uniq -c
 | sort -nk1,1 | tail -n10
 | awk '{print $2}' | paste -sd,

إذا كنت تستخدم macOS: لاحظ أن الأمر كما هو معروض لن يعمل مع paste الخاص بنظام BSD المرفق مع macOS. راجع التمرين 4 من محاضرة أدوات المُعالِج لمزيدٍ من التفاصيل حول الفرق بين BSD وGNU coreutils وتعليمات تثبيت GNU coreutils على macOS.

لنبدأ بـpaste: إنه يتيح لك دمج الأسطر (-s) وفق محددٍ أحرفٍ واحدٍ (-d؛ , في هذه الحالة). لكن ما هذا الأمر awk؟

awk — محررٌ آخر

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

أولًا، ماذا يفعل {print $2}؟ حسنًا، تتخذ برامج awk شكل نمطٍ اختياريٍّ بالإضافة إلى كتلةٍ تقول ماذا تفعل إذا طابق النمط سطرًا معيّنًا. النمط الافتراضي (الذي استخدمناه أعلاه) يطابق جميع الأسطر. داخل الكتلة، يُضبط $0 على محتوى السطر بأكمله، و$1 حتى $n على الحقل n من ذلك السطر، عند الفصل بفاصل حقول awk (المسافات افتراضيًا، يُغيَّر بـ-F). في هذه الحالة، نقول إنه، لكل سطرٍ، اطبع محتوى الحقل الثاني، والذي تصادف أنه اسم المستخدم!

لنرَ ما إذا كان يمكننا القيام بشيءٍ أذكى. لنحسب عدد أسماء المستخدمين ذات الاستخدام الواحد التي تبدأ بـc وتنتهي بـe:

 | awk '$1 == 1 && $2 ~ /^c[^ ]*e$/ { print $2 }' | wc -l

هناك الكثير لتفكيكه هنا. أولًا، لاحظ أن لدينا الآن نمطًا (الأشياء التي تسبق {...}). يقول النمط إن الحقل الأول من السطر يجب أن يساوي 1 (هذا هو العد من uniq -c)، وأن الحقل الثاني يجب أن يطابق التعبير النمطي المعطى. والكتلة تقول فقط اطبع اسم المستخدم. ثم نحسب عدد الأسطر في المخرجات بـwc -l.

ومع ذلك، تذكّر أن awk لغةُ برمجةٍ، أليس كذلك؟

BEGIN { rows = 0 }
$1 == 1 && $2 ~ /^c[^ ]*e$/ { rows += $1 }
END { print rows }

BEGIN نمطٌ يطابق بداية المدخلات (وEND يطابق النهاية). الآن، الكتلة الخاصة بكل سطرٍ تضيف فقط العد من الحقل الأول (وإن كان سيكون دائمًا 1 في هذه الحالة)، ثم نطبعه في النهاية. في الواقع، يمكننا التخلص من grep وsed تمامًا، لأن awk يمكنه فعل كل شيء، لكننا سنترك ذلك كتمرينٍ للقارئ.

تحليل البيانات

يمكنك إجراء عملياتٍ حسابيةٍ مباشرةً في المُعالج باستخدام bc، وهي آلة حاسبة يمكنها القراءة من STDIN! على سبيل المثال، اجمع الأرقام في كل سطرٍ معًا بربطها ببعضها، مفصولةً بـ+:

 | paste -sd+ | bc -l

أو أنتج تعبيراتٍ أكثر تفصيلًا:

echo "2*($(data | paste -sd+))" | bc -l

يمكنك الحصول على الإحصاءات بطرقٍ متنوعةٍ. st أنيقٌ جدًا، لكن إذا كان لديك بالفعل R:

ssh myserver journalctl
 | grep sshd
 | grep "Disconnected from"
 | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'
 | sort | uniq -c
 | awk '{print $1}' | R --no-echo -e 'x <- scan(file="stdin", quiet=TRUE); summary(x)'

R لغةُ برمجةٍ أخرى (غريبةٌ) رائعةٌ في تحليل البيانات والرسم. لن نتعمق في الكثير من التفاصيل، لكن يكفي القول إن summary تطبع إحصاءاتٍ مختصرةً لمتجهٍ (vector)، وقد أنشأنا متجهًا يحتوي على تدفق المدخلات من الأرقام، لذا يمنحنا R الإحصاءات التي أردناها!

إذا كنت تريد فقط بعض الرسم البسيط، فإن gnuplot صديقك:

ssh myserver journalctl
 | grep sshd
 | grep "Disconnected from"
 | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'
 | sort | uniq -c
 | sort -nk1,1 | tail -n10
 | gnuplot -p -e 'set boxwidth 0.5; plot "-" using 1:xtic(2) with boxes'

معالجة البيانات لصنع الوسائط

أحيانًا تريد معالجة البيانات للعثور على أشياءَ لتثبيتها أو إزالتها استنادًا إلى قائمةٍ أطول. معالجة البيانات التي تحدثنا عنها حتى الآن + xargs يمكن أن تكون مزيجًا قويًا.

على سبيل المثال، كما رأينا في المحاضرة، يمكنني استخدام الأمر التالي لإلغاء تثبيت الإصدارات الليلية القديمة (nightly builds) من Rust من نظامي عن طريق استخراج أسماء البناءات القديمة باستخدام أدوات معالجة البيانات ثم تمريرها عبر xargs إلى برنامج إلغاء التثبيت:

rustup toolchain list | grep nightly | grep -vE "nightly-x86" | sed 's/-x86.*//' | xargs rustup toolchain uninstall

معالجة البيانات الثنائية

حتى الآن، تحدثنا في الغالب عن معالجة البيانات النصية، لكن الأنابيب مفيدةٌ بنفس القدر للبيانات الثنائية. على سبيل المثال، يمكننا استخدام ffmpeg لالتقاط صورةٍ من كاميرتنا، وتحويلها إلى تدرجٍ رمادي، وضغطها، وإرسالها إلى جهازٍ بعيدٍ عبر SSH، وفك ضغطها هناك، وعمل نسخةٍ منها، ثم عرضها.

ffmpeg -loglevel panic -i /dev/video0 -frames 1 -f image2 -
 | convert - -colorspace gray -
 | gzip
 | ssh mymachine 'gzip -d | tee copy.jpg | env DISPLAY=:0 feh -'

تمارين

  1. خُذ هذا الدرس التفاعلي القصير للتعبيرات النمطية.
  2. ابحث عن عدد الكلمات (في /usr/share/dict/words) التي تحتوي على ثلاثة أحرف a على الأقل ولا تنتهي بـ's. ما أكثر حرفين نهائيين شيوعًا في تلك الكلمات؟ قد يساعدك أمر y في sed، أو برنامج tr، في عدم حساسية حالة الأحرف. كم عدد تلك التركيبات المكونة من حرفين؟ وللتحدي: أي التركيبات لا تحدث؟
  3. للاستبدال الموضعي، من المغرِي جدًا فعل شيءٍ مثل sed s/REGEX/SUBSTITUTION/ input.txt > input.txt. لكن هذه فكرةٌ سيئة، لماذا؟ هل هذا خاصٌّ بـsed؟ استخدم man sed لمعرفة كيفية إنجاز ذلك.
  4. ابحث عن متوسط ووسيط وأقصى زمن إقلاع نظامك عبر عمليات الإقلاع العشر الأخيرة. استخدم journalctl على لينكس وlog show على macOS، وابحث عن طوابع زمنية في السجل بالقرب من بداية ونهاية كل إقلاعٍ. على لينكس، قد تبدو مثل:
    Logs begin at ...
    
    و
    systemd[577]: Startup finished in ...
    
    على macOS، ابحث عن:
    === system boot:
    
    و
    Previous shutdown cause: 5
    
  5. ابحث عن رسائل الإقلاع التي ليست مشتركةً بين إعادة تشغيلاتك الثلاث السابقة (راجع علمة -b في journalctl). قسّم هذه المهمة إلى خطواتٍ متعددة. أولًا، ابحث عن طريقةٍ للحصول فقط على السجلات من عمليات الإقلاع الثلاث الماضية. قد توجد علمةٌ قابلةٌ للتطبيق على الأداة التي تستخدمها لاستخراج سجلات الإقلاع، أو يمكنك استخدام sed '0,/STRING/d' لإزالة جميع الأسطر التي تسبق سطرًا يطابق STRING. بعد ذلك، أزل أي أجزاءٍ من السطر تتغير دائمًا (مثل الطابع الزمني). ثم، أزل التكرارات من أسطر الإدخال واحتفظ بعدٍّ لكلٍّ منها (uniq صديقك). وأخيرًا، تخلص من أي سطرٍ عدّه 3 (لأنه كان مشتركًا بين جميع عمليات الإقلاع).
  6. ابحث عن مجموعة بياناتٍ عبر الإنترنت مثل هذه، أو هذه، أو ربما واحدةٌ من هنا. اجلبها باستخدام curl واستخرج عمودين فقط من البيانات الرقمية. إذا كنت تجلب بيانات HTML، فقد يكون pup مفيدًا. للبيانات JSON، جرّب jq. ابحث عن أدنى وأقصى قيمةٍ في عمودٍ واحدٍ بأمرٍ واحدٍ، والفرق بين مجموع كل عمودٍ في أمرٍ آخر.