Dane z XML do DataFrame #3

W artykule opiszę jak wczytać dane z pliku XML do obiektu DataFrame przy użyciu modułu minidom – minimalnej implementacji modelu DOM.

Pliki projektu są do pobrania: >>tutaj<<

Używany jest identyczny plik XML, jaki opisałem we wcześniejszym wpisie.

Oprócz xml.dom.minidom używam jeszcze podobnie jak poprzednio moduł pandas oraz klasę defaultdict z modułu collections.

Przy użyciu menadżera kontekstu otwieram plik XML, który zostaje wczytany i z którego utworzony jest dokument DOM.

W utworzonym dokumencie wyszukuję listę elementów z pliku XML o nazwie person i po kolei wszystkie pobieram dla każdego z nich wartość atrybutu id, który zapisuję w słowniku persons. Ponadto dla każdego tagu potomnego tj. position, first_name, last_name itp. zapisuję jego wartość.

Tak utworzony słownik persons jest podawany jako argument przy tworzeniu obiektu ramki DataFrame.

kod źródłowy:

import xml.dom.minidom
from collections import defaultdict
import pandas as pd

persons = defaultdict(list)
with xml.dom.minidom.parse(open('persons.xml')) as tree:
    persons_list = tree.getElementsByTagName('person')
    for person in persons_list:
        persons['id'].append(person.getAttribute('id'))
        for tag in ('position', 'first_name', 'last_name', 'email', 'salary'):
            persons[tag].append(person.getElementsByTagName(tag)[0].firstChild.data)


df = pd.DataFrame(persons, columns=persons.keys()).set_index('id')
df['salary'] = df['salary'].astype(float)
print(df.sort_values(by='salary', ascending=False))

Dane z XML do DataFrame #2

W artykule opiszę jak wczytać dane z pliku XML do obiektu DataFrame przy użyciu modułu xml.sax.

Pliki projektu są do pobrania: >>tutaj<<

Używany jest identyczny plik XML, jaki opisałem we wcześniejszym wpisie.

Oprócz xml.sax używam jeszcze podobnie jak poprzednio moduł pandas oraz klasę defaultdict z modułu collections.

Pierwszym etapem przy odczycie danych z pliku XML przy użyciu SAX jest implementacja własnej klasy, która dziedziczy po klasie ContentHandler(). Klasa handlera przesłania trzy metody bazowej klasy: metodę startElement(), która wywoływana jest przy rozpoczęciu odczytu kolejnego taga, metodę characters(), która odczytuje wartości zapisane dla poszczególnego elementu oraz metodę endElement(), która jest wywoływana po zakończeniu odczytu odpowiedniego tagu. Oprócz tego w metodzie __init__() tworzę egzemplarz słownika, w którym będą zapisywane dane odczytane z pliku XML.

W metodzie startElement() definiuję atrybut klasy o nazwie tag, a także jeśli przetwarzanym tagiem jest ‘person’ zapisuję numer id dla osoby.

Następnie w metodzie characters() zapisuję wartości przechowywane w poszczególnych elementach jako odpowiednie zmienne klasy.

W metodzie endElement() zapisuję pobrane wartości do słownika persons.

Parsowanie pliku XML za pomocą modułu xml.sax polega na wywołaniu metody make_parser(), która zwraca instancję parsera. Następnie do utworzonego parsera jest jako argument funkcji setContentHandler przypisywana jest instancja handlera – w tym przypadku klasy PersonsHandler.

Następnie wywoływana jest metoda parse(), która dokonuje parsowania źródłowego dokumentu XML.

Zmienna persons przechowuje wartości słownika utworzonego przez handlera i ten słownik jest podawany jako parametr przy tworzeniu ramki DataFrame.

kod źródłowy:

import xml.sax
from collections import defaultdict
import pandas as pd

class PersonsHandler(xml.sax.ContentHandler):
    def __init__(self):
        self.persons = defaultdict(list)
    def startElement(self, tag, attr):
        self.tag = tag
        if tag == 'person':
            self.persons['id'].append(attr['id'])
            
    def characters(self, content):
        if content.strip():
            if self.tag == 'position': self.position = content
            elif self.tag == 'first_name': self.first_name = content
            elif self.tag == 'last_name': self.last_name = content
            elif self.tag == 'email': self.email = content
            elif self.tag == 'salary': self.salary = content
    
    def endElement(self, tag):
        if tag == 'position': self.persons['position'].append(self.position)
        elif tag == 'first_name': self.persons['first_name'].append(self.first_name)
        elif tag == 'last_name': self.persons['last_name'].append(self.last_name)
        elif tag == 'email': self.persons['email'].append(self.email)
        elif tag == 'salary': self.persons['salary'].append(self.salary)

parser = xml.sax.make_parser()
parser.setContentHandler(PersonsHandler())
parser.parse(open('persons.xml'))
persons = parser.getContentHandler().persons


df = pd.DataFrame(persons, columns=persons.keys()).set_index('id')
df['salary'] = df['salary'].astype(float)
print(df.sort_values(by='salary', ascending=False))

Dane z XML do DataFrame #1

W artykule opiszę jak wczytać dane z pliku XML do obiektu DataFrame przy użyciu modułu xml.etree.ElementTree.

Pliki projektu są do pobrania: >>tutaj<<

Przykładowy plik XML będzie opisywał osoby zatrudnione w firmie i będzie miał następującą postać:

<persons>
    <person id="">
        <position></position>
        <first_name></first_name>
        <last_name></last_name>
        <email></email>
        <salary></salary>
    </person>
</persons>

Dla każdej osoby, która posiada unikalny atrybut id zapisane są następujące dane: stanowisko, imię, nazwisko, email i wynagrodzenie.

Na początek importuję niezbędne moduły tj. xml.etree.ElementTree do parsowania dokumentu XML. Z modułu collections importuję defaultdict, który będzie przechowywał listy zawierające imiona, nazwiska , wynagrodzenia itd., który to słownik podaję następnie jako argument klasy DataFrame.

import xml.etree.ElementTree as et
from collections import defaultdict
import pandas as pd

W kolejnym wierszu tworzę słownik, który będzie przechowywał dane o pracownikach pozyskane z pliku XML:

persons = defaultdict(list)

Następnie wczytuję plik XML i pobieram element nadrzędny – root – persons, a następnie dla każdego elementu podrzędnego – person pobieram wartość jego atrybutu id, a także wartości jego elementów: position, first_name, last_name itd. Każda z tych wartości jest dodawana jako kolejny element odpowiedniej listy słownika.

tree = et.parse("persons.xml")
root = tree.getroot()
for child in root:
    id = child.attrib.get('id')
    position = child.find('position').text
    first_name = child.find('first_name').text
    last_name = child.find('last_name').text
    email = child.find('email').text
    salary = child.find('salary').text
    
    persons['id'].append(id)
    persons['position'].append(position)
    persons['first_name'].append(first_name)
    persons['last_name'].append(last_name)
    persons['email'].append(email)
    persons['salary'].append(salary)

Tak utworzony słownik podaję jako argument do tworzonego obiektu ramki, przy czym jako nazwy kolumn podaję nazwy kluczy słownika, a jako indeks ramki podaję kolumnę id.

Następnie typ danych kolumny salary zmieniam na float, aby posortować ramkę względem malejących wartości z tej kolumny. Ewentualnie konwersji na float można by dokonać przy tworzeniu obiektu DataFrame podając dodatkowo argument dtype.

Krótki skrypt do obróbki danych z pliku Excela

W bieżącym wpisie opiszę skrypt, który wczytuje dane z listy tankowań w nowszym formacie MS Excel – xlsx.

Skrypt sumuje zakupione paliwo w danym okresie, następnie wyświetla, jaką ilość paliwa zatankowano do poszczególnych pojazdów. W razie rozbieżności z danymi źródłowymi – kartami kierowcy, można wyświetlić szczegółową listę tankowań dla konkretnego pojazdu.

Katalog ze skryptem zawiera również podkatalog “Dane”, w którym umieszczam pliki kolejnych list tankowań.

W pliku Excela komórki pierwszego wiersza definiują nazwy poszczególnych kolumn z danymi m.in. “Ilość” – określa ile paliwa zatankowano podczas jednego tankowania, “Data” – określa datę tankowania, “Numer rejestracyjny” – zawiera numer rejestracyjny pojazdu.

Skrypt zawiera jedną klasę Reports, która zawiera dwie metody: __init__() oraz parser().

W metodzie __init__() określam kolejno katalog, w którym jest umieszczony skrypt main.py i wartość tą przypisuję do zmiennej script_dir. Następnie definiuję zmienną data_dir, która zawiera ścieżkę do podkatalogu “Dane”, który zawiera pliki *.xlsx z danymi. Z plików tych jest sporządzana lista plików, z której jest wybierany najnowszy plik. Dane z najnowszego pliku są wczytywane jako obiekt ramki pandas, który to jest przypisane jako atrybut klasy – zmienna self.df, dzięki czemu obiekt ten będzie dostępny także dla innych metod klasy, gdy metoda __init__() zakończy swoje działanie. Następnie jest wywoływana metoda parser().

W metodzie parser() na początku czyszczę terminal z poprzednich wpisów:

os.system('cls' if os.name == 'nt' else 'clear')

W zależności od systemu, na którym jest uruchamiany skrypt wykonywana jest komenda cls – dla Windows lub clear dla Linux lub MacOS.

Następnie definiuję dwie zmienne: f_date oraz l_date, które przechowują daty odpowiednio pierwszego i ostatniego tankowania na liście tankowań. W kolejnym wierszu wyświetlam nagłówek zestawienia, a następnie zamieniam znak przecinka z danych z kolumny “Ilość” – separatora części dziesiętnej na znak kropki.

Całe pobrane paliwo na wszystkie samochody obliczam za pomocą:

self.df['Ilość'].sum()

W nieskończonej pętli while pobieram tekst zawierający dowolny fragment numeru rejestracyjnego lub “/q” – wyjście za pomocą wywołania wyjątku SystemExit lub “/m” – powrót do głównego zestawienia za pomocą wywołania metody self.parser().

W pętli – for key, value in cars_group[‘Ilość’]: w kolejnych iteracjach sprawdzane jest, czy wybrany fragment tekstu wpisanego przez użytkownika (po zmianie na wielkie litery) jest obecny w numerze rejestracyjnym pojazdu. Jeśli powyższy warunek jest spełniony to zostają wyświetlone wszystkie daty i ilości tankowań. Oprócz wszystkich tankowań dla wybranego pojazdu, wyświetlane jest także podsumowanie zatankowanych litrów dla pojazdu.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
'''
@ author: Sławomir Kwiatkowski
@ date: 2019.10.11
'''

import os
import glob
import pandas as pd


class Reports:
	
    def __init__(self):
        script_dir = os.path.dirname(os.path.abspath(__file__))
        data_dir = os.path.join(script_dir, 'Dane')
        list_of_files = glob.glob(os.path.join(data_dir, '*.xlsx'))
        latest_file = max(list_of_files, key=os.path.getctime)
        path_to_file = os.path.join(data_dir, latest_file)
        self.df = pd.read_excel(path_to_file)
        self.parser()

    
    def parser(self):
        os.system('cls' if os.name == 'nt' else 'clear')
        print()
        f_date = self.df['Data'].min()
        l_date = self.df['Data'].max()

        print('>>>Zestawienie paliwa za okres od {} do {}.{}.{}<<<'.format(f_date.day, 
        l_date.day, l_date.month, l_date.year))
        self.df['Ilość'] = self.df['Ilość'].astype(str)
        self.df['Ilość'] = self.df['Ilość'].str.replace(',', '.')
        self.df['Ilość'] = self.df['Ilość'].astype(float)
        print()
        print('>>>Zakup paliwa: ', self.df['Ilość'].sum())
        cars_group = self.df.groupby('Numer rejestracyjny')
        print()
        print(cars_group['Ilość'].sum())
        print()
        while True:
            choice = input('Podaj nr rej. pojazdu (lub /q-wyjście, /m-ekran główny):  ')
            os.system('cls' if os.name == 'nt' else 'clear')
            if choice == '/q': raise SystemExit
            if choice == '/m': self.parser()
            for key, value in cars_group['Ilość']:
                if choice.upper() in key:
                    print('>>>Tankowania dla pojazdu: ', key)
                    total = 0
                    for i, v in zip(value.index, value.values):
                        print(self.df.loc[i, 'Data'], v)
                        total +=v
                    print(f'>>>Suma tankowań dla {key}: {total} ltr')
                    print()


if __name__ == "__main__":
    reports = Reports()

Obiekt ramki w Pandas na podstawie danych z pliku pdf

Wczytanie danych z pliku pdf wymaga zainstalowanego modułu tabula-py. Moduł ten umożliwia także zapis wczytanych danych do pliku z danymi w formacie csv lub json.

import tabula
df_list = tabula.read_pdf('file.pdf')

Funkcja read_pdf wczytuje domyślnie jedną stronę z pliku pdf, jeśli nie podano wartości dla parametru pages (jeśli chcemy wczytać wszystkie strony to należy wpisać parametr pages=’all’).

Powyższa funkcja zwraca obiekt listy zawierającej kolejne obiekty typu DataFrame, na przykład:

df = df_list[0]        # pierwszy obiekt ramki

Pandas – filtrowanie, grupowanie, eksport do Excela

Na podstawie pliku listy tankowań pobranego w formacie csv przedstawię podstawowe możliwości biblioteki Pandas. We wcześniejszym >>artykule<< opisałem parsowanie za pomocą modułu csv z biblioteki standardowej Pythona.

Plik csv jest plikiem tekstowym, w którym wartości poszczególnych kolumn są oddzielone delimiterem, najczęściej znakiem przecinka. W tym konkretnym przypadku, w pobranym pliku raportu listy tankowań, wartości są oddzielone od siebie znakiem średnika.

Nagłówek danych ma postać: Dane Kontrahenta;Imie;Nazwisko;Numer Korekty;Numer WZ;Data;Godzina;Licznik;Stacja;Numer Rejestracyjny;Numer Karty;Nazwa towaru;VAT procent;Cena bazowa;Cena netto;Cena brutto;Wartość rabatu;Ilość;Netto;VAT;Brutto. Kolejne wiersze zawierają wiersze z danymi dotyczącymi kolejnych tankowań.

Aby wczytać pobrany plik csv o nazwie report.csv musimy utworzyć obiekt ramki danych, gdzie pierwszym argumentem jest ścieżka do pliku, który chcemy wczytać, a argument sep określa rodzaj separatora, tzn.

import pandas as pd

df = pd.read_csv('path/to/report.csv', sep=';')

▣ Na podstawie utworzonego obiektu możemy np. określić ilość poszczególnych wierszy danych np. ilość tankowań z podziałem na różne paliwa tzn.

refuel_amount = df['Nazwa towaru'].value_counts()

Przykładowy wynik :

OLEJ NAPĘDOWY                     466
OPŁATA ZA WYDANIE KARTY FLOTA      1
Name: Nazwa towaru, dtype: int64

▣ Jeśli chcemy np. określić średnią cenę netto dla paliwa zawierającego w nazwie łańcuch: ‘napędowy’ to musimy zastosować filtrowanie tzn.

filter_ = df['Nazwa towaru'].str.lower().str.contains('napędowy')

result = df[filter_]

(Oryginalnie zawartość kolumny Nazwa towaru była pisana dużymi literami, stąd zastosowanie metody lower()). W wyniku zastosowania filtra ramka danych o nazwie result posiada tylko dane związane z wybranym paliwem. Niestety, dane wpisane w kolumnie Cena netto posiadają części dziesiętne oddzielone za pomocą przecinka a nie kropki. Aby dokonać konwersji wszystkich danych z kolumny Cena netto zastosuję metodę replace() a następnie konwersję wartości z kolumny na typ float tzn.

result['Cena netto'] = result['Cena netto'].str.replace(',', '.')

result['Cena netto'] = result['Cena netto'].astype(float)

Średnia cena paliwa dla kolumny Cena netto ramki result utworzonej w efekcie zastosowania filtra:

result['Cena netto'].mean()       # wylicza średnią cenę danego paliwa

Analogicznie stosując funkcje min(), max() możemy wyliczyć najniższą lub najwyższą cenę paliwa w danym okresie dla wybranego paliwa.

▣ Jeśli chcemy obliczyć średnią cenę dla różnych rodzajów paliw z kolumny Nazwa towaru to musimy zastosować grupowanie. Utworzę zmienną fuel przechowującą unikalne wartości z kolumny Nazwa towaru tzn.

fuel = df.groupby(['Nazwa towaru']                # obiekt DataFrameGroupBy

fuel['Cena netto'].mean()        # Zwraca średnią cenę netto dla każdego rodzaju paliwa

W powyższym przykładzie także należy mieć na uwadze, aby kolumna Cena netto zawierała elementy typu float .

▣ Jeśli chcielibyśmy przeszukiwać dane na podstawie daty to można zauważyć, że w pobranym pliku csv mamy dwie kolumny odpowiedzialne za przechowywanie daty i godziny. Są to odpowiednio kolumna Data oraz kolumna Godzina. Dane o wpisach do rejestru tankowań są posortowane, ale jeśli chcielibyśmy znaleźć np. tankowania z określonego zakresu dat lub np. średnią cenę z określonego zakresu dat, to należałoby zamienić wartości z tych obu kolumn na obiekt typu datetime. W poniższym przykładzie utworzę nową kolumnę, która będzie przechowywać wartość typu datetime dla każdego wiersza ramki danych.

df['datetime'] = df['Data'] + ' ' + df['Godzina']    # na razie kolumna z wartościami tekstowymi typu object 

df['datetime'] = df['datetime'].apply(lambda x: datetime.strptime(x, '%Y.%m.%d %H:%M:%S'))

Dla każdej wartości wiersza kolumny datetime zostaje zastosowana funkcja strptime(), która zamienia łańcuch znaków mający postać np. 2020.04.20 22:20:10 w obiekt typu datetime zgodnie z formatem podanym jako drugi argument funkcji.

Wartości z kolumny datetime mogą posłużyć jako index ramki, co ułatwi wyszukiwanie za pomocą wycinków (slices) tzn.

df.set_index('datetime', inplace=True)  # argument inplace=True nadpisuje ramkę df

Jeśli chcemy np. wyświetlić wiersze z kolumn Nazwa towaru oraz Cena netto dla wierszy z zakresu dat np. od 15 do 20 kwietnia 2020 to możemy napisać:

df.loc['2020-04-15':'2020-04-20', ['Nazwa towaru','Cena netto']]

▣ Jeśli chcemy wyświetlić maksymalną i minimalne cenę wszystkich rodzajów paliw z pobranego raportu w tygodniach objętych raportem, to możemy użyć metodę resample():

fuel = df.groupby(['Nazwa towaru'])  # utworzenie grupy
  # z wszystkich rodzajów paliw 
  # występujących w kolumnie 'Nazwa towaru' (unikalne wartości)

fuel['Cena netto'].resample('W').agg(['max', 'min'])  

W powyższym przykładzie utworzyłem najpierw zmienną przechowującą obiekt DataFrameGroupBy, przechowującą unikalne wartości z wierszy tankowań. Następnie dla każdego unikalnego elementu wyświetlam wartości ceny minimalnej i maksymalnej zwracane przez metody min() oraz max(), ale wyniki są wyświetlane dla okresów tygodniowych (argument ‘W’ – week). Analogicznie dla interwałów dwutygodniowych wystarczy jako argument użyć ‘2W’.

Przykładowy wynik :

		                max	min
Nazwa towaru	 datetime		
OLEJ NAPĘDOWY 	2020-04-05	3.39	3.24
                2020-04-19	3.20	2.98
                2020-05-03	2.99	2.87
                2020-05-17	3.11	2.94
                2020-05-31	3.21	3.11
OPŁATA ZA WYDANIE KARTY 2020-05-19 4.07	4.07

▣ Jeśli chcielibyśmy wyświetlić dane dotyczące okresów tygodniowych, ale dla wartości wierszy z kolumny ‘Cena netto’ interesuje nas, jaka w tych tygodniach jest maksymalna i minimalna cena netto, a dla kolumny ‘Ilość’, reprezentującej ilość zatankowanego paliwa chcielibyśmy uzyskać informację o ilości zakupionego paliwa w danym tygodniu:

result = df.resample('W').agg({'Cena netto':['min', 'max'], 'Ilość':'sum'})

W powyższym przykładzie stosujemy funkcję agregującą agg(), która jako argument pobiera słownik, w którym indeksami są nazwy kolumn, a wartościami nazwy funkcji, które chcemy zastosować.

Przykładowy wynik :

	          Cena netto	Ilość
                min	max	sum
datetime			
2020-04-05	3.01	3.11	2969.00
2020-04-12	3.08	3.15	3019.54
2020-04-19	2.88	3.05	3808.03
2020-04-26	2.81	4.02	3902.50
2020-05-03	2.78	2.89	3808.00
2020-05-10	2.74	2.95	4409.00
2020-05-17	2.95	3.05	4008.04
2020-05-24	3.02	3.15	4684.05
2020-05-31	3.12	3.18	4055.00

▣ Zapis lub odczyt z plików Excela:

df = pd.read_excel('path/to/file.xlsx')  # odczyt z pliku file.xlsx

df.to_excel('path/to/file.xlsx')  # zapis do pliku file.xlsx

Aby odczytać/zapisać dane z konkretnego arkusza pliku programu Excel, to jako drugi parametr należy podać argument sheet_name

df.to_excel('path/to/file.xlsx', sheet_name='Arkusz_1')    # zapisuje dane z ramki
 # do pliku file.xlsx w arkuszu o nazwie Arkusz_1

Pandas – wyszukiwanie danych

  • Dwa alternatywne sposoby wyświetlenie danych z konkretnej kolumny i wiersza ramki (w tym przypadku wiersz o indeksie 0 i kolumna o nazwie name)
df.loc[0, "name"]
lub
df.name.loc[0]
  • Wyszukiwanie danych w ramce za pomocą filtra (składnia: df[filtr])
df[df.col_name == "name"]    # zwraca wiersze zawierające wartość name w kolumnie col_name
lub
df[df["col_name"] == "name"] # zwraca wiersze zawierające wartość name w kolumnie col_name

Zawężenie wyniku do wybranych kolumn. Wynikiem są wiersze zawierające wartość name w kolumnie col_name, przy czym wynik jest ograniczony do wyświetlenia wartości z kolumn col_x oraz col_y

df.loc[df.col_name == "name", ["col_x", "col_y"]]
lub
df.loc[df["col_name"] =="name", ["col_x", "col_y"]]