Ewy

Analyse av legejobber

Det har vært mye fokus på LIS-stillinger rundt omkring, men her ønsker jeg å ta en titt på hvilke stillinger som legges ut og hvordan disse jobbene presenteres og hvilke kvalifikasjoner det spørres etter. Er det forskjell i hvor mange midlertidige stillinger de ulike sykehusene lyser ut? Bruker de ulike sykehusene ulik ordlyd? Har ordlyden i annonsene endret seg over tid? Hvor mange ber spesifikt om kvinnelige og mannlige søkere?

Med andre ord vil dette være noen eksempler på (kvantitativ) tekstanalyse.

Etisk vurdering

Man må selvsagt tenke gjennom etikken i det man gjør. Her er det snakk om offentlig informasjon, nærmere bestemt utlysninger. De som har publisert materialet har hatt et tydelig ønske om å nå ut i offentligheten, den forventede leserskaren er veldig stor. Det er derfor liten grunn til å tro at noen vil føle overtramp ved at man analyserer det de har lagt ut. Informasjonen er heller ikke sensitiv eller særlig personlig. Jeg prøvde å finne vilkår for siden, men klarte ikke finne noe som forbød nedlasting av innhold. Vi tenker heller ikke bryte noen form for opphavsrett.

Hente ut stillingsannonser

Dette viste seg å være svært enkelt fordi legejobber.no bruker en løpende indeks. I linux kunne annonsene derfor hentes ut med

wget https://www.legejobber.no/Annonse/?wid={a..b}

hvor a og b er hhv. fra- og til-indekser. Man får da en mappe full av html-filer.

Dataprosessering

Neste steg blir å hente ut data fra alle HTML-filene vi har lastet ned. Det finnes sikkert mer effektive metoder, men denne koden henter ut det som er interessant.

library("rvest")
library("rjson")
library("ggplot2")
library("tm")
library("quanteda")
library("wordcloud")

cleanFun <- function(htmlString) {
  return(gsub("<.*?>", "", htmlString))
}

files <- list.files(path=c("part20500_23000","part23001_26000/"), full.names=TRUE, recursive=TRUE)
txt <- list()
jsRaw <- list()
df <- data.frame()
cc <- 1
for(i in files){
  x <- read_html(i, encoding = "UTF-8")
  txt <- x %>%  html_nodes("div.main-text") %>%  html_text()
  tmp <- x %>%  html_nodes("head") %>% html_nodes("script")
  jsRaw <- html_text(tmp[1])
  if(length(txt) > 0){
    jsRaw <- gsub("\\h","/h",jsRaw, fixed = TRUE)
    jsRaw <- gsub("\\ ","/",jsRaw, fixed = TRUE)
    df <- rbind(df, as.data.frame(fromJSON(jsRaw)))
  }
  cc <- cc + 1
}

df$datePosted <- as.Date(df$datePosted)

I HTML-filene har programmerne vært så snille å legge inn json, slik at mye av dataene er strukturert fra før. I tillegg gjør vi om datoene til faktiske tidsobjekter, ikke bare en tekststreng. Nå er i grunn alt klart.

Vi kommer til å få behov for å fjerne HTML-tags, så oppretter en funksjoner for det

cleanFun <- function(htmlString) {
  return(gsub("<.*?>", "", htmlString))
}

Nå kan vi illustrere hvordan antallet stillinger har utviklet over tid, her for et utvalg av datasettet:

Graf med antall jobber utlyst per dato

ggplot(data=df, aes(x=datePosted, color = employmentType, group = employmentType)) + geom_line(aes(), stat="bin", binwidth=10) + theme_bw()
ggsave("datePosted.png", width = 10, height = 5, units = "in")

Vi ser at den røde kurven ikke vises. Denne ligger faktisk bak den grønne. Det ser ut til at legejobber klassifiserer en utlysning som både FULL_TIME og PART_TIME i dette utvalget. Dette er viktig å være klar over, siden vi ellers kan telle dobbelt så mange utlyste stillinger som det faktisk er.

Like enkelt kan vi finne de viktigste arbeidsgiverne i datautvalget og vise hvor stor andel av stillingsutlysningene de står bak;

Graf med de viktigste arbeidsgiverne

arbeidsgiver <- aggregate(data = df, X.type~hiringOrganization.name + employmentType, length)
ggplot(arbeidsgiver, aes(x = "", y = X.type, fill = ifelse(hiringOrganization.name %in% hiringOrganization.name[order(X.type, decreasing=TRUE)][1:10], hiringOrganization.name,"Other"))) + 
  geom_bar(width = 1, stat = "identity") + coord_polar("y", start=0)  + scale_fill_brewer(palette="Dark2") + theme_bw()  + theme(legend.position = "bottom") + facet_wrap(vars(employmentType)) + labs(fill = "Arbeidsgiver")
ggsave("arbeidsgiver.png", width = 10, height = 5, units = "in")

Igjen ser vi at FULL_TIME og PART_TIME er helt like. Men slik visualisering er effektivt, vi får med en gang mistanke om at i dette utvalget ser Helse Stavanger ut til å ha relativt mange midlertidige stillinger ute.

Nå kan det være gøy å lage en ordsky. Det finnes en guide her som jeg har fulgt. Mye kan kopieres direkte. Her ser vi kun på FULL_TIME.

df$description <- cleanFun(df$description)
df$doc_id <- 1:nrow(df)
df$author <- df$hiringOrganization.name
df$text <- df$description

docs <- Corpus(DataframeSource(df))
toSpace <- content_transformer(function (x , pattern ) gsub(pattern, " ", x))

# Convert the text to lower case
docs <- tm_map(docs, content_transformer(tolower))
# Remove numbers
docs <- tm_map(docs, removeNumbers)
# Remove english common stopwords
docs <- tm_map(docs, removeWords, stopwords("norwegian"))
# Remove your own stop word
# specify your stopwords as a character vector
docs <- tm_map(docs, removeWords, c("en", "det", "må", "tlf", "få", "i", "l", "andre", "innen", "frå", "per", "annen", "samt", "via", "får", "uke", "annet", "inntil", "tre"))
# Remove punctuations
docs <- tm_map(docs, removePunctuation)
# Eliminate extra white spaces
docs <- tm_map(docs, stripWhitespace)

dtm <- TermDocumentMatrix(docs)
m <- as.matrix(dtm)
v <- sort(rowSums(m),decreasing=TRUE)
d <- data.frame(word = names(v),freq=v)
head(d, 10)

png("wordcloud.png")
wordcloud(words = d$word, freq = d$freq, min.freq = 1,
          max.words=100, random.order=FALSE, rot.per=0.35, 
          colors=brewer.pal(8, "Dark2"))
dev.off()

Merk at vi har lagt til noen ekstra stopp-ord som ikke skal regnes med i ordskyen. I dette utvalget var disse de viktigste ordene:

Ordsky

Det skal forsåvidt være mulig å fjerne ordstammen med docs <- tm_map(docs, stemDocument, language = "norwegian). Da slipper vi at så mange utgaver av samme ord dukker opp. Gjør vi det, blir det seende slik ut

Ordsky uten ordstamme

Jeg synes imidlertid den første skyen ser best ut, og vil ikke fjerne ordstamme i det følgende. Men det går altså an.

Ettersom "gode" er et ord som tydeligvis forekommer veldig ofte, vil det være interessant å se hva dette ordet korrelerer med. Merk at vi i første omgang ikke ser på ordet som kommer etterpå, men ser på hele teksten: hvilke ord forekommer ofte når ordet "gode" blir brukt i samme tekst?

> findAssocs(dtm, terms = "gode", corlimit = 0.3)
$gode
   samarbeidsevner bedriftsidrettslag           pensjons    norskkunnskaper 
              0.38               0.33               0.30               0.30 

Vi skal nå begynne å se på forskjellen mellom tekster. Derfor skifter vi over til pakken quanteda, en pakke spesifikt for kvantitativ tekstanalyse. Vi gjør om corpus-objektet vårt:

corp <- corpus(docs)
meta(corp, field = "author") <- df$author

og har nå quanteda-corpus-objekt kalt corp. Først, la oss se mer kvantitativt på de hundre viktigste ordene:

features_dfm <- textstat_frequency(dfm(corp), n = 100)
features_dfm$feature <- with(features_dfm, reorder(feature, -frequency))

ggplot(features_dfm, aes(x = feature, y = frequency)) +
  geom_point() +
  theme(axis.text.x = element_text(angle = 90, hjust = 1, vjust = 0.2))
ggsave("100_viktige_ord.png", width = 18, height = 6)

Bruker alle disse ordene like mye? Her har vi ikke justert for antall annonser eller antall ord per annonse, men grovt ser det slik ut

freq_grouped <- textstat_frequency(dfm(corp),
                                   groups = "author")

freq_ord <- subset(freq_grouped, freq_grouped$feature %in% c("gode", "norsk", "lege", "lønn", "samarbeid", "erfaring", "faglig", "egnethet") & freq_grouped$group %in% c("St. Olavs Hospital HF", "Akershus universitetssykehus","Helse Stavanger HF","Oslo universitetssykehus HF"))

ggplot(freq_ord, aes(x = group, y = frequency)) +
  geom_point() +
  xlab(NULL) +
  ylab("Frequency") +
  theme(axis.text.x = element_text(angle = 90, hjust = 1, vjust = 0.2)) + facet_wrap(vars(feature), scale = "free_y", nrow = 2)
ggsave("freq.png", width = 6, height = 6)

For å få relative tall må vi summere antall ord per forfatter;

freq_ord_alle <- subset(freq_grouped, freq_grouped$group %in% c("St. Olavs Hospital HF", "Akershus universitetssykehus","Helse Stavanger HF","Oslo universitetssykehus HF"))
freq_ord_rel <- freq_ord
for(i in unique(freq_ord$group)){
  freq_ord_rel$relfrequency[freq_ord$group == i] <- freq_ord_rel$frequency[freq_ord$group == i]/sum(freq_ord_alle$frequency[freq_ord$group == i])
}

En overflatisk tolkning tyder kanskje på at St Olavs er opptatt av lønn og samarbeid, mens Akershus bryr seg mer om erfaring og egnethet.

La oss nå sammenligne St Olavs med Oslo universitetssykhus (OUS):

author_corpus <- corpus_subset(corp, author %in% c("St. Olavs Hospital HF", "Oslo universitetssykehus HF"))
author_dfm <- dfm(author_corpus, groups = "author", remove = stopwords("norwegian"),
                remove_punct = TRUE)
result_keyness <- textstat_keyness(author_dfm, target = "St. Olavs Hospital HF")
png("keyness.png")
textplot_keyness(result_keyness, margin = 0.2, n = 10)
dev.off()

Hva ser vi egentlig her?

Keyness is a measure of to what extent some features are specific to a (group of) document in comparison to the rest of the corpus, taking into account that some features may be too rare. (http://pablobarbera.com/ECPR-SC105/code/14-text-discovery.html)

Så dette er ord som hjelper oss med å skille utlysninger fra St Olavs og utlysninger fra OUS. Det ser for eksempel ut som om OUS har noen annonser på engelsk. Det er vel heller ikke overraskende at geografiske ord som Orkdal dukker opp.

En annen måte å sammenligne tekster på, er å vise de hyppigste ordene i hver tekst, slik som vi gjorde med ordskyer over. Vi kan imidlertid separere skyen etter forfatter:

png("wordcloud_author.png")
corpus_subset(corp,
              author %in% c("St. Olavs Hospital HF", "Akershus universitetssykehus","Helse Stavanger HF","Oslo universitetssykehus HF")) %>%
  dfm(groups = "author", remove = stopwords("norwegian"), remove_punct = TRUE) %>%
  dfm_trim(min_termfreq = 5, verbose = FALSE) %>%
  textplot_wordcloud(comparison = TRUE)
dev.off()

Contents