Data Processing: Third Learning Space, NHA

Author

Alexis Davila

Clean District Data

Import

Packages selected for inspecting and cleaning Third Learning Space, National Heritage Academy data.

library(tidyverse)
── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
✔ dplyr     1.2.1     ✔ readr     2.2.0
✔ forcats   1.0.1     ✔ stringr   1.6.0
✔ ggplot2   4.0.3     ✔ tibble    3.3.1
✔ lubridate 1.9.5     ✔ tidyr     1.3.2
✔ purrr     1.2.2     
── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
✖ dplyr::filter() masks stats::filter()
✖ dplyr::lag()    masks stats::lag()
ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors

Import roster.

roster <- read_csv("TSL Impact Evaluation Data.csv")
Rows: 4340 Columns: 18
── Column specification ────────────────────────────────────────────────────────
Delimiter: ","
chr  (7): School Name*, Date of BOY MAP Math Test*, Date of MOY MAP Math Tes...
dbl (10): Student ID*, Student Grade Level*, Teacher ID*, BOY MAP Math Scale...
lgl  (1): Treatment*

ℹ Use `spec()` to retrieve the full column specification for this data.
ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
colnames(roster)
 [1] "Student ID*"                "Student Grade Level*"      
 [3] "School Name*"               "Teacher ID*"               
 [5] "Treatment*"                 "BOY MAP Math Scaled Score*"
 [7] "Date of BOY MAP Math Test*" "MOY MAP Math Scaled Score*"
 [9] "Date of MOY MAP Math Test*" "EOY MAP Math Scaled Score*"
[11] "Date of EOY MAP Math Test*" "Gender"                    
[13] "IEP"                        "ELL"                       
[15] "FRPL"                       "Race"                      
[17] "Ethnicity"                  "Attendance Rate"           

Change column headers for roster.

roster = roster %>%
  rename(
    student_id = "Student ID*",
    teacher_id = "Teacher ID*",
    grade = "Student Grade Level*", 
    school = "School Name*", 
    treat = "Treatment*", 
    
    boy_math_score = "BOY MAP Math Scaled Score*", 
    boy_math_date = "Date of BOY MAP Math Test*", 
    
    moy_math_score = "MOY MAP Math Scaled Score*", 
    moy_math_date = "Date of MOY MAP Math Test*", 

    eoy_math_score = "EOY MAP Math Scaled Score*", 
    eoy_math_date = "Date of EOY MAP Math Test*", 
    
    gender = "Gender", 
    iep = "IEP", 
    ell = "ELL", 
    frpl = "FRPL", 
    race = "Race",
    ethn = "Ethnicity", 
    attend_rate = "Attendance Rate"
  )
colnames(roster)
 [1] "student_id"     "grade"          "school"         "teacher_id"    
 [5] "treat"          "boy_math_score" "boy_math_date"  "moy_math_score"
 [9] "moy_math_date"  "eoy_math_score" "eoy_math_date"  "gender"        
[13] "iep"            "ell"            "frpl"           "race"          
[17] "ethn"           "attend_rate"   

Inspect school names.

table(roster$school)

       Bennett Venture Academy    Burton Glen Charter Academy 
                           225                            401 
            Holly Park Academy                 Laurus Academy 
                           323                            492 
        Linden Charter Academy  North Saginaw Charter Academy 
                           488                            455 
          Oakside Prep Academy      Stambaugh Charter Academy 
                           612                            260 
        Walton Charter Academy Windemere Park Charter Academy 
                           521                            360 
   Winterfield Venture Academy 
                           203 

Create school_num for each school.

roster = roster %>%
  mutate(school_num = recode(school,
                             "Bennett Venture Academy" = 1,
                             "Burton Glen Charter Academy" = 2,
                             "Holly Park Academy" = 3, 
                             "Laurus Academy" = 4, 
                             "Linden Charter Academy" = 5, 
                             "North Saginaw Charter Academy" = 6, 
                             "Oakside Prep Academy" = 7,
                             "Stambaugh Charter Academy" = 8, 
                             "Walton Charter Academy" = 9, 
                             "Windemere Park Charter Academy" = 10, 
                             "Winterfield Venture Academy"= 11))

table(roster$school_num)

  1   2   3   4   5   6   7   8   9  10  11 
225 401 323 492 488 455 612 260 521 360 203 

Inspect duplicates

Roster

Identify duplicate student records within roster file.

# frequency table of student_id occurrences
id_counts_roster = roster %>%
  group_by(student_id) %>%
  summarise(n = n()) %>%
  arrange(desc(n))

# filter only IDs that appear > 1
dupes_roster = id_counts_roster %>%
  filter(n > 1)

# view results
print(dupes_roster)
# A tibble: 8 × 2
  student_id     n
       <dbl> <int>
1     566870     2
2     605643     2
3     632862     2
4     670363     2
5     680297     2
6     732197     2
7     732203     2
8     832717     2
#export
write.csv(dupes_roster, file = "duplicate_ids_roster_tsl_nha.csv", row.names = FALSE)

Observation: Each duplicate student ID appears exactly 2 times. For a given duplicate student record, it appears to be the same individual (e.g., same demographics, same teacher), however, the duplicate record is tied to 2 different schools. These students have taken one test at one school site and completed their EOY test date at a different school site. This suggests that the students have transferred some point between BOY and EOY testing.

dup_preview_roster = roster %>%
  group_by(student_id) %>%
  filter(n() > 1) %>%
  ungroup()

print(dup_preview_roster)
# A tibble: 16 × 19
   student_id grade school         teacher_id treat boy_math_score boy_math_date
        <dbl> <dbl> <chr>               <dbl> <lgl>          <dbl> <chr>        
 1     566870     7 Walton Charte…      19525 NA                NA <NA>         
 2     566870     7 Oakside Prep …      19525 NA               216 9/10/2025    
 3     605643     8 Walton Charte…      21216 NA               229 8/28/2025    
 4     605643     8 Oakside Prep …      21216 NA                NA <NA>         
 5     632862     7 Oakside Prep …      23257 NA                NA <NA>         
 6     632862     7 Walton Charte…      23257 NA               204 8/28/2025    
 7     670363     3 Linden Charte…      18378 NA               196 9/29/2025    
 8     670363     4 Linden Charte…      18378 NA                NA <NA>         
 9     680297     3 Oakside Prep …      23154 NA                NA <NA>         
10     680297     3 Walton Charte…      23154 NA               181 8/28/2025    
11     732197     5 Walton Charte…      17341 NA                NA <NA>         
12     732197     5 Oakside Prep …      17341 NA               198 9/3/2025     
13     732203     8 Walton Charte…      23087 NA                NA <NA>         
14     732203     8 Oakside Prep …      23087 NA               199 9/10/2025    
15     832717     3 Burton Glen C…      18916 NA                NA <NA>         
16     832717     3 Holly Park Ac…      18916 NA               172 8/21/2025    
# ℹ 12 more variables: moy_math_score <dbl>, moy_math_date <chr>,
#   eoy_math_score <dbl>, eoy_math_date <chr>, gender <chr>, iep <dbl>,
#   ell <dbl>, frpl <dbl>, race <chr>, ethn <chr>, attend_rate <dbl>,
#   school_num <dbl>

Decision: Collapse rows to obtain full test data but keep the school, grade, and demographics where EOY test was taken. Total observations reduced from 4340 to 4332 (8 duplicate student records removed).

#placeholder code
roster_unique = roster %>%
  group_by(student_id) %>%
  summarize(
    # Pull BOY values (non-missing)
    boy_math_score = first(na.omit(boy_math_score)),
    boy_math_date  = first(na.omit(boy_math_date)),
    
    # Pull EOY values (non-missing)
    eoy_math_score = first(na.omit(eoy_math_score)),
    eoy_math_date  = first(na.omit(eoy_math_date)),
    
    # Identify the EOY row (where EOY score exists)
    school      = school[!is.na(eoy_math_score)][1],
    school_num = school_num[!is.na(eoy_math_score)][1],
    grade       = grade[!is.na(eoy_math_score)][1],
    teacher_id = teacher_id [!is.na(eoy_math_score)][1],
    moy_math_score       = moy_math_score[!is.na(eoy_math_score)][1],
    moy_math_date       = moy_math_date[!is.na(eoy_math_score)][1],
    gender      = gender[!is.na(eoy_math_score)][1],
    iep         = iep[!is.na(eoy_math_score)][1],
    ell         = ell[!is.na(eoy_math_score)][1],
    frpl        = frpl[!is.na(eoy_math_score)][1],
    race        = race[!is.na(eoy_math_score)][1],
    ethn        = ethn[!is.na(eoy_math_score)][1],
    attend_rate = attend_rate[!is.na(eoy_math_score)][1],
    
    .groups = "drop"
  )

  
#decision to be made
print(roster_unique)
# A tibble: 4,332 × 18
   student_id boy_math_score boy_math_date eoy_math_score eoy_math_date school  
        <dbl>          <dbl> <chr>                  <dbl> <chr>         <chr>   
 1     366259            202 9/3/2025                 207 5/18/2026     Laurus …
 2     430015            218 9/3/2025                 217 5/20/2026     Laurus …
 3     445701            204 9/29/2025                217 5/26/2026     Linden …
 4     461310            170 9/10/2025                173 6/2/2026      Burton …
 5     471757            224 9/24/2025                236 5/20/2026     North S…
 6     472379            209 9/8/2025                 226 6/2/2026      Oakside…
 7     473083            225 9/23/2025                228 5/18/2026     Walton …
 8     473087            204 9/3/2025                 199 5/18/2026     Walton …
 9     474393            212 9/10/2025                 NA <NA>          <NA>    
10     475569            244 8/28/2025                255 5/18/2026     Walton …
# ℹ 4,322 more rows
# ℹ 12 more variables: school_num <dbl>, grade <dbl>, teacher_id <dbl>,
#   moy_math_score <dbl>, moy_math_date <chr>, gender <chr>, iep <dbl>,
#   ell <dbl>, frpl <dbl>, race <chr>, ethn <chr>, attend_rate <dbl>

Du-dupe check. Confirmed: no more duplicate records.

roster_unique %>%
  count(student_id) %>%
  filter(n > 1)
# A tibble: 0 × 2
# ℹ 2 variables: student_id <dbl>, n <int>

Merge Usage Data

Clean NHA Usage Data

Import usage data from TSL.

#import NHA usage data from TSL
tsl_nha_usage = read_csv("TSL Usage Data.csv")
New names:
Rows: 999 Columns: 24
── Column specification
──────────────────────────────────────────────────────── Delimiter: "," chr
(5): SIS ID, School Name, School Clever ID, Session start date, Conf_ra... dbl
(8): TSL Student ID, Count of Check Out Confidence Answer, Total CO Con... num
(1): Num_minutes* lgl (10): ...15, ...16, ...17, ...18, ...19, ...20, ...21,
...22, ...23, ...24
ℹ Use `spec()` to retrieve the full column specification for this data. ℹ
Specify the column types or set `show_col_types = FALSE` to quiet this message.
• `` -> `...15`
• `` -> `...16`
• `` -> `...17`
• `` -> `...18`
• `` -> `...19`
• `` -> `...20`
• `` -> `...21`
• `` -> `...22`
• `` -> `...23`
• `` -> `...24`
colnames(tsl_nha_usage)
 [1] "TSL Student ID"                       
 [2] "SIS ID"                               
 [3] "School Name"                          
 [4] "School Clever ID"                     
 [5] "Session start date"                   
 [6] "Count of Check Out Confidence Answer" 
 [7] "Total CO Confidence Same Or Improved" 
 [8] "Conf_rating* % same or improved"      
 [9] "Conf_rating* same or improved score/5"
[10] "Enjoy_rating*"                        
[11] "Num_session*"                         
[12] "Num_minutes*"                         
[13] "Active_participation*"                
[14] "Total LOs Completed"                  
[15] "...15"                                
[16] "...16"                                
[17] "...17"                                
[18] "...18"                                
[19] "...19"                                
[20] "...20"                                
[21] "...21"                                
[22] "...22"                                
[23] "...23"                                
[24] "...24"                                

Rename columns in usage file. Prepare for merging.

tsl_nha_usage = tsl_nha_usage [, -c(15:24)] #drop empty columns
# rename vars
tsl_nha_usage = tsl_nha_usage %>%
  rename(
    student_id = "SIS ID",
    tsl_acct_id = "TSL Student ID", 
    
    school_tsl = "School Name",
    school_code_tsl = "School Clever ID",
    session_BOY_date = "Session start date",
    
    n_co_confid = "Count of Check Out Confidence Answer",
    n_co_confid_same_imp = "Total CO Confidence Same Or Improved",
    perc_confid_same_imp ="Conf_rating* % same or improved",
    score_confid_same_imp = "Conf_rating* same or improved score/5",
    enjoy_rate = "Enjoy_rating*",
    num_session = "Num_session*", 
    num_min = "Num_minutes*", 
    activ_partic = "Active_participation*",
    n_lo_complete = "Total LOs Completed"
  )
colnames(tsl_nha_usage)
 [1] "tsl_acct_id"           "student_id"            "school_tsl"           
 [4] "school_code_tsl"       "session_BOY_date"      "n_co_confid"          
 [7] "n_co_confid_same_imp"  "perc_confid_same_imp"  "score_confid_same_imp"
[10] "enjoy_rate"            "num_session"           "num_min"              
[13] "activ_partic"          "n_lo_complete"        

Identify duplicate student records within usage data file (N=0).

# frequency table of tsl_acct_id occurrences
id_counts_tsl_usage_nha = tsl_nha_usage %>%
  group_by(tsl_acct_id) %>%
  summarise(n = n()) %>%
  arrange(desc(n))

# filter only acct that appear > 1
dupes_tsl_usage_nha = id_counts_tsl_usage_nha %>%
  filter(n > 1)

# view results
print(dupes_tsl_usage_nha)
# A tibble: 1 × 2
  tsl_acct_id     n
        <dbl> <int>
1          NA   346
#export
#write.csv(dupes_tsl_usage_nha, file = "duplicate_acct_ids_usage_tsl_nha.csv", row.names = FALSE)

Inspect rows without TSL Account ID (N=346). Observation: these cases are all blanks carried over from csv conversion.

tsl_nha_usage %>%
  filter(is.na(tsl_acct_id))
# A tibble: 346 × 14
   tsl_acct_id student_id school_tsl school_code_tsl session_BOY_date
         <dbl> <chr>      <chr>      <chr>           <chr>           
 1          NA <NA>       <NA>       <NA>            <NA>            
 2          NA <NA>       <NA>       <NA>            <NA>            
 3          NA <NA>       <NA>       <NA>            <NA>            
 4          NA <NA>       <NA>       <NA>            <NA>            
 5          NA <NA>       <NA>       <NA>            <NA>            
 6          NA <NA>       <NA>       <NA>            <NA>            
 7          NA <NA>       <NA>       <NA>            <NA>            
 8          NA <NA>       <NA>       <NA>            <NA>            
 9          NA <NA>       <NA>       <NA>            <NA>            
10          NA <NA>       <NA>       <NA>            <NA>            
# ℹ 336 more rows
# ℹ 9 more variables: n_co_confid <dbl>, n_co_confid_same_imp <dbl>,
#   perc_confid_same_imp <chr>, score_confid_same_imp <dbl>, enjoy_rate <dbl>,
#   num_session <dbl>, num_min <dbl>, activ_partic <dbl>, n_lo_complete <dbl>

Decision: Remove rows where TSL Account ID is missing.

tsl_nha_usage = tsl_nha_usage %>%
  filter(!is.na(tsl_acct_id))
print(tsl_nha_usage)
# A tibble: 653 × 14
   tsl_acct_id student_id       school_tsl      school_code_tsl session_BOY_date
         <dbl> <chr>            <chr>           <chr>           <chr>           
 1     1002128 Student Archived Burton Glen Ch… 54c7fe26275d78… 03/02/2026      
 2     1003094 Student Archived Winterfield Ve… 54c7fe26275d78… 25/02/2026      
 3     1002836 854674           Holly Park Aca… 54c7fe26275d78… 26/02/2026      
 4     1002811 737634           Holly Park Aca… 54c7fe26275d78… 26/02/2026      
 5     1002951 688666           Laurus Academy  54c7fe26275d78… 03/02/2026      
 6     1002905 625958           Laurus Academy  54c7fe26275d78… 16/03/2026      
 7     1002113 614413           Burton Glen Ch… 54c7fe26275d78… 02/02/2026      
 8     1001718 592802           Linden Charter… 54c7fe26275d78… 18/02/2026      
 9     1001628 Student Archived Oakside Prep A… 54c7fe26275d78… 28/01/2026      
10     1002111 Student Archived Burton Glen Ch… 54c7fe26275d78… 02/02/2026      
# ℹ 643 more rows
# ℹ 9 more variables: n_co_confid <dbl>, n_co_confid_same_imp <dbl>,
#   perc_confid_same_imp <chr>, score_confid_same_imp <dbl>, enjoy_rate <dbl>,
#   num_session <dbl>, num_min <dbl>, activ_partic <dbl>, n_lo_complete <dbl>

Inspect rows where student ID notes “Student Archived”.

tsl_nha_usage %>%
  filter(student_id == "Student Archived")
# A tibble: 24 × 14
   tsl_acct_id student_id       school_tsl      school_code_tsl session_BOY_date
         <dbl> <chr>            <chr>           <chr>           <chr>           
 1     1002128 Student Archived Burton Glen Ch… 54c7fe26275d78… 03/02/2026      
 2     1003094 Student Archived Winterfield Ve… 54c7fe26275d78… 25/02/2026      
 3     1001628 Student Archived Oakside Prep A… 54c7fe26275d78… 28/01/2026      
 4     1002111 Student Archived Burton Glen Ch… 54c7fe26275d78… 02/02/2026      
 5     1002834 Student Archived Holly Park Aca… 54c7fe26275d78… 27/01/2026      
 6     1001779 Student Archived Linden Charter… 54c7fe26275d78… 18/02/2026      
 7     1002713 Student Archived Walton Charter… 54c7fe26275d78… 29/01/2026      
 8     1003079 Student Archived Winterfield Ve… 54c7fe26275d78… 12/02/2026      
 9     1002123 Student Archived Burton Glen Ch… 54c7fe26275d78… 02/02/2026      
10     1002120 Student Archived Burton Glen Ch… 54c7fe26275d78… 03/02/2026      
# ℹ 14 more rows
# ℹ 9 more variables: n_co_confid <dbl>, n_co_confid_same_imp <dbl>,
#   perc_confid_same_imp <chr>, score_confid_same_imp <dbl>, enjoy_rate <dbl>,
#   num_session <dbl>, num_min <dbl>, activ_partic <dbl>, n_lo_complete <dbl>

Decision: Remove student rows where student ID notes “Student Archived”(N=629).

tsl_nha_usage = tsl_nha_usage %>%
  filter(student_id != "Student Archived")
print(tsl_nha_usage)
# A tibble: 629 × 14
   tsl_acct_id student_id school_tsl            school_code_tsl session_BOY_date
         <dbl> <chr>      <chr>                 <chr>           <chr>           
 1     1002836 854674     Holly Park Academy    54c7fe26275d78… 26/02/2026      
 2     1002811 737634     Holly Park Academy    54c7fe26275d78… 26/02/2026      
 3     1002951 688666     Laurus Academy        54c7fe26275d78… 03/02/2026      
 4     1002905 625958     Laurus Academy        54c7fe26275d78… 16/03/2026      
 5     1002113 614413     Burton Glen Charter … 54c7fe26275d78… 02/02/2026      
 6     1001718 592802     Linden Charter Acade… 54c7fe26275d78… 18/02/2026      
 7     1002843 847934     Holly Park Academy    54c7fe26275d78… 04/03/2026      
 8     1003634 845181     Laurus Academy        54c7fe26275d78… 09/02/2026      
 9     1003044 843138     Stambaugh Charter Ac… 54c7fe26275d78… 05/02/2026      
10     1003096 840127     Winterfield Venture … 54c7fe26275d78… 17/02/2026      
# ℹ 619 more rows
# ℹ 9 more variables: n_co_confid <dbl>, n_co_confid_same_imp <dbl>,
#   perc_confid_same_imp <chr>, score_confid_same_imp <dbl>, enjoy_rate <dbl>,
#   num_session <dbl>, num_min <dbl>, activ_partic <dbl>, n_lo_complete <dbl>

Clean strings

Drop “%” string from perc_confid_same_imp.

tsl_nha_usage$perc_confid_same_imp = as.numeric(sub("%$", "", tsl_nha_usage$perc_confid_same_imp))
mean(tsl_nha_usage$perc_confid_same_imp, na.rm = TRUE)
[1] 82.40602

Merge

Create tsl_present: Equals 1 if student id appears in roster from TSL (N=629). All else equals 0.

roster_unique = roster_unique %>%
  mutate(
    tsl_present = ifelse(student_id %in% tsl_nha_usage$student_id, 1, 0)
  )

table(roster_unique$tsl_present)

   0    1 
3703  629 

Inspect for any duplicates ID among those that appear in the TSL roster. Zero duplicates.

roster_unique %>%
  filter(tsl_present == 1) %>%
  group_by(student_id) %>%
  filter(n() > 1)
# A tibble: 0 × 19
# Groups:   student_id [0]
# ℹ 19 variables: student_id <dbl>, boy_math_score <dbl>, boy_math_date <chr>,
#   eoy_math_score <dbl>, eoy_math_date <chr>, school <chr>, school_num <dbl>,
#   grade <dbl>, teacher_id <dbl>, moy_math_score <dbl>, moy_math_date <chr>,
#   gender <chr>, iep <dbl>, ell <dbl>, frpl <dbl>, race <chr>, ethn <chr>,
#   attend_rate <dbl>, tsl_present <dbl>

Merge usage data to student NHA data.

#ensure student_id is same format for merging
roster_unique$student_id = as.character(roster_unique$student_id)
tsl_nha_usage$student_id = as.character(tsl_nha_usage$student_id)

merge_tsl_nha = roster_unique %>%
  left_join(tsl_nha_usage, by = "student_id")

print(merge_tsl_nha)
# A tibble: 4,332 × 32
   student_id boy_math_score boy_math_date eoy_math_score eoy_math_date school  
   <chr>               <dbl> <chr>                  <dbl> <chr>         <chr>   
 1 366259                202 9/3/2025                 207 5/18/2026     Laurus …
 2 430015                218 9/3/2025                 217 5/20/2026     Laurus …
 3 445701                204 9/29/2025                217 5/26/2026     Linden …
 4 461310                170 9/10/2025                173 6/2/2026      Burton …
 5 471757                224 9/24/2025                236 5/20/2026     North S…
 6 472379                209 9/8/2025                 226 6/2/2026      Oakside…
 7 473083                225 9/23/2025                228 5/18/2026     Walton …
 8 473087                204 9/3/2025                 199 5/18/2026     Walton …
 9 474393                212 9/10/2025                 NA <NA>          <NA>    
10 475569                244 8/28/2025                255 5/18/2026     Walton …
# ℹ 4,322 more rows
# ℹ 26 more variables: school_num <dbl>, grade <dbl>, teacher_id <dbl>,
#   moy_math_score <dbl>, moy_math_date <chr>, gender <chr>, iep <dbl>,
#   ell <dbl>, frpl <dbl>, race <chr>, ethn <chr>, attend_rate <dbl>,
#   tsl_present <dbl>, tsl_acct_id <dbl>, school_tsl <chr>,
#   school_code_tsl <chr>, session_BOY_date <chr>, n_co_confid <dbl>,
#   n_co_confid_same_imp <dbl>, perc_confid_same_imp <dbl>, …

Insert treatment status

Add treatment status. First, filter out students who have less than 2 sessions. They are not part of the final sample group and should not be used as comparison students. Among students where tsl_present = 1, exclude those where num_session < 2.

merge_tsl_nha = merge_tsl_nha %>%
   filter(tsl_present != 1 | num_session > 2) 
print(merge_tsl_nha)
# A tibble: 4,279 × 32
   student_id boy_math_score boy_math_date eoy_math_score eoy_math_date school  
   <chr>               <dbl> <chr>                  <dbl> <chr>         <chr>   
 1 366259                202 9/3/2025                 207 5/18/2026     Laurus …
 2 430015                218 9/3/2025                 217 5/20/2026     Laurus …
 3 445701                204 9/29/2025                217 5/26/2026     Linden …
 4 461310                170 9/10/2025                173 6/2/2026      Burton …
 5 471757                224 9/24/2025                236 5/20/2026     North S…
 6 472379                209 9/8/2025                 226 6/2/2026      Oakside…
 7 473083                225 9/23/2025                228 5/18/2026     Walton …
 8 473087                204 9/3/2025                 199 5/18/2026     Walton …
 9 474393                212 9/10/2025                 NA <NA>          <NA>    
10 475569                244 8/28/2025                255 5/18/2026     Walton …
# ℹ 4,269 more rows
# ℹ 26 more variables: school_num <dbl>, grade <dbl>, teacher_id <dbl>,
#   moy_math_score <dbl>, moy_math_date <chr>, gender <chr>, iep <dbl>,
#   ell <dbl>, frpl <dbl>, race <chr>, ethn <chr>, attend_rate <dbl>,
#   tsl_present <dbl>, tsl_acct_id <dbl>, school_tsl <chr>,
#   school_code_tsl <chr>, session_BOY_date <chr>, n_co_confid <dbl>,
#   n_co_confid_same_imp <dbl>, perc_confid_same_imp <dbl>, …
# keep students that don't appear in TSL usage OR students that meet usage threshold. 

Create treat: Equals 1 if students had more than 2 sessions. All else equals 0.

merge_tsl_nha = merge_tsl_nha %>%
  mutate(treat = ifelse(!is.na(num_session) & num_session > 2, 1, 0))
table(merge_tsl_nha$treat)

   0    1 
3703  576 

Missing Data Inspections

Percentage missing for each variable. Observations: Most variables from the TSL file are missing, as expected; after merging the district roster with TSL usage file, not all students will be in the TSL system. Looking at district data columns, most of the data is present (test info, demographics, etc). The repeating missing percentage (6.37%) across EOY test info variables, school, grade, and demographics suggests there is a small group of students missing this info. The large majority of students are missing MOY test info. No student entry is missing an ID or treatment status, though.

missing_summary = merge_tsl_nha %>%
  summarise(across(
    everything(),
    ~ mean(is.na(.)) * 100
  )) %>%
  pivot_longer(
    cols = everything(),
    names_to = "variable",
    values_to = "percent_missing"
  ) %>%
  arrange(desc(percent_missing))

print(missing_summary)
# A tibble: 33 × 2
   variable              percent_missing
   <chr>                           <dbl>
 1 enjoy_rate                       89.3
 2 n_co_confid                      87.7
 3 n_co_confid_same_imp             87.7
 4 perc_confid_same_imp             87.7
 5 score_confid_same_imp            87.7
 6 tsl_acct_id                      86.5
 7 school_tsl                       86.5
 8 school_code_tsl                  86.5
 9 session_BOY_date                 86.5
10 num_session                      86.5
# ℹ 23 more rows
write.csv(missing_summary, file = "tsl_nha_missing_summary_overall.csv", row.names = FALSE)

Inspect observations where grade is missing (N = 275). All seem to be missing district data except for baseline, BOY test info. Exported.

missing_grade_district_info = merge_tsl_nha %>%
  filter(is.na(grade))
print(missing_grade_district_info)
# A tibble: 275 × 33
   student_id boy_math_score boy_math_date eoy_math_score eoy_math_date school
   <chr>               <dbl> <chr>                  <dbl> <chr>         <chr> 
 1 474393                212 9/10/2025                 NA <NA>          <NA>  
 2 494172                221 9/18/2025                 NA <NA>          <NA>  
 3 502666                179 9/23/2025                 NA <NA>          <NA>  
 4 503758                212 9/3/2025                  NA <NA>          <NA>  
 5 513006                217 9/10/2025                 NA <NA>          <NA>  
 6 517572                204 9/8/2025                  NA <NA>          <NA>  
 7 522347                237 9/29/2025                 NA <NA>          <NA>  
 8 523328                221 9/10/2025                 NA <NA>          <NA>  
 9 529588                195 9/24/2025                 NA <NA>          <NA>  
10 533690                199 8/27/2025                 NA <NA>          <NA>  
# ℹ 265 more rows
# ℹ 27 more variables: school_num <dbl>, grade <dbl>, teacher_id <dbl>,
#   moy_math_score <dbl>, moy_math_date <chr>, gender <chr>, iep <dbl>,
#   ell <dbl>, frpl <dbl>, race <chr>, ethn <chr>, attend_rate <dbl>,
#   tsl_present <dbl>, tsl_acct_id <dbl>, school_tsl <chr>,
#   school_code_tsl <chr>, session_BOY_date <chr>, n_co_confid <dbl>,
#   n_co_confid_same_imp <dbl>, perc_confid_same_imp <dbl>, …
write.csv(missing_grade_district_info, file = "tsl_nha_missing_grade_district_info.csv", row.names = FALSE)

Of these students with missing data, some do have TSL usage data (N = 10). Exported.

missing_grade_district_info_treat = merge_tsl_nha %>%
  filter(treat == 1) %>%
  filter(is.na(grade))
print(missing_grade_district_info_treat)
# A tibble: 10 × 33
   student_id boy_math_score boy_math_date eoy_math_score eoy_math_date school
   <chr>               <dbl> <chr>                  <dbl> <chr>         <chr> 
 1 503758                212 9/3/2025                  NA <NA>          <NA>  
 2 578514                213 8/22/2025                 NA <NA>          <NA>  
 3 579897                199 9/2/2025                  NA <NA>          <NA>  
 4 588508                214 9/2/2025                  NA <NA>          <NA>  
 5 595757                208 9/2/2025                  NA <NA>          <NA>  
 6 602774                192 9/15/2025                 NA <NA>          <NA>  
 7 658690                199 9/18/2025                 NA <NA>          <NA>  
 8 667967                209 9/2/2025                  NA <NA>          <NA>  
 9 703899                170 8/22/2025                 NA <NA>          <NA>  
10 829256                205 9/12/2025                 NA <NA>          <NA>  
# ℹ 27 more variables: school_num <dbl>, grade <dbl>, teacher_id <dbl>,
#   moy_math_score <dbl>, moy_math_date <chr>, gender <chr>, iep <dbl>,
#   ell <dbl>, frpl <dbl>, race <chr>, ethn <chr>, attend_rate <dbl>,
#   tsl_present <dbl>, tsl_acct_id <dbl>, school_tsl <chr>,
#   school_code_tsl <chr>, session_BOY_date <chr>, n_co_confid <dbl>,
#   n_co_confid_same_imp <dbl>, perc_confid_same_imp <dbl>,
#   score_confid_same_imp <dbl>, enjoy_rate <dbl>, num_session <dbl>, …
write.csv(missing_grade_district_info_treat, file = "tsl_nha_missing_grade_district_info_treat.csv", row.names = FALSE)

Decision: Remove observation where grade and other district data is missing.

merge_tsl_nha = merge_tsl_nha %>%
  filter(!is.na(grade))
print(merge_tsl_nha)
# A tibble: 4,004 × 33
   student_id boy_math_score boy_math_date eoy_math_score eoy_math_date school  
   <chr>               <dbl> <chr>                  <dbl> <chr>         <chr>   
 1 366259                202 9/3/2025                 207 5/18/2026     Laurus …
 2 430015                218 9/3/2025                 217 5/20/2026     Laurus …
 3 445701                204 9/29/2025                217 5/26/2026     Linden …
 4 461310                170 9/10/2025                173 6/2/2026      Burton …
 5 471757                224 9/24/2025                236 5/20/2026     North S…
 6 472379                209 9/8/2025                 226 6/2/2026      Oakside…
 7 473083                225 9/23/2025                228 5/18/2026     Walton …
 8 473087                204 9/3/2025                 199 5/18/2026     Walton …
 9 475569                244 8/28/2025                255 5/18/2026     Walton …
10 475590                202 9/2/2025                 212 5/18/2026     Walton …
# ℹ 3,994 more rows
# ℹ 27 more variables: school_num <dbl>, grade <dbl>, teacher_id <dbl>,
#   moy_math_score <dbl>, moy_math_date <chr>, gender <chr>, iep <dbl>,
#   ell <dbl>, frpl <dbl>, race <chr>, ethn <chr>, attend_rate <dbl>,
#   tsl_present <dbl>, tsl_acct_id <dbl>, school_tsl <chr>,
#   school_code_tsl <chr>, session_BOY_date <chr>, n_co_confid <dbl>,
#   n_co_confid_same_imp <dbl>, perc_confid_same_imp <dbl>, …

Percentage missing for each variable, grouped by grade, among treatment students. Exported.

missing_summary_by_grade_treat = merge_tsl_nha %>%
  filter(treat == 1) %>%
  group_by(grade) %>%
  summarise(across(everything(),
                   ~ mean(is.na(.)) * 100,
                   .names = "pct_miss_{.col}"),
            .groups = "drop") %>%
  pivot_longer(
    cols = -grade,
    names_to = "variable",
    values_to = "pct_missing"
  ) %>%
  mutate(variable = sub("^pct_miss_", "", variable))


print(missing_summary_by_grade_treat)
# A tibble: 192 × 3
   grade variable       pct_missing
   <dbl> <chr>                <dbl>
 1     3 student_id             0  
 2     3 boy_math_score         0  
 3     3 boy_math_date          0  
 4     3 eoy_math_score         0  
 5     3 eoy_math_date          0  
 6     3 school                 0  
 7     3 school_num             0  
 8     3 teacher_id             0  
 9     3 moy_math_score        89.6
10     3 moy_math_date         89.6
# ℹ 182 more rows
#export
write.csv(missing_summary_by_grade_treat, file = "tsl_nha_pct_missing_vars_by_grade_treat.csv", row.names = FALSE)

Percentage missing for each variable, grouped by grade, among comparison students. Exported.

missing_summary_by_grade_comp = merge_tsl_nha %>%
  filter(treat == 0) %>%
  group_by(grade) %>%
  summarise(across(everything(),
                   ~ mean(is.na(.)) * 100,
                   .names = "pct_miss_{.col}"),
            .groups = "drop") %>%
  pivot_longer(
    cols = -grade,
    names_to = "variable",
    values_to = "pct_missing"
  ) %>%
  mutate(variable = sub("^pct_miss_", "", variable))


print(missing_summary_by_grade_comp)
# A tibble: 192 × 3
   grade variable       pct_missing
   <dbl> <chr>                <dbl>
 1     3 student_id            0   
 2     3 boy_math_score        7.22
 3     3 boy_math_date         7.22
 4     3 eoy_math_score        0   
 5     3 eoy_math_date         0   
 6     3 school                0   
 7     3 school_num            0   
 8     3 teacher_id            0   
 9     3 moy_math_score       69.9 
10     3 moy_math_date        69.9 
# ℹ 182 more rows
#export
write.csv(missing_summary_by_grade_comp, file = "tsl_nha_pct_missing_vars_by_grade_comp.csv", row.names = FALSE)

Percentage of students who have both BOY and EOY data and ALL math data (BOY, MOY, EOY) overall

#indicator for having BOTH MOY and EOY scores
merge_tsl_nha_test_info = merge_tsl_nha %>%
  mutate(
    boy_eoy_math = !is.na(boy_math_score) & !is.na(eoy_math_score),
    all_math = !is.na(boy_math_score) & !is.na(moy_math_score) # incl. MOY
            & !is.na(eoy_math_score),
  )

#pct across schools
overall_pct = merge_tsl_nha_test_info %>%
  summarise(
    total_students = n(),
    
    students_with_boy_eoy = sum(boy_eoy_math), 
    percent_with_boy_eoy = sum(students_with_boy_eoy / total_students) * 100, 
    
    
    students_with_all_tests = sum(all_math),
    percent_with_all_tests = (students_with_all_tests / total_students) * 100
  ) %>%
  mutate(percent_with_boy_eoy = round(percent_with_boy_eoy, 2))%>%
  mutate(percent_with_all_tests = round(percent_with_all_tests, 2))

print(overall_pct)
# A tibble: 1 × 5
  total_students students_with_boy_eoy percent_with_boy_eoy
           <int>                 <int>                <dbl>
1           4004                  3797                 94.8
# ℹ 2 more variables: students_with_all_tests <int>,
#   percent_with_all_tests <dbl>

Percentage of students who have both BOY and EOY data by school Assuming MOY is not of interest. Only 21% of the sample have all test info, including MOY test.

by_school_pct = merge_tsl_nha_test_info %>%
  group_by(school) %>%
  summarise(
    total_students = n(),
    students_with_boy_eoy = sum(boy_eoy_math),
    percent_with_boy_eoy = (students_with_boy_eoy / total_students) * 100
  ) %>%
  mutate(percent_with_boy_eoy = round(percent_with_boy_eoy, 2)) %>%
  arrange(desc(percent_with_boy_eoy))

print(by_school_pct)
# A tibble: 11 × 4
   school              total_students students_with_boy_eoy percent_with_boy_eoy
   <chr>                        <int>                 <int>                <dbl>
 1 Walton Charter Aca…            490                   482                 98.4
 2 Linden Charter Aca…            452                   442                 97.8
 3 Burton Glen Charte…            372                   360                 96.8
 4 Holly Park Academy             297                   285                 96.0
 5 Oakside Prep Acade…            542                   519                 95.8
 6 Stambaugh Charter …            236                   225                 95.3
 7 Laurus Academy                 449                   428                 95.3
 8 North Saginaw Char…            434                   400                 92.2
 9 Windemere Park Cha…            340                   312                 91.8
10 Bennett Venture Ac…            204                   187                 91.7
11 Winterfield Ventur…            188                   157                 83.5

Percentage of students who have both BOY and EOY data by grade

by_grade_pct = merge_tsl_nha_test_info %>%
  group_by(grade) %>%
  summarise(
    total_students = n(),
    students_with_boy_eoy = sum(boy_eoy_math),
    percent_with_boy_eoy = (students_with_boy_eoy / total_students) * 100
  ) %>%
  mutate(percent_with_boy_eoy = round(percent_with_boy_eoy, 2)) %>%
  arrange(desc(percent_with_boy_eoy))

print(by_grade_pct)
# A tibble: 6 × 4
  grade total_students students_with_boy_eoy percent_with_boy_eoy
  <dbl>          <int>                 <int>                <dbl>
1     7            605                   582                 96.2
2     4            713                   681                 95.5
3     5            688                   654                 95.1
4     8            588                   557                 94.7
5     3            772                   726                 94.0
6     6            638                   597                 93.6

Dummies for test info (complete and missing indicators)

boy_eoy_math: dummy for students with complete math test info for BOY and EOY.

table(merge_tsl_nha_test_info$boy_eoy_math)

FALSE  TRUE 
  207  3797 
#complete math: BOY and EOY present
merge_tsl_nha_test_info$boy_eoy_math = ifelse(merge_tsl_nha_test_info$boy_eoy_math, 1, 0)
table(merge_tsl_nha_test_info$boy_eoy_math) #check counts

   0    1 
 207 3797 

math_boy_missing_eoy_present: dummy for students who have EOY math test info but BOY test info is missing.

merge_tsl_nha_test_info = merge_tsl_nha_test_info %>%
  mutate(
    boy_math_present = !is.na(boy_math_score) & !is.na(boy_math_date),
    eoy_math_present = !is.na(eoy_math_score) & !is.na(eoy_math_date),
    
    math_boy_missing_eoy_present = case_when(
      !boy_math_present &  eoy_math_present ~ 1,   # moy missing, eoy present
       boy_math_present &  eoy_math_present ~ 0,   # both present
      !boy_math_present & !eoy_math_present ~ NA_real_  # both missing
    )
  )
table(merge_tsl_nha_test_info$math_boy_missing_eoy_present)

   0    1 
3797  207 

##Test Info Summary

Proportion of math test info present (BOY present and EOY present, grouped by treatment status).

summary_math_tests_present = merge_tsl_nha_test_info %>%
  mutate(
    boy_math_present = !is.na(boy_math_score) & !is.na(boy_math_date),
    eoy_math_present = !is.na(eoy_math_score) & !is.na(eoy_math_date)
  ) %>%
  group_by(treat) %>%
  summarise(
    n_students = n(),
    
    boy_count = sum(boy_math_present),
    boy_prop  = mean(boy_math_present),
    
    eoy_count = sum(eoy_math_present),
    eoy_prop  = mean(eoy_math_present),
    
    .groups = "drop"
  )

# View result
print(summary_math_tests_present)
# A tibble: 2 × 6
  treat n_students boy_count boy_prop eoy_count eoy_prop
  <dbl>      <int>     <int>    <dbl>     <int>    <dbl>
1     0       3438      3238    0.942      3438        1
2     1        566       559    0.988       566        1

Data Summary

District Data

Generate school-level means for BOY assessment data, number of students, plus distribution across demographics.

#create function to streamline calculations
calc_pct = function(x) {
  prop.table(table(x)) * 100
}

#school-level means and counts for TEST data, including attend rate avg
merge_tsl_nha_test_info = merge_tsl_nha_test_info %>%
  mutate(across(c(boy_math_score, #transform all scores to numeric value
                  eoy_math_score, 
                  attend_rate), as.numeric))

school_means = merge_tsl_nha_test_info %>%
  group_by(school)%>%
  summarise(
    n_students = n(),
    mean_boy_math = mean(boy_math_score, na.rm = TRUE),
    mean_eoy_math = mean(eoy_math_score, na.rm = TRUE),
    attend_rate = mean(attend_rate, na.rm =  TRUE)
  )

# DEMOGRAPHICS

# race
race_pct = merge_tsl_nha_test_info %>%
  group_by(school, race) %>%
  summarise(n = n(), .groups = "drop") %>%
  group_by(school) %>%
  mutate(pct = (n / sum(n)) * 100) %>%
  select(-n) %>%
  pivot_wider(
    names_from = race,
    values_from = pct,
    names_prefix = "race_"
  )

#ethn BEFORE CLEANING
ethn_pct = merge_tsl_nha_test_info %>%
  group_by(school, ethn) %>%
  summarise(n = n(), .groups = "drop") %>%
  group_by(school) %>%
  mutate(pct = (n / sum(n)) * 100) %>%
  select(-n) %>%
  pivot_wider(
    names_from = ethn,
    values_from = pct,
    names_prefix = "ethn_"
  )


#gender
gender_pct = merge_tsl_nha_test_info %>%
  group_by(school, gender) %>%
  summarise(n = n(), .groups = "drop") %>%
  group_by(school) %>%
  mutate(pct = (n / sum(n)) * 100) %>%
  select(-n) %>%
  pivot_wider(
    names_from = gender,
    values_from = pct,
    names_prefix = "gender_"
  )

#frpl
frpl_pct = merge_tsl_nha_test_info %>%
  group_by(school, frpl) %>%
  summarise(n = n(), .groups = "drop") %>%
  group_by(school) %>%
  mutate(pct = (n / sum(n)) * 100) %>%
  select(-n) %>%
  pivot_wider(
    names_from = frpl,
    values_from = pct,
    names_prefix = "frpl_"
  )

#ell
ell_pct = merge_tsl_nha_test_info %>%
  group_by(school, ell) %>%
  summarise(n = n(), .groups = "drop") %>%
  group_by(school) %>%
  mutate(pct = (n / sum(n)) * 100) %>%
  select(-n) %>%
  pivot_wider(
    names_from = ell,
    values_from = pct,
    names_prefix = "ell_"
  )


#iep students
iep_pct = merge_tsl_nha_test_info %>%
  group_by(school, iep) %>%
  summarise(n = n(), .groups = "drop") %>%
  group_by(school) %>%
  mutate(pct = (n / sum(n)) * 100) %>%
  select(-n) %>%
  pivot_wider(
    names_from = iep,
    values_from = pct,
    names_prefix = "iep_"
  )

# COMBINE summaries
school_summary = school_means %>%
  left_join(race_pct,   by = "school") %>%
  left_join(ethn_pct,   by = "school") %>%
  left_join(gender_pct, by = "school") %>%
  left_join(frpl_pct,   by = "school") %>%
  left_join(ell_pct,    by = "school") %>%
  left_join(iep_pct, by = "school")

school_summary = school_summary %>%
  mutate(across(where(is.numeric), ~ round(.x, 2)))

print(school_summary)
# A tibble: 11 × 24
   school                     n_students mean_boy_math mean_eoy_math attend_rate
   <chr>                           <dbl>         <dbl>         <dbl>       <dbl>
 1 Bennett Venture Academy           204          196.          210.        0.87
 2 Burton Glen Charter Acade…        372          198.          212.        0.9 
 3 Holly Park Academy                297          194.          206.        0.91
 4 Laurus Academy                    449          201.          213.        0.88
 5 Linden Charter Academy            452          202.          213.        0.88
 6 North Saginaw Charter Aca…        434          200.          210.        0.9 
 7 Oakside Prep Academy              542          197.          209.        0.9 
 8 Stambaugh Charter Academy         236          197.          211.        0.91
 9 Walton Charter Academy            490          197.          211.        0.92
10 Windemere Park Charter Ac…        340          200.          215.        0.9 
11 Winterfield Venture Acade…        188          197.          210.        0.87
# ℹ 19 more variables: `race_American Indian or Alaskan Native` <dbl>,
#   race_Asian <dbl>, `race_Black or African American` <dbl>, race_White <dbl>,
#   `race_Native Hawaiian or Pacific Islander` <dbl>,
#   `ethn_American Indian or Alaskan Native` <dbl>, ethn_Asian <dbl>,
#   `ethn_Black or African American` <dbl>, ethn_Hispanic <dbl>,
#   ethn_White <dbl>, `ethn_Native Hawaiian or Pacific Islander` <dbl>,
#   gender_Female <dbl>, gender_Male <dbl>, frpl_0 <dbl>, frpl_1 <dbl>, …
write.csv(school_summary, "tsl_nha_overall_district_data_summary_by_school.csv", row.names = FALSE)

Treatment versus comparison: Export school-level means for BOY Math assessment data, number of students, percentages of students by race/ethnicity, gender, iep, attendance rate, and FRPL-status.

treat_comp_means = merge_tsl_nha_test_info %>%
  group_by(treat)%>%
  summarise(
    n_students = n(),
    mean_boy_math = mean(boy_math_score, na.rm = TRUE),
    mean_eoy_math = mean(eoy_math_score, na.rm = TRUE),
    attend_rate = mean(attend_rate, na.rm =  TRUE)
  )

# DEMOGRAPHICS

# race
race_pct = merge_tsl_nha_test_info %>%
  group_by(treat, race) %>%
  summarise(n = n(), .groups = "drop") %>%
  group_by(treat) %>%
  mutate(pct = (n / sum(n)) * 100) %>%
  select(-n) %>%
  pivot_wider(
    names_from = race,
    values_from = pct,
    names_prefix = "race_"
  )

#ethn BEFORE CLEANING
ethn_pct = merge_tsl_nha_test_info %>%
  group_by(treat, ethn) %>%
  summarise(n = n(), .groups = "drop") %>%
  group_by(treat) %>%
  mutate(pct = (n / sum(n)) * 100) %>%
  select(-n) %>%
  pivot_wider(
    names_from = ethn,
    values_from = pct,
    names_prefix = "ethn_"
  )


#gender
gender_pct = merge_tsl_nha_test_info %>%
  group_by(treat, gender) %>%
  summarise(n = n(), .groups = "drop") %>%
  group_by(treat) %>%
  mutate(pct = (n / sum(n)) * 100) %>%
  select(-n) %>%
  pivot_wider(
    names_from = gender,
    values_from = pct,
    names_prefix = "gender_"
  )

#frpl
frpl_pct = merge_tsl_nha_test_info %>%
  group_by(treat, frpl) %>%
  summarise(n = n(), .groups = "drop") %>%
  group_by(treat) %>%
  mutate(pct = (n / sum(n)) * 100) %>%
  select(-n) %>%
  pivot_wider(
    names_from = frpl,
    values_from = pct,
    names_prefix = "frpl_"
  )

#ell
ell_pct = merge_tsl_nha_test_info %>%
  group_by(treat, ell) %>%
  summarise(n = n(), .groups = "drop") %>%
  group_by(treat) %>%
  mutate(pct = (n / sum(n)) * 100) %>%
  select(-n) %>%
  pivot_wider(
    names_from = ell,
    values_from = pct,
    names_prefix = "ell_"
  )


#iep students
iep_pct = merge_tsl_nha_test_info %>%
  group_by(treat, iep) %>%
  summarise(n = n(), .groups = "drop") %>%
  group_by(treat) %>%
  mutate(pct = (n / sum(n)) * 100) %>%
  select(-n) %>%
  pivot_wider(
    names_from = iep,
    values_from = pct,
    names_prefix = "iep_"
  )

# COMBINE summaries
treat_comp_summary = treat_comp_means %>%
  left_join(race_pct,   by = "treat") %>%
  left_join(ethn_pct,   by = "treat") %>%
  left_join(gender_pct, by = "treat") %>%
  left_join(frpl_pct,   by = "treat") %>%
  left_join(ell_pct,    by = "treat") %>%
  left_join(iep_pct, by = "treat")

treat_comp_summary = treat_comp_summary %>%
  mutate(across(where(is.numeric), ~ round(.x, 2)))

print(treat_comp_summary)
# A tibble: 2 × 24
  treat n_students mean_boy_math mean_eoy_math attend_rate
  <dbl>      <dbl>         <dbl>         <dbl>       <dbl>
1     0       3438          198.          211.        0.9 
2     1        566          199.          213.        0.91
# ℹ 19 more variables: `race_American Indian or Alaskan Native` <dbl>,
#   race_Asian <dbl>, `race_Black or African American` <dbl>,
#   `race_Native Hawaiian or Pacific Islander` <dbl>, race_White <dbl>,
#   `ethn_American Indian or Alaskan Native` <dbl>, ethn_Asian <dbl>,
#   `ethn_Black or African American` <dbl>, ethn_Hispanic <dbl>,
#   `ethn_Native Hawaiian or Pacific Islander` <dbl>, ethn_White <dbl>,
#   gender_Female <dbl>, gender_Male <dbl>, frpl_0 <dbl>, frpl_1 <dbl>, …
write.csv(treat_comp_summary, "tsl_nha_district_data_summary_by_treat.csv", row.names = FALSE)

Attrition

Percent of students who did not take the EOY assessment, within treatment group.

summary_attrition = merge_tsl_nha_test_info %>%
  filter(treat == 1) %>%
  summarise(
    n_total = n(),
    
    # Math test missing
    n_missing_eoy_math = sum(is.na(eoy_math_score)),
    pct_missing_eoy_math = (n_missing_eoy_math / n_total) * 100,

  )
print(summary_attrition)
# A tibble: 1 × 3
  n_total n_missing_eoy_math pct_missing_eoy_math
    <int>              <int>                <dbl>
1     566                  0                    0

Usage Data

Num_weeks construction

Reformatted session_BOY_date given by TSL (start date of treatment), from DD/MM/YYYY → MM/DD/YYYY.

#transform all date vars as dates
merge_tsl_nha_test_info = merge_tsl_nha_test_info %>%
  mutate(
    session_BOY_date = as.Date(session_BOY_date, format = "%d/%m/%Y"), 
    eoy_math_date = as.Date(eoy_math_date, format = "%m/%d/%Y"), 
    moy_math_date = as.Date(moy_math_date, format = "%m/%d/%Y"), 
    boy_math_date = as.Date(boy_math_date, format = "%m/%d/%Y"), 
  )

Create num_weeks by taking the difference in time between session start date and EOY assessment date at the student level. Average implementation period was about 13 weeks.

merge_tsl_nha_test_info = merge_tsl_nha_test_info %>%
  mutate(
    num_weeks = as.numeric(difftime(eoy_math_date, session_BOY_date, units = "weeks"))
  )
#subtract 1 week for spring break
merge_tsl_nha_test_info$num_weeks = merge_tsl_nha_test_info$num_weeks - 1

mean(merge_tsl_nha_test_info$num_weeks, na.rm = TRUE)
[1] 13.37658

Tabulations

Count of students with any usage logged (defined as num_session >= 1) by school. Exported.

any_usage = merge_tsl_nha_test_info %>%
  mutate(any_usage = as.integer(num_session >= 1),
         any_usage = replace_na(any_usage, 0))


any_usage_by_school = any_usage %>%
  group_by(school, any_usage) %>%
  summarise(n = n(), .groups = "drop") %>%
  group_by(school) %>%
  mutate(pct = n / sum(n) * 100) %>%
  ungroup() %>%
  pivot_wider(
    names_from = any_usage,
    values_from = c(n, pct),
    values_fill = 0,
    names_glue = "{.value}_usage_{any_usage}"
  )


print(any_usage_by_school)
# A tibble: 11 × 5
   school                         n_usage_0 n_usage_1 pct_usage_0 pct_usage_1
   <chr>                              <int>     <int>       <dbl>       <dbl>
 1 Bennett Venture Academy              168        36        82.4       17.6 
 2 Burton Glen Charter Academy          346        26        93.0        6.99
 3 Holly Park Academy                   246        51        82.8       17.2 
 4 Laurus Academy                       312       137        69.5       30.5 
 5 Linden Charter Academy               404        48        89.4       10.6 
 6 North Saginaw Charter Academy        404        30        93.1        6.91
 7 Oakside Prep Academy                 481        61        88.7       11.3 
 8 Stambaugh Charter Academy            201        35        85.2       14.8 
 9 Walton Charter Academy               432        58        88.2       11.8 
10 Windemere Park Charter Academy       280        60        82.4       17.6 
11 Winterfield Venture Academy          164        24        87.2       12.8 
write.csv(any_usage_by_school, file = "tsl_nha_any_usage_by_school.csv")

Count of students with any usage logged (defined as num_session >= 1) by grade, among treatment students. Exported.

any_usage_by_grade_treat = any_usage %>%
  filter(treat == 1) %>%
  group_by(grade, any_usage) %>%
  summarise(n = n(), .groups = "drop") %>%
  group_by(grade) %>%
  mutate(pct = n / sum(n) * 100) %>%
  ungroup() %>%
  pivot_wider(
    names_from = any_usage,
    values_from = c(n, pct),
    values_fill = 0,
    names_glue = "{.value}_usage_{any_usage}"
  )


print(any_usage_by_grade_treat)
# A tibble: 6 × 3
  grade n_usage_1 pct_usage_1
  <dbl>     <int>       <dbl>
1     3       135         100
2     4       111         100
3     5       105         100
4     6       101         100
5     7        66         100
6     8        48         100
write.csv(any_usage_by_grade_treat, file = "tsl_nha_any_usage_by_grade_treat.csv")

Count of students with any usage logged (defined as num_session => 1) by grade, among comparison students. Exported.

any_usage_by_grade_comp = any_usage %>%
  filter(treat == 0) %>%
  group_by(grade, any_usage) %>%
  summarise(n = n(), .groups = "drop") %>%
  group_by(grade) %>%
  mutate(pct = n / sum(n) * 100) %>%
  ungroup() %>%
  pivot_wider(
    names_from = any_usage,
    values_from = c(n, pct),
    values_fill = 0,
    names_glue = "{.value}_usage_{any_usage}"
  )


print(any_usage_by_grade_comp)
# A tibble: 6 × 3
  grade n_usage_0 pct_usage_0
  <dbl>     <int>       <dbl>
1     3       637         100
2     4       602         100
3     5       583         100
4     6       537         100
5     7       539         100
6     8       540         100
write.csv(any_usage_by_grade_comp, file = "tsl_nha_any_usage_by_grade_comp.csv")

Summary of usage variables, by school. Including Q1, Median, Mean, Q3, and SD. Exported.

usage_vars = c(
  "n_co_confid", 
  "n_co_confid_same_imp",
  "perc_confid_same_imp", 
  "score_confid_same_imp",
  "enjoy_rate",
  "num_session",
  "num_min", 
  "num_weeks",
  "activ_partic",
  "n_lo_complete")


summary_usage_by_school =  merge_tsl_nha_test_info %>%
  group_by(school) %>%
  summarise(across(
    all_of(usage_vars),
    list(
      Q1 = ~ quantile(., 0.25, na.rm = TRUE),
      median = ~ median(., na.rm = TRUE),
      mean = ~ mean(., na.rm = TRUE),
      Q3 = ~ quantile(., 0.75, na.rm = TRUE),
      sd = ~ sd(., na.rm = TRUE)
    ),
    .names = "{.col}_{.fn}"
  ),
  .groups = "drop")
print(summary_usage_by_school)
# A tibble: 11 × 51
   school      n_co_confid_Q1 n_co_confid_median n_co_confid_mean n_co_confid_Q3
   <chr>                <dbl>              <dbl>            <dbl>          <dbl>
 1 Bennett Ve…              7                 13            12.2            15.2
 2 Burton Gle…              2                  3             4.5             6  
 3 Holly Park…              1                  1             2.13            2  
 4 Laurus Aca…              3                  6             7              11  
 5 Linden Cha…              4                  6             7.66           10  
 6 North Sagi…              2                  2             3.09            3  
 7 Oakside Pr…              5                  9             9.33           12  
 8 Stambaugh …              2                  4             3.88            5  
 9 Walton Cha…              4                  6             7.09            9  
10 Windemere …              3                  6             9.61           14  
11 Winterfiel…              1                  2             2.71            4  
# ℹ 46 more variables: n_co_confid_sd <dbl>, n_co_confid_same_imp_Q1 <dbl>,
#   n_co_confid_same_imp_median <dbl>, n_co_confid_same_imp_mean <dbl>,
#   n_co_confid_same_imp_Q3 <dbl>, n_co_confid_same_imp_sd <dbl>,
#   perc_confid_same_imp_Q1 <dbl>, perc_confid_same_imp_median <dbl>,
#   perc_confid_same_imp_mean <dbl>, perc_confid_same_imp_Q3 <dbl>,
#   perc_confid_same_imp_sd <dbl>, score_confid_same_imp_Q1 <dbl>,
#   score_confid_same_imp_median <dbl>, score_confid_same_imp_mean <dbl>, …
write.csv(summary_usage_by_school, file = "tsl_nha_summary_usage_by_school.csv")

Summary of usage variables, by grade, among treatment. Including Q1, Median, Mean, Q3, and SD. Exported.

summary_usage_by_grade_treat =  merge_tsl_nha_test_info %>%
  filter(treat == 1) %>%
  group_by(grade) %>%
  summarise(across(
    all_of(usage_vars),
    list(
      Q1 = ~ quantile(., 0.25, na.rm = TRUE),
      median = ~ median(., na.rm = TRUE),
      mean = ~ mean(., na.rm = TRUE),
      Q3 = ~ quantile(., 0.75, na.rm = TRUE),
      sd = ~ sd(., na.rm = TRUE)
    ),
    .names = "{.col}_{.fn}"
  ),
  .groups = "drop")
print(summary_usage_by_grade_treat)
# A tibble: 6 × 51
  grade n_co_confid_Q1 n_co_confid_median n_co_confid_mean n_co_confid_Q3
  <dbl>          <dbl>              <dbl>            <dbl>          <dbl>
1     3           2                     3             4.28           5   
2     4           4                    10            10.7           14.5 
3     5           3                     5             6.79           9   
4     6           2                     5             5.64           7.25
5     7           4                     6             6.86           9   
6     8           6.75                  7             9.31          12.2 
# ℹ 46 more variables: n_co_confid_sd <dbl>, n_co_confid_same_imp_Q1 <dbl>,
#   n_co_confid_same_imp_median <dbl>, n_co_confid_same_imp_mean <dbl>,
#   n_co_confid_same_imp_Q3 <dbl>, n_co_confid_same_imp_sd <dbl>,
#   perc_confid_same_imp_Q1 <dbl>, perc_confid_same_imp_median <dbl>,
#   perc_confid_same_imp_mean <dbl>, perc_confid_same_imp_Q3 <dbl>,
#   perc_confid_same_imp_sd <dbl>, score_confid_same_imp_Q1 <dbl>,
#   score_confid_same_imp_median <dbl>, score_confid_same_imp_mean <dbl>, …
write.csv(summary_usage_by_grade_treat, file = "tsl_nha_summary_usage_by_grade_treat.csv")

Summary of usage variables, by grade, among comparison. Including Q1, Median, Mean, Q3, and SD. Exported. No comparison students had any usage, as expected.

summary_usage_by_grade_comp =  merge_tsl_nha_test_info %>%
  filter(treat == 0) %>%
  group_by(grade) %>%
  summarise(across(
    all_of(usage_vars),
    list(
      Q1 = ~ quantile(., 0.25, na.rm = TRUE),
      median = ~ median(., na.rm = TRUE),
      mean = ~ mean(., na.rm = TRUE),
      Q3 = ~ quantile(., 0.75, na.rm = TRUE),
      sd = ~ sd(., na.rm = TRUE)
    ),
    .names = "{.col}_{.fn}"
  ),
  .groups = "drop")
print(summary_usage_by_grade_comp)
# A tibble: 6 × 51
  grade n_co_confid_Q1 n_co_confid_median n_co_confid_mean n_co_confid_Q3
  <dbl>          <dbl>              <dbl>            <dbl>          <dbl>
1     3             NA                 NA              NaN             NA
2     4             NA                 NA              NaN             NA
3     5             NA                 NA              NaN             NA
4     6             NA                 NA              NaN             NA
5     7             NA                 NA              NaN             NA
6     8             NA                 NA              NaN             NA
# ℹ 46 more variables: n_co_confid_sd <dbl>, n_co_confid_same_imp_Q1 <dbl>,
#   n_co_confid_same_imp_median <dbl>, n_co_confid_same_imp_mean <dbl>,
#   n_co_confid_same_imp_Q3 <dbl>, n_co_confid_same_imp_sd <dbl>,
#   perc_confid_same_imp_Q1 <dbl>, perc_confid_same_imp_median <dbl>,
#   perc_confid_same_imp_mean <dbl>, perc_confid_same_imp_Q3 <dbl>,
#   perc_confid_same_imp_sd <dbl>, score_confid_same_imp_Q1 <dbl>,
#   score_confid_same_imp_median <dbl>, score_confid_same_imp_mean <dbl>, …
write.csv(summary_usage_by_grade_comp, file = "tsl_nha_summary_usage_by_grade_comp.csv")

Reformat Data

Create dummy variables

Create numeric binary for gender.

table(merge_tsl_nha_test_info$gender)

Female   Male 
  2048   1956 
merge_tsl_nha_test_info = merge_tsl_nha_test_info %>%
  mutate(
    male = case_when(
      gender == "Male" ~ 1,
      gender == "Female" ~ 0,
      TRUE ~ NA_real_   # handles missing or unexpected values
    )
  )
table(merge_tsl_nha_test_info$male)

   0    1 
2048 1956 

Create numeric binaries for each race.

table(merge_tsl_nha_test_info$race)

  American Indian or Alaskan Native                               Asian 
                                119                                  43 
          Black or African American Native Hawaiian or Pacific Islander 
                               2877                                  48 
                              White 
                                917 
merge_tsl_nha_test_info = merge_tsl_nha_test_info %>%
  mutate(
    race_AI     = ifelse(race == "American Indian or Alaskan Native", 1, ifelse(is.na(race), NA, 0)),
    race_A     = ifelse(race == "Asian", 1, ifelse(is.na(race), NA, 0)),
    race_B     = ifelse(race == "Black or African American", 1, ifelse(is.na(race), NA, 0)),
    race_NH     = ifelse(race == "Native Hawaiian or Pacific Islander", 1, ifelse(is.na(race), NA, 0)),
    race_W     = ifelse(race == "White", 1, ifelse(is.na(race), NA, 0))
  )
table(merge_tsl_nha_test_info$race_B) #quick check coding on race_B

   0    1 
1127 2877 

Create dummy for ethnicity. Observation: Ethn is often a duplication of Race, and Hispanic never appears under Race. Recode ethnicity into a Hispanic indicator?

table(merge_tsl_nha_test_info$ethn)

  American Indian or Alaskan Native                               Asian 
                                 39                                  40 
          Black or African American                            Hispanic 
                               2688                                 904 
Native Hawaiian or Pacific Islander                               White 
                                  4                                 329 

Decision: Keep Race as-is; Mutate Ethn to Hispanic indicator.

merge_tsl_nha_test_info = merge_tsl_nha_test_info %>%
  mutate(
    hisp_eth = ifelse(
      is.na(ethn),
      NA,
      ifelse(ethn == "Hispanic", 1, 0)
    )
  )

table(merge_tsl_nha_test_info$hisp_eth)#check recoding

   0    1 
3100  904 

Export files for e2i Coach

Save e2i file with all matched students from the merge, regardless of missing test info.

#select columns ready for e2i
tsl_nha_e2i_usage_test_info = merge_tsl_nha_test_info %>%
  dplyr::select(student_id, 
                teacher_id,
                grade, 
                school_num,
                boy_math_score, boy_math_date,
                eoy_math_score, eoy_math_date,
                male, iep, ell, frpl, attend_rate,
                treat,
                race_AI, race_A, race_B, race_NH, race_W,hisp_eth,
                boy_eoy_math,#1/0 indicator: boy and eoy math info present
                math_boy_missing_eoy_present, 
                
                tsl_acct_id, 
                session_BOY_date, #start date of TSL/treat
                n_co_confid, n_co_confid_same_imp, perc_confid_same_imp, 
                score_confid_same_imp, enjoy_rate, activ_partic,
                n_lo_complete, 
                num_session, num_min, num_weeks
                )

#export e2i with test indicators, all matched students from the merge, regardless of missing test info (all_tests == 1 or 0)
write.csv(tsl_nha_e2i_usage_test_info, file = "tsl_nha_e2i_all_students.csv", row.names = FALSE)

Inspect number of students with complete tests info

table(tsl_nha_e2i_usage_test_info$boy_eoy_math)

   0    1 
 207 3797 

Inspect number of students with complete tests info, by treatment.

tsl_nha_e2i_usage_test_info %>%
  group_by(treat) %>%
  summarise(
    complete_test   = sum(boy_eoy_math == 1, na.rm = TRUE),
    incomplete_test = sum(boy_eoy_math == 0, na.rm = TRUE),
    total           = n(),
    pct_complete    = complete_test / total * 100,
    pct_incomplete  = incomplete_test / total * 100,
    .groups = "drop"
  )
# A tibble: 2 × 6
  treat complete_test incomplete_test total pct_complete pct_incomplete
  <dbl>         <int>           <int> <int>        <dbl>          <dbl>
1     0          3238             200  3438         94.2           5.82
2     1           559               7   566         98.8           1.24

Save e2i file with only containing matched students from the merge with complete test info (all_tests means student has all BOY and EOY scores and dates).

#subset: keep only students with complete test info
tsl_nha_e2i_all_tests = tsl_nha_e2i_usage_test_info %>% filter(boy_eoy_math == 1)
print(tsl_nha_e2i_all_tests) # N=3846
# A tibble: 3,797 × 34
   student_id teacher_id grade school_num boy_math_score boy_math_date
   <chr>           <dbl> <dbl>      <dbl>          <dbl> <date>       
 1 366259          19461     7          4            202 2025-09-03   
 2 430015          13501     8          4            218 2025-09-03   
 3 445701           4891     8          5            204 2025-09-29   
 4 461310           4416     7          2            170 2025-09-10   
 5 471757          13405     8          6            224 2025-09-24   
 6 472379           6179     8          7            209 2025-09-08   
 7 473083          23087     8          9            225 2025-09-23   
 8 473087          23087     8          9            204 2025-09-03   
 9 475569          23087     8          9            244 2025-08-28   
10 475590          23087     8          9            202 2025-09-02   
# ℹ 3,787 more rows
# ℹ 28 more variables: eoy_math_score <dbl>, eoy_math_date <date>, male <dbl>,
#   iep <dbl>, ell <dbl>, frpl <dbl>, attend_rate <dbl>, treat <dbl>,
#   race_AI <dbl>, race_A <dbl>, race_B <dbl>, race_NH <dbl>, race_W <dbl>,
#   hisp_eth <dbl>, boy_eoy_math <dbl>, math_boy_missing_eoy_present <dbl>,
#   tsl_acct_id <dbl>, session_BOY_date <date>, n_co_confid <dbl>,
#   n_co_confid_same_imp <dbl>, perc_confid_same_imp <dbl>, …

Count of treatment students among the subset with complete test info.

table(tsl_nha_e2i_all_tests$treat)

   0    1 
3238  559 
#export e2i only containing matched students from the merge with complete test info 
write.csv(tsl_nha_e2i_all_tests, file = "tsl_nha_e2i_complete_tests.csv", row.names = FALSE)