momente şi schiţe de informatică şi matematică
To attain knowledge, write. To attain wisdom, rewrite.

Explorarea datelor orarului şcolar - reveniri

limbajul R | orar şcolar
2020 dec

Unele funcţii din [1] au fost concepute cam după canoanele obişnuite, explicitând ansamblurile de date la nivel individual şi repetând element cu element operaţii asupra acestora (dar în R putem opera direct la nivelul ansamblurilor, ceea ce scurtează programele şi le face expresive); se cade să revenim, încercând să reformulăm lucrurile şi într-un stil mai potrivit limbajului R actual (folosind pachetul purrr).

În fişierul "qar.rds" avem datele unui orar şcolar, structurate ca un tabel cu 5 coloane, indicând pentru fiecare oră (dintre cele 12 posibile, pentru cazul lucrului în două schimburi), din fiecare zi de lucru (dintre cele 5 posibile), la ce clasă trebuie să intre un anumit profesor, pentru a desfăşura o lecţie din corpul unei anumite discipline de studiu; absenţa clasei în acea oră din zi, la acel profesor este marcată prin constanta NA, iar numărul liniilor din tabel este egal cu produsul dintre numărul de zile, numărul de ore pe zi şi numărul de profesori.

În termeni specifici limbajului R, tabelul respectiv este un obiect de tip data.frame, sau unul cu mai multe facilităţi, tibble (introdus de pachetul tidyverse). Se poate accesa (ori extrage) o linie de date, o coloană, sau un element individual, folosind operatorii "[", "[[", sau "$"; de exemplu – indicând prin "." obiectul datelor – .$prof, sau .[[prof]], sau .[, 2] vizează în ansamblu (ca vector) toate valorile din coloana profesorilor, iar .[3, ] vizează valorile de pe a treia linie; .[1, 3:5] extrage un vector cu 3 elemente: clasa, ziua şi ora alocate profesorului din linia 1. Există desigur şi alte posibilităţi de filtrare a liniilor şi de selectare a coloanelor de date.

# explore.R
library(tidyverse)
OPC <- readRDS("qar.rds") %>%
       filter(!is.na(clasa)) %>%
       select(obj, prof, clasa)

'OPC' referă obiectul reconstituit prin readRDS() din fişierul "qar.rds", din care s-au eliminat prin filter() toate liniile care aveau NA în coloana clasa (rămânând astfel cam un sfert din numărul iniţial de linii) şi s-au păstrat (prin select()) numai coloanele care indică disciplina, profesorul şi clasa. %>% (operatorul "pipe") asigură înlănţuirea operaţiilor: obiectul rezultat în stânga devine obiectul operaţiilor din dreapta.

Avem 28 de clase, câte 7 pe fiecare nivel, desemnate printr-o cifră (sau două) care indică nivelul şi o literă dintre primele 7 ale alfabetului. Să constituim un vector care să conţină clasele, în ordinea nivelelor şi literelor (avem şi în [1] – "CLS", dar cu două atribuiri, a doua referind – şi încă de două ori – pe prima):

    # Clasele, după nivel şi literă;  chr[1:28]
Cls <- sort(unique(OPC$clasa))[c(22:28, 1:21)]

OPC$clasa reprezintă vectorul valorilor din coloana 'clasa'; prin unique() se reţin numai valorile distincte, iar acestea sunt apoi aranjate alfabetic prin sort(); în final, prin operatorul de selectare '[' (şi folosind metoda de concatenare c()), am mutat ultimele 7 valori (clasele '9A', ..., '9G') la începutul vectorului (pentru a păstra şi ordinea nivelelor).

Reluăm din [1] (cu anumite reformatări), definiţia care ne dă vectorul disciplinelor:

    # Disciplinele, după nr. de ore alocat (descrescător);  chr[1:15]
Dscp <- OPC %>% group_by(obj) %>% 
                count(sort=TRUE) %>%
        .$obj

În '.$obj', caracterul "." referă obiectul R curent – cel rezultat în stânga operatorului %>% ("pipe") precedent.

Am explicitat cei doi vectori (pentru clase şi pentru discipline) pentru a evita să-i calculăm de fiecare dată când am avea nevoie de ei.

Funcţia "obj_prof()" din [1] ne dădea (în stil imperativ) numărul de profesori pe fiecare disciplină – ea poate fi rescrisă mai în spiritul limbajului R (şi implicit, mult mai scurt şi mai expresiv) astfel:

    # Câţi profesori, pe fiecare disciplină;  Named int[1:15], descrescător
objNprof1 <- function() {
    tmp <- OPC %>% group_by(obj, prof) %>% 
                   count(obj)  # profesorii, pe fiecare disciplină
    sort(summary(as.factor(tmp$obj)), decreasing=TRUE)
}

as.factor() transformă în "factor" coloana indicată, asociindu-i o metodă (levels()) care furnizează vectorul valorilor disticte existente în coloană; apoi, summarize() produce tabelul frecvenţelor acestor nivele în coloana respectivă (rezultând numărul de profesori pe fiecare disciplină).

Putem elimina variabila temporară 'tmp', folosind consecvent "%>%":

objNprof <- function() {  # rescriere a funcţiei 'objNprof1()'
    OPC %>% group_by(obj, prof) %>% 
            count(obj) %>%
            .subset2(1) %>%  # extrage coloana 'obj'
            as.factor() %>%  
            summary() %>%  # vectorul frecvenţelor (câţi profesori pe disciplină)
            sort(., decreasing=TRUE)
}

Rezultă un "vector cu nume", având ca valori numere întregi (spre deosebire de [1], unde obj_prof() ne dădea o matrice cu valori de tip chr, cu o linie a disciplinelor şi una a frecvenţei profesorilor):

> objNprof()
eco eng inf rom mat chi fiz fra geo ist sum art edf rel bio  # nivelele factorului
 10   5   5   5   4   3   3   3   3   3   3   2   2   2   1  # frecvenţa nivelelor

Presupunând că profesorii au fost grupaţi pe discipline, în ordinea descrescătoare a numărului de profesori pe disciplină, ce index va avea în tabelul respectiv, primul profesor de la fiecare disciplină? De exemplu, primul de "eco" ar avea indexul 1, primul de "eng" ar avea indexul 1+10=11, primul de "inf" ar avea indexul 11+5=16, ş.a.m.d.
Rezolvăm cel mai simplu folosind funcţia cumsum() (care pentru un vector numeric dat, produce vectorul sumelor cumulate):

    # indecşii primilor pe disciplină (şi numele fiecăreia), în tabelul încadrărilor
firstIdx <- function() {
    oNp <- objNprof();  n <- length(oNp)
    idx <- cumsum(oNp) + 1
    idx <- c(1, idx[1:(n-1)])
    names(idx) <- names(oNp)
    idx  # return(idx)
}
> firstIdx()
eco eng inf rom mat chi fiz fra geo ist sum art edf rel bio 
  1  11  16  21  26  30  33  36  39  42  45  48  50  52  54 

Vom putea folosi firstIdx() pentru a înscrie disciplina numai la primul profesor din grupul de linii asociate în tabelul de încadrare disciplinei respective (în loc de a repeta de exemplu "eco" pe 10 linii consecutive) – ca în funcţia următoare, care şablonează „tabelul de încadrare” (v. şi [1]), ca obiect data.frame cu câte o coloană pentru fiecare clasă (şi coloane pentru disciplină şi profesor); lungimea coloanelor este egală cu suma numerelor din vectorul dat de objNprof():

framTable <- function() {
    np <- sum(objNprof())
    fram <- data.frame(obj = vector(mode="character", length=np), 
                       prf = vector(mode="character", length=np))
    fram[Cls] = vector(mode="integer", length=np) 
    ids <- firstIdx()
    fram[ids, 1] <- names(ids)  # înscrie disciplina, primului pe fiecare disciplină
    fram  # return(fram)
}

Ar fi totuşi de observat că lucrurile nu sunt formulate în cel mai bun mod: funcţia objNprof() este apelată de două ori (pentru a determina np şi apoi, din funcţia firstIdx() prin care am obţinut vectorul ids); n-ar fi deloc greu de "reparat", dar nu mai revenim aici (mult mai greu ar fi, de îndreptat denumirile de funcţii şi variabile, alese cam neinspirat…). Vom folosi mai jos, obiectul "fram" returnat de framTable().

Mai interesant este de rescris „expresiv”, funcţia denumită schedule(), din [1]; era de construit un „tabel de încadrare”, sintetizând profesorii (ca intrări pe linie) şi numărul de ore la fiecare dintre clasele repartizate acestora (având 28 intrări pe coloană).

Să zicem pentru început, că vrem tabelul de încadrare pentru o anumită disciplină, de exemplu "mat"; din tabelul de frecvenţe redat mai sus ştim că sunt 4 profesori pentru "mat", deci tabelul de încadrare va avea 4 linii, cu numele profesorului în primul câmp; deocamdată să nu ne gândim la cele 28 de coloane corespunzătoare claselor, ci doar să listăm una după alta clasele respective pe o a doua coloană, iar într-o a treia coloană să listăm unul după altul numărul de ore la aceste clase ale profesorului de pe linia respectivă.

Putem realiza un astfel de tabel înlănţuind următoarele operaţii: extragem liniile din OPC care au "mat" în coloana $obj; „grupăm” liniile obţinute după valorile din coloanele $prof şi $clasa; contorizăm liniile cu aceeaşi valoare în aceste două coloane, rezultând un „tabel” cu câte o singură astfel de linie (pentru fiecare profesor şi clasă) şi cu un câmp suplimentar $n pe care se înscrie numărul de linii identice găsite iniţial (deci numărul de ore ale profesorului, la clasa respectivă); despărţim (prin split()) tabelul rezultat, în subtabele – câte unul pentru fiecare profesor; parcurgem subtabelele (prin walk()) şi afişăm (folosind cat() şi paste) profesorul, lista claselor şi lista valorilor $n corespunzătoare:

    OPC %>% filter(obj == "mat") %>%
            group_by(prof, clasa) %>%
            count() %>%
            split(., .$prof) %>%
            walk(., function(P) {
                cat(paste(c(P[1, 1], 
                            paste(P$clasa, collapse=" "),
                            paste(P$n, collapse=" "))
                          ), sep=": ")
                cat("\n")
            })

P15: 10A 10E 11A 11B 12A 9D: 4 3 4 4 4 3  # încadrarea profesorului 'P15'
P16: 10B 11C 11E 12D 12F 9C 9E 9F: 5 2 3 3 3 2 3 3
P17: 10C 10F 10G 11D 11F 12B 9A: 2 3 3 3 3 5 5
P18: 10D 11G 12C 12E 12G 9B 9G: 3 3 2 3 3 4 3

Metodele walk() şi map() (din pachetul purrr, ataşat implicit prin tidyverse) servesc pentru a executa – mai eficient decât buclând operaţiile prin for – o anumită prelucrare pe elementele unei liste date; pentru map() se recomandă ca funcţia de prelucrare să fie una „pură”, fără efecte secundare precum de exemplu, afişarea pe ecran (cum avem mai sus cu walk()).

Desigur, am avea de făcut operaţiile înlănţuite mai sus nu numai pentru "mat", ci pe toate disciplinele înregistrate în vectorul Dscp; pe de altă parte, trebuie nu să afişăm încadrările rezultate, ci să le structurăm într-un anumit obiect R (pe care apoi, să-l transformăm cumva încât să avem clasele pe 28 de coloane).

Înlocuind walk() din secvenţa de mai sus, cu map_df(), producem un obiect data.frame; ambalăm sub map(), pentru a viza toate disciplinele (şi apoi, sub-tabelele); desigur, mai întâi trebuie (ca şi mai sus) să „grupăm” OPC şi să contorizăm:

grOPC <- OPC %>% group_by(obj, prof, clasa) %>% count()

df_allocations <- function() {
    map(Dscp, function(ob) grOPC %>% filter(obj == ob)) %>%
    map(., function(D) split(D, D$prof)) %>%
    map_df(., map_df, function(P) 
                      data.frame(P[1, 1], P[1, 2], 
                                 CLS = paste(c(P$clasa), collapse=" "), 
                                 ORE = paste(c(P$n), collapse=" ")))
}

Dacă D corespunde disciplinei curente (din lista de obiecte tibble rezultată prin filtrarea din primul map()), iar apoi P este obiectul tibble curent din lista rezultată prin despărţirea lui D în „sub-tabele” prin funcţia aplicată în al doilea map() – atunci obiectul data.frame constituit şi returnat în final pentru acest P conţine o singură linie conţinând disciplina P[1,1], numele profesorului P[1,2], şirul CLS al claselor repartizate acestuia şi numerele de ore ORE corespunzătoare lor.
map_df() reuneşte „liniile” (obiecte data.frame) rezultate pentru fiecare P (sub-tabele în D-curent), iar apoi obiectele data.frame rezultate pentru fiecare instanţă D sunt şi acestea „reunite” prin primul apel map_df() (cel exterior).

Subliniem că a trebuit să apelăm map_df() de două ori: odată (în interiorul apelului iniţial) pentru a produce câte o linie-data.frame pentru fiecare P din D-curent şi a reuni aceste linii într-un data.frame – şi încă o dată (apelul iniţial, aplicat listei de obiecte D) pentru a reuni tabelele-data.frame constituite în interior, într-un obiect data.frame care să fie returnat în final, drept rezultat al funcţiei df_allocations().

Invocăm funcţia din consola R, pentru a exemplifica selectiv rezultatul:

> dfa <- df_allocations()
> dfa[c(1:2,10:12), ]
   obj prof                    CLS         ORE
1  eco  P33     10D 10F 11D 12G 9D   4 3 6 5 4
2  eco  P34 11D 11E 11F 12D 12E 9F 2 8 2 2 2 3
10 eco  P54                    10F           4
11 rom  P02  10E 11A 11F 12E 9D 9E 4 3 3 3 3 3
12 rom  P03      10B 11C 11D 9A 9B   3 5 4 4 4

Însă în coloanele CLS şi ORE avem „şiruri de caractere”, nu (cum vom avea nevoie mai jos) vectori conţinând clasele şi respectiv, numerele de ore; introducem funcţia următoare, care prin strsplit() ne va separa clasele într-o listă şi apoi prin unlist() va tranforma lista respectivă în vector:

splt <- function(Txt)  unlist(strsplit(Txt, " "))
> dfa <- df_allocations()
> dfa[1, 3]
[1] "10D 10F 11D 12G 9D"  # şir de caractere (de lungime 18)
> splt(dfa[1,3])
[1] "10D" "10F" "11D" "12G" "9D"  # vector cu 5 valori (de tip "chr")

Acum, coloanele din fram corespunzătoare claselor respective pot fi accesate prin fram(1, splt(dfa[1,3])) şi vor putea fi înscrise cu numerele din splt(dfa[1, 4]) (în cazul primei linii); funcţia următoare iterează aceasta pentru toate liniile, returnând în final tabelul de încadrare (şi salvându-l ca obiect R, într-un fişier .rds):

allocationsTable <- function() {
    dfa <- df_allocations()
    fram <- framTable()
    for(i in 1:nrow(fram)) {
        fram[i, 2] <- dfa[i, 2]  # numele profesorului
        fram[i, splt(dfa[i, 3])] <- splt(dfa[i, 4])  # numărul de ore la clasele sale
    }
    saveRDS(fram, file="frame.rds")
    return(fram)
}

Pentru a ilustra lucrurile aici, ar fi suficient să selectăm primele câte două linii de la două discipline şi numai coloanele claselor de pe nivelele 9 şi 10:

> source("explore.R")
> fram <- allocationsTable()                                      
> fram[c(1:2, 11:12), 1:16]
   obj prf 9A 9B 9C 9D 9E 9F 9G 10A 10B 10C 10D 10E 10F 10G
1  eco P33  0  0  0  4  0  0  0   0   0   0   4   0   3   0
2      P34  0  0  0  0  0  3  0   0   0   0   0   0   0   0
11 eng P02  0  0  0  3  3  0  0   0   0   0   0   4   0   0
12     P03  4  4  0  0  0  0  0   0   3   0   0   0   0   0

Folosind acum funcţia frame_fmt() din [1], putem re-obţine un fişier-text care poate fi scris pe o coală A4 obişnuită, conţinând încadrarea săptămânală completă (valorile '0' vor fi înlocuite prin '.', iar denumirile coloanelor vor fi simplificate, indicând nivelul clasei numai câte o singură dată).

vezi Cărţile mele (de programare)

docerpro | Prev | Next