DATA 607 - Project 1 – Chess Tournament Cross-Table Parsing and Data Transformation

Bikash Bhowmik —- 27 Sep 2026

Introduction

For this project, I worked with a chess tournament cross-table that was not in a tidy format. My goal was to convert the original data into a clean dataset that could be used for analysis.

The final dataset has one row for each player and includes these columns:

• Player Name
• Player State
• Total Points
• Pre-Tournament Rating
• Average Pre-Tournament Rating of Opponents

I used the tournament crosstable provided for the assignment. I think this dataset was complex enough to demonstrate the data cleaning and parsing skills required for this project. Each player is represented across multiple lines, and the round information includes opponent numbers and game results.

Objective

The main goal was to extract the player information from the original crosstable and determine the average pre-tournament rating of each player’s opponents.

The most difficult part of the project was not calculating an average. The main challenge was correctly identifying each opponent and connecting that opponent to the correct pre-tournament rating.

I also needed to handle situations where a player did not play a particular round, such as a bye or an unplayed game. Finally, I wanted the entire process to be reproducible so that the same code could be run again and produce the same results.

Data Loading

I loaded the tournament information directly from my GitHub repository using the raw text file URL. I used readLines() to read the file into R and then checked the number of lines and the first 10 rows to make sure the data was loaded correctly.

# Read the tournament information from GitHub
url <- "https://raw.githubusercontent.com/BIKASHBHOWMIK15/Data-607/main/Project-1/tournamentinfo.txt"

tournament_data <- readLines(url, warn = FALSE)

# Check the data
length(tournament_data)
[1] 196
head(tournament_data, 10)
 [1] "-----------------------------------------------------------------------------------------" 
 [2] " Pair | Player Name                     |Total|Round|Round|Round|Round|Round|Round|Round| "
 [3] " Num  | USCF ID / Rtg (Pre->Post)       | Pts |  1  |  2  |  3  |  4  |  5  |  6  |  7  | "
 [4] "-----------------------------------------------------------------------------------------" 
 [5] "    1 | GARY HUA                        |6.0  |W  39|W  21|W  18|W  14|W   7|D  12|D   4|" 
 [6] "   ON | 15445895 / R: 1794   ->1817     |N:2  |W    |B    |W    |B    |W    |B    |W    |" 
 [7] "-----------------------------------------------------------------------------------------" 
 [8] "    2 | DAKSHESH DARURI                 |6.0  |W  63|W  58|L   4|W  17|W  16|W  20|W   7|" 
 [9] "   MI | 14598900 / R: 1553   ->1663     |N:2  |B    |W    |B    |W    |B    |W    |B    |" 
[10] "-----------------------------------------------------------------------------------------" 

Identifying Player Information Lines

I identified the two types of lines containing player information in the raw tournament data. I then separated these lines so that the player details could be extracted and processed correctly.

# Identify the two types of player information lines
is_player_line1 <- str_detect(tournament_data, "^\\s*\\d+\\s*\\|")
is_player_line2 <- str_detect(tournament_data, "^\\s*[A-Z]{2}\\s*\\|")

# Extract the player information lines
player_line1 <- tournament_data[is_player_line1]
player_line2 <- tournament_data[is_player_line2]

# Check the number of lines found
length(player_line1)
[1] 64
length(player_line2)
[1] 64
# Extract information from the first player line
pair_number <- as.integer(
  str_match(player_line1, "^\\s*(\\d+)\\s*\\|")[, 2]
)

player_name <- str_match(
  player_line1, "^\\s*\\d+\\s*\\|\\s*(.*?)\\s*\\|"
)[, 2] %>%
  str_squish()

total_points <- as.numeric(
  str_match(player_line1, "\\|\\s*([0-9]+\\.?[0-9]*)\\s*\\|")[, 2]
)

# Store the extracted information in a data frame
player_data <- tibble(
  pair_number = pair_number,
  player_name = str_to_title(player_name),
  total_points = total_points,
  line1_raw = player_line1
)

# Display the first five players
player_data %>%
  slice(1:5)
# A tibble: 5 × 4
  pair_number player_name         total_points line1_raw                        
        <int> <chr>                      <dbl> <chr>                            
1           1 Gary Hua                     6   "    1 | GARY HUA               …
2           2 Dakshesh Daruri              6   "    2 | DAKSHESH DARURI        …
3           3 Aditya Bajaj                 6   "    3 | ADITYA BAJAJ           …
4           4 Patrick H Schilling          5.5 "    4 | PATRICK H SCHILLING    …
5           5 Hanshi Zuo                   5.5 "    5 | HANSHI ZUO             …

Extracting Player Details

I extracted the player’s pair number, name, and total points from the first player information line. I stored these values in a tidy data frame for use in the next steps of the analysis.

# Extract the state abbreviation
state <- str_match(
  player_line2, "^\\s*([A-Z]{2})\\s*\\|"
)[, 2]

# Extract the pre-tournament rating
# The pattern captures the numeric value after "R:"
pre_rating <- as.integer(
  str_match(player_line2, "R:\\s*([0-9]+)")[, 2]
)

# Store the extracted information
player_details <- tibble(
  state = state,
  pre_rating = pre_rating,
  line2_raw = player_line2
)

# Display the first five records
player_details %>%
  slice(1:5)
# A tibble: 5 × 3
  state pre_rating line2_raw                                                    
  <chr>      <int> <chr>                                                        
1 ON          1794 "   ON | 15445895 / R: 1794   ->1817     |N:2  |W    |B    |…
2 MI          1553 "   MI | 14598900 / R: 1553   ->1663     |N:2  |B    |W    |…
3 MI          1384 "   MI | 14959604 / R: 1384   ->1640     |N:2  |W    |B    |…
4 MI          1716 "   MI | 12616049 / R: 1716   ->1744     |N:2  |W    |B    |…
5 MI          1655 "   MI | 14601533 / R: 1655   ->1690     |N:2  |B    |W    |…
# Combine the two sets of player information
players <- bind_cols(
  player_data %>%
    select(pair_number, player_name, total_points, line1_raw),
  player_details %>%
    select(state, pre_rating, line2_raw)
) %>%
  arrange(pair_number)

# Display the first 10 players
players %>%
  slice(1:10)
# A tibble: 10 × 7
   pair_number player_name     total_points line1_raw state pre_rating line2_raw
         <int> <chr>                  <dbl> <chr>     <chr>      <int> <chr>    
 1           1 Gary Hua                 6   "    1 |… ON          1794 "   ON |…
 2           2 Dakshesh Daruri          6   "    2 |… MI          1553 "   MI |…
 3           3 Aditya Bajaj             6   "    3 |… MI          1384 "   MI |…
 4           4 Patrick H Schi…          5.5 "    4 |… MI          1716 "   MI |…
 5           5 Hanshi Zuo               5.5 "    5 |… MI          1655 "   MI |…
 6           6 Hansen Song              5   "    6 |… OH          1686 "   OH |…
 7           7 Gary Dee Swath…          5   "    7 |… MI          1649 "   MI |…
 8           8 Ezekiel Hought…          5   "    8 |… MI          1641 "   MI |…
 9           9 Stefano Lee              5   "    9 |… ON          1411 "   ON |…
10          10 Anvit Rao                5   "   10 |… MI          1365 "   MI |…

Overall Approach


1. Data Ingestion

I read the tournament text file from a public URL so that the project did not depend on a file stored only on my computer.

I read the file line by line and preserved the original formatting. This allowed me to work with the structure of the original cross-table.

2. Reconstructing Player Records

Each player is represented by two lines in the cross-table.

The first line contains information such as:

• Pair number
• Player name
• Total points
• Results for each round

The second line contains information such as:

• State
• USCF ID
• Pre-tournament and post-tournament ratings

I identified the two types of player information separately and combined them in their original order after verifying that 64 records of each type were found.

Regular expressions and string manipulation were used to extract the information I needed.


3. Extracting the Variables

For each player, I extracted:

• pair_number
• player_name
• state
• total_points
• pre_rating
• Opponent pair numbers for each round

The round results may look like:

• W 39
• L 21
• D 12

The letter shows the result of the game, while the number identifies the opponent. I only needed the opponent number for calculating the average opponent rating.

Calculating the Average Opponent Rating

To calculate the average opponent rating, I created a lookup table that connected each player’s pair number with their pre-tournament rating.

For example:

pair_number → pre_rating

Then, for each player, I collected the opponent pair numbers from all rounds and used the lookup table to obtain the corresponding pre-tournament ratings.

Valid Games


I counted a round as a game when it contained a result indicator (W, L, or D) followed by an opponent number. I did not count entries such as:

• H – half-point bye
• B – bye
• U – unplayed
• X – forfeit or win by absence

These entries do not provide an actual opponent whose pre-tournament rating should be included in the average.
After identifying the valid opponents, I used their pair numbers to look up their pre-tournament ratings and calculate the mean.

This means that a player who played all seven rounds had the average calculated from all of their opponents, while a player who played fewer rounds had the average calculated only from the opponents they actually played.

Validation

I performed manual checks to make sure the program produced the correct results.

Test Case 1: Player Who Played All Games

I selected a player who played all rounds and manually identified their opponents. I identified the opponents, found the pre-tournament rating of each opponent, and calculated the average by hand. I then compared this result with the value produced by my program.

Test Case 2: Player Who Played Fewer Games

I selected a player who did not play every round. I identified the rounds that were actually played and excluded byes or other non-game entries. I then manually calculated the average opponent rating and compared it with the program’s result.

These checks helped confirm that the opponent numbers were parsed correctly and that the correct denominator was used when calculating the average.

Reproducibility

I stored all of the code in the Quarto file so that the complete process can be reproduced from beginning to end. The data was read from a public URL rather than from a local file. There was no manual editing of the data.

I also included checks to make sure that:
• Exactly 64 players were processed.
• Opponent pair numbers were between 1 and 64.
• Opponent pair numbers successfully matched a player in the rating lookup table.
• No missing pre-tournament ratings were created during the lookup process.
• The final dataset contained one row per player.

Data Parsing Challenges

The main challenges in processing the data included:

• Correctly combining the two lines for each player.
• Dealing with inconsistent spacing in the original text.
• Correctly extracting opponent numbers from the round results.
• Distinguishing actual games from byes and unplayed rounds.
• Making sure that opponent numbers are connected to the correct player ratings.
• Avoiding parsing errors that could shift information from one player to another.

Code

# Extract opponent pair numbers from each player's tournament line
extract_opponents <- function(line) {
  
  matches <- str_match_all(line, "(W|L|D)\\s*(\\d+)")[[1]]
  
  if (nrow(matches) == 0) {
    return(integer(0))
  }
  
  as.integer(matches[, 3])
}

# Add opponents and number of games played
players <- players %>%
  mutate(
    opponents = map(line1_raw, extract_opponents),
    games_played = map_int(opponents, length)
  )

# Display the first 12 players
players %>%
  select(
    pair_number,
    player_name,
    total_points,
    games_played,
    opponents
  ) %>%
  slice(1:12)
# A tibble: 12 × 5
   pair_number player_name              total_points games_played opponents
         <int> <chr>                           <dbl>        <int> <list>   
 1           1 Gary Hua                          6              7 <int [7]>
 2           2 Dakshesh Daruri                   6              7 <int [7]>
 3           3 Aditya Bajaj                      6              7 <int [7]>
 4           4 Patrick H Schilling               5.5            7 <int [7]>
 5           5 Hanshi Zuo                        5.5            7 <int [7]>
 6           6 Hansen Song                       5              7 <int [7]>
 7           7 Gary Dee Swathell                 5              7 <int [7]>
 8           8 Ezekiel Houghton                  5              7 <int [7]>
 9           9 Stefano Lee                       5              7 <int [7]>
10          10 Anvit Rao                         5              7 <int [7]>
11          11 Cameron William Mc Leman          4.5            7 <int [7]>
12          12 Kenneth J Tack                    4.5            6 <int [6]>
# Create a lookup table for each player's pre-tournament rating
rating_lookup <- players %>%
  select(pair_number, pre_rating)

# Match each opponent with their pre-tournament rating
players <- players %>%
  mutate(
    opponent_pre_ratings = map(
      opponents,
      ~ rating_lookup %>%
        filter(pair_number %in% .x) %>%
        pull(pre_rating)
    ),
    
    # Calculate the average pre-tournament rating of the opponents
    avg_opp_pre_rating = map_dbl(
      opponent_pre_ratings,
      ~ if (length(.x) == 0) {
          NA_real_
        } else {
          round(mean(.x), 0)
        }
    )
  )

# Display the results for the first 12 players
players %>%
  select(
    pair_number,
    player_name,
    pre_rating,
    opponents,
    opponent_pre_ratings,
    avg_opp_pre_rating
  ) %>%
  slice(1:12)
# A tibble: 12 × 6
   pair_number player_name             pre_rating opponents opponent_pre_ratings
         <int> <chr>                        <int> <list>    <list>              
 1           1 Gary Hua                      1794 <int [7]> <int [7]>           
 2           2 Dakshesh Daruri               1553 <int [7]> <int [7]>           
 3           3 Aditya Bajaj                  1384 <int [7]> <int [7]>           
 4           4 Patrick H Schilling           1716 <int [7]> <int [7]>           
 5           5 Hanshi Zuo                    1655 <int [7]> <int [7]>           
 6           6 Hansen Song                   1686 <int [7]> <int [7]>           
 7           7 Gary Dee Swathell             1649 <int [7]> <int [7]>           
 8           8 Ezekiel Houghton              1641 <int [7]> <int [7]>           
 9           9 Stefano Lee                   1411 <int [7]> <int [7]>           
10          10 Anvit Rao                     1365 <int [7]> <int [7]>           
11          11 Cameron William Mc Lem…       1712 <int [7]> <int [7]>           
12          12 Kenneth J Tack                1663 <int [6]> <int [6]>           
# ℹ 1 more variable: avg_opp_pre_rating <dbl>
# Check the opponent ratings for player 1
players %>%
  filter(pair_number == 1) %>%
  select(
    pair_number,
    player_name,
    pre_rating,
    opponents,
    opponent_pre_ratings,
    avg_opp_pre_rating
  )
# A tibble: 1 × 6
  pair_number player_name pre_rating opponents opponent_pre_ratings
        <int> <chr>            <int> <list>    <list>              
1           1 Gary Hua          1794 <int [7]> <int [7]>           
# ℹ 1 more variable: avg_opp_pre_rating <dbl>

Manual Verification-Step 1

For Player 1 (Pair #1), the opponents played were:

39, 21, 18, 14, 7, 12, 4

Their corresponding pre-ratings are:

1436, 1563, 1600, 1610, 1649, 1663, 1716

Hand calculation:

Sum = 1436 + 1563 + 1600 + 1610 + 1649 + 1663 + 1716
Sum = 11237

Number of games played = 7

Average = 11237 / 7 = 1605.2857
Rounded to nearest integer = 1605

The program output for Player 1 is 1605, confirming correct extraction, mapping, and denominator logic.

# Compare two selected players
players %>%
  filter(pair_number %in% c(12, 16)) %>%
  select(
    pair_number,
    player_name,
    pre_rating,
    total_points,
    games_played,
    opponents,
    opponent_pre_ratings,
    avg_opp_pre_rating
  )
# A tibble: 2 × 8
  pair_number player_name    pre_rating total_points games_played opponents
        <int> <chr>               <int>        <dbl>        <int> <list>   
1          12 Kenneth J Tack       1663          4.5            6 <int [6]>
2          16 Mike Nikitin         1604          4              5 <int [5]>
# ℹ 2 more variables: opponent_pre_ratings <list>, avg_opp_pre_rating <dbl>

Manual Verification-Step 2

For Player 16 (Pair #16), the valid games were against:

10, 15, 39, 2, 36

The entries “H” and “U” are excluded because they do not represent valid opponents.

Their corresponding pre-ratings are:

1365, 1220, 1436, 1553, 1355

Hand calculation:

Sum = 1365 + 1220 + 1436 + 1553 + 1355
Sum = 6929

Number of valid games played = 5

Average = 6929 / 5 = 1385.8
Rounded to nearest integer = 1386

The program output (1386) matches the hand calculation, confirming correct handling of partial participation and exclusion of non-games.

Automated Validation

# Automated Validation

# Validation 1: Confirm that the tournament has exactly 64 players
stopifnot(nrow(players) == 64)

# Verify that the expected number of player records was extracted
stopifnot(length(player_line1) == 64)
stopifnot(length(player_line2) == 64)


# Validation 2: Confirm that all opponent numbers are valid
all_opponent_numbers <- unlist(players$opponents)

stopifnot(
  all(
    all_opponent_numbers >= 1 &
    all_opponent_numbers <= 64
  )
)

# Validation 3: Confirm that every opponent has a valid pre-tournament rating
all_opponent_ratings <- unlist(players$opponent_pre_ratings)

stopifnot(!any(is.na(all_opponent_ratings)))


cat("All automated validation checks passed successfully.")
All automated validation checks passed successfully.
# Review the distribution of average opponent ratings
summary(players$avg_opp_pre_rating)
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
   1107    1310    1382    1379    1481    1605 

Final Output

The final dataset contains exactly one row for each player. The columns are:

• Player_Name
• State
• Total_Points
• Pre_Rating
• Average_Opponent_Pre_Rating

The complete final dataset was exported as a CSV file.

# Create the final summary dataset
final_df <- players %>%
  transmute(
    Player_Name = str_to_title(player_name),
    State = state,
    Total_Points = total_points,
    Pre_Rating = pre_rating,
    Average_Opponent_Pre_Rating = round(avg_opp_pre_rating, 0)
  )

# Display the first 10 players as a table
final_df %>%
  slice(1:10) %>%
  knitr::kable(
    caption = "Chess Tournament Data Summary"
  )
Chess Tournament Data Summary
Player_Name State Total_Points Pre_Rating Average_Opponent_Pre_Rating
Gary Hua ON 6.0 1794 1605
Dakshesh Daruri MI 6.0 1553 1469
Aditya Bajaj MI 6.0 1384 1564
Patrick H Schilling MI 5.5 1716 1574
Hanshi Zuo MI 5.5 1655 1501
Hansen Song OH 5.0 1686 1519
Gary Dee Swathell MI 5.0 1649 1372
Ezekiel Houghton MI 5.0 1641 1468
Stefano Lee ON 5.0 1411 1523
Anvit Rao MI 5.0 1365 1554
# Check that the final dataset contains exactly 64 players
stopifnot(nrow(final_df) == 64)

# Check that each player appears only once in the final dataset
stopifnot(!anyDuplicated(final_df$Player_Name))

# Verify that the expected number of player records was extracted
stopifnot(length(player_line1) == 64)
stopifnot(length(player_line2) == 64)


# Save the complete dataset as a CSV file
write_csv(final_df, "Project1_Chess_Data_Summary.csv")

Conclusion

In this project, I worked with a semi-structured chess tournament cross-table and converted it into a tidy dataset using R. I extracted player names, states, total points, pre-tournament ratings, and opponent information from the original text file. I then matched each opponent with their pre-tournament rating and calculated the average pre-tournament rating of each player’s opponents.

I also used manual checks and automated validation to make sure the data was parsed correctly. The final dataset contains 64 players and provides the main information needed for further analysis. I created a summary table and saved the cleaned data as a CSV file. Overall, this project gave me practical experience working with semi-structured data, string parsing, data transformation, validation, and reproducible analysis in R.