مسابقه دویدن برج مسابقه‌ای است که از پله‌های ساختمان بالا می‌روید. اینها در سراسر جهان اتفاق می افتد. من این شانس را داشتم که در مسابقه Empire State Run Up در نیویورک، نسخه 1402 شرکت کنم.

مسابقه دو برج امپایر استیت (ESBRU) – اولین و مشهورترین مسابقه برج در جهان – دوندگان را از دور و نزدیک به چالش می کشد تا 86 پرواز معروف خود را طی کنند – 1576 پله.

در حالی که بازدیدکنندگان می توانند از طریق آسانسور در کمتر از یک دقیقه به رصدخانه ساختمان برسند، سریع ترین دوندگان 86 طبقه را با پای پیاده در حدود 10 دقیقه طی کرده اند.

رهبران ورزش برج‌دوانی حرفه‌ای در ساختمان امپایر استیت گرد هم می‌آیند و برخی آن را آزمون نهایی استقامت می‌دانند.

شانس آوردم و موفق شدم در این مسابقه شرکت کنم. چند روز پس از پایان مسابقه، متوجه شدم که می‌خواهم درباره عملکردم بیشتر بدانم و چه کارهایی می‌توانم انجام دهم تا بهتر انجام دهم.

بنابراین به طور طبیعی به وب سایت برگزارکننده مسابقه رفتم و شروع به بررسی اعداد کردم. و آهسته و خسته کننده بود، به علاوه مشکلات بیشتری را به همراه داشت:

  1. به دست آوردن داده ها برای تجزیه و تحلیل آفلاین دشوار است. می‌توانید نتایج خود و سایر موارد را برای مقایسه ببینید، اما متوجه شدم که ابزارها گزینه‌ای برای دانلود داده‌های خام ارائه نمی‌دهند و استفاده از آنها ناشیانه است.
  2. اکثر ابزارهای موجود برای تجزیه و تحلیل نتایج مسابقه پولی هستند یا برای این نوع مسابقه کاربرد ندارند. دانستن اینکه چه انتظاری دارید اضطراب شما را کاهش می دهد، به شما امکان می دهد بهتر تمرین کنید و انتظارات شما را کنترل می کند.

احتمالاً تا به حال حدس زده اید که می توانید مشکلات بازیابی داده ها و تجزیه و تحلیل پس از مسابقه را با استفاده از Open کم هزینه حل کنید. Source ابزار. این همچنین به شما امکان می دهد تکنیک های مختلفی را برای یادگیری در مورد مسابقه و بسته به آن اعمال کنید روی کیفیت داده ها، حتی پیش بینی عملکرد.

این یک قطعه بسیار شخصی برای من است. من نتایج مسابقه خود را به اشتراک می گذارم و نظر مغرضانه خود را در مورد مسابقه به شما می دهم. 😁

فهرست مطالب

  1. چگونه در نهایت به بالای ساختمان امپایر استیت دویدم
  2. آنچه برای دنبال کردن این آموزش نیاز دارید
  3. روش دریافت داده ها با استفاده از Web Scraping
  4. روش پاکسازی داده ها
  5. روش تجزیه و تحلیل داده ها
  6. روش تجسم نتایج
  7. روش اجرای برنامه ها
  8. چه چیز دیگری می توانیم یاد بگیریم؟

چگونه در نهایت به بالای ساختمان امپایر استیت دویدم

بسیاری از ما در مقطعی از زندگی خود یک مسابقه منظم را اجرا کرده ایم – مسافت های زیادی مانند این وجود دارد 5K، 10 هزار، نیم ماراتن، و پر شده ماراتن. اما هیچ راهی برای مقایسه عملکرد شما در حین دویدن از پله‌ها وجود ندارد
بالای یکی از معروف ترین ساختمان های جهان.

اگر تا به حال در پایه آسمان خراش ها در شهر نیویورک بوده اید و به بالا نگاه کرده اید، این ایده را دریافت کرده اید. خودتان را تصور کنید که بدون توقف از پله‌ها، تا بالا، بالا می‌روید.

قبول شدن سخت است، زیرا برخلاف مسابقه‌ای مانند ماراتن نیویورک، ساختمان امپایر استیت فقط می‌تواند حدود 500 دونده را در خود جای دهد (یا بهتر است بگویم). کوهنوردان؟).

به این واقعیت اضافه کنید که تقاضا برای شرکت در آن زیاد است، و سپس می توانید ببینید که شانس شما برای شرکت در قرعه کشی بسیار اندک است (در جایی خواندم که تنها 50 موقعیت لاتاری برای بیش از 5000 متقاضی وجود دارد).

می توانید تعجب من را تصور کنید وقتی ایمیلی دریافت کردم که می گفت پس از 4 سال تلاش متوالی برای شرکت انتخاب شده ام.

وحشت کردم. آیا تا به حال در پایگاه امپایر استیت بوده اید و به بالا نگاه کرده اید؟ بعضی روزها که هوا ابری است حتی نمی توانید بالای ساختمان را ببینید.

من ناآماده نبودم اما من مجبور شدم روال تمرینی خود را طوری تنظیم کنم که برای این چالش با یک پنجره کوچک دو ماهه آماده باشم و هیچ تجربه ای در دویدن برج نداشته باشم.

روز مسابقه فرا رسید و برای من اینگونه گذشت:

  • سخت بود. می‌دانستم که باید خودم را پیش ببرم، وگرنه مسابقه برای من تمام می‌شد روی طبقه 20 بر خلاف 86. باید تمرکز کنی روی یک ذهنیت “ادامه دهید”، صرف نظر از اینکه چقدر احساس خستگی می کنید. و بعد تمام می شود، درست مثل آن.
  • شما دوی سرعت نمی‌کنید، هر بار 2 پله را با سرعت ثابت بالا می‌روید و از نرده‌ها برای برداشتن وزن از روی پاهایتان استفاده می‌کنید.
  • بدون نیاز به بارگیری کربوهیدرات یا هیدراته کردن بیش از حد. اگر خوب عمل کنید، در حدود 30 دقیقه تمام خواهید شد.
  • کسی به کسی فشار نمی آورد. حداقل برای مسابقه دهندگان غیر نخبه مانند من، من در بیشتر مسابقات تنها بودم.
  • من قبول شدم و از افراد زیادی عبور کردم که قانون “خودت سرعت” را فراموش کردند. اگر دوی سرعت داشته باشید، مطمئناً قبل از طبقه 25 برشته می شوید.

من خیلی خوشحال شدم و از اینکه این مسابقه از لیست سطل من حذف شد، بسیار رضایت داشتم، همان چیزی که بعد از دویدن در ماراتن نیویورک احساس کردم.

اکنون زمان آن رسیده بود که با استفاده از چندین مورد از اوپن مورد علاقه خود، یک تحلیل پس از مسابقه انجام دهم Source ابزارهایی که در قسمت بعدی توضیح خواهم داد.

آنچه برای دنبال کردن این آموزش نیاز دارید

مانند مسابقه، بیشتر چالش‌های نوشتن این اپلیکیشن ذهنی بود. شما فقط باید مشکل اصلی را به قطعات کوچکتر تقسیم کنید و سپس هر یک را در یک زمان انجام دهید:

  1. داده ها را با خراش دادن وب سایت دریافت کنید (سایت های بسیار کمی به شما اجازه می دهند export نتایج مسابقه به عنوان CSV).
  2. داده ها را پاک کنید، نرمال کنید و برای پردازش خودکار آماده کنید.
  3. سوال بپرس. سپس آن سوالات را به کد و تست ترجمه کنید، در حالت ایده آل از آمار برای دریافت پاسخ های قابل اعتماد استفاده کنید.
  4. نتایج را ارائه دهید. یک رابط کاربری (متن یا گرافیک) به دلیل مصرف کمش معجزه می کند، اما نمودارها نیز گویای خوبی هستند.

برای استفاده بیشتر از این مقاله باید در یک زبان برنامه نویسی تجربه داشته باشید. کد من در پایتون نوشته شده است (به نسخه 3.8 و بالاتر نیاز دارید) و اجرا می شود روی لینوکس (من از توزیع فدورا 37 استفاده کردم).

به طور خلاصه، می خواهم نشان دهم که انجام تمام موارد بالا با Open امکان پذیر است Source فن آوری ها سپس می توانید از این دانش برای پروژه های دیگر، نه فقط برای تجزیه و تحلیل مسابقه برج، استفاده مجدد کنید. 😅

من قویاً توصیه می کنم که کد منبع را دریافت کنید (باز است Source!). دست هایتان را کثیف کنید، فیلمنامه ها را بشکنید و لذت ببرید. برای کلون کردن مخزن به Git نیاز دارید:

git clone https://github.com/josevnz/tutorials.git
cd tutorials/docs/EmpireStateRunUp/
python -m ~/virtualenv/EmpireStateRunUp
. ~/virtualenv/EmpireStateRunUp/bin/activate
pip install --upgrade pip
pip install --upgrade build
pip install --upgrade wheel
pip install --editable .

یا اگر فقط می خواهید کد را هنگام خواندن این آموزش اجرا کنید (با استفاده از آخرین نسخه من از Pypi):

python -m ~/virtualenv/EmpireStateRunUp
. ~/virtualenv/EmpireStateRunUp/bin/activate 
pip install --upgrade EmpireStateRunUp

اکنون می‌توانیم به مرحله بعدی برویم: دریافت داده‌ها.

روش دریافت داده ها با استفاده از Web Scraping

سایت نتایج مسابقه ندارد export ویژگی، و من هرگز از تیم پشتیبانی آنها نشنیده ام تا ببینم آیا راه دیگری برای دریافت داده های مسابقه وجود دارد یا خیر. بنابراین تنها جایگزین باقی مانده انجام برخی از خراش دادن وب بود.

این وب‌سایت بسیار ابتدایی است و فقط امکان پیمایش در هر رکورد را می‌دهد، بنابراین تصمیم گرفتم که وب‌سایت را انجام دهم تا نتایج را به قالبی برسانم که بتوانم بعداً برای تجزیه و تحلیل داده‌ها از آن استفاده کنم.

قوانین خراش دادن وب

3 قانون بسیار ساده وجود دارد:

  1. قانون شماره 1: انجامش نده. جریان داده تغییر می‌کند، و اسکراپر شما در دقیقه‌ای که دریافت داده‌ها را تمام کردید، خراب می‌شود. نیاز به زمان و تلاش خواهد داشت. مقدار زیادی از آن.
  2. قانون شماره 2: قانون شماره 1 را دوباره بخوانید. اگر نمی توانید داده ها را در قالب دیگری دریافت کنید، به قانون شماره 3 بروید
  3. قانون شماره 3: یک چارچوب خوب برای خودکارسازی آنچه می توانید انتخاب کنید و آماده شدن برای پاکسازی داده های سنگین (همچنین به عنوان “برای چیزهایی که نمی توانم کنترل کنم، مانند HTML و CSS ضعیف انجام شده، به من صبر بده”).

من تصمیم گرفتم از Selenium Web Driver همانطور که یک مرورگر واقعی مانند فایرفاکس می نامد برای پیمایش در وب سایت استفاده کنم. سلنیوم به شما امکان می‌دهد تا عملکردهای مرورگر را خودکار کنید در حالی که همان HTML رندر شده‌ای را دریافت می‌کنید که هنگام پیمایش در سایت مشاهده می‌کنید.

سلنیوم یک ابزار پیچیده است و از شما می‌خواهد که مدتی را صرف آزمایش کنید که چه چیزی مؤثر است و چه چیزی نیست. در زیر یک اسکریپت ساده است که من نوشتم تا همه اسامی دوندگان و پیوندهای جزئیات مسابقه را در یک اجرا دریافت کنم:

import re
from time import sleep

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.firefox.options import Options
from selenium.webdriver.firefox.webdriver import WebDriver
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.support import expected_conditions
# AthLinks is nice enough to post the race results and their interface is very human-friendly. Not so machine parsing friendly.
RESULTS = "https://www.athlinks.com/event/382111/results/Event/1062909/Course/2407855/Results"
LINKS = {}


def print_links(web_driver: WebDriver, page: int) -> None:
    for a in web_driver.find_elements(By.TAG_NAME, "a"):
        href = a.get_attribute('href')
        if re.search('Bib', href):
            name = a.text.strip().title()
            print(f"Page={page}, {name}={href.strip()}")
            LINKS[name] = href.strip()


def click(level: int) -> None:
    button = WebDriverWait(driver, 20).until(
        expected_conditions.element_to_be_clickable((By.CSS_SELECTOR, f"div:nth-child({level}) > button")))
    driver.execute_script("arguments[0].click();", button)
    sleep(2.5)


options = Options()
options.add_argument("--headless")
driver = webdriver.Firefox(options=options)
driver.get(RESULTS)
sleep(2.5)
print_links(driver, 1)
click(6)
print_links(driver, 2)
click(7)
print_links(driver, 3)
click(7)
print_links(driver, 4)
click(9)
print_links(driver, 5)
click(9)
print_links(driver, 6)
click(7)
print_links(driver, 7)
click(7)
print_links(driver, 8)
print(len(LINKS))

کد بالا به سختی قابل استفاده مجدد است، اما با انجام کارهای زیر کار را انجام می دهد:

  1. وب اصلی را دریافت می کند -page با driver.get(...) روش
  2. سپس می شود <a href تگ می کند و کمی می خوابد تا فرصتی برای رندر HTML داشته باشد
  3. سپس آن را پیدا کرده و کلیک می کند > (بعد page) دکمه
  4. این مراحل را در مجموع 8 بار انجام می دهد، زیرا این تعداد صفحاتی از نتایج موجود است (هر کدام page دارای 50 دونده)

برای دریافت نتایج کامل مسابقه، اسکراپر را نوشتم.py کد این کد با پیمایش چندین صفحه و استخراج داده ها سروکار دارد. تظاهرات زیر:

(EmpireStateRunUp) [josevnz@dmaf5 EmpireStateRunUp]$ esru_scraper /home/josevnz/temp/raw_data.csv
1402-12-30 14:05:00,987 Saving results to /home/josevnz/temp/raw_data.csv
1402-12-30 14:05:53,091 Got 377 racer results
1402-12-30 14:05:53,091 Processing BIB: 19, will fetch: https://www.athlinks.com/event/382111/results/Event/1062909/Course/2407855/Bib/19
1402-12-30 14:06:02,207 Wrote: name=Wai Ching Soh, position=1, {'name': 'Wai Ching Soh', 'url': 'https://www.athlinks.com/event/382111/results/Event/1062909/Course/2407855/Bib/19', 'overall position': '1', 'gender': 'M', 'age': 29, 'city': 'Kuala Lumpur', 'state': '-', 'country': 'MYS', 'bib': 19, '20th floor position': '1', '20th floor gender position': '1', '20th floor division position': '1', '20th floor pace': '42:30', '20th floor time': '1:42', '65th floor position': '1', '65th floor gender position': '1', '65th floor division position': '1', '65th floor pace': '54:03', '65th floor time': '7:34', 'gender position': '1', 'division position': '1', 'pace': '53:00', 'time': '10:36', 'level': 'Full Course'}
...

این فقط حداقل دستکاری داده های وب را انجام می دهد page. هدف این کد فقط دریافت اطلاعات در سریع ترین زمان ممکن قبل از تغییر قالب است.

هنوز نمی توان از داده ها همانطور که هست استفاده کرد – نیاز به تمیز کردن دارد. و این مرحله بعدی در این مقاله است.

روش پاکسازی داده ها

دریافت داده‌ها اولین نبرد از بسیاری دیگر است. متوجه ناهماهنگی خواهید شد روی داده ها و مقادیر از دست رفته برای اینکه نتایج عددی خود را خوب کنید، باید فرضیاتی داشته باشید.

خوشبختانه برای من، مجموعه داده بسیار کوچک است (بیش از 375 رکورد، یک رکورد برای هر دونده) بنابراین من توانستم چند قانون برای مرتب کردن فایل داده ای که قرار بود در طول تجزیه و تحلیل خود استفاده کنم، ارائه دهم.

من همچنین داده های خود را با مجموعه داده دیگری که دارای کدهای 3 رقمی کشور و همچنین سایر جزئیات است، تکمیل کردم تا ارائه بهتری داشته باشم.

این data_normalizer.raw_read(raw_file: Path) -> Iterable[Dict[str, Any]] روش کار سنگین رفع ناسازگاری داده ها را قبل از ذخیره در قالب CSV انجام می دهد.

در اینجا هیچ قانون سختی وجود ندارد، زیرا پاکسازی با مجموعه داده ها همبستگی بالایی دارد. به عنوان مثال، برای اینکه بفهمم هر دونده به کدام موج اختصاص داده شده است، باید فرضیاتی را بر اساس آن انجام دهم روی چیزی که روز مسابقه دیدم

اجازه دهید منظورم را با چند کد به شما نشان دهم:

import datetime
from enum import Enum
from typing import Dict

"""
Runners started روی waves, but for basic analysis, we will assume all runners were able to run
at the same time.
"""
BASE_RACE_DATETIME = datetime.datetime(
    year=1402,
    month=9,
    day=4,
    hour=20,
    minute=0,
    second=0,
    microsecond=0
)

class Waves(Enum):
    """
    22 Elite male
    17 Elite female
    There are some holes, so either some runners did not show up or there was spare capacity.
    https://runsignup.com/Race/EmpireStateBuildingRunUp/Page-4
    https://runsignup.com/Race/EmpireStateBuildingRunUp/Page-5
    I guessed who went into which category, based روی the BIB numbers I saw that day
    """
    ELITE_MEN = ["Elite Men", [1, 25], BASE_RACE_DATETIME]
    ELITE_WOMEN = ["Elite Women", [26, 49], BASE_RACE_DATETIME + datetime.timedelta(minutes=2)]
    PURPLE = ["Specialty", [100, 199], BASE_RACE_DATETIME + datetime.timedelta(minutes=10)]
    GREEN = ["Sponsors", [200, 299], BASE_RACE_DATETIME + datetime.timedelta(minutes=20)]
    """
    The date people applied for the lottery determined the colors. Let's assume that
    General Lottery Open: 7/17 9AM- 7/28 11:59PM
    General Lottery Draw Date: 8/1
    """
    ORANGE = ["Tenants", [300, 399], BASE_RACE_DATETIME + datetime.timedelta(minutes=30)]
    GREY = ["General 1", [400, 499], BASE_RACE_DATETIME + datetime.timedelta(minutes=40)]
    GOLD = ["General 2", [500, 599], BASE_RACE_DATETIME + datetime.timedelta(minutes=50)]
    BLACK = ["General 3", [600, 699], BASE_RACE_DATETIME + datetime.timedelta(minutes=60)]

"""
Interested only in people who completed the 86 floors. So is it either a full course or dnf
"""
class Level(Enum):
    FULL = "Full Course"
    DNF = "DNF"

# Fields are sorted by interest
class RaceFields(Enum):
    BIB = "bib"
    NAME = "name"
    OVERALL_POSITION = "overall position"
    TIME = "time"
    GENDER = "gender"
    GENDER_POSITION = "gender position"
    AGE = "age"
    DIVISION_POSITION = "division position"
    COUNTRY = "country"
    STATE = "state"
    CITY = "city"
    PACE = "pace"
    TWENTY_FLOOR_POSITION = "20th floor position"
    TWENTY_FLOOR_GENDER_POSITION = "20th floor gender position"
    TWENTY_FLOOR_DIVISION_POSITION = "20th floor division position"
    TWENTY_FLOOR_PACE = '20th floor pace'
    TWENTY_FLOOR_TIME = '20th floor time'
    SIXTY_FLOOR_POSITION = "65th floor position"
    SIXTY_FIVE_FLOOR_GENDER_POSITION = "65th floor gender position"
    SIXTY_FIVE_FLOOR_DIVISION_POSITION = "65th floor division position"
    SIXTY_FIVE_FLOOR_PACE = '65th floor pace'
    SIXTY_FIVE_FLOOR_TIME = '65th floor time'
    WAVE = "wave"
    LEVEL = "level"
    URL = "url"

FIELD_NAMES = [x.value for x in RaceFields if x != RaceFields.URL]
FIELD_NAMES_FOR_SCRAPING = [x.value for x in RaceFields]
FIELD_NAMES_AND_POS: Dict[RaceFields, int] = {}
pos = 0
for field in RaceFields:
    FIELD_NAMES_AND_POS[field] = pos
    pos += 1

def get_wave_from_bib(bib: int) -> Waves:
    for wave in Waves:
        (lower, upper) = wave.value[1]
        if lower <= bib <= upper:
            return wave
    return Waves.BLACK

def get_description_for_wave(wave: Waves) -> str:
    return wave.value[0]

من از enums استفاده کردم تا مشخص کنم با چه نوع داده ای کار می کنم onمخصوصاً برای نام فیلدها. سازگاری کلیدی است.

پیشنهاد می‌کنیم بخوانید:  مقدمه ای بر ماژول تقویم پایتون

در مورد تمیز کردن داده ها، برخی از اصلاحات واضح وجود داشت که باید اعمال می کردم مانند:

  1. فرمت زمان ها مانند سرعت، زمان مسابقه و غیره روی بنابراین می توان آن را بعدا تجزیه کرد
  2. برخی از مقادیر را با حروف بزرگ بنویسید تا خواندن آنها آسان تر شود
  3. تبدیل رشته اولیه به عدد صحیح برای مقادیری مانند سن، موقعیت و غیره روی. اگر شکست خورد، «عدد نیست» را اختصاص دهید.

به هر حال، ما ماساژ داده ها را تمام نکرده ایم. یک تابع ساده از این مرحله در داخل ماژول داده مراقبت می کند:

# Omitted imports and Enum declarations as they were shown early روی. 
# Check the source code for 'data.py' for more details
def raw_csv_read(raw_file: Path) -> Iterable[Dict[str, Any]]:
    record = {}
    with open(raw_file, 'r') as raw_csv_file:
        reader = csv.DictReader(raw_csv_file)
        row: Dict[str, Any]
        for row in reader:
            try:
                csv_field: str
                for csv_field in FIELD_NAMES_FOR_SCRAPING:
                    column_val = row[csv_field].strip()
                    if csv_field == RaceFields.BIB.value:
                        bib = int(column_val)
                        record[csv_field] = bib
                    elif csv_field in [ RaceFields.GENDER_POSITION.value, RaceFields.DIVISION_POSITION.value, RaceFields.OVERALL_POSITION.value,  RaceFields.TWENTY_FLOOR_POSITION.value,
                        RaceFields.TWENTY_FLOOR_DIVISION_POSITION.value, RaceFields.TWENTY_FLOOR_GENDER_POSITION.value, RaceFields.SIXTY_FLOOR_POSITION.value, RaceFields.SIXTY_FIVE_FLOOR_DIVISION_POSITION.value,
                        RaceFields.SIXTY_FIVE_FLOOR_GENDER_POSITION.value, RaceFields.AGE.value ]:
                        try:
                            record[csv_field] = int(column_val)
                        except ValueError:
                            record[csv_field] = math.nan
                    elif csv_field == RaceFields.WAVE.value:
                        record[csv_field] = get_description_for_wave(get_wave_from_bib(bib)).upper()
                    elif csv_field in [RaceFields.GENDER.value, RaceFields.COUNTRY.value]:
                        record[csv_field] = column_val.upper()
                    elif csv_field in [RaceFields.CITY.value, RaceFields.STATE.value,

                    ]:
                        record[csv_field] = column_val.capitalize()
                    elif csv_field in [RaceFields.SIXTY_FIVE_FLOOR_PACE.value, RaceFields.SIXTY_FIVE_FLOOR_TIME.value, RaceFields.TWENTY_FLOOR_PACE.value,
                        RaceFields.TWENTY_FLOOR_TIME.value, RaceFields.PACE.value, RaceFields.TIME.value ]:
                        parts = column_val.strip().split(':')
                        for idx in range(0, len(parts)):
                            if len(parts[idx]) == 1:
                                parts[idx] = f"0{parts[idx]}"
                        if len(parts) == 2:
                            parts.insert(0, "00")
                        record[csv_field] = ":".join(parts)
                    else:
                        record[csv_field] = column_val
                if record[csv_field] in ['-', '--']:
                    record[csv_field] = ""
                yield record
            except IndexError:
                raise

این esru_csv_cleaner اسکریپت مجموع تلاش های پاکسازی مرحله اول است که داده های خام گرفته شده را می گیرد و یک فایل CSV با برخی اصلاحات مهم می نویسد:

esru_csv_cleaner --rawfile /home/josevnz/temp/raw_data.csv /home/josevnz/tutorials/docs/EmpireStateRunUp/empirestaterunup/results-full-level-1402.csv

اکنون با آماده شدن داده ها، می توانیم به بارگذاری داده ها و پرسیدن چند سوال در مورد مسابقه اقدام کنیم.

روش تجزیه و تحلیل داده ها

هنگامی که داده ها پاک شدند (یا تا حدی که بتوانیم آن ها را تمیز کنیم)، زمان آن است که به اجرای برخی اعداد بپردازیم. قبل از نوشتن کد بیشتر، یک تکه کاغذ برداشتم و از خودم چند سوال در مورد مسابقه پرسیدم:

  • هیچ سطل/خوشه جالبی برای سن، زمان مسابقه، موج و مشارکت کشوری وجود دارد؟
  • دیدن یک هیستوگرام برای سن و کشور خوب است
  • داده ها را شرح دهید! (میانگین، صدک و غیره روی)
  • موارد پرت را پیدا کنید راهی برای اعمال امتیاز Z در اینجا وجود دارد؟

تصمیم گرفتم از پایتون پاندا برای این کار استفاده کنم. این باز شود Source چارچوب دارای زرادخانه ای از ابزارها برای دستکاری داده ها و محاسبه آمار است. همچنین ابزارهای خوبی برای انجام پاکسازی اضافی در صورت نیاز دارد.

پس پانداها چگونه کار می کنند؟

دوره سقوط روی پانداها

اکیداً توصیه می کنم اگر با این ابزار آشنایی ندارید، 10 دقیقه به پانداها مراجعه کنید. برای DataFrame خود، BIB را به دلیل منحصربه‌فرد بودن آن به عنوان یک شاخص تبدیل کردم، و ارزش خاصی برای توابع تجمع ندارد – اما ویژگی «id» منحصربه‌فرد است.

توجه به این نکته مهم است که در این مرحله نیز باید داده ها را عادی سازی کنم که به زودی توضیح خواهم داد:

# Omitted imports and Enum declarations as they were shown early روی. 
# Check the source code for 'data.py' for more details
def load_data(data_file: Path = None, remove_dnf: bool = True) -> DataFrame:
    """
    * The code removes by default the DNF runners to avoid distortion روی the results.
    * Replace unknown/ nan values with the median, to make analysis easier and avoid distortions
    """
    if data_file:
        def_file = data_file
    else:
        def_file = RACE_RESULTS_FULL_LEVEL
    df = pandas.read_csv(
        def_file
    )
    for time_field in [
        RaceFields.PACE.value,
        RaceFields.TIME.value,
        RaceFields.TWENTY_FLOOR_PACE.value,
        RaceFields.TWENTY_FLOOR_TIME.value,
        RaceFields.SIXTY_FIVE_FLOOR_PACE.value,
        RaceFields.SIXTY_FIVE_FLOOR_TIME.value
    ]:
        try:
            df[time_field] = pandas.to_timedelta(df[time_field])
        except ValueError as ve:
            raise ValueError(f'{time_field}={df[time_field]}', ve)
    df['finishtimestamp'] = BASE_RACE_DATETIME + df[RaceFields.TIME.value]
    if remove_dnf:
        df.drop(df[df.level == 'DNF'].index, inplace=True)

    # Normalize Age
    median_age = df[RaceFields.AGE.value].median()
    df[RaceFields.AGE.value].fillna(median_age, inplace=True)
    df[RaceFields.AGE.value] = df[RaceFields.AGE.value].astype(int)

    # Normalize state and city
    df.replace({RaceFields.STATE.value: {'-': ''}}, inplace=True)
    df[RaceFields.STATE.value].fillna('', inplace=True)
    df[RaceFields.CITY.value].fillna('', inplace=True)

    # Normalize overall position, 3 levels
    median_pos = df[RaceFields.OVERALL_POSITION.value].median()
    df[RaceFields.OVERALL_POSITION.value].fillna(median_pos, inplace=True)
    df[RaceFields.OVERALL_POSITION.value] = df[RaceFields.OVERALL_POSITION.value].astype(int)
    median_pos = df[RaceFields.TWENTY_FLOOR_POSITION.value].median()
    df[RaceFields.TWENTY_FLOOR_POSITION.value].fillna(median_pos, inplace=True)
    df[RaceFields.TWENTY_FLOOR_POSITION.value] = df[RaceFields.TWENTY_FLOOR_POSITION.value].astype(int)
    median_pos = df[RaceFields.SIXTY_FLOOR_POSITION.value].median()
    df[RaceFields.SIXTY_FLOOR_POSITION.value].fillna(median_pos, inplace=True)
    df[RaceFields.SIXTY_FLOOR_POSITION.value] = df[RaceFields.SIXTY_FLOOR_POSITION.value].astype(int)

    # Normalize gender position, 3 levels
    median_gender_pos = df[RaceFields.GENDER_POSITION.value].median()
    df[RaceFields.GENDER_POSITION.value].fillna(median_gender_pos, inplace=True)
    df[RaceFields.GENDER_POSITION.value] = df[RaceFields.GENDER_POSITION.value].astype(int)
    median_gender_pos = df[RaceFields.TWENTY_FLOOR_GENDER_POSITION.value].median()
    df[RaceFields.TWENTY_FLOOR_GENDER_POSITION.value].fillna(median_gender_pos, inplace=True)
    df[RaceFields.TWENTY_FLOOR_GENDER_POSITION.value] = df[RaceFields.TWENTY_FLOOR_GENDER_POSITION.value].astype(int)
    median_gender_pos = df[RaceFields.SIXTY_FIVE_FLOOR_GENDER_POSITION.value].median()
    df[RaceFields.SIXTY_FIVE_FLOOR_GENDER_POSITION.value].fillna(median_gender_pos, inplace=True)
    df[RaceFields.SIXTY_FIVE_FLOOR_GENDER_POSITION.value] = df[
        RaceFields.SIXTY_FIVE_FLOOR_GENDER_POSITION.value].astype(int)

    # Normalize age/ division position, 3 levels
    median_div_pos = df[RaceFields.DIVISION_POSITION.value].median()
    df[RaceFields.DIVISION_POSITION.value].fillna(median_div_pos, inplace=True)
    df[RaceFields.DIVISION_POSITION.value] = df[RaceFields.DIVISION_POSITION.value].astype(int)
    median_div_pos = df[RaceFields.TWENTY_FLOOR_DIVISION_POSITION.value].median()
    df[RaceFields.TWENTY_FLOOR_DIVISION_POSITION.value].fillna(median_div_pos, inplace=True)
    df[RaceFields.TWENTY_FLOOR_DIVISION_POSITION.value] = df[RaceFields.TWENTY_FLOOR_DIVISION_POSITION.value].astype(int)
    median_div_pos = df[RaceFields.SIXTY_FIVE_FLOOR_DIVISION_POSITION.value].median()
    df[RaceFields.SIXTY_FIVE_FLOOR_DIVISION_POSITION.value].fillna(median_div_pos, inplace=True)
    df[RaceFields.SIXTY_FIVE_FLOOR_DIVISION_POSITION.value] = df[
        RaceFields.SIXTY_FIVE_FLOOR_DIVISION_POSITION.value].astype(int)

    # Normalize 65th floor pace and time
    sixty_five_floor_pace_median = df[RaceFields.SIXTY_FIVE_FLOOR_PACE.value].median()
    sixty_five_floor_time_median = df[RaceFields.SIXTY_FIVE_FLOOR_TIME.value].median()
    df[RaceFields.SIXTY_FIVE_FLOOR_PACE.value].fillna(sixty_five_floor_pace_median, inplace=True)
    df[RaceFields.SIXTY_FIVE_FLOOR_TIME.value].fillna(sixty_five_floor_time_median, inplace=True)

    # Normalize BIB and make it the index
    df[RaceFields.BIB.value] = df[RaceFields.BIB.value].astype(int)
    df.set_index(RaceFields.BIB.value, inplace=True)

    # URL was useful during scraping, not needed for analysis
    df.drop([RaceFields.URL.value], axis=1, inplace=True)

    return df

من چند کار را در اینجا پس از بازگرداندن CSV تبدیل شده به کاربر به عنوان DataFrame انجام می دهم:

  • برای جلوگیری از تأثیرگذاری بر نتایج تجمع، مقادیر «عدد نیست» (nan) را با میانه جایگزین کرد. این امر تجزیه و تحلیل را آسان تر می کند.
  • ردیف‌های رها شده برای دوندگانی که به طبقه 86 نرسیده‌اند. تجزیه و تحلیل را آسان‌تر می‌کند و تعداد آنها بسیار کم است.
  • برخی از ستون های رشته را به انواع داده های بومی مانند اعداد صحیح، مهرهای زمانی تبدیل کنید
  • تعداد کمی از ورودی ها جنسیت تعریف نشده بودند. این بر زمینه‌های دیگری مانند “جنس_موقعیت” تأثیر گذاشت. برای جلوگیری از تحریف، اینها با میانه پر شدند.

در پایان، بارگذاری DataFrame من به این صورت بود:

(EmpireStateRunUp) [josevnz@dmaf5 EmpireStateRunUp]$ python3
Python 3.11.6 (main, Oct  3 1402, 00:00:00) [GCC 12.3.1 20230508 (Red Hat 12.3.1-1)] روی linux
Type "help", "copyright", "credits" or "license" for more information.

و حاصل آن DataFrame نمونه، مثال:

>>> # Using custom load_data function that returns a Panda DataFrame
>>> from empirestaterunup.data import load_data
>>> load_data('empirestaterunup/results-full-level-1402.csv')
                    name  overall position            time gender  gender position  age  ...  65th floor division position 65th floor pace 65th floor time       wave        level     finishtimestamp
bib                                                                                      ...                                                                                                          
19         Wai Ching Soh                 1 0 days 00:10:36      M                1   29  ...                             1 0 days 00:54:03 0 days 00:07:34  ELITE MEN  Full Course 1402-09-04 20:10:36
22        Ryoji Watanabe                 2 0 days 00:10:52      M                2   40  ...                             1 0 days 00:54:31 0 days 00:07:38  ELITE MEN  Full Course 1402-09-04 20:10:52
16            Fabio Ruga                 3 0 days 00:11:14      M                3   42  ...                             2 0 days 00:57:09 0 days 00:08:00  ELITE MEN  Full Course 1402-09-04 20:11:14
11        Emanuele Manzi                 4 0 days 00:11:28      M                4   45  ...                             3 0 days 00:59:17 0 days 00:08:18  ELITE MEN  Full Course 1402-09-04 20:11:28
249             Alex Cyr                 5 0 days 00:11:52      M                5   28  ...                             2 0 days 01:01:19 0 days 00:08:35   SPONSORS  Full Course 1402-09-04 20:11:52
..                   ...               ...             ...    ...              ...  ...  ...                           ...             ...             ...        ...          ...                 ...
555     Caroline Edwards               372 0 days 00:55:17      F              143   47  ...                            39 0 days 04:57:23 0 days 00:41:38  GENERAL 2  Full Course 1402-09-04 20:55:17
557        Sarah Preston               373 0 days 00:55:22      F              144   34  ...                            41 0 days 04:58:20 0 days 00:41:46  GENERAL 2  Full Course 1402-09-04 20:55:22
544  Christopher Winkler               374 0 days 01:00:10      M              228   40  ...                            18 0 days 01:49:53 0 days 00:15:23  GENERAL 2  Full Course 1402-09-04 21:00:10
545          Jay Winkler               375 0 days 01:05:19      U               93   33  ...                            18 0 days 05:28:56 0 days 00:46:03  GENERAL 2  Full Course 1402-09-04 21:05:19
646           Dana Zajko               376 0 days 01:06:48      F              145   38  ...                            42 0 days 05:15:14 0 days 00:44:08  GENERAL 3  Full Course 1402-09-04 21:06:48

[375 rows x 24 columns]

پس از بارگیری داده ها، من توانستم شروع به سؤال کردن کنم. به عنوان مثال، برای تشخیص نقاط پرت، از امتیاز Z استفاده کردم.

تمام منطق تجزیه و تحلیل با هم نگه داشته شد روی یک ماژول واحد به نام «تجزیه و تحلیل»، جدا از ارائه، بارگذاری داده یا گزارش، برای ترویج استفاده مجدد.

from pandas import DataFrame
import numpy as np
def get_zscore(df: DataFrame, column: str):
    filtered = df
return filtered.sub(filtered.mean()).div(filtered.std(ddof=0)) def get_outliers(df: DataFrame, column: str, std_threshold: int = 3) -> DataFrame: """ Use the z-score, anything further away than 3 standard deviations is considered an outlier. """ filtered_df = df
z_scores = get_zscore(df=df, column=column) is_over = np.abs(z_scores) > std_threshold return filtered_df[is_over]

همچنین، دریافت آمار رایج فقط با تماس بسیار ساده است describe روی داده های ما:

from pandas import DataFrame
def get_5_number(criteria: str, data: DataFrame) -> DataFrame:
    return data[criteria].describe()

برای مثال، اجازه دهید معیارهای خلاصه برای جنبه‌های مختلف مسابقه را به شما نشان دهم:

>>> from empirestaterunup.data import load_data
>>> df = load_data('empirestaterunup/results-full-level-1402.csv')
>>> from empirestaterunup.analyze import get_5_number
>>> from empirestaterunup.analyze import SUMMARY_METRICS
>>> print(SUMMARY_METRICS)
('age', 'time', 'pace')
>>> for key in SUMMARY_METRICS:
...     ndf = get_5_number(criteria=key, data=df)
...     print(ndf)
... 
count    375.000000
mean      41.309333
std       11.735968
min       11.000000
25%       33.000000
50%       40.000000
75%       49.000000
max       78.000000
Name: age, dtype: float64
count                          375
mean     0 days 00:23:03.461333333
std      0 days 00:08:06.313479117
min                0 days 00:10:36
25%                0 days 00:18:09
50%                0 days 00:21:20
75%         0 days 00:25:13.500000
max                0 days 01:06:48
Name: time, dtype: object
count                          375
mean     0 days 01:55:17.306666666
std      0 days 00:40:31.567395588
min                0 days 00:53:00
25%                0 days 01:30:45
50%                0 days 01:46:40
75%         0 days 02:06:07.500000
max                0 days 05:34:00
Name: pace, dtype: object

اطمینان از کارکرد خوب خراش دادن وب، بارگذاری داده ها و تجزیه و تحلیل داده ها ضروری است. تست بخشی جدایی ناپذیر از نوشتن کد است، بنابراین من به اضافه کردن بیشتر آن ادامه دادم و به نوشتن تست های واحد بازگشتم.

بیایید بررسی کنیم که چگونه کد خود را آزمایش کنیم (اگر با تست واحد آشنا هستید، می توانید بخش بعدی را رد کنید)

تست، تست، و بعد از آن … تست بیشتر

من فرض می کنم که شما با نوشتن کدهای کوچک و مستقل برای آزمایش کد خود آشنا هستید. به این آزمایشات واحد می گویند.

چارچوب واحد تست واحد در اصل از JUnit الهام گرفته شده است و طعمی مشابه چارچوب‌های تست واحد اصلی در زبان‌های دیگر دارد. از اتوماسیون تست، به اشتراک گذاری کد راه اندازی و خاموش شدن برای تست ها، تجمیع تست ها در مجموعه ها و استقلال تست ها از چارچوب گزارش پشتیبانی می کند. (از اسناد پایتون)

سعی کردم برای هر روشی که نوشتم یک تست واحد ساده داشته باشم روی کد. این من را از سردردهای زیادی در جاده نجات داد. همانطور که کد را مجدداً اصلاح کردم، راه های بهتری برای بدست آوردن همان نتایج پیدا کردم و اعداد درست را تولید کردم.

آزمون واحد در این زمینه کلاسی است که گسترش می یابد unittest.TestCase. هر روشی که با test_ آزمونی است که باید چندین ادعا را بگذراند.

به عنوان مثال، برای اطمینان از اینکه تجزیه و تحلیل ها مطابق انتظار کار می کنند، یک ماژول تست نوشتم به نام test_analyze:

# Not all test cases are shown, please check the full code of 'test/test_analyze.py'
import unittest
from pandas import DataFrame
from empirestaterunup.analyze import get_country_counts
from empirestaterunup.data import load_data

class AnalyzeTestCase(unittest.TestCase):
    df: DataFrame

    @classmethod
    def setUpClass(cls) -> None:
        cls.df = load_data()

    def test_get_country_counts(self):
        country_counts, min_countries, max_countries = get_country_counts(df=AnalyzeTestCase.df)
        self.assertIsNotNone(country_counts)
        self.assertEqual(2, country_counts['JPN'])
        self.assertIsNotNone(min_countries)
        self.assertEqual(3, min_countries.shape[0])
        self.assertIsNotNone(max_countries)
        self.assertEqual(14, max_countries.shape[0])


if __name__ == '__main__':
    unittest.main()

تا اینجا ما داده ها را دریافت کردیم و مطمئن شدیم که انتظارات را برآورده می کند. من تست های جداگانه ای برای کد تجزیه و تحلیل و همچنین برای اسکراپر نوشتم.

آزمایش رابط کاربری به رویکرد متفاوتی نیاز دارد، زیرا باید کلیک‌ها را شبیه‌سازی کند و منتظر تغییرات صفحه نمایش باشد. گاهی اوقات خرابی ها به راحتی قابل تشخیص هستند (مانند خرابی ها)، اما گاهی اوقات مشکلات بسیار ظریف تر هستند (آیا ما داده های مناسب نمایش داده شده است؟).

پس از اینکه ابتدا روش تجسم نتایج را معرفی کردیم، مجدداً این روش آزمایشی خاص را بررسی خواهیم کرد.

روش تجسم نتایج

من میخواستم استفاده کنم terminal تا حد امکان یافته هایم را تجسم کنم و الزامات را به حداقل برسانم. من تصمیم گرفتم از چارچوب متنی برای انجام آن استفاده کنم.

این فریم ورک بسیار کامل است و به شما اجازه می دهد تا برنامه های متنی بسازید که واکنش گرا و زیبا هستند.

نوشتن آن‌ها نیز آسان است، بنابراین قبل از اینکه به برنامه‌های به‌دست‌آمده عمیق‌تر برویم، بیایید برای یادگیری در مورد Textual مکث کنیم.

رابط های کاربری متنی (TUI) با متنی

پروژه Textual یک آموزش خوب دارد که می توانید آن را بخوانید تا به سرعت عمل کنید.

بیایید کدی را ببینیم. یکی از برنامه ها نام دارد esru_outlier. کد TUI زنده است روی را ماژول برنامه‌ها که با استفاده از z-score چندین جدول را به همراه موارد پرت که قبلاً پیدا کردیم نشان می‌دهد.

OutlierApp (برنامه را گسترش می دهد) تمام اطلاعات اولیه را جمع آوری می کند روی یک جدول برای هر گروه پرت و سپس آن را فراخوانی می کند RunnerDetailScreen برای نمایش جزئیات یک دونده

پیشنهاد می‌کنیم بخوانید:  مقدمه ای بر اجزای Vue هنگام توسعه یک برنامه کاربردی بزرگ، همیشه ایده خوبی است که آن را به اجزای کوچکتر تقسیم کنید تا خواندن، ساختار و نگهداری کد آسانتر شود. اکثر مبتدیان Vue درک می کنند که چه اجزایی حداقل از نظر مفهومی هستند، اما ممکن است به طور کامل درک نکنند که چه چیزی را می توانند و چه کاری را نمی توانند انجام دهند.
تصویری از جدول OutlierApp که موارد پرت را نشان می دهد on نتایج مسابقه
صفحه اول پرت (بر اساس سن، زمان اجرا و سرعت)

کد بعدی با توضیحاتی است که روش ساخت این صفحه را نشان می دهد:

# Only the code of the application shown here
# This application shows 3 tables: SUMMARY_METRICS = (RaceFields.AGE.value, RaceFields.TIME.value, RaceFields.PACE.value)
# Every application in Textual extends the App class
class OutlierApp(App):
    DF: DataFrame = None
    BINDINGS = [ ("q", "quit_app", "Quit"), ]  # Bind 'q' to 'quit_app' method `action_quit_app`, which in turn exists the app
    CSS_PATH = "outliers.tcss"  # Styling can be done externally, similar to using CSS
    ENABLE_COMMAND_PALETTE = False

    def action_quit_app(self):
        self.exit(0)

    def compose(self) -> ComposeResult:
        """
        Here we 'Yield' Widgets/ components that will be rendered in order روی the TUI
        How do the components get their layout روی the screen? They use a cascading style sheet (CSS): outliers.tcss and
        some explicit layout containers like the class `Vertical` that can contain other Widgets
        Here we have a header, tables, and a footer 
        """
        yield Header(show_clock=True)
        for column_name in SUMMARY_METRICS:
            table = DataTable(id=f'{column_name}_outlier')
            table.cursor_type="row"
            table.zebra_stripes = True
            table.tooltip = "Get runner details"
            if column_name == RaceFields.AGE.value:
                label = Label(f"{column_name} (older) outliers:".title())
            else:
                label = Label(f"{column_name} (slower) outliers:".title())
            yield Vertical(
                label,
                table
            )
        yield Footer()

    def on_mount(self) -> None:
        """
        Here we populate each table with data from the DataFrame. Each table has outliers of different types.
        All can be obtained with the `get_outliers` method.
        """
        for column in SUMMARY_METRICS:
            table = self.get_widget_by_id(f'{column}_outlier', expect_type=DataTable)
            columns = [x.title() for x in ['bib', column]]
            table.add_columns(*columns)
            table.add_rows(*[get_outliers(df=OutlierApp.DF, column=column).to_dict().items()])

    @روی(DataTable.HeaderSelected)
    def on_header_clicked(self, event: DataTable.HeaderSelected):
        """
        When the user selects a column header it generates a 'HeaderSelected' event.
        The annotation روی this method tells Textual that we will handle this event here
        We can extract the table, the selected column, and then sort the table contents.
        """
        table = event.data_table
        table.sort(event.column_key)

    @روی(DataTable.RowSelected)
    def on_row_clicked(self, event: DataTable.RowSelected) -> None:
        """
        Similarly, when the user selects a row it generates a RowSelected method
        What we do روی the 'on_row_clicked' method is capture the event, get the row contents, and construct
        a new modal screen (RunnerDetailScreen) which we push روی top of the regular screen.
        There we show the runner details differently. 
        """
        table = event.data_table
        row = table.get_row(event.row_key)
        runner_detail = RunnerDetailScreen(df=OutlierApp.DF, row=row)
        self.push_screen(runner_detail)

کلاس RunnerDetailScreen (تمدید می شود ModalScreen) دستگیره هایی است که جزئیات مسابقه را با استفاده از Markdown فرمت شده نشان می دهد، که با کلیک کردن نشان داده می شود روی جدولی که قبلا رندر شده بود:

اسکرین شات از جزئیات دونده OutlierApp که موارد پرت را نشان می دهد on نتایج مسابقه
Markdown با جزئیات در مورد دونده انتخاب شده ارائه شده است

و در اینجا کدی است که با توضیحات اجازه می دهد:

# Omitted imports and helper methods, only showing TUI-related code. See the 'apps.py' file for full code
class RunnerDetailScreen(ModalScreen):
    ENABLE_COMMAND_PALETTE = False  # Disable the search bar, it is active by default and is not needed here
    CSS_PATH = "runner_details.tcss"  # Handle the styles using external CSS

    def __init__(
            self,
            name: str | None = None,
            ident: str | None = None,
            classes: str | None = None,
            row: List[Any] | None = None,
            df: DataFrame = None,
            country_df: DataFrame = None
    ):
        """
        Override the constructor and load useful data like country ISO codes
        We get the Pandas DataFrame with the details that will be shown to the user
        """
        super().__init__(name, ident, classes)
        self.row = row
        self.df = df
        if not country_df:
            self.country_df = load_country_details()
        else:
            self.country_df = country_df

    def compose(self) -> ComposeResult:
        """
        In compose we prepare the markdown, and we let the MarkdownViewer handle details like 
        a nice automatic table of contents.
        Notice that we call `self.log.info('xxx'). We use that for debugging when this application
        is called using 'textual'.
        """
        bib_idx = FIELD_NAMES_AND_POS[RaceFields.BIB]
        bibs = [self.row[bib_idx]]
        columns, details = df_to_list_of_tuples(self.df, bibs)
        self.log.info(f"Columns: {columns}")
        self.log.info(f"Details: {details}")
        row_markdown = ""
        position_markdown = {}
        split_markdown = {}
        for legend in ['full', '20th', '65th']:
            position_markdown[legend] = ''
            split_markdown[legend] = ''
        for i in range(0, len(columns)):
            column = columns[i]
            detail = details[0][i]
            if re.search('pace|time', column):
                if re.search('20th', column):
                    split_markdown['20th'] += f"\n* **{column.title()}:** {detail}"
                elif re.search('65th', column):
                    split_markdown['65th'] += f"\n* **{column.title()}:** {detail}"
                else:
                    split_markdown['full'] += f"\n* **{column.title()}:** {detail}"
            elif re.search('position', column):
                if re.search('20th', column):
                    position_markdown['20th'] += f"\n* **{column.title()}:** {detail}"
                elif re.search('65th', column):
                    position_markdown['65th'] += f"\n* **{column.title()}:** {detail}"
                else:
                    position_markdown['full'] += f"\n* **{column.title()}:** {detail}"
            elif re.search('url|bib', column):
                pass  # Skip uninteresting columns
            else:
                row_markdown += f"\n* **{column.title()}:** {detail}"
        yield MarkdownViewer(f"""# Full Course Race details     
## Runner BIO (BIB: {bibs[0]})
{row_markdown}
## Positions
### 20th floor        
{position_markdown['20th']}
### 65th floor        
{position_markdown['65th']}
### Full course        
{position_markdown['full']}                
## Race time split   
### 20th floor        
{split_markdown['20th']}
### 65th floor        
{split_markdown['65th']}
### Full course        
{split_markdown['full']}         
        """)
        # This button is used to close this screen and send the user to the previous screen
        btn = Button("Close", variant="primary", id="close")
        btn.tooltip = "Back to main screen"
        yield btn

    @روی(Button.Pressed, "#close")
    def on_button_pressed(self, _) -> None:
        """
        Simple logic, pop the previous screen and make this one disappear
        """
        self.app.pop_screen()

این کلاس قابل استفاده مجدد است. کلاس های دیگری نیز وجود دارد (مانند BrowserApp در این آموزش) که هنگام کلیک کاربر نیز داده ها را ارسال می کند روی یک ردیف جدول، و آن جزئیات با استفاده از این صفحه نمایش مدال نمایش داده می شود.

ما می توانیم ظاهر را با استفاده از CSS سفارشی کنیم (بله، مانند یک برنامه وب). به نظر بسیار شبیه CSS یک برنامه وب است (اما دقیقاً یکسان نیست). به عنوان مثال برای افزودن سبک به یک دکمه، کد زیر است:

Button {
    dock: bottom;
    width: 100%;
    height: auto;
}

همانطور که می بینید، Textual یک چارچوب بسیار قدرتمند است. من را به یاد جاوا Swing می اندازد، اما بدون پیچیدگی اضافی.

اما آیا این فقط اطلاعات در قالب جدول است؟ من همچنین می‌خواستم انواع نمودارهای مختلفی داشته باشم که بتواند رفتارهایی مانند خوشه سنی و توزیع جنسیتی را توضیح دهد. برای آن چند کلاس نوشتم روی ماژول “برنامه ها” با کمک Matplotlib.

نقشه ها با Matplotlib

من می خواستم از چند نمودار برای نمایش داده ها استفاده کنم و آنها را با matplotlib ساختم. کد ایجاد یک جعبه نمودار سن، که نشان می دهد دوندگان شرکت کننده چند سال داشتند، بسیار ساده است.

نمودار جعبه ای که توزیع سنی را در بین مسابقه دهندگان نشان می دهد
نمودار جعبه سنی در Matplotlib که نشان می دهد بیشتر دوندگان در محدوده سنی 40-50 سال قرار داشتند.

و کدی که این نمودار را ایجاد کرده است:

# Not all code is shown here (helper methods, imports)
# Please check the apps.py module to see all missing code
class Plotter:
    def plot_gender(self):
        """
        In this method, we get our data frame filtering by gender and get counts
        Then we create a pie plot
        """
        series = self.df[RaceFields.GENDER.value].value_counts()
        fig, ax = plt.subplots(layout="constrained")
        wedges, texts, auto_texts = ax.pie(
            series.values,
            labels=series.keys(),
            autopct="%%%.2f",
            shadow=True,
            startangle=90,
            explode=(0.1, 0, 0)
        )
        ax.set_title = "Gender participation"
        ax.set_xlabel('Gender distribution')
        
        # Legend with the fastest runners by gender
        fastest = find_fastest(self.df, FastestFilters.Gender)
        fastest_legend = [f"{fastest[gender]['name']} - {beautify_race_times(fastest[gender]['time'])}" for gender in
                          series.keys()]
        ax.legend(wedges, fastest_legend,
                  title="Fastest by gender",
                  loc="center left",
                  bbox_to_anchor=(1, 0, 0.5, 1))

جالب است – بیشتر دوندگان بین 40 تا 50 سال سن داشتند.

حالا بیایید به آزمایش TUI برگردیم.

تست رابط های کاربری

وقتی شروع به کار کردم روی این پروژه کوچک، من می دانستم که قرار است آزمایش های زیادی انجام شود. چیزی که من در مورد آن مطمئن نبودم این بود که چگونه می توانم TUI را آزمایش کنم.

من حدس زدم که حداقل دو راه برای Textual مفید است: یکی اینکه بتوانیم جریان پیام را بین مؤلفه‌ها ببینیم و دیگری با استفاده از تست‌های واحد با چرخش:

دنبال کردن جریان پیام با Textual

Textual از یک حالت توسعه جالب پشتیبانی می کند که به شما امکان می دهد CSS را تغییر دهید و تغییرات را مشاهده کنید روی برنامه شما بدون راه اندازی مجدد همچنین، می‌توانید روش انتشار رویدادهای TUI را مشاهده کنید، که برای اشکال‌زدایی بسیار ارزشمند است.

در یک terminal، شروع کنید console:

(EmpireStateRunUp) [josevnz@dmaf5 EmpireStateRunUp]$ . ~/virtualenv/EmpireStateRunUp/bin/activate
(EmpireStateRunUp) [josevnz@dmaf5 EmpireStateRunUp]$ textual console
▌Textual Development Console v0.46.0                                                                                                                                             
▌Run a Textual app with textual run --dev my_app.py to connect.                                                                                                                  
▌Press Ctrl+C to quit.

سپس در دیگری terminal، برنامه خود را شروع کنید اما از حالت توسعه استفاده کنید:

(EmpireStateRunUp) [josevnz@dmaf5 EmpireStateRunUp]$ textual run --dev --command esru_browser

اگر دوباره چک کنید روی شما console terminal، هر پیامی را که با App.log ارسال کرده اید همراه با رویدادها خواهید دید:

─────────────────────────────────────────────────────────────────────────── Client '127.0.0.1' connected ───────────────────────────────────────────────────────────────────────────
[18:28:17] SYSTEM                                                                                                                                                        app.py:2188
Connected to devtools ( ws://127.0.0.1:8081 )
[18:28:17] SYSTEM                                                                                                                                                        app.py:2192
---
[18:28:17] SYSTEM                                                                                                                                                        app.py:2194
driver=<class 'textual.drivers.linux_driver.LinuxDriver'>
[18:28:17] SYSTEM                                                                                                                                                        app.py:2195
loop=<_UnixSelectorEventLoop running=True closed=False debug=False>
[18:28:17] SYSTEM                                                                                                                                                        app.py:2196
features=frozenset({'debug', 'devtools'})
[18:28:17] SYSTEM                                                                                                                                                        app.py:2228
STARTED FileMonitor({PosixPath('/home/josevnz/EmpireStateCleanup/docs/EmpireStateRunUp/empirestaterunup/browser.tcss')})
[18:28:17] EVENT                                                                                                                                                 message_pump.py:706
Load() >>> BrowserApp(title="Race Runners", classes={'-dark-mode'}) method=None
[18:28:17] EVENT                                                                                                                                                 message_pump.py:697
Mount() >>> DataTable(id='runners') method=<ScrollView.on_mount>
[18:28:17] EVENT                                                                                                                                                 message_pump.py:697
Mount() >>> DataTable(id='runners') method=<Widget.on_mount>
[18:28:17] EVENT                                                                                                                                                 message_pump.py:697
Mount() >>> Footer() method=<Footer.on_mount>
[18:28:17] EVENT                                                                                                                                                 message_pump.py:697
Mount() >>> Footer() method=<Widget.on_mount>
[18:28:17] EVENT                                                                                                                                                 message_pump.py:697
Mount() >>> ToastRack(id='textual-toastrack') method=<Widget.on_mount>
...
RowHighlighted(cursor_row=0, row_key=<textual.widgets._data_table.RowKey object at 0x7fc8d98800d0>) >>> BrowserApp(title="Race Runners", classes={'-dark-mode'}) method=None
[18:28:17] EVENT                                                                                                                                                 message_pump.py:697
Mount() >>> ScrollBarCorner() method=<Widget.on_mount>
[18:28:17] EVENT                                                                                                                                                 message_pump.py:706
Resize(size=Size(width=2, height=1), virtual_size=Size(width=178, height=47), container_size=Size(width=178, height=47)) >>> ScrollBarCorner() method=None
[18:28:17] EVENT                                                                                                                                                 message_pump.py:706
Show() >>> ScrollBarCorner() method=None

با استفاده از unittest و Pilot

این چارچوب دارای کلاس Pilot است که می‌توانید از آن برای برقراری تماس خودکار با ابزارک‌های متنی و منتظر رویدادها استفاده کنید. این بدان معناست که می‌توانید تعامل کاربر با برنامه را شبیه‌سازی کنید تا تأیید کنید که طبق انتظار عمل می‌کند. این قدرتمندتر از تست های واحد معمولی است زیرا می توانید تعاملات UI را با نتایج مورد انتظار پوشش دهید:

import unittest
from textual.widgets import DataTable, MarkdownViewer
from empirestaterunup.apps import BrowserApp


class AppTestCase(unittest.IsolatedAsyncioTestCase):
    async def test_browser_app(self):
        app = BrowserApp()
        self.assertIsNotNone(app)
        async with app.run_test() as pilot:

            """
            Test the command palette
            """
            await pilot.press("ctrl+\\")
            for char in "jose".split():
                await pilot.press(char)
            await pilot.press("enter")
            # This returns the runner screen. Check that it has some contents
            markdown_viewer = app.screen.query(MarkdownViewer).first()
            self.assertTrue(markdown_viewer.document)
            await pilot.click("#close")  # Close the new screen, pop the original one
            # Go back to the main screen, now select a runner but using the table
            table = app.screen.query(DataTable).first()
            coordinate = table.cursor_coordinate
            self.assertTrue(table.is_valid_coordinate(coordinate))
            await pilot.press("enter")
            await pilot.pause()
            markdown_viewer = app.screen.query(MarkdownViewer).first()
            self.assertTrue(markdown_viewer)
            # After validating the markdown one more time, close the app
            # Quit the app by pressing q
            await pilot.press("q")

if __name__ == '__main__':
    unittest.main()

این بسیار باارزش است، و چیزی که بارها برای تأیید اعتبار به یک مجموعه ابزار خارجی نیاز دارد (مثلاً در جاوا کلاس Robot را دارید).

روش اجرای برنامه ها

در نهایت، زمان آشنایی با برنامه های کوچک فرا رسیده است (شما می توانید یک نمایش متحرک از برنامه های کاربردی TUI را در اینجا مشاهده کنید).

مرور از طریق داده ها

این esru_browser یک مرورگر ساده است که به شما امکان می دهد در داده های خام مسابقه پیمایش کنید.

esru_browser

این برنامه تمام جزئیات مسابقه را برای هر دونده در جدولی نشان می دهد که امکان مرتب سازی بر اساس ستون را فراهم می کند.

داده های خام دونده در یک جدول
پنجره esru_browser نتایج همه دوندگان را نشان می دهد. در اینجا می توانید مرتب سازی کنید، دونده ها را جستجو کنید و برای دریافت جزئیات بیشتر کلیک کنید

و پالت فرمان امکان جستجوی دونده ها بر اساس نام را فراهم می کند (این اساساً یک نوار جستجو با منطق فازی است):

race_runners_2023-12-31T18_35_53_558956.svg، جستجوی دوندگان بر اساس نام
مسابقات نشان داده می شود روی پالت را همانطور که تایپ می کنید

گزارش های خلاصه

برای به دست آوردن بینش در مورد رفتار مسابقه‌دهنده، به گزارش‌های خلاصه نیاز دارید (برخلاف بررسی جزئیات هر مسابقه).

این نرم افزار جزئیات مربوط به موارد زیر را ارائه می دهد:

  • تعداد، انحراف معیار، میانگین، حداقل، حداکثر 45%، 50% و 75% برای سن، زمان و سرعت
  • توزیع گروه و تعداد برای سن، موج و جنسیت
esru_numbers

چند واقعیت جالب در مورد مسابقه:

  • میانگین سنی 41 سال و 40 سال بزرگترین گروه سنی بود.
  • اکثریت افراد متعلق به «موج سیاه» بودند.
  • اکثریت مردم مسابقه را بین 20 تا 30 دقیقه به پایان رساندند.
  • جوانترین دونده 11 ساله و مسن ترین آنها 78 سال سن داشت.
آمارهای مورد علاقه، مانند میانگین سنی، موج تعلق آنها، زمان پایان
esru_numbers نمای پرنده ای از همه مسابقه دهندگان را نشان می دهد، دسته بندی شده بر اساس سطل

یافتن موارد پرت

این نرم افزار از امتیاز Z برای پیدا کردن نقاط پرت برای چندین معیار برای این نژاد:

esru_outlier
جدول با جزئیات پرت
صفحه اصلی esru_outlier مسابقه‌هایی را به شما نشان می‌دهد که از الگوهای معمولی پیروی نکرده‌اند

از آنجا که این نتایج به شماره BIB خلاصه می شود، می توانید کلیک کنید روی یک ردیف و دریافت جزئیات بیشتر در مورد یک دونده:

جزئیات مسابقه پرت، از جمله BIB
و شما می توانید جزئیات مربوط به هر نقطه را دریافت کنید. بله، کد قابل استفاده مجدد است و برای نمایش جزئیات برای هر دونده یکسان است

Textual پشتیبانی عالی برای رندر Markdown و همچنین زبان های برنامه نویسی دارد. به کد نگاه کنید تا خودتان متوجه شوید.

چند طرح گرافیکی برای شما

برنامه esru_plot چند نمودار گرافیکی برای کمک به تجسم داده ها ارائه می دهد. داخل، کلاس Plotter تمام کارهای سنگین را انجام می دهد

نمودارهای سنی

این برنامه می تواند دو طعم را برای یک داده ایجاد کند، یکی نمودار جعبه است:

نمودار سنی، نمودار دایره ای
نمودار جعبه سنی که قبلا دیدیم

دومی یک هیستوگرام منظم است:

هیستوگرام سن
هیستوگرام سن مانند نمودار جعبه را نشان می دهد اما سطل ها بیشتر قابل مشاهده هستند. داده های مشابه، راه های زیادی برای توضیح جمعیت شناسی مسابقه.

از هر دو نمودار مشاهده می کنید که سن گروهی که بیشترین شرکت کننده را دارد، براکت 40 تا 45 سال و نقاط پرت در گروه های 10 تا 20 و 70 تا 80 سال است.

شرکت کنندگان در هر طرح کشور

هیستوگرام
این نمودار تمام کشورها را با تعداد شرکت کنندگان، با بهترین دونده از هر کدام نشان می دهد.

در اینجا جای تعجب نیست: اکثریت قریب به اتفاق مسابقات اتومبیلرانی از ایالات متحده و پس از آن مکزیک هستند. جالب اینجاست که برنده مسابقه 1402 اهل مالزی است و تنها 2 دونده در آن شرکت می کنند.

توزیع جنسیتی

پای جنسیت
پای توزیع جنسیتی که بهترین مسابقه را برای هر دسته نشان می دهد

اکثر دوندگان خود را مرد و به دنبال آن زن معرفی کردند.

چه چیز دیگری می توانیم یاد بگیریم؟

esru2023_nyc-1
نیویورک به خوبی نماینده بود روی رویداد بله، من در مورد اداره پلیس نیویورک صحبت می کنم که با تجهیزات کامل کار می کند، نه من روی چپ ؛-)

شرکت در این مسابقه تجربه بسیار خوبی بود. بهترین بخش این بود که به کنجکاوی من دامن زد و باعث شد این کد را بنویسم تا حقایق جالب تری در مورد مسابقه به دست بیاورم.

چیزهای بیشتری برای یادگیری در مورد ابزارهایی که به تازگی در این آموزش دیدید وجود دارد: