În exercițiul Statistici SARS-CoV-2 am analizat împreună niște date. Dacă vă gândiți la efortul depus, bună parte din muncă a fost dedicată citirii datelor din fișier. Acela era și scopul principal al exercițiului. Cu toate acestea, ne așteptăm ca procesul de citire a datelor să fie destul de similar pentru multe analize. De asemenea, ne putem gândi la o serie de operații comune, pe care le aplicăm destul de des încât să nu merite rescrise de fiecare dată.
Biblioteca pandas
oferă funcționalități pentru manipularea facilă a datelor, ce ne vor face munca mult mai ușoară. În această lecție vom face un scurt tur prin funcționalitățile oferite, în timp ce vom încerca să aflăm lucruri noi analizând datele climatologice publicate de Guvernul României.
Documentația oficială pentru pandas
poate fi gasită aici - acesta este locul unde veți găsi răspuns la toate întrebările legate de această bibliotecă. De fiecare dată când folosim o nouă funcție, investiți câteva secunde în a răsfoi documentația oficială pentru funcția respectivă. Vă veți mulțumi mai târziu!
Formatul în care datele sunt colectate și stocate depinde foarte mult de tipul de date, sursa datelor, cantitatea de date și tipul de analiză pe care dorim să îl facem.
Pentru moment, vom presupune că operăm cu relativ putine date, ce pot fi scrise într-un fișier în format text. Un format popular pentru acest tip de date este CSV (Comma Separated Values). Este doar un fișier text în care putem scrie un tabel ale cărui celulele sunt separate prin virgule, similar cu un tabel Microsoft Excel. Motivul pentru care acest format este atât popular este simplitatea lui: este doar un fișier text, ce poate fi prelucrat de orice editor de text. Alte formate pentru fișiere, precum .xls, folosit de Excel, au nevoie de programe specializate, ce adesea necesită licențe scumpe.
Un exemplu de fișier CSV este:
CODST,ALT,LAT,LON,DATCLIM,TMED,TMAX,TMIN,R24 15015,503.00,47.7769444,23.9405556,2016/01/01 00, -10.0, -5.7, -13.6, 15015,503.00,47.7769444,23.9405556,2016/01/02 00, -11.0, -5.9, -14.4, 15015,503.00,47.7769444,23.9405556,2016/01/03 00, -12.2, -5.5, -16.4, 15015,503.00,47.7769444,23.9405556,2016/01/04 00, -11.0, -5.3, -16.6, .0 15015,503.00,47.7769444,23.9405556,2016/01/05 00, -5.7, -4.6, -8.1, 1.5
Acest fișier conține date climatologice de la 23 de stații esențiale de pe teritoriul României. Prima linie conține o descriere a coloanelor, iar valorile sunt separate prin virgulă.
Biblioteca pandas pune la dispoziție funcționalități pentru citirea datelor din multe formate comune, precum CSV sau Excel.
# Este prima dată când folosim pandas, deci trebuie să importăm biblioteca
# O importăm cu prescurtarea pd - o tehnică comună pentru a scurta codul
import pandas as pd
# Fișierul CSV poate fi citit folosind funcția read_csv
# Aruncați un ochi pe documentația funcției: https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.read_csv.html#pandas.read_csv
# După cum observați, are un număr impresionant de argumente ce permit o mare flexibilitate
climatic_2016 = pd.read_csv('Data/RoGovClimatic/climrbsn2016.csv')
# Tipul de date returnat de read_csv este DataFrame - tipul fundamental în Pandas pentru manipularea datelor
# Putem inspecta un DataFrame doar scriindu-i numele într-o celula
climatic_2016
https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.shape.htmlÎnainte de orice analiză, trebuie să înțelegem datele. Uitându-ne la rezultatul celulei de mai sus, putem face multe observații despre structura datelor noastre.
Descrierea oficială ne spune că tabelul conține: Date climatologice de la cele 23 de stații meteorologice esențiale (flux RBSN: 15015-Ocna Sugatag, 15020-Botoșani, 15090-Iași, 15108-Ceahlău Toaca, 15120-Cluj-Napoca, 15150-Bacău, 15170-Miercurea Ciuc, 15200-Arad, 15230-Deva, 15260-Sibiu, 15280-Vârfu Omu, 15292-Caransebeș, 15310-Galați, 15335-Tulcea, 15346-Râmnicu Valcea, 15350-Buzău, 15360-Sulina, 15410-Drobeta Turnu Severin, 15420-București-Băneasa, 15450-Craiova, 15460-Călărași, 15470-Roșiorii de Vede, 15480-Constanța) care includ temperatura minimă zilnică, temperatura maximă zilnică, temperatura medie zilnică și cantitatea de precipitații zilnice pe intervalul 1961-prezent. Noi analizăm pentru moment doar datele din anul 2016.
Datele sunt aranjate într-un tabel cu 9 coloane, fiecare coloană reprezentând o valoare masurată, iar fiecare linie o măsurătoare. Există o coloană adițională, fără nume, care reprezintă un index. Orice tabel trebuie să aibă una sau mai multe coloane cu rol de index: un identificator unic pentru fiecare observație. În cazul acestor date, nu avem (momentan) un index natural, ca parte din date, așa că vom folosi index-ul construit automat de Pandas.
Pentru claritate, semificația coloanelor este:
CODST - Codul de identificare al stației meteoEste important să înțelegem dimensiunea datelor pe care le avem disponibile și să verificăm dacă sunt date lipsă. Conform descrierii oficiale, ar trebui să avem câte o observație (linie) pe zi din 23 de stații meteorologice pentru fiecare din cele 366 de zile ale anului 2016 (an bisect). Așadar, ne așteptăm la $366 \times 23 = 8418$ linii în tabel:
print('Dimensiunea tabelului este de:', climatic_2016.shape)
Membrul shape este un tuple ce reprezintă dimensiunea tabelului, în formatul (linii, coloane).
Din fericire, nu ne lipsește nicio observație, întrucât numărul de linii returnat de shape
este 8418 - valoarea calculată anterior. Dimensiunea tabelului poate fi văzută și la baza rezultatului celulei care inspectează tabelul.
Ca să aflăm dimensiunea totală a tabelului (numărul de celule din tabel), putem folosi membrul size pus la dispoziție de Pandas.
print('Numărul total de celule din tabel este de:', climatic_2016.size)
Din diverse motive, precum date lipsă (observați că valoarea pentru R24 în primele 3 randuri lipsește) sau date aflate în formate greșite, trebuie să prelucrăm datele înainte de a le analiza. Un început bun este să folosim metoda info, ce ne prezintă informații utile despre tabel:
climatic_2016.info()
Aici este descris intervalul index-ului (de la 0 la 8417), cele 9 coloane cu tipul de date asociat și numărul de valori nenule pentru fiecare coloană. Observăm că mai puțin de jumătate din numărul de observații au valori nenule în coloana R24
, în timp ce toate celelate coloane sunt complete. Mai observăm că tabelul nostru necesită aproape 600 KB, o dimensiune mică pentru computerele moderne, ceea ce înseamnă că ar trebui să fie relativ ușor de procesat.
Într-un DataFrame
, toate valorile dintr-o coloană au același tip de date. Putem vizualiza aceste tipuri de date folosind membrul dtypes.
Haideți să ne asigurăm că tipurile de date pentru fiecare coloană sunt corecte.
climatic_2016.dtypes
Deși teoretic putem face o analiză folosind orice tipuri de date, dacă ne asigurăm de la început că avem tipurile corecte pentru fiecare coloană, ne va fi mult mai ușor în timpul analizei propriu-zise. De examplu, este destul de greu să adăugăm 30 de minute la o oră în format str
sau să comparăm două date în format str
pentru sortare cronologică. Cu toate acestea, dacă datele sunt stocate în formatul corespunzător, aceste operații devin extrem de facile.
Observăm că măsurile climatologice (temperaturi și precipitații) sunt în format float64
. Ne asteptăm să fie numere reale, deci este un tip de date corespunzător. Putem dezbate faptul că nu avem nevoie de o precizie atât de mare, și am putea folosi float32
, un tip de date pentru numere reale ce folosește doar jumătate din cei 64 de biți necesari stocării unei valori de tip float64
. Deși în acest caz, la doar 600 KB, nu va face o diferență practică, vom schimba tipul de date la float32
din motive didactice.
Tipul de date al coloanelor poate fi schimbat folosind metoda astype:
climatic_2016 = climatic_2016.astype({'ALT': 'float32', 'TMED': 'float32', 'TMIN': 'float32', 'TMAX': 'float32', 'R24': 'float32'})
Daca ne uitam iar la informațiile despre tabel,
climatic_2016.info()
, observăm că dimensiunea tabelului a scăzut la aproximativ 430 KB. Putem estima această valoare chiar înainte de a face conversia. Întrucât am schimbat 4 coloane cu 8418 elemente fiecare din float32
in float64
, economisind 4 bytes pentru fiecare element:
print('Ar trebui să economism %d bytes, adică %d kilobytes.' % (4 * 8418 * 4, 4 * 8418 * 4 / 1024))
Destul de precisă estimarea. Putem folosi astfel de estimări să evaluăm dacă merită efectuată operația sau nu.
Poate vă întrebați de ce nu am schimbat și tipul coloanelor LAT
și LON
. Pentru asta, trebuie să facem o mică pauză și să înțelegem cum sunt stocate numerele reale.
În formatul cunoscut ca IEEE754, numerele reale sunt memorate cu o tehnică de virgula mobilă (en: floating point): numărul este reținut ca un întreg și un exponent. Astfel, $1234000$ este reținut ca $1234e3$ (notație științifică) sau $1234 * 10 ^ 3$, în timp ce $123.45678$ este reținut ca $123456e-5$ sau $123456 * 10 ^ {-5}$. Tipurile de date float32
și float64
folosesc 32, respectiv 64 de biți pentru a stoca atât întregul cât și exponentul. În cazul valorilor pentru Longitudine și Latitudine, ele au nevoie de un număr întreg mare, cu 8 cifre. Acesta nu poate fi stocat precis în numarul de biți alocați de float32
(32 de biți), așa că vom avea niște aproximări. Haideți să vedem cât de mari ar fi erorile introduse, folosind Pandas
!
Ideea e simplă: trebuie să calculăm diferențele, la fiecare element, dintre valoarea în float32
și cea in float64
, apoi să vedem care ar fi cea mai mare eroare. Pentru asta, trebuie să accesăm coloana LAT
și să scădem din ea valoarea coloanei cu noul tip de date.
# Putem scădea pur și simplu două coloane cu același număr de elemente
diff = climatic_2016.LAT - climatic_2016.LAT.astype('float32')
# Sintaxă echivalentă pentru accesul unei coloane
diff = climatic_2016['LAT'] - climatic_2016['LAT'].astype('float32')
La extragerea unei coloane, tipul de date returnat este pandas.Series, o listă indexată cu același index ca cel din DataFrame
. Evident, după scădere, rămânem tot cu un obiect de tip Series
:
diff
Ne-ar interesa acum eroarea cu valoarea absolută maximă: cea mai mare eroare pe care o putem întâlni. Pentru asta, calculăm întâi modulul tututor valorilor folosind metoda abs():
diff.abs()
Apoi calculăm valoarea maximă, folosind metoda max():
diff.abs().max()
, ceea ce se traduce într-o eroare de aproximativ 0.2 metri (doar pentru latitudine). În cazul acesta, probabil e acceptabilă eroarea, întrucât nu suntem interesați de localizarea atât de precisă a stațiilor meteo.
Observați că unele metode, precum abs()
, aplică o funcție pe fiecare element al obiectului, fie el DataFrame
sau Series
, în timp ce altele, precum max()
, returnează un scalar.
Observăm că, exceptând coloana DATCLIM
, toate coloanele din tabelul nostru au un tip de date real (float32
sau float64
). Pentru DATCLIM
, în coloana DType
este menționat object
. Acesta nu este un tip propriu-zis, ci este un înlocuitor pentru orice fel de obiect nativ Python, precum list
, dict
, str
etc.
Putem să aflăm tipul valorilor din coloana DATCLIM
folosind funcția Python type
:
# Aici accesăm elementul de la index-ul 0 din Seria climatic_2016.DATCLIM
type(climatic_2016.DATCLIM[0])
Observăm că tipul valorilor este str
. Acest lucru este datorită felului cum read_csv
interpretează datele citite: datele numerice sunt convertite la tipuri numerice, în timp ce restul coloanelor sunt interpretate ca str
, urmând ca în timpul procesării datelor să atribuim tipul corect acestor coloane.
Tipul corect pentru date calendaristice, precum și pentru ore, este datetime64
. Așa că aplicăm o conversie:
climatic_2016 = climatic_2016.astype({'DATCLIM': 'datetime64'})
climatic_2016.head()
Observăm că formatul în care este afișată data s-a schimat, datorită noului tip de date.
În orice set de date trebuie să investigăm dacă avem date lipsă sau eronate. Așa cum am observat mai devreme, avem multe observații cărora le lipsește valoarea R24
. Aceste valori lipsesc chiar din fișier, deci nu e o problemă cu modul de citire al datelor.
Acum trebuie să răspundem la două întrebari: de ce lipsesc aceste valori și cum le tratăm? De exemplu, am putea elimina observațiile incomplete. Din păcate, sunt multe observații fără aceasta valoare, și nu este clar motivul lipsei valorii. Am putea să le înlocuim cu valoarea 0, dar, întrucât există observații cu 0 precipitații, se pare că pur și simplu nu a fost înregistrată valoarea în zilele respective. A pune 0 ar însemna să alterăm datele, întrucât nu suntem siguri că lipsesc observații doar în zilele fără precipitații. Tot ce putem face e să păstrăm coloana așa cum e și să luăm în considerare acest lucru în timpul analizei.
Observăm că valorile din coloanele ALT
, LAT
și LON
descriu doar stația meteo, fiind copiate pentru fiecare observație de la fiecare stație. Pe lângă faptul că această copiere introduce redundanță în tabel, poate îngreuna anumite analize. Haideți să separăm aceste informații într-un alt DataFrame
, ce descrie stațiile meteo, și în acest tabel să păstrăm doar indicativul din coloana CODST
.
Începem prin a separa coloanele CODST
, ALT
, LAT
și LON
într-un alt DataFrame
:
# Folosim paranteze duble pentru că accesam o listă de coloane
statii_meteo = climatic_2016[['CODST', 'ALT', 'LAT', 'LON']]
# Ștergem coloanele suplimentare - păstrăm doar codul stației meteo
climatic_2016 = climatic_2016.drop(columns=['ALT', 'LAT', 'LON'])
statii_meteo
# Eliminăm observațiile duplicate
# https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.drop_duplicates.html
statii_meteo = statii_meteo.drop_duplicates()
statii_meteo
Observați că indexul a rămas bazat pe indexul din vechiul DataFrame
. L-am putea recalcula, folosind metoda reset_index:
statii_meteo.reset_index()
, dar acest DataFrame
are un index natural: CODST
. Așa că vom seta coloana CODST
ca index, folosind metoda set_index:
statii_meteo = statii_meteo.set_index('CODST')
statii_meteo
Ar fi util să avem și numele statiei în acest tabel. Vom pleca de la datele din descriere și vom aplica niște transformări:
nume_statii_str = '15015-Ocna Sugatag, 15020-Botosani, 15090-Iasi, 15108-Ceahlau Toaca, 15120-Cluj-Napoca, 15150-Bacau, 15170-Miercurea Ciuc, 15200-Arad, 15230-Deva, 15260-Sibiu, 15280-Varfu Omu, 15292-Caransebes, 15310-Galati, 15335-Tulcea, 15346-Ramnicu Valcea, 15350-Buzau, 15360-Sulina, 15410-Drobeta Turnu Severin, 15420-Bucuresti-Baneasa, 15450-Craiova, 15460-Calarasi, 15470-Rosiorii de Vede, 15480-Constanta'
# Împărțim string-ul într-o listă de string-uri
nume_statii_list = [x.split('-', 1) for x in nume_statii_str.split(', ')]
nume_statii_list
# Transformăm lista într-un mic DataFrame, avem grijă la numele coloanelor, la tipul lor și la index
# Tipul de date al CODST trebuie să se potrivească cu tipul de date al CODST din celălalt DataFrame
# Observați înlanțuirea operațiilor
nume_statii = pd.DataFrame(nume_statii_list, columns=['CODST', 'Nume']).astype({'CODST': 'int64'}).set_index('CODST')
# Combinăm DataFrame-urile, bazându-ne pe coloana CODST, detectată automat după nume
statii_meteo = statii_meteo.join(nume_statii)
statii_meteo
Acum, că am terminat curățarea datelor, putem să începem analizele propriu-zise. Haideți, pentru început, să vedem care au fost temperaturile minime și maxime în anul 2016.
Pentru asta, putem să ne uităm individual la coloane:
print('Minima înregistrată în anul 2016 a fost de %.1f grade Celsius' % climatic_2016.TMIN.min())
Sau putem să folosim functia describe
, care raportează valori statistice pentru fiecare coloana.
climatic_2016.describe()
Aici primim informații statistice despre fiecare coloană. Înainte de a intra în detalii, observăm că coloana CODST
este tratată numeric: ni se raportează valoarea minimă, maximă, media, etc. Ce sens are media acestor ID-uri? Întrucât ele nu sunt valori numerice, sunt doar identificatori, trebuie tratați ca atare. Putem indica asta folosind tipul de date category
.
climatic_2016 = climatic_2016.astype({'CODST': 'category'})
statii_meteo.index = statii_meteo.index.astype('category')
climatic_2016.describe()
Acum coloana CODST
nu mai este considerată numerică. Să vedem ce informații ne oferă acest tabel.
Prima linie indică numărul de valori nenule (valide) din fiecare coloană, unde vedem din nou valorile lipsă din coloana R24
.
A doua linie indică media fiecarei coloane. Putem observa o medie a temperaturilor maxime zilnice de aproximativ 16 grade Celsius.
A treia linie este o valoare statistică, deviatia standard. O vom explica în detaliu în lecțiile următoare. Intuitiv, ea reprezintă o masură a cât de mult variază datele și este de obicei raportată alături de medie.
A patra linie reprezintă valorile minime. Cum era de așteptat, pentru precipitații minimul este 0, în zilele însorite. Cea mai mică temperatură înregistrată a fost de -24 grade Celsius.
Următoarele trei linii sunt iărași măsuri statistice ce dau informații despre cum sunt distribuite datele. Mai multe despre ele în lecția viitoare.
Ultima linie reprezintă, evident, valoarea maximă.
Haideți să căutăm stația care a înregistrat valoarea minimă:
# Putem selecta linii dintr-un DataFrame indexându-l cu un Series cu valori boolean
# Pentru a găsi linia ce conține temperatura minimă, obținem întâi un Series ce are True doar la indecșii unde întâlnim valoarea minimă
# și folosim acest Series pentru a indexa DataFrame-ul inițial.
temperatura_minima = climatic_2016[climatic_2016.TMIN == climatic_2016.TMIN.min()]
temperatura_minima
Temperatura minimă din 2016 a fost înregistrată pe 23 ianuarie. Haideți să aflăm și numele stației!
# Parametrul `on` specifică numele coloanei folosită când sunt combinate tabelele
temperatura_minima.join(statii_meteo, on='CODST')
Temperatura minimă a fost pe Vârfu Omu, la 2504 metri altitudine. Era de așteptat! Haideți să vedem unde a fost temperatura minimă în afara munților, la altitudini mai mici de 1000 metri.
Avem doar 2 stații la peste 1000 de metri:
statii_meteo[statii_meteo.ALT > 1000]
# Selectăm doar stațiile meteo de deal și de câmpie, aflate la altitudini sub 1000 metri
statii_meteo_deal_campie = statii_meteo[statii_meteo.ALT < 1000]
# Trebuie să combinăm stațiile meteo selectate cu datele climatice, și să eliminăm observațiile din cele două stații meteo montane
# Tipul de join, sau felul în care sunt combinate cele două tabele, este specificat de parametrul `how`
#
# Într-un left join (varianta implicită), vor fi păstrate toate liniile din tabelul din stanga (aici climatic_2016),
# și coloanele adiționale (provenite de la statii_meteo_deal_campie) vor fi populate cu NaN dacă nu s-a găsit un corespondent.
# Practic, am fi păstrat valorile temperaturilor pentru observațiile de la stațiile montane, dar am fi avut NaN pentru ALT, LAT, LON, Nume.
#
# Într-un right join, totul funcționează identic, doar că sunt păstrate liniile tabelului din dreapta.
#
# Într-un outer join am păstra toate liniile și completa cu NaN valorile care lipsesc din oricare tabel.
#
# Într-un inner join păstrăm doar liniile complete, ce avem noi nevoie.
#
# Join-urile sunt operatii importante, și complexe. Nu vă faceți griji dacă nu ați înțeles totul din acest scurt sumar: le vom explora mai în detaliu în curând.
#
climatic_deal_campie = climatic_2016.join(statii_meteo_deal_campie, on='CODST', how='inner')
climatic_deal_campie[climatic_deal_campie.TMIN == climatic_deal_campie.TMIN.min()]
Minimul a fost înregistrat pe 21 ianuarie, la stația din Roșiorii de Vede.
Să ne gândim la următorul scenariu. Suntem întrebați care a fost temperatura medie în anul 2016 pe teritoriul Romaniei. Găsim datele pe care le-am analizat împreună, oficiale, facem analiza de mai sus, și venim cu raspunsul: temperatura medie în 2016 a fost de 10.36 grade Celsius.
Doar că răspunsul nostru este greșit. Noi nu avem date de pe întreg teritoriul României, ci doar de la 23 de stații meteo. Pentru a putea calcula într-adevăr temperatura medie de pe întreg teritoriul țării ar trebui să acoperim întreaga țară cu termometre. Noi am calculat temperatura medie înregistrată în 2016 în principalele stații meteo. Din acelasi motiv, la rubrica meteo întălnim adesea sintagme precum a fost înregistrată o temperatură de - nu știm exact ce temperatură a fost, atât am înregistrat noi, folosind dispozitive ce au o oarecare eroare (orice dispozitiv de masură are o eroare asociată).
O problemă a analizei noastre este ca nu știm eroarea măsurătorilor, ce ar fi trebuit raportată. Ideal, am fi spus nu doar temperatura minimă, ci și un interval de eroare, calculat pe baza erorii observațiilor.
Nu numai că nu am avut destul de multe puncte de măsurare, dar nici nu știm exact cum s-au facut măsuratorile pentru temperatura medie zilnică. Temperatura poate fluctua, și noi să nu fi observat aceste fluctuații pentru că măsurăm temperatura prea rar.
În viața de zi cu zi, unele din aceste erori sunt considerate neglijabile. Da, măsurăm într-un număr mic de puncte, dar beneficiem de faptul că atmosfera regulează variațiile termice atât în spațiu cât și în timp. Așadar, nu observăm adesea variații extreme în perioade scurte de timp, de ordinul minutelor, și nici pe distanțe scurte, de ordinul kilometrilor. O implicație a acestui fapt este că mediile tind să fie mai precise decat valorile extreme.
Așadar, la fiecare analiză, nu trebuie doar să procesăm datele, trebuie de asemenea să ne gândim la sursa lor, la ce erori pot avea și cât de relvante sunt datele pentru analiza noastră.